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") } }