Files
brain/internal/http/trust_test.go
T
Andrew S Erwin 5eab58f99c
release gate / check (push) Failing after 6m36s
Add trust features: history, trash, export, and collection sharing
Implement Notion-like upgrade phase 4: append-only page revisions with restore-as-a-new-version; recoverable bookmark/page/collection deletions whose restores never resurrect public flags or shares; owner-scoped JSON export; and explicit, revocable read-only collection sharing with owner-only mutations and no transitive third-party bookmark leaks. Add migration 009, API/docs, integration coverage, and page/collections/trash UI.
2026-10-19 23:07:38 -05:00

282 lines
14 KiB
Go

package http
import (
"testing"
)
// Trust phase: page revision history, recoverable deletions that never
// resurrect access grants, structured export, and explicit collection sharing.
func TestPageRevisionHistory(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-history@computer.test")
bob := newAPIUser(t, srv, "trust-history-other@computer.test")
page := alice.request("POST", "/api/pages", map[string]any{"title": "History", "content": "version one"}, 201)["id"].(string)
revisions := alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revisions) != 1 || revisions[0]["version"].(float64) != 1 || revisions[0]["content"] != nil {
t.Fatalf("initial revision metadata: %v", revisions)
}
first := revisions[0]["id"].(string)
body := alice.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 200)
if body["content"] != "version one" {
t.Fatalf("revision body: %v", body)
}
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "version two", "version": 1}, 200)
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revisions) != 2 || revisions[0]["version"].(float64) != 2 {
t.Fatalf("history did not append: %v", revisions)
}
// Restore writes a NEW version rather than rewriting history.
restored := alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 200)
if restored["content"] != "version one" || restored["version"].(float64) != 3 {
t.Fatalf("restore did not create a new version: %v", restored)
}
revisions = alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revisions) != 3 || revisions[0]["version"].(float64) != 3 {
t.Fatalf("restore was not recorded as history: %v", revisions)
}
if got := alice.request("GET", "/api/pages/"+page+"/revisions/"+revisions[0]["id"].(string), nil, 200); got["content"] != "version one" {
t.Fatalf("restored revision body: %v", got)
}
// A stale base is a 409, not a silent overwrite.
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 2}, 409)
alice.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{}, 400)
bob.request("GET", "/api/pages/"+page+"/revisions", nil, 404)
bob.request("GET", "/api/pages/"+page+"/revisions/"+first, nil, 404)
bob.request("POST", "/api/pages/"+page+"/revisions/"+first+"/restore", map[string]any{"version": 3}, 404)
}
func TestTrashRestoreNeverResurrectsGrants(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-trash@computer.test")
bob := newAPIUser(t, srv, "trust-trash-other@computer.test")
bobID := meID(bob)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Restored tag"}, 201)["id"].(string)
page := alice.request("POST", "/api/pages", map[string]any{"title": "Host page"}, 201)["id"].(string)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Restored project"}, 201)
cid := col["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
bm := alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://127.0.0.1/restore-me", "title": "Restore me", "tagIds": []string{tag}, "isPublic": true,
}, 201)
bid := bm["id"].(string)
alice.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": bid}, 200)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "kept"}}, 201)
alice.request("POST", "/api/bookmarks/"+bid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 200)
alice.request("DELETE", "/api/bookmarks/"+bid, nil, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
trash := alice.getJSON("/api/trash", 200)
if len(trash) != 1 || trash[0]["type"] != "bookmark" || trash[0]["title"] != "Restore me" {
t.Fatalf("bookmark trash entry: %v", trash)
}
entry := trash[0]["id"].(string)
bob.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
restored := alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 200)
if restored["ok"] != true || restored["id"] != bid {
t.Fatalf("restore response: %v", restored)
}
// Intrinsic links return; access grants do not.
got := alice.request("GET", "/api/bookmarks/"+bid, nil, 200)
if got["title"] != "Restore me" || len(got["tags"].([]any)) != 1 || got["isPublic"] != false {
t.Fatalf("restored bookmark: %v", got)
}
if len(got["pageLinks"].([]any)) != 1 {
t.Fatalf("page link was not restored: %v", got["pageLinks"])
}
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
alice.request("GET", "/api/public/bookmarks/"+bid, nil, 404)
items := alice.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "kept" {
t.Fatalf("collection membership was not restored: %v", items)
}
if trash := alice.getJSON("/api/trash", 200); len(trash) != 0 {
t.Fatalf("restored entry stayed in trash: %v", trash)
}
// Pages restore with their history, and the restore is itself a revision.
alice.request("PATCH", "/api/pages/"+page, map[string]any{"content": "page body", "version": 1}, 200)
alice.request("DELETE", "/api/pages/"+page, nil, 200)
alice.request("GET", "/api/pages/"+page, nil, 404)
trash = alice.getJSON("/api/trash", 200)
var pageEntry string
for _, row := range trash {
if row["type"] == "page" {
pageEntry = row["id"].(string)
}
}
if pageEntry == "" {
t.Fatalf("page missing from trash: %v", trash)
}
alice.request("POST", "/api/trash/"+pageEntry+"/restore", map[string]any{}, 200)
restoredPage := alice.request("GET", "/api/pages/"+page, nil, 200)
if restoredPage["content"] != "page body" || len(restoredPage["bookmarks"].([]any)) != 1 {
t.Fatalf("restored page: %v", restoredPage)
}
revs := alice.getJSON("/api/pages/"+page+"/revisions", 200)
if len(revs) < 3 {
t.Fatalf("page history was not restored/extended: %v", revs)
}
if revs[0]["version"].(float64) != restoredPage["version"].(float64) {
t.Fatalf("restore revision is not the current version: revs=%v page=%v", revs[0], restoredPage["version"])
}
// Collections restore structure and items; prior shares do not come back.
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/collections/"+cid, nil, 200)
alice.request("DELETE", "/api/collections/"+cid, nil, 200)
bob.request("GET", "/api/collections/"+cid, nil, 404)
trash = alice.getJSON("/api/trash", 200)
var collectionEntry string
for _, row := range trash {
if row["type"] == "collection" {
collectionEntry = row["id"].(string)
}
}
if collectionEntry == "" {
t.Fatalf("collection missing from trash: %v", trash)
}
alice.request("POST", "/api/trash/"+collectionEntry+"/restore", map[string]any{}, 200)
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
if detail["name"] != "Restored project" || len(detail["properties"].([]any)) != 1 {
t.Fatalf("restored collection: %v", detail)
}
if items := alice.getJSON("/api/collections/"+cid+"/items", 200); len(items) != 1 {
t.Fatalf("restored collection items: %v", items)
}
bob.request("GET", "/api/collections/"+cid, nil, 404)
// Permanent purge is explicit and owner-scoped.
alice.request("POST", "/api/trash/"+entry+"/restore", map[string]any{}, 404)
}
func TestCollectionSharingPermissionBoundary(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-coll-share@computer.test")
bob := newAPIUser(t, srv, "trust-coll-recipient@computer.test")
carol := newAPIUser(t, srv, "trust-coll-third@computer.test")
bobID, carolID := meID(bob), meID(carol)
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/collection-share", "title": "Shared via project", "notes": "secret owner notes", "isPublic": true}, 201)
bid := bm["id"].(string)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Team project"}, 201)
cid := col["id"].(string)
prop := alice.request("POST", "/api/collections/"+cid+"/properties", map[string]any{"name": "Status", "type": "TEXT"}, 201)["id"].(string)
alice.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid, "propertyValues": map[string]any{prop: "Doing"}}, 201)
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
// Owner sees the disclosure.
detail := alice.request("GET", "/api/collections/"+cid, nil, 200)
if detail["shared"] != false || len(detail["sharedWith"].([]any)) != 1 {
t.Fatalf("owner share disclosure: %v", detail)
}
// Recipient sees the collection, its items, and the narrowed bookmark
// projection — never the owner's contact/share/recipient metadata.
listed := bob.getJSON("/api/collections", 200)
found := false
for _, c := range listed {
if c["id"] == cid {
found = c["shared"] == true
}
}
if !found {
t.Fatalf("shared collection missing from recipient list: %v", listed)
}
bobDetail := bob.request("GET", "/api/collections/"+cid, nil, 200)
if bobDetail["shared"] != true || len(bobDetail["sharedWith"].([]any)) != 0 {
t.Fatalf("recipient detail disclosure: %v", bobDetail)
}
items := bob.getJSON("/api/collections/"+cid+"/items", 200)
if len(items) != 1 || items[0]["propertyValues"].(map[string]any)[prop] != "Doing" {
t.Fatalf("recipient items: %v", items)
}
sharedBookmark := bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
if sharedBookmark["title"] != "Shared via project" || sharedBookmark["notes"] != "secret owner notes" {
t.Fatalf("recipient bookmark projection: %v", sharedBookmark)
}
if _, leaked := sharedBookmark["sharedWith"]; leaked {
t.Fatalf("recipient saw the owner's recipient list: %v", sharedBookmark)
}
// Read-only: mutations remain owner-only, including membership changes.
bob.request("PATCH", "/api/collections/"+cid, map[string]any{"version": 1, "name": "hijack"}, 404)
bob.request("DELETE", "/api/collections/"+cid, nil, 404)
bob.request("POST", "/api/collections/"+cid+"/items", map[string]any{"bookmarkId": bid}, 404)
bob.request("PATCH", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), map[string]any{"version": 1, "propertyValues": map[string]any{prop: "Hijacked"}}, 404)
bob.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{carolID}}, 404)
bob.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 404)
// Revocation removes collection and bookmark access immediately.
alice.request("DELETE", "/api/collections/"+cid+"/share/"+bobID, nil, 200)
bob.request("GET", "/api/collections/"+cid, nil, 404)
bob.request("GET", "/api/collections/"+cid+"/items", nil, 404)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
// Removing an item also removes the implicit bookmark grant.
alice.request("POST", "/api/collections/"+cid+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 200)
alice.request("DELETE", "/api/collections/"+cid+"/items/"+items[0]["id"].(string), nil, 200)
bob.request("GET", "/api/bookmarks/"+bid, nil, 404)
// Transitive leaks are refused: Carol can see Alice's bookmark only because
// Alice shared it with Carol. Carol's collection share must not pass that
// grant on to Bob.
thirdParty := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/third-party", "title": "Alice's private link"}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+thirdParty+"/share", map[string]any{"userIds": []string{carolID}}, 200)
carol.request("GET", "/api/bookmarks/"+thirdParty, nil, 200)
carolCol := carol.request("POST", "/api/collections", map[string]any{"name": "Carol's project"}, 201)["id"].(string)
carol.request("POST", "/api/collections/"+carolCol+"/items", map[string]any{"bookmarkId": thirdParty}, 201)
carol.request("POST", "/api/collections/"+carolCol+"/share", map[string]any{"userIds": []string{bobID}}, 200)
bob.request("GET", "/api/collections/"+carolCol, nil, 200)
if leakedItems := bob.getJSON("/api/collections/"+carolCol+"/items", 200); len(leakedItems) != 0 {
t.Fatalf("collection share leaked a third-party bookmark: %v", leakedItems)
}
bob.request("GET", "/api/bookmarks/"+thirdParty, nil, 404)
}
func TestStructuredExport(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "trust-export@computer.test")
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Export tag"}, 201)["id"].(string)
bm := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/export", "title": "Exported bookmark", "tagIds": []string{tag}}, 201)["id"].(string)
page := alice.request("POST", "/api/pages", map[string]any{"title": "Exported page", "content": "exported body"}, 201)["id"].(string)
col := alice.request("POST", "/api/collections", map[string]any{"name": "Exported collection"}, 201)["id"].(string)
alice.request("POST", "/api/collections/"+col+"/items", map[string]any{"bookmarkId": bm}, 201)
v := alice.request("GET", "/api/export", nil, 200)
if v["format"] != "computer-export" || v["version"].(float64) != 1 {
t.Fatalf("export envelope: %v", v)
}
if len(v["bookmarks"].([]any)) != 1 || len(v["pages"].([]any)) != 1 || len(v["tags"].([]any)) != 1 || len(v["collections"].([]any)) != 1 {
t.Fatalf("export sections: %v", v)
}
exPage := v["pages"].([]any)[0].(map[string]any)
if exPage["content"] != "exported body" || len(exPage["revisions"].([]any)) == 0 {
t.Fatalf("export page content/history: %v", exPage)
}
exCol := v["collections"].([]any)[0].(map[string]any)
if len(exCol["items"].([]any)) != 1 {
t.Fatalf("export collection items: %v", exCol)
}
if _, ok := v["user"].(map[string]any)["id"]; !ok {
t.Fatalf("export user identity: %v", v["user"])
}
if page == "" {
t.Fatal("page id missing")
}
}