test(canvas): cover migration admission guards with valid legacy edges
This commit is contained in:
@@ -39,7 +39,7 @@ func newCanonicalAtomicFixture(t *testing.T) *canonicalAtomicFixture {
|
||||
f := &canonicalAtomicFixture{db: db, project: "canonical-atomic-" + hex.EncodeToString(entropy[:])}
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() {
|
||||
for _, table := range []string{"canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_subgraphs", "workspaces", "project_accounts"} {
|
||||
for _, table := range []string{"canvas_ai_runs", "canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_subgraphs", "workspaces", "project_accounts"} {
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", f.project)
|
||||
}
|
||||
})
|
||||
@@ -59,7 +59,7 @@ func newCanonicalAtomicFixture(t *testing.T) *canonicalAtomicFixture {
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, f.project, f.workspace, f.account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.legacy = json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"preserved","frameId":"f"}},{"id":"b","type":"plainText","position":{"x":180,"y":300},"width":240,"height":72,"data":{"kind":"plain-text","title":"b","text":"preserved","frameId":"f"}},{"id":"c","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"c","text":"unrelated"}}],"edges":[{"id":"e","source":"a","target":"b"}],"viewport":{"x":0,"y":0,"zoom":1}}`)
|
||||
f.legacy = json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"material","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"text","title":"a","text":"preserved","frameId":"f"}},{"id":"b","type":"material","position":{"x":180,"y":300},"width":240,"height":72,"data":{"kind":"text","title":"b","text":"preserved","frameId":"f"}},{"id":"c","type":"material","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"text","title":"c","text":"unrelated"}}],"edges":[{"id":"e","source":"a","target":"b"}],"viewport":{"x":0,"y":0,"zoom":1}}`)
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, f.project, f.workspace, f.account, f.legacy).Scan(&f.root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -298,3 +298,92 @@ func TestCanonicalMigrationLateCancellationRollsBack(t *testing.T) {
|
||||
t.Fatal("retry after rollback duplicated identities or the implicit partition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalMigrationPermissionVersionAndRunGuards(t *testing.T) {
|
||||
f := newCanonicalAtomicFixture(t)
|
||||
ctx := context.Background()
|
||||
input := func(key string, version int64) response {
|
||||
return response{"protocol_version": 2, "request_id": key, "expected_versions": map[string]int64{f.root: version}}
|
||||
}
|
||||
call := func(key string, version int64, status int) response {
|
||||
t.Helper()
|
||||
return canonicalAtomicResponse(t, f.request(ctx, "POST", f.path()+"/migrate", key, modelJSON(input(key, version))), status)
|
||||
}
|
||||
assertLegacy := func(version int64) {
|
||||
t.Helper()
|
||||
for _, table := range []string{"canvas_model_projects", "canvas_model_canvases", "canvas_model_elements", "canvas_model_identity_map", "canvas_subgraphs"} {
|
||||
var count int
|
||||
if err := f.db.QueryRowContext(ctx, "SELECT count(*) FROM "+table+" WHERE project=$1", f.project).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("rejected migration left %s rows: %d %v", table, count, err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
var current int64
|
||||
var source json.RawMessage
|
||||
if err := f.db.QueryRowContext(ctx, `SELECT count(*) FROM materials WHERE project=$1`, f.project).Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("rejected migration created a material: %d %v", count, err)
|
||||
}
|
||||
if err := f.db.QueryRowContext(ctx, `SELECT canvas_version,canvas_document FROM materials WHERE project=$1 AND id=$2`, f.project, f.root).Scan(¤t, &source); err != nil || current != version || !modelEqual(f.legacy, source) {
|
||||
t.Fatalf("rejected migration changed legacy source/version: %d %v", current, err)
|
||||
}
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE workspace_members SET role='viewer' WHERE project=$1 AND workspace_id=$2 AND account_id=$3`, f.project, f.workspace, f.account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if call(modelTestID(8500), 1, 403)["error"] != "forbidden" {
|
||||
t.Fatal("viewer crossed the identity migration barrier")
|
||||
}
|
||||
assertLegacy(1)
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE workspace_members SET role='admin' WHERE project=$1 AND workspace_id=$2 AND account_id=$3`, f.project, f.workspace, f.account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
staleKey := modelTestID(8501)
|
||||
rejected := call(staleKey, 2, 412)
|
||||
if rejected["status"] != "not-applied" || rejected["terminal"] != true || rejected["error"] != "migration_version_conflict" {
|
||||
t.Fatal("stale source migration did not produce a zero-effect receipt")
|
||||
}
|
||||
assertLegacy(1)
|
||||
// Advance the old writer to the formerly rejected expected version. That
|
||||
// cannot turn the same frozen negative request into a migration later.
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE materials SET canvas_version=2 WHERE project=$1 AND id=$2`, f.project, f.root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !modelEqual(rejected, call(staleKey, 2, 412)) {
|
||||
t.Fatal("a source version change reevaluated a rejected migration key")
|
||||
}
|
||||
assertLegacy(2)
|
||||
run := modelTestID(8400)
|
||||
if _, err := f.db.ExecContext(ctx, `INSERT INTO canvas_ai_runs(project,workspace_id,board_id,id,account_id,idempotency_key,payload_hash,status,plan,create_response) VALUES($1,$2,$3,$4,$5,$6,'fixture','queued','{}','{}')`, f.project, f.workspace, f.root, run, f.account, modelTestID(8401)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `INSERT INTO canvas_ai_steps(project,workspace_id,run_id,id,board_id,node_id,ordinal,status,model_id,frozen_input,target_fingerprint) VALUES($1,$2,$3,'fixture',$4,'a',0,'queued','fixture','{}','fixture')`, f.project, f.workspace, run, f.root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index, status := range []string{"queued", "running"} {
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_runs SET status=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, f.project, f.workspace, run, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_steps SET status=$4 WHERE project=$1 AND workspace_id=$2 AND run_id=$3`, f.project, f.workspace, run, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := call(modelTestID(8600+index), 2, 409)
|
||||
if body["status"] != "not-applied" || body["terminal"] != true || body["error"] != "migration_waiting_for_runs" {
|
||||
t.Fatal("identity migration crossed queued or outcome-unknown execution")
|
||||
}
|
||||
assertLegacy(2)
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_steps SET status='cancelled' WHERE project=$1 AND workspace_id=$2 AND run_id=$3`, f.project, f.workspace, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.db.ExecContext(ctx, `UPDATE canvas_ai_runs SET status='cancelled' WHERE project=$1 AND workspace_id=$2 AND id=$3`, f.project, f.workspace, run); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if call(modelTestID(8601), 2, 409)["error"] != "migration_waiting_for_runs" {
|
||||
t.Fatal("settling paid work changed the frozen rejection")
|
||||
}
|
||||
call(modelTestID(8602), 2, 200)
|
||||
model := f.read(t)
|
||||
if model.Versions[f.root] != 3 || len(model.Canvases) != 2 {
|
||||
t.Fatal("explicit migration after execution settled lost its source version barrier")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user