Files
foundry-core/internal/platform/integration_test.go
codex-bot 09c78d3ab8
Foundry Go checks / check (push) Successful in 8m18s
Foundry Go checks / check (pull_request) Successful in 6m16s
Accept final member positions in ownership transactions
2026-11-06 22:46:46 +08:00

785 lines
43 KiB
Go

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