package platform import ( "bytes" "context" "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "gitea.entroia.com/Zayden-Jung/foundry-core/internal/auth" "image" "image/png" "io" "mime/multipart" "net/http" "net/http/httptest" "os" "regexp" "strconv" "strings" "sync" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" ) func TestOnboardingAndSharing(t *testing.T) { dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL") if dsn == "" { t.Skip("requires an isolated integration database") } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } defer db.Close() ctx := context.Background() var raw [12]byte rand.Read(raw[:]) project := "test-" + hex.EncodeToString(raw[:]) accounts := []string{} tokens := []string{} for range 3 { var id string err = db.QueryRowContext(ctx, "INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text", project).Scan(&id) if err != nil { t.Fatal(err) } accounts = append(accounts, id) var value [32]byte rand.Read(value[:]) token := base64.RawURLEncoding.EncodeToString(value[:]) hash := sha256.Sum256([]byte(token)) _, err = db.ExecContext(ctx, "INSERT INTO project_sessions VALUES($1,$2,$3,now()+interval '1 hour')", base64.RawURLEncoding.EncodeToString(hash[:]), project, id) if err != nil { t.Fatal(err) } tokens = append(tokens, token) } defer func() { _, _ = db.ExecContext(ctx, "DELETE FROM canvas_asset_bindings WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM canvas_assets WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM canvas_transactions WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM canvas_subgraphs WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM workspaces WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM project_accounts WHERE project=$1", project) _, _ = db.ExecContext(ctx, "DELETE FROM email_outbox WHERE project=$1", project) }() callbacks := map[string]string{project: "https://app.example.com/auth/callback"} mux := http.NewServeMux() authHandler, err := auth.New(db, callbacks, "https://api.example.com/auth/google/callback", "fixture", "fixture", &auth.EmailConfig{}, nil) if err != nil { t.Fatal(err) } mux.Handle("/", authHandler) Register(mux, db, callbacks, "https://api.example.com", map[string]OnboardingConfig{ project: {RequireWorkspace: true, Questions: []Question{{Key: "purpose", Required: true}}}, }) call := func(method, path, token string, body any, status int, versions ...string) response { t.Helper() encoded, err := json.Marshal(body) if err != nil { t.Fatal(err) } r := httptest.NewRequest(method, path, bytes.NewReader(encoded)) if token != "" { r.Header.Set("Authorization", "Bearer "+token) } if len(versions) > 0 { r.Header.Set("If-Match", versions[0]) } if len(versions) > 1 { r.Header.Set("Accept", versions[1]) } if len(versions) > 2 { r.Header.Set("Idempotency-Key", versions[2]) } w := httptest.NewRecorder() mux.ServeHTTP(w, r) if w.Code != status { t.Fatalf("%s %s got %d %s want %d", method, path, w.Code, w.Body.String(), status) } out := response{} if w.Body.Len() > 0 && w.Code != 302 { if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { t.Fatal(err) } } return out } base := "/projects/" + project call("GET", base+"/workspaces", "", nil, 401) call("POST", base+"/onboarding", tokens[0], response{"answers": response{}}, 400) first := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 201) workspace := first["result"].(map[string]any)["workspace_id"].(string) retry := call("POST", base+"/onboarding", tokens[0], response{"team_name": "Team", "answers": response{"purpose": "Research"}, "generate_invite": true}, 200) if retry["result"].(map[string]any)["workspace_id"] != workspace { t.Fatal("onboarding created a second workspace") } workspacePath := base + "/workspaces/" + workspace share := call("POST", workspacePath+"/shares", tokens[0], response{"role": "viewer", "max_uses": 1}, 201) token := share["token"].(string) call("GET", "/s/"+token, "", nil, 302) clicks := call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[0], nil, 200) if len(clicks["items"].([]any)) != 1 { t.Fatal("click event API did not return the redirect") } call("GET", workspacePath+"/shares/"+share["id"].(string)+"/clicks", tokens[2], nil, 404) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[1], response{"token": token}, 200) call("POST", base+"/shares/redeem", tokens[2], response{"token": token}, 410) call("PATCH", workspacePath, tokens[1], response{"name": "Forbidden"}, 403) call("GET", workspacePath, tokens[2], nil, 404) call("PATCH", workspacePath+"/members/"+accounts[0], tokens[0], response{"role": "viewer"}, 409) call("DELETE", workspacePath+"/shares/"+share["id"].(string), tokens[0], nil, 204) call("GET", "/s/"+token, "", nil, 404) var material string err = db.QueryRowContext(ctx, "INSERT INTO materials(project,workspace_id,kind,title,created_by) VALUES($1,$2,'article','Shared',$3) RETURNING id::text", project, workspace, accounts[0]).Scan(&material) if err != nil { t.Fatal(err) } contentShare := call("POST", workspacePath+"/shares", tokens[0], response{"role": "editor", "material_id": material}, 201) call("POST", base+"/shares/redeem", tokens[2], response{"token": contentShare["token"]}, 200) call("GET", workspacePath, tokens[2], nil, 404) articleContent := response{"schema_version": 1, "article": response{"sections": []any{response{ "id": "chapter-1", "title": "Chapter", "description": "Chapter summary", "paragraphs": []any{response{"id": "p1", "text": "Paragraph", "description": "Paragraph summary"}}, }}}} created := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Article", "content": articleContent}, 201) articleID := created["id"].(string) text := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "text", "title": "Alpha", "content": response{"text": "Original"}}, 201) textPath := workspacePath + "/materials/" + text["id"].(string) call("GET", textPath, tokens[2], nil, 404) call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) call("GET", textPath, tokens[2], nil, 200) changed := response{"title": "Beta", "content": response{"text": "Changed"}} call("PATCH", textPath, tokens[2], changed, 403, `"1"`) call("POST", textPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "editor"}, 204) call("PATCH", textPath, tokens[2], changed, 428) call("PATCH", textPath, tokens[2], changed, 204, `"1"`) call("PATCH", textPath, tokens[0], changed, 412, `"1"`) call("POST", workspacePath+"/materials", tokens[0], response{"kind": "article", "title": "Invalid", "content": response{"markdown": "raw markdown"}}, 400) tag := call("POST", workspacePath+"/tags", tokens[0], response{"name": "Research", "color": "#123456"}, 201) call("GET", workspacePath+"/tags", tokens[2], nil, 404) call("POST", textPath+"/tags", tokens[2], response{"tags": []string{tag["id"].(string)}}, 204, `"2"`) call("POST", textPath+"/open", tokens[2], nil, 204) filtered := call("GET", workspacePath+"/materials?sort=opened&tag="+tag["id"].(string), tokens[2], nil, 200) if len(filtered["items"].([]any)) != 1 { t.Fatal("tag-filtered material list leaked inaccessible materials") } board := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Board", "content": response{}}, 201) boardPath := workspacePath + "/materials/" + board["id"].(string) canvasPath := boardPath + "/canvas" empty := call("GET", canvasPath, tokens[0], nil, 200) if empty["version"] != float64(1) { t.Fatal("old board not initialized at version 1") } snapshot := response{"document": json.RawMessage(canvasFixture)} call("PATCH", canvasPath, "", snapshot, 401, `"1"`) call("PATCH", canvasPath, tokens[1], snapshot, 403, `"1"`) call("PATCH", canvasPath, tokens[2], snapshot, 404, `"1"`) call("PATCH", canvasPath, tokens[0], snapshot, 428) call("PATCH", canvasPath, tokens[0], snapshot, 200, `"1"`) call("PATCH", canvasPath, tokens[0], snapshot, 412, `"1"`) saved := call("GET", canvasPath, tokens[0], nil, 200) expected, _ := normalizeCanvas(json.RawMessage(canvasFixture)) expectedJSON, _ := json.Marshal(json.RawMessage(expected)) actualJSON, _ := json.Marshal(saved["document"]) if !bytes.Equal(expectedJSON, actualJSON) { t.Fatal("snapshot failed round trip") } call("PATCH", canvasPath, tokens[0], response{"document": response{}}, 400, `"2"`) call("PATCH", boardPath, tokens[0], response{"title": "Renamed", "slug": "my-canvas", "content": response{}}, 204, `"1"`) if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(2) { t.Fatal("rename changed canvas version") } slugPath := workspacePath + "/materials/my-canvas" if call("GET", slugPath, tokens[0], nil, 200)["title"] != "Renamed" { t.Fatal("slug lookup failed") } // A PNG at the local upload limit must survive JSON and database storage. var imageDoc map[string]any json.Unmarshal([]byte(canvasFixture), &imageDoc) imageBytes := make([]byte, 2<<20) copy(imageBytes, []byte{137, 80, 78, 71, 13, 10, 26, 10}) data := imageDoc["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any) data["kind"] = "image" data["src"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(imageBytes) call("PATCH", canvasPath, tokens[0], response{"document": imageDoc}, 200, `"2"`) restored := call("GET", canvasPath, tokens[0], nil, 200) if restored["document"].(map[string]any)["nodes"].([]any)[0].(map[string]any)["data"].(map[string]any)["src"] != data["src"] { t.Fatal("large image changed") } call("PATCH", canvasPath, tokens[0], response{"document": strings.Repeat("x", canvasLimit)}, 413, `"3"`) if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(3) { t.Fatal("rejected snapshot changed version") } // Restore the material version expected by the pre-existing layout checks below. // Exercise two independent HTTP transactions against the same expected version. encodedSnapshot, _ := json.Marshal(snapshot) statuses := make(chan int, 2) start := make(chan struct{}) for range 2 { go func() { <-start request := httptest.NewRequest("PATCH", canvasPath, bytes.NewReader(encodedSnapshot)) request.Header.Set("Authorization", "Bearer "+tokens[0]) request.Header.Set("If-Match", `"3"`) recorder := httptest.NewRecorder() mux.ServeHTTP(recorder, request) statuses <- recorder.Code }() } close(start) one, two := <-statuses, <-statuses if !((one == 200 && two == 412) || (one == 412 && two == 200)) { t.Fatalf("concurrent writes: %d %d", one, two) } if call("GET", boardPath, tokens[0], nil, 200)["title"] != "Renamed" { t.Fatal("canvas save changed title") } call("PATCH", workspacePath, tokens[0], response{"name": "Team", "slug": "research-team"}, 204) call("GET", base+"/workspaces/research-team/materials/my-canvas/canvas", tokens[0], nil, 200) call("PATCH", workspacePath, tokens[1], response{"name": "Team", "slug": "stolen"}, 403) call("POST", base+"/workspaces", tokens[0], response{"name": "Duplicate", "slug": "research-team"}, 409) call("PATCH", workspacePath, tokens[0], response{"name": "Renamed Team"}, 204) if call("GET", workspacePath, tokens[0], nil, 200)["slug"] != "research-team" { t.Fatal("rename changed slug") } // Frames round-trip through PostgreSQL with all label modes and explicit memberships. for index, mode := range []string{"hidden", "fixed", "scaled"} { version := strconv.Itoa(4 + index) raw := strings.Replace(frameFixture, `"labelMode":"fixed"`, `"labelMode":"`+mode+`"`, 1) call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(raw)}, 200, `"`+version+`"`) persisted := call("GET", canvasPath, tokens[0], nil, 200) normalized, _ := normalizeCanvas(json.RawMessage(raw)) want, _ := json.Marshal(json.RawMessage(normalized)) got, _ := json.Marshal(persisted["document"]) if !bytes.Equal(want, got) { t.Fatal("frame snapshot not preserved") } } invalidFrame := strings.Replace(frameFixture, `"frameId":"frame"`, `"frameId":"missing"`, 1) rejected := call("PATCH", canvasPath, tokens[0], response{"document": json.RawMessage(invalidFrame)}, 400, `"7"`) if rejected["error"] != "invalid_canvas" { t.Fatal("unexpected frame error") } if call("GET", canvasPath, tokens[0], nil, 200)["version"] != float64(7) { t.Fatal("invalid frame changed saved version") } layout := call("POST", boardPath+"/layouts", tokens[0], response{"material_id": articleID, "x": 1, "y": 2, "width": 320, "height": 200, "z_index": 1}, 201, `"2"`) call("GET", boardPath+"/layouts", tokens[1], nil, 200) call("POST", boardPath+"/grants", tokens[0], response{"account_id": accounts[2], "role": "viewer"}, 204) hidden := call("GET", boardPath+"/layouts", tokens[2], nil, 200) if len(hidden["items"].([]any)) != 0 { t.Fatal("board share disclosed private child material") } call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 412, `"1"`) call("DELETE", boardPath+"/layouts/"+layout["id"].(string), tokens[0], nil, 204, `"3"`) call("DELETE", textPath+"/grants/"+accounts[2], tokens[0], nil, 204) call("GET", textPath, tokens[2], nil, 404) call("DELETE", workspacePath+"/tags/"+tag["id"].(string), tokens[0], nil, 204) call("PATCH", textPath, tokens[0], changed, 412, `"3"`) imageContent := response{"image": response{"url": "https://assets.example.com/image.png", "alt": "Photo"}} call("POST", workspacePath+"/materials", tokens[0], response{"kind": "image", "title": "Photo", "content": imageContent}, 201) call("POST", workspacePath+"/materials", tokens[0], response{"kind": "card", "title": "Mixed card", "content": response{"parts": []any{ response{"kind": "text", "text": "Caption"}, response{"kind": "article", "article": articleContent["article"]}, response{"kind": "image", "image": imageContent["image"]}, }}}, 201) for _, sort := range []string{"modified", "created", "alphabetical", "opened", "tag"} { call("GET", workspacePath+"/materials?sort="+sort+"&limit=2", tokens[0], nil, 200) } images := call("GET", workspacePath+"/materials?kind=image&q=Photo", tokens[0], nil, 200) if len(images["items"].([]any)) != 1 { t.Fatal("kind/title filter returned incorrect materials") } // Subgraph creation is atomic and idempotent, and cannot be undone by // a stale or legacy whole-document save. subBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Subgraphs", "content": response{}}, 201) subPath := workspacePath + "/materials/" + subBoard["id"].(string) + "/canvas" accept := "application/vnd.foundry.canvas+json;version=2" key := "11111111-1111-4111-8111-111111111111" command := response{"frame": response{"id": "frame-new", "title": "Child", "position": response{"x": 100, "y": 100}, "width": 640, "height": 400, "labelMode": "fixed"}} subgraphCreated := call("POST", subPath+"/subgraphs", tokens[0], command, 201, `"1"`, accept, key) retried := call("POST", subPath+"/subgraphs", tokens[0], command, 201, `"1"`, accept, key) if subgraphCreated["transaction_id"] != retried["transaction_id"] { t.Fatal("retry created another transaction") } call("POST", subPath+"/subgraphs", tokens[0], response{"frame": response{"id": "other", "title": "Child", "position": response{"x": 100, "y": 100}, "width": 640, "height": 400, "labelMode": "fixed"}}, 409, `"1"`, accept, key) call("GET", subPath, tokens[0], nil, 409) savedSub := call("GET", subPath, tokens[0], nil, 200, "", accept) if savedSub["version"] != float64(2) { t.Fatal("idempotent retry changed version") } call("PATCH", subPath, tokens[0], response{"document": response{"schema_version": 2, "nodes": []any{}, "edges": []any{}, "viewport": response{"x": 0, "y": 0, "zoom": 1}}}, 409, `"2"`, accept) childID := subgraphCreated["child_board"].(map[string]any)["id"].(string) childPath := workspacePath + "/materials/" + childID + "/canvas" child := call("GET", childPath, tokens[0], nil, 200, "", accept) if child["version"] != float64(1) { t.Fatal("child initial version") } call("GET", childPath, tokens[2], nil, 404, "", accept) var children int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM canvas_subgraphs WHERE project=$1 AND parent_board_id=$2", project, subBoard["id"]).Scan(&children); err != nil || children != 1 { t.Fatalf("child relationship count: %d %v", children, err) } defaultMaterials := call("GET", workspacePath+"/materials?limit=100", tokens[0], nil, 200) for _, v := range defaultMaterials["items"].([]any) { if v.(map[string]any)["id"] == childID { t.Fatal("child leaked into dashboard") } } explicitMaterials := call("GET", workspacePath+"/materials?limit=100&include_subgraphs=true", tokens[0], nil, 200) foundChild := false for _, v := range explicitMaterials["items"].([]any) { m := v.(map[string]any) if m["id"] == childID { foundChild = true if m["parent_board_id"] != subBoard["id"] || m["frame_id"] != "frame-new" { t.Fatal("missing child ownership metadata") } } } if !foundChild { t.Fatal("include_subgraphs omitted authorized child") } dissolvePath := subPath + "/subgraphs/frame-new/dissolve" call("POST", dissolvePath, tokens[0], response{"expected_versions": map[string]int64{subBoard["id"].(string): 1, childID: 1}}, 412, "", accept, "66666666-6666-4666-8666-666666666666") call("GET", childPath, tokens[0], nil, 200, "", accept) dissolveCommand := response{"expected_versions": map[string]int64{subBoard["id"].(string): 2, childID: 1}} dissolved := call("POST", dissolvePath, tokens[0], dissolveCommand, 200, "", accept, "77777777-7777-4777-8777-777777777777") dissolveRetry := call("POST", dissolvePath, tokens[0], dissolveCommand, 200, "", accept, "77777777-7777-4777-8777-777777777777") if dissolved["transaction_id"] != dissolveRetry["transaction_id"] { t.Fatal("dissolve retry duplicated") } call("GET", childPath, tokens[0], nil, 404, "", accept) dissolveUndoPath := subPath + "/transactions/" + dissolved["transaction_id"].(string) + "/undo" call("POST", dissolveUndoPath, tokens[0], response{"expected_versions": map[string]int64{subBoard["id"].(string): 3, childID: 2}}, 200, "", accept, "88888888-8888-4888-8888-888888888888") call("GET", childPath, tokens[0], nil, 200, "", accept) migrationBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Legacy migration", "content": response{}}, 201) migrationPath := workspacePath + "/materials/" + migrationBoard["id"].(string) + "/canvas" call("PATCH", migrationPath, tokens[0], response{"document": json.RawMessage(legacyMigrationFixture)}, 200, `"1"`) plan := call("POST", migrationPath+"/subgraphs/migrate", tokens[0], response{"dry_run": true}, 200, "", accept) if plan["child_count"] != float64(2) { t.Fatal("migration preview child count") } var countBefore int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM materials WHERE project=$1", project).Scan(&countBefore); err != nil { t.Fatal(err) } call("POST", migrationPath+"/subgraphs/migrate", tokens[0], response{"dry_run": false, "plan_id": "stale"}, 412, `"2"`, accept, "22222222-2222-4222-8222-222222222222") var countAfter int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM materials WHERE project=$1", project).Scan(&countAfter); err != nil || countBefore != countAfter { t.Fatal("preflight/stale plan created orphan child") } migrationCommand := response{"dry_run": false, "plan_id": plan["plan_id"]} migrated := call("POST", migrationPath+"/subgraphs/migrate", tokens[0], migrationCommand, 200, `"2"`, accept, "33333333-3333-4333-8333-333333333333") migrationRetry := call("POST", migrationPath+"/subgraphs/migrate", tokens[0], migrationCommand, 200, `"2"`, accept, "33333333-3333-4333-8333-333333333333") if migrated["transaction_id"] != migrationRetry["transaction_id"] { t.Fatal("migration retry duplicated transaction") } expectedUndo := map[string]int64{migrationBoard["id"].(string): 3} for _, v := range migrated["children"].([]any) { c := v.(map[string]any) expectedUndo[c["board_id"].(string)] = 1 } undoPath := migrationPath + "/transactions/" + migrated["transaction_id"].(string) + "/undo" undoCommand := response{"expected_versions": expectedUndo} undone := call("POST", undoPath, tokens[0], undoCommand, 200, "", accept, "44444444-4444-4444-8444-444444444444") undoRetry := call("POST", undoPath, tokens[0], undoCommand, 200, "", accept, "44444444-4444-4444-8444-444444444444") if undone["transaction_id"] != undoRetry["transaction_id"] { t.Fatal("undo retry duplicated") } restoredLegacy := call("GET", migrationPath, tokens[0], nil, 200) if restoredLegacy["version"] != float64(4) || restoredLegacy["document"].(map[string]any)["schema_version"] != float64(1) { t.Fatal("undo did not restore legacy document") } for child := range expectedUndo { if child != migrationBoard["id"].(string) { call("GET", workspacePath+"/materials/"+child+"/canvas", tokens[0], nil, 404, "", accept) } } redoPlan := call("POST", migrationPath+"/subgraphs/migrate", tokens[0], response{"dry_run": true}, 200, "", accept) redone := call("POST", migrationPath+"/subgraphs/migrate", tokens[0], response{"dry_run": false, "plan_id": redoPlan["plan_id"]}, 200, `"4"`, accept, "55555555-5555-4555-8555-555555555555") var membersSource, membersTarget string for _, v := range redone["child_boards"].([]any) { child := v.(map[string]any) if child["frame_id"] == "f" { membersSource = child["id"].(string) } else if child["frame_id"] == "empty" { membersTarget = child["id"].(string) } } parentID := migrationBoard["id"].(string) memberPath := migrationPath + "/subgraphs/f/members" moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": map[string]memberDropPosition{"a": {X: 40, Y: 50}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}} call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": response{"wrong": response{"x": 1, "y": 2}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}, 400, "", accept, "eeeeeeee-1111-4111-8111-111111111111") movedMembers := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") memberRetry := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb") if movedMembers["transaction_id"] != memberRetry["transaction_id"] { t.Fatal("member retry duplicated transaction") } sourceCanvasPath := workspacePath + "/materials/" + membersSource + "/canvas" targetCanvasPath := workspacePath + "/materials/" + membersTarget + "/canvas" sourceRead := call("GET", sourceCanvasPath, tokens[0], nil, 200, "", accept) targetRead := call("GET", targetCanvasPath, tokens[0], nil, 200, "", accept) sourceJSON, _ := json.Marshal(sourceRead["document"]) targetJSON, _ := json.Marshal(targetRead["document"]) dropPosition := canvasNodesByID(targetJSON)["a"]["position"].(map[string]any) if dropPosition["x"] != float64(140) || dropPosition["y"] != float64(250) { t.Fatalf("atomic drop geometry %v", dropPosition) } if canvasNodeData(sourceJSON)["a"] != nil || canvasNodeData(targetJSON)["a"] == nil { t.Fatal("member move duplicated or lost ownership") } call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"b"}, "expected_versions": map[string]int64{parentID: 5, membersSource: 2, membersTarget: 2}}, 412, "", accept, "cccccccc-cccc-4ccc-8ccc-cccccccccccc") call("POST", migrationPath+"/transactions/"+movedMembers["transaction_id"].(string)+"/undo", tokens[0], response{"expected_versions": map[string]int64{parentID: 6, membersSource: 2, membersTarget: 2}}, 200, "", accept, "dddddddd-dddd-4ddd-8ddd-dddddddddddd") parentMove := call("POST", memberPath, tokens[0], response{"source_board_id": parentID, "target_board_id": membersSource, "node_ids": []string{"outside"}, "expected_versions": map[string]int64{parentID: 7, membersSource: 3}}, 200, "", accept, "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee") call("POST", migrationPath+"/transactions/"+parentMove["transaction_id"].(string)+"/undo", tokens[0], response{"expected_versions": map[string]int64{parentID: 8, membersSource: 4}}, 200, "", accept, "ffffffff-ffff-4fff-8fff-ffffffffffff") innerCreated := call("POST", sourceCanvasPath+"/subgraphs", tokens[0], response{"frame": response{"id": "inner-frame", "title": "Inner", "position": response{"x": 20, "y": 40}, "width": 400, "height": 300, "labelMode": "fixed"}}, 201, `"5"`, accept, "10000000-0000-4000-8000-000000000001") innerID := innerCreated["child_board"].(map[string]any)["id"].(string) nestedMove := call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"inner-frame"}, "expected_versions": map[string]int64{parentID: 9, membersSource: 6, membersTarget: 3, innerID: 1}}, 200, "", accept, "10000000-0000-4000-8000-000000000002") var nestedParent string if err := db.QueryRowContext(ctx, "SELECT parent_board_id::text FROM canvas_subgraphs WHERE project=$1 AND child_board_id=$2", project, innerID).Scan(&nestedParent); err != nil || nestedParent != membersTarget { t.Fatalf("nested child was copied or not reparented: %s %v", nestedParent, err) } call("POST", migrationPath+"/transactions/"+nestedMove["transaction_id"].(string)+"/undo", tokens[0], response{"expected_versions": map[string]int64{parentID: 10, membersSource: 7, membersTarget: 4, innerID: 2}}, 200, "", accept, "10000000-0000-4000-8000-000000000003") if err := db.QueryRowContext(ctx, "SELECT parent_board_id::text FROM canvas_subgraphs WHERE project=$1 AND child_board_id=$2", project, innerID).Scan(&nestedParent); err != nil || nestedParent != membersSource { t.Fatalf("undo did not restore nested ownership: %s %v", nestedParent, err) } // Moving an externally exposed node inside a nested frame must repair the // ancestor boundary atomically and preserve its port/edge identities. innerMemberPath := sourceCanvasPath + "/subgraphs/inner-frame/members" nestedMemberCommand := response{"source_board_id": membersSource, "target_board_id": innerID, "node_ids": []string{"b"}, "expected_versions": map[string]int64{parentID: 11, membersSource: 8, innerID: 3}} nestedMemberMove := call("POST", innerMemberPath, tokens[0], nestedMemberCommand, 200, "", accept, "30000000-0000-4000-8000-000000000001") ancestorRead := call("GET", migrationPath, tokens[0], nil, 200, "", accept) ancestorJSON, _ := json.Marshal(ancestorRead["document"]) frameData := canvasNodeData(ancestorJSON)["f"] ancestorPorts := frameData["ports"].([]any) if len(ancestorPorts) != 1 || ancestorPorts[0].(map[string]any)["nodeId"] != "inner-frame" || !strings.HasPrefix(ancestorPorts[0].(map[string]any)["handle"].(string), "port:") { t.Fatal("ancestor port not redirected through nested frame") } outerPortID := ancestorPorts[0].(map[string]any)["id"] call("POST", sourceCanvasPath+"/subgraphs/inner-frame/dissolve", tokens[0], response{"expected_versions": map[string]int64{membersSource: 9, innerID: 4}}, 428, "", accept, "30000000-0000-4000-8000-000000000002") dissolvedNested := call("POST", sourceCanvasPath+"/subgraphs/inner-frame/dissolve", tokens[0], response{"expected_versions": map[string]int64{parentID: 12, membersSource: 9, innerID: 4}}, 200, "", accept, "30000000-0000-4000-8000-000000000002") dissolvedAncestor := call("GET", migrationPath, tokens[0], nil, 200, "", accept) dissolvedAncestorJSON, _ := json.Marshal(dissolvedAncestor["document"]) port := canvasNodeData(dissolvedAncestorJSON)["f"]["ports"].([]any)[0].(map[string]any) if port["id"] != outerPortID || port["nodeId"] != "b" || port["handle"] != "right" { t.Fatal("dissolve lost ancestor logical endpoint") } call("POST", sourceCanvasPath+"/transactions/"+dissolvedNested["transaction_id"].(string)+"/undo", tokens[0], response{"expected_versions": map[string]int64{parentID: 13, membersSource: 10, innerID: 5}}, 200, "", accept, "30000000-0000-4000-8000-000000000003") // The documents now match the previous move's after-state even though all // versions are newer. Undo must work as a stack without resetting versions. call("POST", sourceCanvasPath+"/transactions/"+nestedMemberMove["transaction_id"].(string)+"/undo", tokens[0], response{"expected_versions": map[string]int64{parentID: 14, membersSource: 11, innerID: 6}}, 200, "", accept, "30000000-0000-4000-8000-000000000004") restoredAncestor := call("GET", migrationPath, tokens[0], nil, 200, "", accept) restoredAncestorJSON, _ := json.Marshal(restoredAncestor["document"]) port = canvasNodeData(restoredAncestorJSON)["f"]["ports"].([]any)[0].(map[string]any) if port["id"] != outerPortID || port["nodeId"] != "b" || port["handle"] != "right" { t.Fatal("undo stack did not restore ancestor port") } var assetPNG bytes.Buffer if err := png.Encode(&assetPNG, image.NewRGBA(image.Rect(0, 0, 3, 2))); err != nil { t.Fatal(err) } upload := func(encoded []byte, key string, status int, resourceIDs ...string) response { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile("image", "misleading.jpg") if err != nil { t.Fatal(err) } _, _ = part.Write(encoded) _ = writer.Close() request := httptest.NewRequest("POST", workspacePath+"/assets", &body) request.Header.Set("Authorization", "Bearer "+tokens[0]) request.Header.Set("Content-Type", writer.FormDataContentType()) request.Header.Set("Idempotency-Key", key) if len(resourceIDs) > 0 { request.Header.Set("X-Resource-Id", resourceIDs[0]) } result := httptest.NewRecorder() mux.ServeHTTP(result, request) if result.Code != status { t.Fatalf("asset upload got %d: %s", result.Code, result.Body.String()) } out := response{} if err := json.Unmarshal(result.Body.Bytes(), &out); err != nil { t.Fatal(err) } return out } uploaded := upload(assetPNG.Bytes(), "99999999-9999-4999-8999-999999999999", 201) retriedAsset := upload(assetPNG.Bytes(), "99999999-9999-4999-8999-999999999999", 201) if uploaded["id"] != retriedAsset["id"] || uploaded["mime_type"] != "image/png" { t.Fatal("asset idempotency or actual MIME detection") } // New uploads keep the resource identity allocated in the frontend durable cut. resourceID, resourceKey := modelTestID(9100), modelTestID(9101) managed := upload(assetPNG.Bytes(), resourceKey, 201, resourceID) if managed["protocol_version"] != float64(2) || managed["request_id"] != resourceKey || managed["asset"].(map[string]any)["id"] != resourceID { t.Fatal("upload did not retain client resource identity") } repeated := upload(assetPNG.Bytes(), resourceKey, 201, resourceID) if !modelEqual(managed, repeated) { t.Fatal("resource retry changed receipt") } lookup := call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[0], nil, 200) if lookup["status"] != "applied" || !modelEqual(lookup["asset"], managed["asset"]) { t.Fatal("lost upload acknowledgment cannot be recovered") } unknown := call("GET", workspacePath+"/assets/operations/"+modelTestID(9102), tokens[0], nil, 404) if unknown["status"] != "unknown" || unknown["terminal"] == true { t.Fatal("missing asset receipt misreported zero-write") } call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[2], nil, 404) upload(assetPNG.Bytes(), resourceKey, 409, modelTestID(9103)) upload(assetPNG.Bytes(), modelTestID(9104), 409, resourceID) var alternatePNG bytes.Buffer if err := png.Encode(&alternatePNG, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil { t.Fatal(err) } upload(alternatePNG.Bytes(), resourceKey, 409, resourceID) upload(assetPNG.Bytes(), modelTestID(9105), 400, "invalid-resource") download := func(token string, status int) { t.Helper() request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string), nil) request.Header.Set("Authorization", "Bearer "+token) result := httptest.NewRecorder() mux.ServeHTTP(result, request) if result.Code != status { t.Fatalf("asset download got %d want %d: %s", result.Code, status, result.Body.String()) } if status == 200 && (!bytes.Equal(result.Body.Bytes(), assetPNG.Bytes()) || result.Header().Get("Content-Type") != "image/png") { t.Fatal("asset bytes changed") } } // Warm the immutable preview cache, then verify it never bypasses permission. for _, sample := range []struct { token string status int }{{tokens[0], 200}, {tokens[1], 404}, {tokens[2], 404}} { request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string)+"?variant=preview", nil) request.Header.Set("Authorization", "Bearer "+sample.token) result := httptest.NewRecorder() mux.ServeHTTP(result, request) if result.Code != sample.status { t.Fatalf("preview permission got %d want %d", result.Code, sample.status) } if result.Code == 200 && (!bytes.Equal(result.Body.Bytes(), assetPNG.Bytes()) || result.Header().Get("Cache-Control") != "private, no-store") { t.Fatal("preview changed small original or privacy headers") } } // Neither a snapshot nor an asset response may keep the workspace locked // while a slow downstream consumer is accepting its body. for _, path := range []string{canvasPath, workspacePath + "/assets/" + uploaded["id"].(string)} { var probeErr error writer := &readLockProbe{ResponseRecorder: httptest.NewRecorder(), probe: func() { lockTx, e := db.BeginTx(ctx, nil) if e != nil { probeErr = e return } defer lockTx.Rollback() _, probeErr = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace) }} request := httptest.NewRequest("GET", path, nil) request.Header.Set("Authorization", "Bearer "+tokens[0]) request.Header.Set("Accept", accept) mux.ServeHTTP(writer, request) if writer.Code != 200 || probeErr != nil { t.Fatalf("read held workspace lock during response: status=%d error=%v", writer.Code, probeErr) } } // A slow multipart body must not hold the workspace lock either. func() { reader, pipe := io.Pipe() defer reader.Close() defer pipe.Close() form := multipart.NewWriter(pipe) headers := make(chan struct{}) release := make(chan struct{}) done := make(chan struct{}) var once sync.Once unblock := func() { once.Do(func() { close(release) }) } defer unblock() request := httptest.NewRequest("POST", workspacePath+"/assets", reader) request.Header.Set("Authorization", "Bearer "+tokens[0]) request.Header.Set("Content-Type", form.FormDataContentType()) request.Header.Set("Idempotency-Key", "99999999-9999-4999-8999-999999999998") result := httptest.NewRecorder() go func() { mux.ServeHTTP(result, request); close(done) }() go func() { part, e := form.CreateFormFile("image", "slow.png") close(headers) if e != nil { return } <-release _, _ = part.Write(assetPNG.Bytes()) _ = form.Close() _ = pipe.Close() }() select { case <-headers: case <-time.After(5 * time.Second): t.Fatal("upload did not consume headers") } lockTx, e := db.BeginTx(ctx, nil) if e != nil { t.Fatal(e) } _, e = lockTx.ExecContext(ctx, "SELECT id FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE NOWAIT", project, workspace) _ = lockTx.Rollback() unblock() if e != nil { t.Fatalf("upload held workspace lock while receiving body: %v", e) } select { case <-done: case <-time.After(5 * time.Second): t.Fatal("upload did not finish") } if result.Code != 201 { t.Fatalf("slow upload failed: %d", result.Code) } }() download(tokens[0], 200) download(tokens[1], 404) download(tokens[2], 404) assetBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Assets", "content": response{}}, 201) assetDoc := response{"schema_version": 2, "nodes": []any{response{"id": "asset-node", "type": "plainImage", "position": response{"x": 0, "y": 0}, "data": response{"kind": "plain-image", "title": "Image", "text": "", "src": uploaded["url"], "assetId": uploaded["id"]}}}, "edges": []any{}, "viewport": response{"x": 0, "y": 0, "zoom": 1}} call("PATCH", workspacePath+"/materials/"+assetBoard["id"].(string)+"/canvas", tokens[0], response{"document": assetDoc}, 200, `"1"`, accept) download(tokens[1], 200) download(tokens[2], 404) // assetId is optional: moving a stable managed URL must still add a // durable binding for its new owning canvas. assetCanvasPath := workspacePath + "/materials/" + assetBoard["id"].(string) + "/canvas" delete(assetDoc["nodes"].([]any)[0].(response)["data"].(response), "assetId") call("PATCH", assetCanvasPath, tokens[0], response{"document": assetDoc}, 200, `"2"`, accept) assetFrame := call("POST", assetCanvasPath+"/subgraphs", tokens[0], response{"frame": response{"id": "asset-frame", "title": "Images", "position": response{"x": 0, "y": 0}, "width": 640, "height": 400, "labelMode": "fixed"}}, 201, `"3"`, accept, "40000000-0000-4000-8000-000000000001") assetChild := assetFrame["child_board"].(map[string]any)["id"].(string) call("POST", assetCanvasPath+"/subgraphs/asset-frame/members", tokens[0], response{"source_board_id": assetBoard["id"], "target_board_id": assetChild, "node_ids": []string{"asset-node"}, "expected_versions": map[string]int64{assetBoard["id"].(string): 4, assetChild: 1}}, 200, "", accept, "40000000-0000-4000-8000-000000000002") var managedBindings int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM canvas_asset_bindings WHERE project=$1 AND asset_id=$2 AND board_id=$3", project, uploaded["id"], assetChild).Scan(&managedBindings); err != nil || managedBindings != 1 { t.Fatalf("stable URL lost child asset binding: %v count=%d", err, managedBindings) } call("POST", assetCanvasPath+"/subgraphs/asset-frame/dissolve", tokens[0], response{"expected_versions": map[string]int64{assetBoard["id"].(string): 5, assetChild: 2}}, 200, "", accept, "40000000-0000-4000-8000-000000000003") download(tokens[1], 200) upload([]byte("not an image"), "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", 400) limitBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Limits", "content": response{}}, 201) limitPath := workspacePath + "/materials/" + limitBoard["id"].(string) + "/canvas" oversizedNodes := []any{} for i := 0; i < 501; i++ { oversizedNodes = append(oversizedNodes, response{"id": "limit-" + strconv.Itoa(i), "type": "material", "position": response{"x": 0, "y": 0}, "data": response{"kind": "text", "title": "", "text": ""}}) } call("PATCH", limitPath, tokens[0], response{"document": response{"schema_version": 1, "nodes": oversizedNodes, "edges": []any{}, "viewport": response{"x": 0, "y": 0, "zoom": 1}}}, 413, `"1"`) if call("GET", limitPath, tokens[0], nil, 200)["version"] != float64(1) { t.Fatal("graph limit partially saved") } caseFrame := response{"frame": response{"id": "case-frame", "title": "Case", "position": response{"x": 0, "y": 0}, "width": 640, "height": 400, "labelMode": "fixed"}} upperKey := call("POST", limitPath+"/subgraphs", tokens[0], caseFrame, 201, `"1"`, accept, "ABCDEFAB-CDEF-4ABC-8DEF-ABCDEFABCDEF") lowerKey := call("POST", limitPath+"/subgraphs", tokens[0], caseFrame, 201, `"1"`, accept, "abcdefab-cdef-4abc-8def-abcdefabcdef") if upperKey["transaction_id"] != lowerKey["transaction_id"] { t.Fatal("UUID key case changed operation identity") } concurrentBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Concurrent create", "content": response{}}, 201) concurrentPath := workspacePath + "/materials/" + concurrentBoard["id"].(string) + "/canvas/subgraphs" type concurrentResult struct { code int body []byte } results := make(chan concurrentResult, 4) startConcurrent := make(chan struct{}) encodedFrame, _ := json.Marshal(caseFrame) for i := 0; i < 4; i++ { go func() { <-startConcurrent r := httptest.NewRequest("POST", concurrentPath, bytes.NewReader(encodedFrame)) r.Header.Set("Authorization", "Bearer "+tokens[0]) r.Header.Set("Accept", accept) r.Header.Set("If-Match", `"1"`) r.Header.Set("Idempotency-Key", "60000000-0000-4000-8000-000000000001") w := httptest.NewRecorder() mux.ServeHTTP(w, r) results <- concurrentResult{w.Code, append([]byte(nil), w.Body.Bytes()...)} }() } close(startConcurrent) var concurrentTransaction string for i := 0; i < 4; i++ { result := <-results var decoded response if result.code != 201 || json.Unmarshal(result.body, &decoded) != nil { t.Fatalf("concurrent create: %d %s", result.code, result.body) } id := decoded["transaction_id"].(string) if concurrentTransaction != "" && concurrentTransaction != id { t.Fatal("concurrent retry duplicated transaction") } concurrentTransaction = id } var concurrentChildren int if err := db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_subgraphs WHERE project=$1 AND parent_board_id=$2`, project, concurrentBoard["id"]).Scan(&concurrentChildren); err != nil || concurrentChildren != 1 { t.Fatalf("concurrent retry duplicated children: %d %v", concurrentChildren, err) } rollbackBoard := call("POST", workspacePath+"/materials", tokens[0], response{"kind": "board", "title": "Rollback", "content": response{}}, 201) rollbackPath := workspacePath + "/materials/" + rollbackBoard["id"].(string) + "/canvas" nearLimit := []any{} for i := 0; i < 53; i++ { text := "" if i < 52 { text = strings.Repeat("x", 100000) } nearLimit = append(nearLimit, response{"id": "rollback-" + strconv.Itoa(i), "type": "material", "position": response{"x": 0, "y": 0}, "data": response{"kind": "text", "title": "", "text": text}}) } rollbackDoc := response{"schema_version": 1, "nodes": nearLimit, "edges": []any{}, "viewport": response{"x": 0, "y": 0, "zoom": 1}} encodedRollback, _ := json.Marshal(rollbackDoc) remaining := canvasLimit - 300 - len(encodedRollback) if remaining <= 0 || remaining > 100000 { t.Fatal("rollback fixture budget") } nearLimit[52].(response)["data"].(response)["text"] = strings.Repeat("x", remaining) call("PATCH", rollbackPath, tokens[0], response{"document": rollbackDoc}, 200, `"1"`) var materialBeforeFailure, materialAfterFailure int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM materials WHERE project=$1", project).Scan(&materialBeforeFailure); err != nil { t.Fatal(err) } call("POST", rollbackPath+"/subgraphs", tokens[0], caseFrame, 413, `"2"`, accept, "50000000-0000-4000-8000-000000000001") if err := db.QueryRowContext(ctx, "SELECT count(*) FROM materials WHERE project=$1", project).Scan(&materialAfterFailure); err != nil || materialAfterFailure != materialBeforeFailure { t.Fatal("failed parent validation left an orphan child") } if call("GET", rollbackPath, tokens[0], nil, 200)["version"] != float64(2) { t.Fatal("failed create changed parent version") } email := "fixture@example.invalid" password := "a-long-fixture-password" call("POST", base+"/auth/register", "", response{"email": email, "password": password}, 202) call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 401) var body string if err := db.QueryRowContext(ctx, "SELECT body FROM email_outbox WHERE project=$1 AND recipient=$2 ORDER BY created_at DESC LIMIT 1", project, email).Scan(&body); err != nil { t.Fatal(err) } match := regexp.MustCompile(`verify_email=([A-Za-z0-9_-]{43})`).FindStringSubmatch(body) if len(match) != 2 { t.Fatal("missing email verification link") } verified := call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 201) call("POST", base+"/auth/verify-email", "", response{"token": match[1]}, 400) call("GET", base+"/profile", verified["access_token"].(string), nil, 200) call("POST", base+"/auth/login", "", response{"email": email, "password": "wrong-long-password"}, 401) loggedIn := call("POST", base+"/auth/login", "", response{"email": email, "password": password}, 200) call("POST", base+"/auth/logout", loggedIn["access_token"].(string), nil, 204) call("GET", base+"/profile", loggedIn["access_token"].(string), nil, 401) var count int if err := db.QueryRowContext(ctx, "SELECT count(*) FROM material_grants WHERE project=$1 AND account_id=$2 AND material_id=$3", project, accounts[2], material).Scan(&count); err != nil || count != 1 { t.Fatalf("material grant missing %v", err) } if err := db.QueryRowContext(ctx, "SELECT count(*) FROM link_clicks WHERE project=$1", project).Scan(&count); err != nil || count != 1 { t.Fatalf("click accounting incorrect %v", err) } } // A real SQL NOWAIT probe at the response-write boundary catches locks that // would otherwise remain invisible in a buffered httptest recorder. type readLockProbe struct { *httptest.ResponseRecorder probe func() } func (w *readLockProbe) Write(body []byte) (int, error) { if w.probe != nil { probe := w.probe w.probe = nil probe() } return w.ResponseRecorder.Write(body) }