initial commit on Federted servers
release gate / check (push) Failing after 8m35s

This commit is contained in:
Andrew S Erwin
2026-10-19 16:23:55 -05:00
commit b9721ff13f
217 changed files with 31534 additions and 0 deletions
+420
View File
@@ -0,0 +1,420 @@
package http
import (
"testing"
)
// Collections (Notion-like upgrades phase 2). Proves the locked semantics:
// contextual membership (same bookmark, independent workflows, bookmark
// never owned by the membership), versioned PATCHes (DATA-05 lesson),
// server-side validated filters/sorts with deterministic cursor pagination,
// and access revocation reaching every item surface (N-08).
func collectionBookmark(t *testing.T, c apiClient, url string) string {
t.Helper()
return c.request("POST", "/api/bookmarks", map[string]any{"url": url, "title": "BM " + url}, 201)["id"].(string)
}
func TestCollectionsLifecycle(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-life@computer.test")
bob := newAPIUser(t, srv, "coll-other@computer.test")
bm1 := collectionBookmark(t, alice, "https://127.0.0.1/one")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Research", "description": "phase 2"}, 201)
cid := col["id"].(string)
views, _ := col["views"].([]any)
if len(views) != 1 {
t.Fatalf("new collection did not get a default view: %v", col)
}
// Property surface: typed, validated, RELATION reserved but rejected.
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Rel", "type": "RELATION"}, 400)
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Bad", "type": "PERSON"}, 400)
status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{
map[string]any{"label": "Backlog"}, map[string]any{"label": "Doing"}, map[string]any{"label": "Done"},
}},
}, 201)
statusID := status["id"].(string)
opts, _ := status["config"].(map[string]any)["options"].([]any)
doing := opts[1].(map[string]any)["id"].(string)
done := opts[2].(map[string]any)["id"].(string)
priority := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "NUMBER"}, 201)
priorityID := priority["id"].(string)
notes := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Notes", "type": "TEXT"}, 201)
notesID := notes["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Due", "type": "DATE", "config": map[string]any{"mode": "bogus"},
}, 400)
due := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Due", "type": "DATE", "config": map[string]any{"mode": "date"},
}, 201)
dueID := due["id"].(string)
// Duplicate property name is a unique violation -> 409.
alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Priority", "type": "TEXT"}, 409)
// Items: visibility required at creation, values validated, duplicates 409.
foreign := collectionBookmark(t, bob, "https://127.0.0.1/private")
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": foreign}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{statusID: "not-an-option"}}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{notesID: 5}}, 400)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{dueID: "2026-13-45"}}, 400)
item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1,
"propertyValues": map[string]any{statusID: doing, priorityID: 2, dueID: "2026-12-31", notesID: "context"}}, 201)
itemID := item["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 409)
pv, _ := item["propertyValues"].(map[string]any)
if pv[dueID] != "2026-12-31" {
t.Fatalf("date-only value not stored verbatim: %v", pv[dueID])
}
if _, ok := item["bookmark"]; !ok {
t.Fatal("item response missing the bookmark projection")
}
// Item PATCH enforces version (DATA-05 from day one) and merge semantics:
// omitted keys untouched, null removes.
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"propertyValues": map[string]any{notesID: "x"}}, 400)
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 99, "propertyValues": map[string]any{notesID: "x"}}, 409)
updated := alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{
"version": 1, "propertyValues": map[string]any{notesID: nil, statusID: done},
}, 200)
if updated["version"].(float64) != 2 {
t.Fatalf("item patch did not bump version: %v", updated["version"])
}
pv, _ = updated["propertyValues"].(map[string]any)
if _, stillThere := pv[notesID]; stillThere {
t.Fatal("null did not remove the value")
}
if pv[statusID] != done {
t.Fatal("status not updated")
}
// The same bookmark in a second collection: contextual values independent,
// intrinsic edits propagate to both (test matrix row 1).
col2 := alice.request("POST", "/api/collections", map[string]any{"name": "Reading"}, 201)
cid2 := col2["id"].(string)
status2 := alice.request("POST", "/api/collections/"+cid2+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{map[string]any{"label": "Unread"}, map[string]any{"label": "Read"}}},
}, 201)
unread := status2["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string)
item2 := alice.request("POST", "/api/collections/"+cid2+"/items", map[string]any{
"bookmarkId": bm1, "propertyValues": map[string]any{status2["id"].(string): unread},
}, 201)
putBookmark(alice, bm1, map[string]any{"title": "Renamed intrinsically"}, 200)
for _, pair := range [][2]string{{cid, itemID}, {cid2, item2["id"].(string)}} {
got := alice.getJSON("/api/collections/"+pair[0]+"/items", 200)
found := false
for _, it := range got {
if it["id"] == pair[1] {
found = true
bmk := it["bookmark"].(map[string]any)
if bmk["title"] != "Renamed intrinsically" {
t.Fatal("intrinsic edit did not propagate to membership", pair[0])
}
}
}
if !found {
t.Fatal("item missing from list", pair[0])
}
}
// Contextual independence: collection 1 says Done, collection 2 says Unread.
first := alice.getJSON("/api/collections/"+cid+"/items", 200)[0]
if first["propertyValues"].(map[string]any)[statusID] != done {
t.Fatal("contextual value leaked between collections")
}
// Removing membership and then the whole collection never touches the
// bookmark (N-01 acceptance).
alice.request("DELETE", "/api/collections/"+cid2+"/items/"+item2["id"].(string), nil, 200)
alice.request("GET", "/api/bookmarks/"+bm1, nil, 200)
alice.request("DELETE", "/api/collections/"+cid2, nil, 200)
alice.request("GET", "/api/bookmarks/"+bm1, nil, 200)
alice.request("GET", "/api/collections/"+cid2, nil, 404)
// Foreign users get 404 on every collection surface (ownership flow).
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1}, 404)
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
}
func TestCollectionViewsFilterSortPaginate(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-views@computer.test")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Pipeline"}, 201)
cid := col["id"].(string)
status := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{
"name": "Status", "type": "SELECT",
"config": map[string]any{"options": []any{map[string]any{"label": "Doing"}, map[string]any{"label": "Done"}}},
}, 201)
statusID := status["id"].(string)
doing := status["config"].(map[string]any)["options"].([]any)[0].(map[string]any)["id"].(string)
done := status["config"].(map[string]any)["options"].([]any)[1].(map[string]any)["id"].(string)
prio := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Prio", "type": "NUMBER"}, 201)
prioID := prio["id"].(string)
ids := []string{}
for i, s := range []string{doing, done, doing} {
bm := collectionBookmark(t, alice, "https://127.0.0.1/v"+string(rune('a'+i)))
p := float64(i)
if i == 2 {
p = 1 // tie with item 1 on the sort key: id must break it deterministically
}
it := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{
"bookmarkId": bm, "propertyValues": map[string]any{statusID: s, prioID: p},
}, 201)
ids = append(ids, it["id"].(string))
}
// View validation: unknown properties and type-incompatible operators 400.
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad", "filters": map[string]any{
"operator": "AND", "clauses": []any{map[string]any{"propertyId": "nope", "op": "eq", "value": "x"}},
}}, 400)
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad2", "filters": map[string]any{
"clauses": []any{map[string]any{"propertyId": statusID, "op": "gt", "value": doing}},
}}, 400)
alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{"name": "Bad3", "sorts": []any{
map[string]any{"field": "nope"},
}}, 400)
board := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{
"name": "Doing board", "type": "BOARD",
"filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": statusID, "op": "eq", "value": doing}}},
"sorts": []any{map[string]any{"field": prioID, "dir": "desc"}},
"groupByPropertyId": statusID,
}, 201)
vid := board["id"].(string)
// Server-side filtering: only the two Doing items come back.
doingItems := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid, 200)
if len(doingItems) != 2 {
t.Fatalf("filter returned %d items, want 2", len(doingItems))
}
// Sorted by priority desc: first page item has prio 1 (the tie item), and
// cursor pagination with limit=1 walks both pages exactly once.
first := doingItems[0]
if first["propertyValues"].(map[string]any)[prioID].(float64) != 1 {
t.Fatal("sort desc did not put the higher priority first")
}
res, e := alice.c.Get(alice.base + "/api/collections/" + cid + "/items?viewId=" + vid + "&limit=1")
if e != nil {
t.Fatal(e)
}
cursor := res.Header.Get("X-Next-Cursor")
res.Body.Close()
if cursor == "" {
t.Fatal("no cursor despite more rows")
}
page := alice.getJSON("/api/collections/"+cid+"/items?viewId="+vid+"&limit=1&cursor="+cursor, 200)
if len(page) != 1 {
t.Fatalf("cursor page returned %d items", len(page))
}
if page[0]["id"] == first["id"] {
t.Fatal("cursor page repeated the first row (non-deterministic tie-break)")
}
// View PATCH enforces version like everything else.
alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"name": "No version"}, 400)
alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 99, "name": "Stale"}, 409)
renamed := alice.request("PATCH", "/api/collections/"+cid+"/views/"+vid, map[string]any{"version": 1, "name": "Doing board v2"}, 200)
if renamed["version"].(float64) != 2 {
t.Fatalf("view patch did not bump version: %v", renamed["version"])
}
// Option removal with existing values is refused (stated policy); after
// clearing the values it succeeds. Renames keep the option identity.
alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1,
"config": map[string]any{"options": []any{map[string]any{"id": doing, "label": "In flight"}}}}, 400)
// Clear the Doing values by moving every item to Done.
for _, row := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
alice.request("PATCH", "/api/collections/"+cid+"/items/"+row["id"].(string), map[string]any{
"version": row["version"].(float64), "propertyValues": map[string]any{statusID: done},
}, 200)
}
renamedProp := alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 1,
"config": map[string]any{"options": []any{map[string]any{"id": done, "label": "Finished"}}}}, 200)
opts := renamedProp["config"].(map[string]any)["options"].([]any)
if len(opts) != 1 || opts[0].(map[string]any)["id"] != done {
t.Fatalf("option removal/rename produced %+v", opts)
}
// Type changes are forbidden; deleting the property scrubs values and views.
alice.request("PATCH", "/api/collections/"+cid+"/properties/"+statusID, map[string]any{"version": 2, "type": "TEXT"}, 400)
alice.request("DELETE", "/api/collections/"+cid+"/properties/"+statusID, nil, 200)
view := alice.request("GET", "/api/collections/"+cid, nil, 200)
for _, raw := range view["views"].([]any) {
v := raw.(map[string]any)
if v["id"] != vid {
continue
}
f := v["filters"].(map[string]any)
if len(f["clauses"].([]any)) != 0 {
t.Fatalf("deleted property left filter clauses behind: %v", f)
}
// The sort keys on the surviving Prio property and must be untouched.
if len(v["sorts"].([]any)) != 1 {
t.Fatalf("unrelated sorts were scrubbed: %v", v["sorts"])
}
if v["groupByPropertyId"] != nil {
t.Fatalf("groupBy was not cleared: %v", v["groupByPropertyId"])
}
}
for _, raw := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
pv, _ := raw["propertyValues"].(map[string]any)
if _, still := pv[statusID]; still {
t.Fatal("deleted property left item values behind")
}
}
}
func TestCollectionRevocationReachesItems(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-rev@computer.test")
aliceID := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
bob := newAPIUser(t, srv, "coll-rev-owner@computer.test")
// Bob owns a bookmark and shares it with Alice; Alice puts it in HER
// collection. Revocation must make the item vanish from every surface
// (list, count, mutation) without deleting anything (N-08, decision #4).
shared := collectionBookmark(t, bob, "https://127.0.0.1/shared")
bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Watchlist"}, 201)
cid := col["id"].(string)
item := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": shared}, 201)
itemID := item["id"].(string)
if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 1 {
t.Fatal("shared bookmark item not listed")
}
for _, c := range alice.getJSON("/api/collections", 200) {
if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 1 {
t.Fatalf("authorized count wrong: %v", c["_count"])
}
}
bob.request("DELETE", "/api/bookmarks/"+shared+"/share/"+aliceID, nil, 200)
if len(alice.getJSON("/api/collections/"+cid+"/items", 200)) != 0 {
t.Fatal("revoked bookmark still listed in collection items")
}
for _, c := range alice.getJSON("/api/collections", 200) {
if c["id"] == cid && c["_count"].(map[string]any)["items"].(float64) != 0 {
t.Fatalf("revoked bookmark still counted: %v", c["_count"])
}
}
alice.request("PATCH", "/api/collections/"+cid+"/items/"+itemID, map[string]any{"version": 1, "position": 3}, 404)
alice.request("DELETE", "/api/collections/"+cid+"/items/"+itemID, nil, 404)
// Review COR-2: a hidden membership row must not wedge reorder. Alice
// adds one of her own bookmarks, then reorders the visible items — the
// revoked membership stays hidden at the tail instead of making every
// PUT /items fail "Order must contain every item exactly once".
own := collectionBookmark(t, alice, "https://127.0.0.1/alice-own")
ownItem := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": own}, 201)
ownID := ownItem["id"].(string)
visible := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(visible) != 1 || visible[0]["id"] != ownID {
t.Fatalf("expected only the own item visible, got %v", visible)
}
alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{ownID}}, 200)
// The membership row itself survives revocation: re-sharing restores it.
bob.request("POST", "/api/bookmarks/"+shared+"/share", map[string]any{"userIds": []string{aliceID}}, 200)
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 2 {
t.Fatal("re-sharing did not restore the existing membership")
}
}
// Review CR-09: indirect mutations advance revisions — reorder bumps every
// affected item, property deletion bumps the items whose values it dropped
// and only the views whose definitions it actually changed. Stale PATCHes
// based on the pre-reorder / pre-deletion state get 409s.
func TestCollectionIndirectMutationsAdvanceVersions(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coll-rev@computer.test")
bm1 := collectionBookmark(t, alice, "https://127.0.0.1/r1")
bm2 := collectionBookmark(t, alice, "https://127.0.0.1/r2")
col := alice.request("POST", "/api/collections", map[string]any{"name": "Revisions"}, 201)
cid := col["id"].(string)
dvid := col["views"].([]any)[0].(map[string]any)["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Label", "type": "TEXT"}, 201)
pid := prop["id"].(string)
it1 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm1, "propertyValues": map[string]any{pid: "one"}}, 201)
it2 := alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bm2}, 201)
id1, id2 := it1["id"].(string), it2["id"].(string)
filtered := alice.request("POST", "/api/collections/"+cid+"/views", map[string]any{
"name": "Filtered", "type": "TABLE",
"filters": map[string]any{"operator": "AND", "clauses": []any{map[string]any{"propertyId": pid, "op": "eq", "value": "one"}}},
}, 201)
fvid := filtered["id"].(string)
itemVersion := func(id string) int {
t.Helper()
for _, it := range alice.getJSON("/api/collections/"+cid+"/items", 200) {
if it["id"] == id {
v, _ := it["version"].(float64)
return int(v)
}
}
t.Fatalf("item %s missing from list", id)
return -1
}
viewVersion := func(id string) int {
t.Helper()
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
for _, raw := range detail["views"].([]any) {
v := raw.(map[string]any)
if v["id"] == id {
n, _ := v["version"].(float64)
return int(n)
}
}
t.Fatalf("view %s missing", id)
return -1
}
// Baselines: collection entities start at version 1 (migration 002).
base1, base2 := itemVersion(id1), itemVersion(id2)
fv0, dv0 := viewVersion(fvid), viewVersion(dvid)
// Reorder advances every affected item's revision...
alice.request("PUT", "/api/collections/"+cid+"/items", map[string]any{"order": []string{id2, id1}}, 200)
if v := itemVersion(id1); v != base1+1 {
t.Fatalf("reorder left item1 at version %d, want %d", v, base1+1)
}
if v := itemVersion(id2); v != base2+1 {
t.Fatalf("reorder left item2 at version %d, want %d", v, base2+1)
}
// ...so a PATCH based on the pre-reorder snapshot is a 409, and the
// current base still succeeds.
alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1}, 409)
alice.request("PATCH", "/api/collections/"+cid+"/items/"+id1, map[string]any{"position": 0, "version": base1 + 1}, 200)
if v := itemVersion(id1); v != base1+2 {
t.Fatalf("PATCH did not advance item1 to version %d: %d", base1+2, v)
}
// Property deletion: the item carrying a value advances; the item without
// one does not. The view filtering on the property advances (and its
// clause is scrubbed); the untouched default view keeps its revision.
alice.request("DELETE", "/api/collections/"+cid+"/properties/"+pid, nil, 200)
if v := itemVersion(id1); v != base1+3 {
t.Fatalf("property deletion left valued item1 at version %d, want %d", v, base1+3)
}
if v := itemVersion(id2); v != base2+1 {
t.Fatalf("property deletion bumped valueless item2 to %d, want %d", v, base2+1)
}
if v := viewVersion(fvid); v != fv0+1 {
t.Fatalf("property deletion left the filtered view at version %d, want %d", v, fv0+1)
}
if v := viewVersion(dvid); v != dv0 {
t.Fatalf("property deletion bumped the untouched default view to %d, want %d", v, dv0)
}
}
+510
View File
@@ -0,0 +1,510 @@
package http
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/signals"
"github.com/emersion/go-imap/backend/memory"
imapserver "github.com/emersion/go-imap/server"
"github.com/golang-jwt/jwt/v5"
)
// Covers routes that previously had no coverage at any level (CODE_REVIEW
// TEST-1) plus signout revocation (AUTH-5) and the background scheduler
// (TEST-2).
func (a apiClient) getJSON(path string, status int) []map[string]any {
a.t.Helper()
res, e := a.c.Get(a.base + path)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw)
}
var v []map[string]any
if e := json.Unmarshal(raw, &v); e != nil {
a.t.Fatalf("GET %s: not a JSON array: %v: %s", path, e, raw)
}
return v
}
func (a apiClient) getText(path string, status int) string {
a.t.Helper()
res, e := a.c.Get(a.base + path)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("GET %s: wanted %d, got %d: %s", path, status, res.StatusCode, raw)
}
return string(raw)
}
func newAPIUser(t *testing.T, srv *httptest.Server, email string) apiClient {
t.Helper()
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, srv.URL}
creds := map[string]any{"email": email, "password": "coverage-test-password"}
c.request("POST", "/api/signup", creds, 201)
c.request("POST", "/api/auth/login", creds, 200)
return c
}
func TestPreviouslyUncoveredRoutes(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "coverage-alice@computer.test")
newAPIUser(t, srv, "coverage-searchable@computer.test")
// GET /api/preview — handler plumbing (auth + query + parse).
html := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
io.WriteString(w, `<html><head><title>Preview Target</title><meta name="description" content="Described"></head><body>x</body></html>`)
}))
defer html.Close()
anon, _ := cookiejar.New(nil)
anonymous := apiClient{t, &http.Client{Jar: anon}, srv.URL}
anonymous.request("GET", "/api/preview?url="+html.URL, nil, 401)
if v := alice.request("GET", "/api/preview?url="+html.URL, nil, 200); v["title"] != "Preview Target" || v["description"] != "Described" {
t.Fatal("preview handler:", v)
}
// GET /api/bookmarks/{id}/markdown — content, not just the link element.
b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/coverage", "title": "Coverage Bookmark", "notes": "# Coverage notes"}, 201)
id := b["id"].(string)
md := alice.getText("/api/bookmarks/"+id+"/markdown", 200)
if !strings.Contains(md, "https://example.org/coverage") || !strings.Contains(md, "Coverage notes") {
t.Fatal("bookmark markdown:", md)
}
// GET /api/users/search — name substring, exact email, masked output,
// no email-substring enumeration, 2-rune guard.
users := alice.getJSON("/api/users/search?q=coverage-sea", 200)
if len(users) != 1 || users[0]["email"] != "c***@computer.test" {
t.Fatal("users/search name match:", users)
}
if users := alice.getJSON("/api/users/search?q=coverage-searchable@computer.test", 200); len(users) != 1 {
t.Fatal("users/search exact email:", users)
}
// "able@computer.test" is a substring of the target's email but not a
// full email and not part of any name: must not match.
if users := alice.getJSON("/api/users/search?q=able%40computer.test", 200); len(users) != 0 {
t.Fatal("users/search must not substring-match emails:", users)
}
if users := alice.getJSON("/api/users/search?q=c", 200); len(users) != 0 {
t.Fatal("users/search short-query guard:", users)
}
// Review SEC-3: LIKE wildcards are escaped, so "%%"/"_%" cannot enumerate
// the whole user base. "%%" matches a literal percent-pair (2 runes, so
// it passes the minimum-length guard); a name literally containing "%%"
// still matches, but nothing else does.
if users := alice.getJSON("/api/users/search?q=%25%25", 200); len(users) != 0 {
t.Fatalf("users/search wildcard enumeration: %v", users)
}
// PATCH /api/kanban/columns/{id} and PUT /api/kanban/columns happy paths.
col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Coverage"}, 201)["id"].(string)
alice.request("PATCH", "/api/kanban/columns/"+col, map[string]any{"label": "Renamed", "color": "#00ff00"}, 200)
cols := alice.getJSON("/api/kanban/columns", 200)
var found bool
order := make([]string, 0, len(cols))
for _, c := range cols {
order = append(order, c["id"].(string))
if c["id"] == col {
found = c["label"] == "Renamed" && c["color"] == "#00ff00"
}
}
if !found {
t.Fatal("column patch not applied:", cols)
}
reversed := make([]string, 0, len(order))
for i := len(order) - 1; i >= 0; i-- {
reversed = append(reversed, order[i])
}
alice.request("PUT", "/api/kanban/columns", map[string]any{"order": reversed}, 200)
after := alice.getJSON("/api/kanban/columns", 200)
if after[0]["id"] != reversed[0] || after[len(after)-1]["id"] != reversed[len(reversed)-1] {
t.Fatal("column reorder not applied")
}
// PUT /api/pages reorder.
p1 := alice.request("POST", "/api/pages", map[string]any{"title": "P1"}, 201)["id"].(string)
p2 := alice.request("POST", "/api/pages", map[string]any{"title": "P2"}, 201)["id"].(string)
p3 := alice.request("POST", "/api/pages", map[string]any{"title": "P3"}, 201)["id"].(string)
alice.request("PUT", "/api/pages", map[string]any{"order": []string{p3, p1, p2}}, 200)
pages := alice.getJSON("/api/pages", 200)
if pages[0]["id"] != p3 || pages[1]["id"] != p1 || pages[2]["id"] != p2 {
t.Fatal("pages reorder not applied:", pages)
}
// DELETE /api/tags/{id} owner success + BookmarkTag cascade.
tag := alice.request("POST", "/api/tags", map[string]any{"name": "DeleteMe"}, 201)["id"].(string)
putBookmark(alice, id, map[string]any{"tagIds": []string{tag}}, 200)
alice.request("DELETE", "/api/tags/"+tag, nil, 200)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); len(v["tags"].([]any)) != 0 {
t.Fatal("tag delete did not cascade bookmark_tags")
}
for _, tg := range alice.getJSON("/api/tags", 200) {
if tg["id"] == tag {
t.Fatal("deleted tag still listed")
}
}
// DELETE /api/signal-sources/{id} owner success.
source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "x"}}, 201)["id"].(string)
alice.request("DELETE", "/api/signal-sources/"+source, nil, 200)
if v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200); len(v["sources"].([]any)) != 0 {
t.Fatal("signal source still present after delete")
}
}
// Signout must revoke every issued cookie JWT, not just clear this browser's
// cookie: a token copied before signout stops working.
func TestSignoutRevokesIssuedTokens(t *testing.T) {
srv := testServer(t)
u := newAPIUser(t, srv, "revoke@computer.test")
creds, _ := json.Marshal(map[string]any{"email": "revoke@computer.test", "password": "coverage-test-password"})
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(creds))
req.Header.Set("Content-Type", "application/json")
res, e := u.c.Do(req)
if e != nil {
t.Fatal(e)
}
res.Body.Close()
var stale string
for _, c := range res.Cookies() {
if c.Name == "computer_session" {
stale = c.Value
}
}
if stale == "" {
t.Fatal("login did not set a session cookie")
}
u.request("POST", "/api/auth/signout", map[string]any{}, 200)
// Replay the pre-signout token directly (no jar): must be rejected.
replay := func(path string) int {
rq, _ := http.NewRequest("GET", srv.URL+path, nil)
rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale})
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
io.Copy(io.Discard, rs.Body)
return rs.StatusCode
}
if code := replay("/api/me"); code != 401 {
t.Fatalf("stale cookie accepted on /api/me: %d", code)
}
if code := replay("/api/bookmarks"); code != 401 {
t.Fatalf("stale cookie accepted on data route: %d", code)
}
rq, _ := http.NewRequest("GET", srv.URL+"/api/auth/session", nil)
rq.AddCookie(&http.Cookie{Name: "computer_session", Value: stale})
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
raw, _ := io.ReadAll(rs.Body)
if !bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
t.Fatalf("stale cookie still resolves a session: %s", raw)
}
}
// One scheduler tick creates signals and marks sources checked (TEST-2).
func TestSignalSchedulerRunOnce(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "scheduler@computer.test")
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
mail := imapserver.New(memory.New())
mail.AllowInsecureAuth = true
go mail.Serve(listener)
defer mail.Close()
host, port, _ := net.SplitHostPort(listener.Addr().String())
pn, _ := strconv.Atoi(port)
alice.request("POST", "/api/settings/imap", map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}, 200)
id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/scheduler", "imapQuery": "little"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201)
signals.RunOnce(context.Background(), d)
v := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) == 0 {
t.Fatal("scheduler tick created no signals")
}
sources := v["sources"].([]any)
if len(sources) != 1 {
t.Fatalf("expected 1 source, got %d", len(sources))
}
src := sources[0].(map[string]any)
if src["lastStatus"] != "Checked" || src["lastCheckedAt"] == nil {
t.Fatal("scheduler did not mark source checked:", src)
}
// A second tick must not duplicate signals (dedup under the scheduler path).
before := len(v["signals"].([]any))
signals.RunOnce(context.Background(), d)
v = alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) != before {
t.Fatal("scheduler tick duplicated signals")
}
}
// Review CR-16: mailbox failures are scheduler failures. A pass whose mailbox
// cannot even load must record the per-source status, answer manual checks
// with the structured outcome (failed count, not a blanket 500), and drive
// the per-user backoff so the next ticks are skipped.
func TestSchedulerBacksOffAfterMailboxFailure(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "backoff@computer.test")
id := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/backoff"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"subject": "anything"}}, 201)
// Deliberately no saved IMAP configuration: every pass fails in LoadMailbox.
v := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["checked"] != float64(1) || v["failed"] != float64(1) {
t.Fatal("manual check did not report the structured failure outcome:", v)
}
src := func() map[string]any {
got := alice.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
return got["sources"].([]any)[0].(map[string]any)
}
if status, _ := src()["lastStatus"].(string); !strings.HasPrefix(status, "Error:") {
t.Fatal("per-source failure status not recorded:", status)
}
s := signals.NewScheduler()
ctx := context.Background()
s.RunOnce(ctx, d) // tick 1: runs and fails → failures=1
s.RunOnce(ctx, d) // tick 2: 2%2==0 → runs and fails → failures=2
after := src()["lastCheckedAt"]
s.RunOnce(ctx, d) // tick 3: 3%4!=0 → backoff must skip it
if got := src()["lastCheckedAt"]; got != after {
t.Fatalf("backoff did not skip tick 3: lastCheckedAt changed %v → %v", after, got)
}
}
// Page saves enforce optimistic concurrency (review DATA-01/DATA-05): a stale
// base version is rejected with 409 and a missing version with 400, both
// leaving the row untouched; content saves bump the revision, and metadata
// patches neither require nor bump it. The list projection omits content
// bodies (review PERF-01).
func TestPageVersionAndListProjection(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pagever@computer.test")
created := alice.request("POST", "/api/pages", map[string]any{"title": "Doc", "content": "first"}, 201)
id := created["id"].(string)
if created["version"].(float64) != 1 {
t.Fatalf("new page did not start at version 1: %v", created["version"])
}
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "stale tab", "version": 99}, 409)
if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "first" {
t.Fatal("rejected stale write still modified content")
}
saved := alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "second", "version": 1}, 200)
if saved["version"].(float64) != 2 {
t.Fatalf("content save did not bump version: %v", saved["version"])
}
// Review DATA-05: omitting the version cannot bypass concurrency — the
// write is rejected and the row is untouched. Metadata patches stay
// version-free (they do not bump the revision either).
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "unversioned clobber"}, 400)
if v := alice.request("GET", "/api/pages/"+id, nil, 200); v["content"] != "second" || v["version"].(float64) != 2 {
t.Fatalf("version-less content write mutated the page: %v", v)
}
pin := alice.request("PATCH", "/api/pages/"+id, map[string]any{"pinned": true}, 200)
if pin["version"].(float64) != 2 {
t.Fatalf("metadata patch changed the revision: %v", pin["version"])
}
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "third", "version": 1}, 409)
alice.request("PATCH", "/api/pages/"+id, map[string]any{"content": "x", "version": -1}, 400)
found := false
for _, p := range alice.getJSON("/api/pages", 200) {
if p["id"] != id {
continue
}
found = true
if _, ok := p["content"]; ok {
t.Fatal("list projection still includes content")
}
if p["title"] != "Doc" || p["_count"] == nil {
t.Fatal("list projection lost metadata:", p)
}
}
if !found {
t.Fatal("page missing from list")
}
}
// Review CR-18: a content-save acknowledgement must describe the revision
// THIS write produced. Concurrent writers racing on the same base: exactly
// one wins and its response reports base+1 (the snapshot is read inside the
// write transaction), every loser gets 409 — no acknowledgement can carry a
// competing writer's committed revision.
func TestConcurrentPageSaveAckReportsOwnWrite(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "pageack@computer.test")
id := alice.request("POST", "/api/pages", map[string]any{"title": "Ack", "content": "v1"}, 201)["id"].(string)
const writers = 8
type outcome struct {
status int
version float64
}
results := make(chan outcome, writers)
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
body, _ := json.Marshal(map[string]any{"content": fmt.Sprintf("writer-%d", n), "version": 1})
rq, _ := http.NewRequest("PATCH", srv.URL+"/api/pages/"+id, bytes.NewReader(body))
rq.Header.Set("Content-Type", "application/json")
rs, e := alice.c.Do(rq)
if e != nil {
results <- outcome{status: -1}
return
}
defer rs.Body.Close()
raw, _ := io.ReadAll(rs.Body)
out := outcome{status: rs.StatusCode}
var v map[string]any
if json.Unmarshal(raw, &v) == nil {
out.version, _ = v["version"].(float64)
}
results <- out
}(i)
}
wg.Wait()
close(results)
wins := 0
for o := range results {
switch o.status {
case 200:
wins++
if o.version != 2 {
t.Fatalf("winning acknowledgement reported version %v, want exactly base+1 = 2", o.version)
}
case 409:
default:
t.Fatalf("concurrent writer got unexpected status %d", o.status)
}
}
if wins != 1 {
t.Fatalf("expected exactly one winning writer, got %d", wins)
}
}
// Review CR-04: bookmark updates enforce optimistic concurrency — a missing
// version is 400, a stale base is 409 with the row untouched, success bumps
// the revision, and tag replacement rides the same contract.
func TestBookmarkVersionContract(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "bmver@computer.test")
created := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://example.org/ver", "title": "v0"}, 201)
id := created["id"].(string)
if created["version"].(float64) != 0 {
t.Fatalf("new bookmark did not start at version 0: %v", created["version"])
}
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "no version"}, 400)
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "stale", "version": 5}, 409)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "v0" || v["version"].(float64) != 0 {
t.Fatalf("rejected writes modified the bookmark: %v", v)
}
saved := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "v1", "version": 0}, 200)
if saved["version"].(float64) != 1 {
t.Fatalf("successful update did not bump the version: %v", saved["version"])
}
// A second writer holding the same base must not both succeed.
alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"title": "late", "version": 0}, 409)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "versioned"}, 201)["id"].(string)
out := alice.request("PUT", "/api/bookmarks/"+id, map[string]any{"tagIds": []string{tag}, "version": 1}, 200)
if out["version"].(float64) != 2 {
t.Fatalf("tag replacement did not advance the version: %v", out["version"])
}
if tags, ok := out["tags"].([]any); !ok || len(tags) != 1 {
t.Fatalf("tag replacement lost tags: %v", out["tags"])
}
}
// Assertions ride the tokenVersion (review SEC-02): "sign out everywhere"
// must revoke NextAuth-backed Bearer assertions, not just Go cookie sessions.
func TestAssertionRevocationFollowsTokenVersion(t *testing.T) {
srv, d := testServerDB(t)
u := newAPIUser(t, srv, "assert-revoke@computer.test")
uid := u.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
var ver int
if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&ver); e != nil {
t.Fatal(e)
}
sign := func(v int) string {
now := time.Now()
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, auth.SessionClaims{
UserID: uid, Type: auth.TokenTypeAssertion, Ver: v,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "computer",
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)),
},
})
s, e := tok.SignedString([]byte("integration-test-secret"))
if e != nil {
t.Fatal(e)
}
return s
}
bearer := func(token string) int {
rq, _ := http.NewRequest("GET", srv.URL+"/api/me", nil)
rq.Header.Set("Authorization", "Bearer "+token)
rs, e := u.c.Do(rq)
if e != nil {
t.Fatal(e)
}
defer rs.Body.Close()
io.Copy(io.Discard, rs.Body)
return rs.StatusCode
}
if code := bearer(sign(ver)); code != 200 {
t.Fatalf("current-version assertion rejected: %d", code)
}
if code := bearer(sign(0)); code != 401 {
t.Fatalf("version-less assertion accepted: %d", code)
}
u.request("POST", "/api/auth/signout", map[string]any{}, 200)
if code := bearer(sign(ver)); code != 401 {
t.Fatalf("assertion minted before the revocation bump still accepted: %d", code)
}
var bumped int
if e := d.QueryRow(`SELECT "tokenVersion" FROM "User" WHERE id=$1`, uid).Scan(&bumped); e != nil {
t.Fatal(e)
}
if code := bearer(sign(bumped)); code != 200 {
t.Fatalf("assertion with the post-bump version rejected: %d", code)
}
}
+220
View File
@@ -0,0 +1,220 @@
package http
import (
"bytes"
"encoding/json"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"testing"
)
// Integration tests create and drop only their own random PostgreSQL schema.
// They never truncate application tables or delete pre-existing accounts.
func testServer(t *testing.T) *httptest.Server {
t.Helper()
srv, _ := testServerDB(t)
return srv
}
func testServerDB(t *testing.T) (*httptest.Server, *db.DB) {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration tests")
}
// The limiter stores are process-global and login has no loopback
// exemption (review SEC-1); give every test a clean slate.
auth.ResetRateLimits()
t.Cleanup(auth.ResetRateLimits)
t.Setenv("AUTH_SECRET", "integration-test-secret")
t.Setenv("NEXTAUTH_SECRET", "integration-test-secret")
t.Setenv("ADMIN_EMAILS", "")
// CardDAV/IMAP fakes listen on loopback; netguard blocks private ranges by
// default (production compose never sets this).
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
// The IMAP fixture is a plaintext loopback server; production refuses
// non-TLS mailboxes (review NET-02) and this flag is the test-only escape
// hatch (see signals.AllowInsecure).
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1")
admin, e := db.New(&config.Config{DatabaseURL: dsn})
if e != nil {
t.Fatal(e)
}
schema := "test_" + db.NewID()
if _, e = admin.Exec(`CREATE SCHEMA ` + db.Quote(schema)); e != nil {
t.Fatal(e)
}
u, e := url.Parse(dsn)
if e != nil {
t.Fatal(e)
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
d, e := db.New(&config.Config{DatabaseURL: u.String()})
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { d.Close(); admin.Exec(`DROP SCHEMA ` + db.Quote(schema) + ` CASCADE`); admin.Close() })
raw, e := os.ReadFile("../../db/schema.sql")
if e != nil {
t.Fatal(e)
}
if _, e = d.Exec(string(raw)); e != nil {
t.Fatal(e)
}
srv := httptest.NewServer(NewServer(config.Load(), d).Handler)
t.Cleanup(srv.Close)
return srv, d
}
type apiClient struct {
t *testing.T
c *http.Client
base string
}
func (a apiClient) request(method, path string, body any, status int) map[string]any {
a.t.Helper()
var b io.Reader
if body != nil {
raw, _ := json.Marshal(body)
b = bytes.NewReader(raw)
}
req, _ := http.NewRequest(method, a.base+path, b)
req.Header.Set("Content-Type", "application/json")
res, e := a.c.Do(req)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("%s %s: wanted %d, got %d: %s", method, path, status, res.StatusCode, raw)
}
var v map[string]any
json.Unmarshal(raw, &v)
return v
}
// meID resolves the authenticated client's user id. Signup responses no
// longer carry it (review CR-03: identical bodies for every accepted
// registration, so no unauthenticated existence oracle); identity comes from
// the session after login.
func meID(c apiClient) string {
c.t.Helper()
return c.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
}
// putBookmark sends PUT /api/bookmarks/{id} with the bookmark's CURRENT
// version attached (review CR-04: the concurrency contract requires it).
func putBookmark(c apiClient, id string, body map[string]any, status int) map[string]any {
c.t.Helper()
cur := c.request("GET", "/api/bookmarks/"+id, nil, 200)
v, _ := cur["version"].(float64)
body["version"] = int(v)
return c.request("PUT", "/api/bookmarks/"+id, body, status)
}
func TestApplicationFlows(t *testing.T) {
srv := testServer(t)
newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} }
alice, bob, anonymous := newClient(), newClient(), newClient()
post := func(email string) map[string]any {
return map[string]any{"email": email, "password": "a-long-test-password"}
}
alice.request("POST", "/api/signup", post("alice@computer.test"), 201)
bob.request("POST", "/api/signup", post("bob@computer.test"), 201)
// Review SEC-4/CR-03: a duplicate signup returns the SAME 201 {"ok":true}
// body as a fresh creation (no 409, no distinguishing field).
dup := alice.request("POST", "/api/signup", post("alice@computer.test"), 201)
if len(dup) != 1 || dup["ok"] != true {
t.Fatalf("duplicate signup body differs from a fresh creation: %v", dup)
}
alice.request("POST", "/api/auth/login", post("alice@computer.test"), 200)
bob.request("POST", "/api/auth/login", post("bob@computer.test"), 200)
au := meID(alice)
bu := meID(bob)
if alice.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("first signup was not admin")
}
anonymous.request("GET", "/api/bookmarks", nil, 401)
bob.request("GET", "/api/admin/users", nil, 403)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Research"}, 201)["id"].(string)
b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/link", "title": "Original", "notes": "# Notes", "tagIds": []string{tag}, "dueDate": "2026-10-07", "alertAt": "2000-01-01"}, 201)
id := b["id"].(string)
bob.request("GET", "/api/bookmarks/"+id, nil, 404)
bob.request("PUT", "/api/bookmarks/"+id, map[string]any{"notes": "attack"}, 404)
putBookmark(alice, id, map[string]any{"dueDate": nil, "isPublic": false}, 200)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "Original" || v["dueDate"] != nil {
t.Fatal("partial/null update broken")
}
putBookmark(alice, id, map[string]any{"tagIds": []string{"missing"}}, 400)
v := alice.request("GET", "/api/bookmarks/"+id, nil, 200)
if len(v["tags"].([]any)) != 1 {
t.Fatal("failed update lost tags")
}
alice.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bu}, "isPublic": true}, 200)
bob.request("GET", "/api/bookmarks/"+id, nil, 200)
anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 200)
bob.request("DELETE", "/api/bookmarks/"+id, nil, 404)
alice.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bu}}, 200)
bob.request("GET", "/api/bookmarks?tagId="+tag, nil, 200)
page := bob.request("POST", "/api/pages", map[string]any{"title": "Workspace", "content": "Markdown text"}, 201)["id"].(string)
bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200)
bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200)
v = bob.request("GET", "/api/pages/"+page, nil, 200)
if len(v["bookmarks"].([]any)) != 1 {
t.Fatal("duplicate page link")
}
bob.request("GET", "/api/pages/"+page+"/markdown", nil, 200)
alice.request("GET", "/api/kanban/columns", nil, 200)
col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Custom"}, 201)["id"].(string)
putBookmark(alice, id, map[string]any{"kanbanColumnId": col}, 200)
v = alice.request("DELETE", "/api/kanban/columns/"+col, nil, 200)
if v["moved"] != float64(1) {
t.Fatal("deleted column did not move card")
}
alice.request("GET", "/api/bookmarks/"+id, nil, 200)
alice.request("PUT", "/api/kanban/columns", map[string]any{"order": []string{"not-mine"}}, 400)
alice.request("GET", "/api/alerts/check", nil, 200)
alice.request("DELETE", "/api/alerts/"+id, nil, 200)
v = alice.request("GET", "/api/bookmarks/"+id, nil, 200)
if v["alertSent"] != true {
t.Fatal("alert dismissal")
}
putBookmark(alice, id, map[string]any{"alertAt": "2000-01-02"}, 200)
source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "sender@computer.test"}}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
alice.request("PATCH", "/api/signal-sources/"+source, map[string]any{"enabled": false}, 200)
bob.request("DELETE", "/api/signal-sources/"+source, nil, 404)
alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": "encrypted-password"}, 200)
v = alice.request("GET", "/api/settings/imap", nil, 200)
if v["password"] != "••••••••" {
t.Fatal("mailbox password exposed")
}
alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": ""}, 200)
alice.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bu}}, 201)
bob.request("GET", "/api/shared-dates", nil, 200)
alice.request("POST", "/api/admin/view-as", map[string]any{"userId": bu}, 200)
v = alice.request("GET", "/api/me", nil, 200)
if v["effectiveUser"].(map[string]any)["id"] != bu || v["user"].(map[string]any)["id"] != au {
t.Fatal("view-as identity")
}
alice.request("GET", "/api/admin/users", nil, 200)
alice.request("GET", "/api/pages/"+page, nil, 200)
alice.request("POST", "/api/admin/view-as", map[string]any{"userId": nil}, 200)
alice.request("PATCH", "/api/admin/users", map[string]any{"userId": au, "role": "USER"}, 400)
alice.request("GET", "/api/auth/oidc/status", nil, 200)
alice.request("GET", "/api/auth/callback/authelia", nil, 501)
bob.request("DELETE", "/api/pages/"+page, nil, 200)
alice.request("DELETE", "/api/bookmarks/"+id, nil, 200)
anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 404)
alice.request("POST", "/api/auth/signout", map[string]any{}, 200)
alice.request("GET", "/api/me", nil, 401)
}
+251
View File
@@ -0,0 +1,251 @@
package http
import (
"context"
"encoding/json"
crypt "github.com/andrew/go-computer/internal/crypto"
"github.com/andrew/go-computer/internal/signals"
"github.com/emersion/go-imap/backend/memory"
imapserver "github.com/emersion/go-imap/server"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
)
func TestExternalIntegrations(t *testing.T) {
srv, d := testServerDB(t)
jar, _ := cookiejar.New(nil)
a := apiClient{t, &http.Client{Jar: jar}, srv.URL}
credentials := map[string]any{"email": "integrations@computer.test", "password": "test-password"}
a.request("POST", "/api/signup", credentials, 201)
a.request("POST", "/api/auth/login", credentials, 200)
// CardDAV fixture (reviews CR-07/CR-08): the served multistatus is
// mutable so one test can walk the reconciliation policy — full snapshot,
// partially-withheld response, remote deletion, empty response.
var davMu sync.Mutex
var davBody string
setBody := func(body string) { davMu.Lock(); davBody = body; davMu.Unlock() }
getBody := func() string { davMu.Lock(); defer davMu.Unlock(); return davBody }
dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "REPORT" || r.Header.Get("Depth") != "1" {
t.Error("invalid CardDAV request")
}
u, p, _ := r.BasicAuth()
if u != "nextcloud-user" || p != "app-password" {
t.Error("CardDAV authentication")
}
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(207)
io.WriteString(w, getBody())
}))
defer dav.Close()
card1 := `<d:response><d:href>/card.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:contact-1
FN:Contact Name
EMAIL:last@computer.test
EMAIL;TYPE=PREF:preferred@computer.test
TEL:1234
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
card2 := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
UID:contact-2
FN:Second Contact
EMAIL:second@computer.test
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
card2Withheld := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>`
wrap := func(responses ...string) string {
return `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">` + strings.Join(responses, "") + `</d:multistatus>`
}
contactsOf := func() []map[string]any {
t.Helper()
res, e := a.c.Get(srv.URL + "/api/contacts")
if e != nil {
t.Fatal(e)
}
defer res.Body.Close()
var contacts []map[string]any
json.NewDecoder(res.Body).Decode(&contacts)
return contacts
}
req := map[string]any{"nextcloudUrl": dav.URL, "username": "nextcloud-user", "password": "app-password"}
// Full snapshot: both cards import; the PREF occurrence wins the repeated
// EMAIL property (review CR-08 — deterministic selection, no data loss).
setBody(wrap(card1, card2))
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) {
t.Fatal("CardDAV sync", v)
}
contacts := contactsOf()
if len(contacts) != 2 {
t.Fatal("CardDAV import count", contacts)
}
for _, c := range contacts {
if c["uid"] == "contact-1" && c["email"] != "preferred@computer.test" {
t.Fatal("repeated EMAIL did not resolve to the PREF value:", c)
}
}
// Idempotent re-sync: nothing removed, nothing duplicated.
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) {
t.Fatal("CardDAV re-sync", v)
}
if len(contactsOf()) != 2 {
t.Fatal("CardDAV re-sync duplicated contacts")
}
// Partially-withheld response (card2's data 404s): must NOT be read as a
// remote deletion (review CR-07 — never delete from a partial snapshot).
setBody(wrap(card1, card2Withheld))
if v := a.request("POST", "/api/contacts/sync", req, 200); v["removed"] != float64(0) {
t.Fatal("partial response triggered reconciliation:", v)
}
if len(contactsOf()) != 2 {
t.Fatal("partial response deleted a contact")
}
// Complete snapshot without card2: the remote deletion is mirrored.
setBody(wrap(card1))
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(1) || v["removed"] != float64(1) {
t.Fatal("CardDAV deletion reconciliation", v)
}
contacts = contactsOf()
if len(contacts) != 1 || contacts[0]["uid"] != "contact-1" {
t.Fatal("wrong contact survived reconciliation:", contacts)
}
// Empty response: the guard preserves everything (a server fault that
// answers 207 with zero cards must not wipe the address book).
setBody(wrap())
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(0) || v["removed"] != float64(0) {
t.Fatal("empty response reconciliation", v)
}
if len(contactsOf()) != 1 {
t.Fatal("empty response deleted contacts")
}
listener, e := net.Listen("tcp", "127.0.0.1:0")
if e != nil {
t.Fatal(e)
}
mail := imapserver.New(memory.New())
mail.AllowInsecureAuth = true
go mail.Serve(listener)
defer mail.Close()
host, port, _ := net.SplitHostPort(listener.Addr().String())
pn, _ := strconv.Atoi(port)
mailbox := map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}
a.request("POST", "/api/imap/test", mailbox, 200)
a.request("POST", "/api/settings/imap", mailbox, 200)
id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "imapQuery": "little"}, 201)["id"].(string)
a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201)
v := a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["created"] != float64(1) {
t.Fatal("IMAP signal creation", v)
}
// Review SYNC-02: the pass persisted a UID checkpoint for the source.
var lastUID int64
if e := d.QueryRow(`SELECT "lastUid" FROM "SignalCheckpoint"`).Scan(&lastUID); e != nil {
t.Fatal("checkpoint not persisted:", e)
}
if lastUID < 1 {
t.Fatal("checkpoint did not advance:", lastUID)
}
v = a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
if v["created"] != float64(0) {
t.Fatal("signal deduplication", v)
}
v = a.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
if len(v["signals"].([]any)) != 1 {
t.Fatal("IMAP envelope fetch")
}
putBookmark(a, id, map[string]any{"imapWatchEnabled": true}, 200)
v = a.request("GET", "/api/imap/check", nil, 200)
if len(v["matches"].([]any)) != 1 {
t.Fatal("legacy mailbox watch")
}
}
// CardDAV transport (review recheck): Basic-auth credentials must never
// travel plaintext. Without the netguard fixture escape hatch, an http
// Nextcloud URL is rejected at the API boundary — no dial, no credentials.
func TestCardDAVRequiresTLSOutsideFixtures(t *testing.T) {
srv := testServer(t)
a := newAPIUser(t, srv, "carddav-tls@computer.test")
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "")
a.request("POST", "/api/contacts/sync", map[string]any{
"nextcloudUrl": "http://nextcloud.example.test", "username": "u", "password": "p",
}, 400)
// https passes the transport gate (the dial itself fails: no such host
// through netguard — the point is the 400 above never happens for TLS).
resp := a.request("POST", "/api/contacts/sync", map[string]any{
"nextcloudUrl": "https://nextcloud.invalid", "username": "u", "password": "p",
}, 400)
if resp == nil {
t.Fatal("expected a connect-level rejection, not a transport-policy one")
}
}
// Review CR-21: credential writes must never restore a stale password. The
// lazy ENCRYPTION_KEY migration re-encrypts with a compare-and-swap against
// the exact ciphertext it read, and a blank/masked settings save no longer
// rewrites the password column at all.
func TestImapCredentialWritesNeverRestoreStalePasswords(t *testing.T) {
srv, d := testServerDB(t)
alice := newAPIUser(t, srv, "imap-stale@computer.test")
t.Setenv("ENCRYPTION_KEY", "dedicated-encryption-key")
uid := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
// Seed a legacy row encrypted under the AUTH secret (pre-ENCRYPTION_KEY),
// exactly the shape the transparent migration expects.
legacy, e := crypt.Encrypt("legacy-password", "integration-test-secret")
if e != nil {
t.Fatal(e)
}
if _, e := d.Exec(`INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES('imap-cfg-1','legacy.example.test',993,true,'user',$1,'INBOX',$2)`, legacy, uid); e != nil {
t.Fatal(e)
}
// Loading migrates: the password decrypts and the row is re-encrypted
// under the dedicated key.
m, e := signals.LoadMailbox(context.Background(), d, uid)
if e != nil || m.Password != "legacy-password" {
t.Fatalf("legacy load: %v %+v", e, m)
}
var stored string
if e := d.QueryRow(`SELECT password FROM "ImapConfig" WHERE "userId"=$1`, uid).Scan(&stored); e != nil {
t.Fatal(e)
}
if p, e := crypt.Decrypt(stored, "dedicated-encryption-key"); e != nil || p != "legacy-password" {
t.Fatalf("row was not migrated under ENCRYPTION_KEY: %v", e)
}
// The CAS guard: an update conditioned on a stale ciphertext touches no
// rows, so a concurrent replacement can never be rolled back by migration.
res, e := d.Exec(`UPDATE "ImapConfig" SET password=$1 WHERE "userId"=$2 AND password=$3`, "stale-rewrite", uid, legacy)
if e != nil {
t.Fatal(e)
}
if n, _ := res.RowsAffected(); n != 0 {
t.Fatal("compare-and-swap against a stale ciphertext updated the row")
}
// A concurrent replacement through the API survives...
alice.request("POST", "/api/settings/imap", map[string]any{"host": "new.example.test", "port": 993, "tls": true, "username": "user", "password": "replacement-password", "folder": "INBOX"}, 200)
// ...and a later masked save (connection settings only) must keep it.
alice.request("POST", "/api/settings/imap", map[string]any{"host": "masked.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 200)
m, e = signals.LoadMailbox(context.Background(), d, uid)
if e != nil {
t.Fatal(e)
}
if m.Password != "replacement-password" || m.Host != "masked.example.test" {
t.Fatalf("masked save clobbered the stored credentials: %+v", m)
}
// A masked save with no stored configuration is a 400, never an empty
// or resurrected password.
bob := newAPIUser(t, srv, "imap-nocfg@computer.test")
bob.request("POST", "/api/settings/imap", map[string]any{"host": "h.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 400)
}
+123
View File
@@ -0,0 +1,123 @@
package http
import "testing"
// Kanban ordering is persisted, not just a client-side illusion: a card move
// normalizes the target column, keeps legacy status-only cards in place, and
// still honors the bookmark concurrency version (review CR-04).
func TestKanbanCardMovePersistsOrdering(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "kanban-order@computer.test")
cols := alice.getJSON("/api/kanban/columns", 200)
if len(cols) < 2 {
t.Fatalf("expected default columns, got %d", len(cols))
}
inbox := cols[0]["id"].(string)
todo := cols[1]["id"].(string)
create := func(title, column string) map[string]any {
return alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/" + title,
"title": title,
"kanbanColumnId": column,
}, 201)
}
first := create("first", inbox)
second := create("second", inbox)
third := create("third", inbox)
// New cards start newest-first (updatedAt desc), so establish the known
// first/second/third order before asserting a reorder. Third stays at its
// original version for the move below.
for _, step := range []struct {
id string
position int
}{{first["id"].(string), 0}, {second["id"].(string), 1}} {
cur := alice.request("GET", "/api/bookmarks/"+step.id, nil, 200)
alice.request("PUT", "/api/kanban/cards/"+step.id, map[string]any{
"columnId": inbox, "position": step.position, "version": cur["version"],
}, 200)
}
// Move the newest card to the top of its own column.
moved := alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{
"columnId": inbox, "position": 0, "version": 0,
}, 200)
if moved["kanbanPosition"] != float64(0) || moved["version"] != float64(1) {
t.Fatalf("move did not persist position/version: %v", moved)
}
if moved["kanbanColumnId"] != inbox {
t.Fatalf("move lost column: %v", moved)
}
board := func(column string) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, row := range alice.getJSON("/api/bookmarks", 200) {
if row["kanbanColumnId"] == column {
out = append(out, row)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j]["kanbanPosition"].(float64) < out[j-1]["kanbanPosition"].(float64); j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
order := board(inbox)
if len(order) != 3 {
t.Fatalf("expected 3 cards, got %d", len(order))
}
for i, want := range []string{third["id"].(string), first["id"].(string), second["id"].(string)} {
if order[i]["id"] != want {
t.Fatalf("position %d: got %v, want %s", i, order[i]["id"], want)
}
if order[i]["kanbanPosition"] != float64(i) {
t.Fatalf("card %s position = %v, want %d", order[i]["id"], order[i]["kanbanPosition"], i)
}
}
// Missing/stale versions are rejected without changing order.
alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{
"columnId": inbox, "position": 0,
}, 400)
alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{
"columnId": inbox, "position": 0, "version": 0,
}, 409)
// Cross-column moves also normalize the target and leave the source intact.
secondNow := alice.request("GET", "/api/bookmarks/"+second["id"].(string), nil, 200)
alice.request("PUT", "/api/kanban/cards/"+second["id"].(string), map[string]any{
"columnId": todo, "position": 0, "version": secondNow["version"],
}, 200)
if got := board(todo); len(got) != 1 || got[0]["id"] != second["id"] {
t.Fatalf("cross-column move failed: %v", got)
}
if got := board(inbox); len(got) != 2 || got[0]["id"] != third["id"] || got[1]["id"] != first["id"] {
t.Fatalf("source column order changed unexpectedly: %v", got)
}
// A legacy status-only card joins the target column's explicit ordering.
legacy := alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/legacy", "title": "legacy", "kanbanStatus": "TODO",
}, 201)
alice.request("PUT", "/api/kanban/cards/"+legacy["id"].(string), map[string]any{
"columnId": todo, "position": 0, "version": 0,
}, 200)
if got := board(todo); len(got) != 2 || got[0]["id"] != legacy["id"] || got[1]["id"] != second["id"] {
t.Fatalf("legacy card did not join explicit ordering: %v", got)
}
if got := alice.request("GET", "/api/bookmarks/"+legacy["id"].(string), nil, 200); got["kanbanColumnId"] != todo {
t.Fatalf("legacy card did not gain explicit membership: %v", got)
}
// Foreign columns are never accepted.
bob := newAPIUser(t, srv, "kanban-other@computer.test")
bobCols := bob.getJSON("/api/kanban/columns", 200)
foreign := bobCols[0]["id"].(string)
firstNow := alice.request("GET", "/api/bookmarks/"+first["id"].(string), nil, 200)
alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{
"columnId": foreign, "position": 0, "version": firstNow["version"],
}, 400)
}
+73
View File
@@ -0,0 +1,73 @@
package http
import (
"net/url"
"strings"
"testing"
)
// Review PERF-2: page search treats % and _ literally and is backed by the
// trigram indexes introduced alongside the escaping change.
func TestPageSearchEscapesWildcards(t *testing.T) {
srv, d := testServerDB(t)
alice, _ := accountClient(t, srv.URL, "pagesearch@computer.test")
other, _ := accountClient(t, srv.URL, "pagesearch-other@computer.test")
alice.request("POST", "/api/pages", map[string]any{"title": "100% done", "content": "literal percent"}, 201)
alice.request("POST", "/api/pages", map[string]any{"title": "100x done", "content": "not a percent"}, 201)
alice.request("POST", "/api/pages", map[string]any{"title": "snake_case", "content": "underscore"}, 201)
alice.request("POST", "/api/pages", map[string]any{"title": "snakeXcase", "content": "not an underscore"}, 201)
other.request("POST", "/api/pages", map[string]any{"title": "100% private", "content": "someone else"}, 201)
search := func(term string) []map[string]any {
return alice.getJSON("/api/pages?search="+url.QueryEscape(term), 200)
}
if got := search("100%"); len(got) != 1 || got[0]["title"] != "100% done" {
t.Fatalf("literal %% search returned %v", got)
}
if got := search("snake_"); len(got) != 1 || got[0]["title"] != "snake_case" {
t.Fatalf("literal _ search returned %v", got)
}
if got := search("100x"); len(got) != 1 || got[0]["title"] != "100x done" {
t.Fatalf("plain substring search returned %v", got)
}
for _, idx := range []string{"Page_title_trgm_idx", "Page_content_trgm_idx"} {
var n int
if e := d.QueryRow(`SELECT count(*) FROM pg_indexes WHERE schemaname=current_schema() AND indexname=$1`, idx).Scan(&n); e != nil || n != 1 {
t.Fatalf("missing page search index %s: n=%d err=%v", idx, n, e)
}
}
}
// Review "cap input sizes": per-field limits reject absurd values with a 400
// instead of persisting them (the 1 MiB body cap alone is too coarse).
func TestTextFieldSizeLimits(t *testing.T) {
srv := testServer(t)
alice, _ := accountClient(t, srv.URL, "limits@computer.test")
const (
urlLimit = 2048
titleLimit = 500
tagNameLimit = 80
contentLimit = 512 << 10
)
alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/" + strings.Repeat("u", urlLimit),
"title": "too long",
}, 400)
alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/ok",
"title": strings.Repeat("t", titleLimit+1),
}, 400)
alice.request("POST", "/api/tags", map[string]any{"name": strings.Repeat("n", tagNameLimit+1)}, 400)
alice.request("POST", "/api/pages", map[string]any{
"title": "huge",
"content": strings.Repeat("x", contentLimit+1),
}, 400)
// The boundary itself remains accepted.
if got := alice.request("POST", "/api/tags", map[string]any{"name": strings.Repeat("n", tagNameLimit)}, 201); got["name"] != strings.Repeat("n", tagNameLimit) {
t.Fatal("boundary-length tag rejected or altered")
}
}
+68
View File
@@ -0,0 +1,68 @@
package http
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// The ledgered migrations (review MIG-01) must stay idempotent: applying
// every file twice in order cannot change the backfill guarantees.
func TestUpgradeBackfillsLegacyWatchesWithoutChangingExistingSources(t *testing.T) {
srv, d := testServerDB(t)
_, uid := accountClient(t, srv.URL, "watcher@computer.test")
if _, err := d.Exec(`INSERT INTO "Bookmark"(id,url,"ownerId","imapWatchEnabled","imapQuery") VALUES('old','https://127.0.0.1',$1,true,'Invoices'),('paused','https://127.0.0.1',$1,true,'Receipts'),('off','https://127.0.0.1',$1,false,'Other')`, uid); err != nil {
t.Fatal(err)
}
if _, err := d.Exec(`INSERT INTO "SignalSource"(id,type,config,enabled,"bookmarkId","ownerId") VALUES('modern','email','{"subject":"Modern"}',true,'old',$1),('existing','email','{"legacy":true,"subject":"Paused"}',false,'paused',$1)`, uid); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir("../../db/migrations")
if err != nil {
t.Fatal(err)
}
names := []string{}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".sql") {
names = append(names, e.Name())
}
}
sort.Strings(names)
if len(names) == 0 {
t.Fatal("no migration files found")
}
for i := 0; i < 2; i++ {
for _, name := range names {
raw, err := os.ReadFile(filepath.Join("../../db/migrations", name))
if err != nil {
t.Fatal(err)
}
if _, err := d.Exec(string(raw)); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
}
var count int
if err := d.QueryRow(`SELECT count(*) FROM "SignalSource"`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 3 {
t.Fatalf("expected existing two sources and one backfill, got %d", count)
}
var enabled bool
var subject string
if err := d.QueryRow(`SELECT enabled,config->>'subject' FROM "SignalSource" WHERE id='legacy-old'`).Scan(&enabled, &subject); err != nil {
t.Fatal(err)
}
if !enabled || subject != "Invoices" {
t.Fatal("legacy switch not migrated")
}
if err := d.QueryRow(`SELECT enabled,config->>'subject' FROM "SignalSource" WHERE id='existing'`).Scan(&enabled, &subject); err != nil {
t.Fatal(err)
}
if enabled || subject != "Paused" {
t.Fatal("existing paused watcher modified")
}
}
+63
View File
@@ -0,0 +1,63 @@
package http
import (
"encoding/json"
"fmt"
"net/url"
"testing"
)
func TestBookmarkPaginationPreservesCollectionAndOwnership(t *testing.T) {
srv, d := testServerDB(t)
owner, uid := accountClient(t, srv.URL, "owner@computer.test")
other, oid := accountClient(t, srv.URL, "other@computer.test")
for _, user := range []string{uid, oid} {
if _, err := d.Exec(`INSERT INTO "Bookmark"(id,url,title,"ownerId","updatedAt") SELECT $1||'-'||lpad(n::text,4,'0'),'https://127.0.0.1/test','Match '||n,$1,'2026-10-07T12:00:00Z' FROM generate_series(1,205) n`, user); err != nil {
t.Fatal(err)
}
}
for _, c := range []apiClient{owner, other} {
seen := map[string]bool{}
cursor := ""
for {
res, err := c.c.Get(srv.URL + "/api/bookmarks?limit=100&search=Match&cursor=" + url.QueryEscape(cursor))
if err != nil {
t.Fatal(err)
}
var records []map[string]any
if err := json.NewDecoder(res.Body).Decode(&records); err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != 200 || len(records) > 100 {
t.Fatal("invalid page", res.StatusCode, len(records))
}
for _, row := range records {
id := row["id"].(string)
if seen[id] {
t.Fatal("duplicate bookmark across pages", id)
}
seen[id] = true
}
cursor = res.Header.Get("X-Next-Cursor")
if cursor == "" {
break
}
}
if len(seen) != 205 {
t.Fatalf("wanted 205 bookmarks, got %d", len(seen))
}
expected := uid
if c.c == other.c {
expected = oid
}
for i := 1; i <= 205; i++ {
if !seen[fmt.Sprintf("%s-%04d", expected, i)] {
t.Fatal("bookmark missing or foreign collection included")
}
}
}
owner.request("GET", "/api/bookmarks?limit=201", nil, 400)
owner.request("GET", "/api/bookmarks?limit=0", nil, 400)
owner.request("GET", "/api/bookmarks?cursor=invalid", nil, 400)
}
+256
View File
@@ -0,0 +1,256 @@
package http
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"github.com/andrew/go-computer/internal/scripts"
"net/http"
"net/http/cookiejar"
"os"
"strconv"
"testing"
"time"
)
func TestConfiguredAdminCannotBeBypassedDuringBootstrap(t *testing.T) {
srv, d := testServerDB(t)
t.Setenv("ADMIN_EMAILS", "operator@computer.test")
visitor, _ := accountClient(t, srv.URL, "visitor@computer.test")
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("public signup bypassed reserved administrator bootstrap")
}
visitor.request("GET", "/api/admin/users", nil, 403)
t.Setenv("SEED_EMAIL", "operator@computer.test")
t.Setenv("SEED_PASSWORD", "operator-test-password")
if err := scripts.Seed(context.Background(), d); err != nil {
t.Fatal(err)
}
visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200)
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("operator-provisioned account is not admin")
}
t.Setenv("SEED_PASSWORD", "different-password")
if err := scripts.Seed(context.Background(), d); err != nil {
t.Fatal(err)
}
visitor.request("POST", "/api/auth/login", map[string]any{"email": "operator@computer.test", "password": "operator-test-password"}, 200)
}
func accountClient(t *testing.T, base, email string) (apiClient, string) {
t.Helper()
jar, _ := cookiejar.New(nil)
c := apiClient{t, &http.Client{Jar: jar}, base}
body := map[string]any{"email": email, "password": "test-long-password"}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
// Review CR-03: the signup body no longer carries the id; identity comes
// from the authenticated session.
return c, meID(c)
}
func TestAdminEnrollmentRequiresTrustedIdentity(t *testing.T) {
srv, d := testServerDB(t)
admin, _ := accountClient(t, srv.URL, "admin@computer.test")
user, uid := accountClient(t, srv.URL, "unverified@computer.test")
t.Setenv("ADMIN_EMAILS", "reserved@computer.test,unverified@computer.test")
// Reserved addresses answer exactly like existing accounts (no 403 oracle).
// Review SEC-4/CR-03: operator-reserved addresses return the SAME uniform
// 201 {"ok":true} body as every other accepted registration — no userId
// field, no 409.
reserved := user.request("POST", "/api/signup", map[string]any{"email": "reserved@computer.test", "password": "test-password"}, 201)
if len(reserved) != 1 || reserved["ok"] != true {
t.Fatalf("operator-reserved address body is distinguishable: %v", reserved)
}
if user.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("unverified address promoted to admin")
}
user.request("GET", "/api/admin/users", nil, 403)
if _, err := d.Exec(`UPDATE "User" SET "emailVerified"=now() WHERE id=$1`, uid); err != nil {
t.Fatal(err)
}
if user.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("verified allowlisted address was not promoted")
}
admin.request("GET", "/api/admin/users", nil, 200)
}
func TestPublicBookmarksDoNotExposePrivateMetadata(t *testing.T) {
srv, d := testServerDB(t)
owner, uid := accountClient(t, srv.URL, "owner@computer.test")
viewer, vid := accountClient(t, srv.URL, "viewer@computer.test")
if _, err := d.Exec(`INSERT INTO "Contact"(id,uid,"displayName",email,phone,"userId") VALUES('contact','vcard','Private','private@computer.test','555-private',$1)`, uid); err != nil {
t.Fatal(err)
}
id := owner.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "isPublic": true, "contactId": "contact", "imapQuery": "private mailbox query", "alertAt": "2026-10-07"}, 201)["id"].(string)
v := viewer.request("GET", "/api/public/bookmarks/"+id, nil, 200)
// Review PRIV-04: public links carry no owner attribution at all; the
// README promises owner identity stays private.
for _, key := range []string{"contact", "contactId", "sharedWith", "owner", "ownerId", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition", "pageLinks"} {
if _, exists := v[key]; exists {
t.Errorf("public response includes %s", key)
}
}
viewer.request("GET", "/api/bookmarks/"+id, nil, 404)
owner.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{vid}}, 200)
// Review PRIV-02: an authenticated shared reader gets the narrowed
// projection — no contact record, no recipient list, no owner operational
// metadata — while the owner keeps the full view.
shared := viewer.request("GET", "/api/bookmarks/"+id, nil, 200)
for _, key := range []string{"contact", "contactId", "sharedWith", "imapQuery", "imapWatchEnabled", "alertAt", "alertSent", "kanbanColumnId", "kanbanPosition"} {
if _, exists := shared[key]; exists {
t.Errorf("shared-reader response includes %s", key)
}
}
if shared["url"] != "https://127.0.0.1/test" || shared["ownerId"] != uid {
t.Error("shared-reader response lost basic bookmark fields")
}
if _, exists := shared["tags"]; !exists {
t.Error("shared-reader response lost tags")
}
full := owner.request("GET", "/api/bookmarks/"+id, nil, 200)
if c, _ := full["contact"].(map[string]any); c == nil || c["email"] != "private@computer.test" {
t.Error("owner projection lost the contact record")
}
if s, ok := full["sharedWith"].([]any); !ok || len(s) != 1 {
t.Error("owner projection lost the recipient list")
}
if _, exists := full["imapQuery"]; !exists {
t.Error("owner projection lost mailbox-watch settings")
}
// The list endpoint must apply the same permission-specific projections.
res, err := viewer.c.Get(srv.URL + "/api/bookmarks?shared=true")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
var list []map[string]any
if err := json.NewDecoder(res.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0]["id"] != id {
t.Fatalf("shared list returned %d entries", len(list))
}
for _, key := range []string{"contact", "sharedWith", "imapQuery", "alertAt"} {
if _, exists := list[0][key]; exists {
t.Errorf("shared list response includes %s", key)
}
}
putBookmark(owner, id, map[string]any{"isPublic": false}, 200)
viewer.request("GET", "/api/public/bookmarks/"+id, nil, 404)
}
func TestAdminBootstrapIsOneShot(t *testing.T) {
srv, d := testServerDB(t)
// Fresh installation: the first signup completes bootstrap as admin.
admin, adminID := accountClient(t, srv.URL, "first@computer.test")
if admin.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("first signup on a fresh installation was not admin")
}
// Review AUTH-06 acceptance: demoting the last administrator must not
// re-open administrator assignment through signup.
if _, err := d.Exec(`UPDATE "User" SET role='USER' WHERE id=$1`, adminID); err != nil {
t.Fatal(err)
}
latecomer, _ := accountClient(t, srv.URL, "late@computer.test")
if latecomer.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("signup re-opened admin bootstrap after the last admin was demoted")
}
// Removing every administrator entirely does not re-open it either.
if _, err := d.Exec(`DELETE FROM "User" WHERE id=$1`, adminID); err != nil {
t.Fatal(err)
}
successor, _ := accountClient(t, srv.URL, "successor@computer.test")
if successor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("signup re-opened admin bootstrap after the last admin was removed")
}
successor.request("GET", "/api/admin/users", nil, 403)
}
// Review SEC-5: with ALLOW_BOOTSTRAP_SIGNUP=0 a fresh installation must not
// hand administrator to the first signup — provisioning stays operator-driven.
func TestBootstrapSignupCanBeDisabled(t *testing.T) {
srv := testServer(t)
t.Setenv("ALLOW_BOOTSTRAP_SIGNUP", "0")
visitor, _ := accountClient(t, srv.URL, "unclaimed@computer.test")
if visitor.request("GET", "/api/me", nil, 200)["isAdmin"] != false {
t.Fatal("first signup became admin despite ALLOW_BOOTSTRAP_SIGNUP=0")
}
visitor.request("GET", "/api/admin/users", nil, 403)
}
func TestDirectLoginRejectsBrowserForgery(t *testing.T) {
srv := testServer(t)
for _, tc := range []struct {
contentType, origin string
status int
}{
{"text/plain", "https://attacker.invalid", 415},
{"application/json", "https://attacker.invalid", 403},
} {
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewBufferString(`{"email":"attacker@computer.test","password":"password"}`))
req.Header.Set("Content-Type", tc.contentType)
req.Header.Set("Origin", tc.origin)
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != tc.status || len(res.Cookies()) != 0 {
t.Fatalf("forged login: status=%d cookies=%v", res.StatusCode, res.Cookies())
}
}
}
// Review SEC-1/SEC-2: the failure lockout keys on the proxy-attested browser
// IP. Two different signed identities get independent lockout buckets; a
// replayed (stale-timestamp) signature is not honored.
func TestLoginLockoutHonorsSignedForwardedIdentity(t *testing.T) {
srv := testServer(t)
secret := os.Getenv("AUTH_SECRET")
sign := func(ip string, ts int64) (string, string, string) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ip + "|" + strconv.FormatInt(ts, 10)))
return ip, strconv.FormatInt(ts, 10), hex.EncodeToString(mac.Sum(nil))
}
post := func(email, password, ip string, ts int64) int {
body, _ := json.Marshal(map[string]any{"email": email, "password": password})
req, _ := http.NewRequest("POST", srv.URL+"/api/auth/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if ip != "" {
hip, hts, sig := sign(ip, ts)
req.Header.Set("X-Computer-Client-Ip", hip)
req.Header.Set("X-Computer-Client-Ts", hts)
req.Header.Set("X-Computer-Client-Sig", sig)
}
res, e := http.DefaultClient.Do(req)
if e != nil {
t.Fatal(e)
}
res.Body.Close()
return res.StatusCode
}
now := time.Now().Unix()
// Browser A: five failures lock out its (IP, email) bucket.
for i := 0; i < 5; i++ { // failureThreshold
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 401 {
t.Fatalf("browser A attempt %d: %d", i+1, code)
}
}
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.1", now); code != 429 {
t.Fatalf("browser A not locked out: %d", code)
}
// Browser B behind the same proxy peer keeps its own buckets.
if code := post("victim-a@computer.test", "wrong-password", "198.51.100.2", now); code != 401 {
t.Fatalf("browser B locked out by browser A's failures: %d", code)
}
// A stale signature (outside the ~60s window) falls back to the peer, so
// it cannot smuggle a fresh identity: these attempts count against the
// peer bucket and stay 401.
if code := post("victim-b@computer.test", "wrong-password", "198.51.100.9", now-3600); code != 401 {
t.Fatalf("stale signature changed behavior: %d", code)
}
}
+181
View File
@@ -0,0 +1,181 @@
package http
import (
"context"
"net/http"
"os"
"time"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/authguard"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"github.com/andrew/go-computer/internal/handlers"
"github.com/andrew/go-computer/internal/oidc"
)
// NewServer builds the HTTP server with all routes registered.
// Porting guide section 4 — every data endpoint resolves the effective user,
// returns 401 if none, checks ownership, returns 404 if not theirs, then acts.
//
// All routes return JSON unless marked .md. Errors are {error: string}.
func NewServer(cfg *config.Config, database *db.DB) *http.Server {
mux := http.NewServeMux()
// Health / readiness. No auth, no DB.
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := database.PingContext(ctx); err != nil {
handlers.WriteJSON(w, 503, handlers.Error{Error: "Database unavailable"})
return
}
handlers.WriteJSON(w, 200, map[string]string{"status": "ready"})
})
// 4.1 Auth and account (rate limited for non-loopback peers)
mux.HandleFunc("POST /api/signup", auth.RateLimitSignup(auth.HandleSignup))
mux.HandleFunc("POST /api/auth/login", auth.RateLimitLogin(auth.HandleLogin))
mux.HandleFunc("GET /api/auth/", auth.HandleAuth) // csrf, callbacks, session, signout
mux.HandleFunc("POST /api/auth/", auth.HandleAuth)
mux.HandleFunc("GET /api/me", authed(auth.HandleMe))
// Authelia OIDC (stub-aware; the client drives the flow via NextAuth)
mux.HandleFunc("GET /api/auth/oidc/status", oidc.HandleStatus)
mux.HandleFunc("GET /api/auth/oidc/login", oidc.HandleLogin)
mux.HandleFunc("GET /api/auth/callback/authelia", oidc.HandleCallback)
mux.HandleFunc("POST /api/auth/callback/authelia", oidc.HandleCallback)
// 4.2 Admin (use real user; 403 unless ADMIN)
mux.HandleFunc("GET /api/admin/users", admin(authguard.HandleAdminListUsers))
mux.HandleFunc("PATCH /api/admin/users", admin(authguard.HandleAdminPatchUser))
mux.HandleFunc("POST /api/admin/view-as", admin(authguard.HandleAdminViewAs))
// 4.3 Bookmarks
mux.HandleFunc("GET /api/bookmarks", authed(handlers.HandleBookmarksList))
mux.HandleFunc("POST /api/bookmarks", authed(handlers.HandleBookmarksCreate))
mux.HandleFunc("GET /api/bookmarks/{id}", authed(handlers.HandleBookmarksGet))
mux.HandleFunc("PUT /api/bookmarks/{id}", authed(handlers.HandleBookmarksUpdate))
mux.HandleFunc("DELETE /api/bookmarks/{id}", authed(handlers.HandleBookmarksDelete))
mux.HandleFunc("POST /api/bookmarks/{id}/share", authed(handlers.HandleBookmarksShare))
mux.HandleFunc("DELETE /api/bookmarks/{id}/share/{userId}", authed(handlers.HandleBookmarkUnshare))
mux.HandleFunc("GET /api/bookmarks/{id}/markdown", authed(handlers.HandleBookmarksMarkdown))
// 4.4 Tags
mux.HandleFunc("GET /api/tags", authed(handlers.HandleTagsList))
mux.HandleFunc("POST /api/tags", authed(handlers.HandleTagsCreate))
mux.HandleFunc("PUT /api/tags/{id}", authed(handlers.HandleTagsUpdate))
mux.HandleFunc("DELETE /api/tags/{id}", authed(handlers.HandleTagsDelete))
mux.HandleFunc("DELETE /api/tags/{id}/share/{userId}", authed(handlers.HandleTagUnshare))
// 4.5 Kanban columns
mux.HandleFunc("GET /api/kanban/columns", authed(handlers.HandleKanbanColumnsList))
mux.HandleFunc("POST /api/kanban/columns", authed(handlers.HandleKanbanColumnsCreate))
mux.HandleFunc("PUT /api/kanban/columns", authed(handlers.HandleKanbanColumnsReorder))
mux.HandleFunc("PATCH /api/kanban/columns/{id}", authed(handlers.HandleKanbanColumnsUpdate))
mux.HandleFunc("DELETE /api/kanban/columns/{id}", authed(handlers.HandleKanbanColumnsDelete))
mux.HandleFunc("PUT /api/kanban/cards/{id}", authed(handlers.HandleKanbanCardsMove))
// 4.6 Pages
mux.HandleFunc("GET /api/pages", authed(handlers.HandlePagesList))
mux.HandleFunc("POST /api/pages", authed(handlers.HandlePagesCreate))
mux.HandleFunc("PUT /api/pages", authed(handlers.HandlePagesReorder))
mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet))
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
// 4.7 Signals
mux.HandleFunc("GET /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsList))
mux.HandleFunc("POST /api/bookmarks/{id}/signals", authed(handlers.HandleSignalsCreate))
mux.HandleFunc("PATCH /api/signal-sources/{id}", authed(handlers.HandleSignalSourcesUpdate))
mux.HandleFunc("DELETE /api/signal-sources/{id}", authed(handlers.HandleSignalSourcesDelete))
// 4.8 Alerts, contacts, email, dates, users
mux.HandleFunc("GET /api/alerts/check", authed(handlers.HandleAlertsCheck))
mux.HandleFunc("DELETE /api/alerts/{bookmarkId}", authed(handlers.HandleAlertsDismiss))
mux.HandleFunc("GET /api/contacts", authed(handlers.HandleContactsList))
mux.HandleFunc("POST /api/contacts/sync", authed(handlers.HandleContactsSync))
mux.HandleFunc("GET /api/settings/imap", authed(handlers.HandleImapGet))
mux.HandleFunc("POST /api/settings/imap", authed(handlers.HandleImapSave))
mux.HandleFunc("POST /api/imap/test", authed(handlers.HandleImapTest))
mux.HandleFunc("GET /api/imap/check", authed(handlers.HandleImapCheck))
mux.HandleFunc("GET /api/shared-dates", authed(handlers.HandleSharedDatesList))
mux.HandleFunc("POST /api/shared-dates", authed(handlers.HandleSharedDatesCreate))
mux.HandleFunc("DELETE /api/shared-dates/{id}", authed(handlers.HandleSharedDateDelete))
mux.HandleFunc("GET /api/users/search", authed(handlers.HandleUsersSearch))
// 4.9 Collections (Notion-like upgrades phase 2). Owner-scoped in this
// phase; membership never broadens bookmark access (N-08).
mux.HandleFunc("GET /api/collections", authed(handlers.HandleCollectionsList))
mux.HandleFunc("POST /api/collections", authed(handlers.HandleCollectionsCreate))
mux.HandleFunc("GET /api/collections/{id}", authed(handlers.HandleCollectionsGet))
mux.HandleFunc("PATCH /api/collections/{id}", authed(handlers.HandleCollectionsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}", authed(handlers.HandleCollectionsDelete))
mux.HandleFunc("POST /api/collections/{id}/properties", authed(handlers.HandleCollectionPropertiesCreate))
mux.HandleFunc("PATCH /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/properties/{propertyId}", authed(handlers.HandleCollectionPropertiesDelete))
mux.HandleFunc("GET /api/collections/{id}/items", authed(handlers.HandleCollectionItemsList))
mux.HandleFunc("POST /api/collections/{id}/items", authed(handlers.HandleCollectionItemsCreate))
mux.HandleFunc("PUT /api/collections/{id}/items", authed(handlers.HandleCollectionItemsReorder))
mux.HandleFunc("PATCH /api/collections/{id}/items/{itemId}", authed(handlers.HandleCollectionItemsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/items/{itemId}", authed(handlers.HandleCollectionItemsDelete))
mux.HandleFunc("POST /api/collections/{id}/views", authed(handlers.HandleCollectionViewsCreate))
mux.HandleFunc("PATCH /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsUpdate))
mux.HandleFunc("DELETE /api/collections/{id}/views/{viewId}", authed(handlers.HandleCollectionViewsDelete))
// 4.10 Public page (no login)
// Review SEC-10: public endpoints get per-IP rate limiting (no auth
// context, so no per-user limit). 30 req/min for public bookmark lookup,
// 20 req/min for preview (heavier outbound work).
publicBookmarkLimiter := auth.NewLimiterStore()
previewLimiter := auth.NewLimiterStore()
publicLimit := auth.Limiter(publicBookmarkLimiter, 30, time.Minute)
previewLimit := auth.Limiter(previewLimiter, 20, time.Minute)
mux.HandleFunc("GET /share/bookmark/{id}", publicLimit(handlers.HandlePublicBookmark))
mux.HandleFunc("GET /api/public/bookmarks/{id}", publicLimit(handlers.HandlePublicBookmark))
mux.HandleFunc("GET /api/preview", previewLimit(authed(handlers.HandlePreview)))
return &http.Server{
Handler: withDB(database, mux),
Addr: ":" + port(),
ReadTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 60 * time.Second,
}
}
// withDB carries the database into request context so handlers can pull it via
// db.FromContext. This keeps the DB out of global state (testable, swappable).
func withDB(database *db.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := db.NewContext(r.Context(), database)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// authed wraps a data route with session authentication (401 if no valid
// session). All data endpoints follow: resolve effective user -> 401 ->
// ownership -> 404 -> act (porting guide 5.1).
func authed(fn http.HandlerFunc) http.HandlerFunc {
return auth.AuthMiddleware(http.HandlerFunc(fn)).ServeHTTP
}
// admin wraps a route with auth + admin-only (403 unless ADMIN).
// AuthMiddleware runs first (attaches the session), then RequireAdmin checks it.
func admin(fn http.HandlerFunc) http.HandlerFunc {
return auth.AuthMiddleware(auth.RequireAdmin(http.HandlerFunc(fn))).ServeHTTP
}
// port returns the listen port from PORT env, defaulting to 8080.
func port() string {
if p := os.Getenv("PORT"); p != "" {
return p
}
return "8080"
}
+68
View File
@@ -0,0 +1,68 @@
package http
import (
"encoding/json"
"net/http"
"net/http/cookiejar"
"testing"
)
func TestRevocationAndForeignRelations(t *testing.T) {
srv := testServer(t)
newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} }
a, b := newClient(), newClient()
account := func(c apiClient, email string) string {
body := map[string]any{"email": email, "password": "test-password"}
c.request("POST", "/api/signup", body, 201)
c.request("POST", "/api/auth/login", body, 200)
// Review CR-03: the id comes from the authenticated session, not the
// uniform signup body.
return meID(c)
}
account(a, "owner@computer.test")
bid := account(b, "recipient@computer.test")
tag := a.request("POST", "/api/tags", map[string]any{"name": "Shared"}, 201)["id"].(string)
id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "tagIds": []string{tag}, "dueDate": "2026-10-07"}, 201)["id"].(string)
a.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bid}}, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 200)
b.request("DELETE", "/api/bookmarks/"+id+"/share/"+bid, nil, 404)
a.request("DELETE", "/api/bookmarks/"+id+"/share/"+bid, nil, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 404)
a.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bid}}, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 200)
a.request("DELETE", "/api/tags/"+tag+"/share/"+bid, nil, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 404)
// Unsharing a tag must remove every discovery surface, not just the
// detail path (review recheck): list and search share the single
// BookmarkVisible predicate, so prove it holds end-to-end.
for _, q := range []string{"", "?search=127.0.0.1"} {
var list []map[string]any
res, e := b.c.Get(srv.URL + "/api/bookmarks" + q)
if e != nil {
t.Fatal(e)
}
json.NewDecoder(res.Body).Decode(&list)
res.Body.Close()
for _, item := range list {
if item["id"] == id {
t.Fatalf("revoked bookmark still visible in list %q", q)
}
}
}
a.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bid}}, 201)
response, e := a.c.Get(srv.URL + "/api/shared-dates")
if e != nil {
t.Fatal(e)
}
var dates []map[string]any
if e = json.NewDecoder(response.Body).Decode(&dates); e != nil {
t.Fatal(e)
}
response.Body.Close()
b.request("GET", "/api/bookmarks/"+id, nil, 200)
a.request("DELETE", "/api/shared-dates/"+dates[0]["id"].(string), nil, 200)
b.request("GET", "/api/bookmarks/"+id, nil, 404)
foreign := b.request("POST", "/api/kanban/columns", map[string]any{"label": "Private"}, 201)["id"].(string)
putBookmark(a, id, map[string]any{"kanbanColumnId": foreign}, 400)
putBookmark(a, id, map[string]any{"dueDate": map[string]any{}}, 400)
}