785 lines
43 KiB
Go
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)
|
|
}
|