release gate / check (push) Failing after 6m36s
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.
282 lines
14 KiB
Go
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")
|
|
}
|
|
}
|