This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user