From bf14c1168a4a91836686ecb16e33ed8e0848bfca Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 30 Jul 2026 00:59:03 +0000 Subject: [PATCH 1/6] feat(store): add item_workspace_moves provenance table (TASK-2356) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of PLAN-2357. Durable record of "this item was copied/moved from workspace A to workspace B", backing the forward redirect (TASK-2359) and the destination's back-pointer. Implements DR-2 / DR-2a. Paired, dual-dialect, forward-only migrations (migrations/077 + pgmigrations/055). archived_source distinguishes a move from a plain copy (INTEGER on SQLite, BOOLEAN on Postgres, written through dialect.BoolToInt). source_seq is a NULLABLE per-source move ordinal that exists solely so two moves inside the same second are orderable — created_at is second-precision RFC3339, so archive -> restore -> move again would otherwise resolve to an arbitrary destination. Partial index (source_item_id, source_seq DESC) WHERE archived_source, deliberately NOT unique: restore-then-move-again legitimately repeats. The back direction IS uniquely indexed — a destination item is created by exactly one copy, in the same transaction that writes its provenance row, so a duplicate there would silently change which source the back-pointer names. Cascade is asymmetric on purpose, inverting item_collection_moves: the archived source is precisely the row whose pointer must survive, so source_item_id carries no FK at all; target_item_id cascades, because a pointer at a vanished destination is worse than no pointer. Store accessors: a tx-taking insert helper (no self-committing variant — the row must land in the copy transaction), a forward lookup returning a SET newest-first, and a back lookup. The insert rejects an archived row with no seq and a copy row with one, so DR-2a's ordering invariant is enforced at the write boundary rather than assumed. NULL ordering is normalized with COALESCE because SQLite and Postgres disagree on DESC NULL placement. Also wires workspace purge, which the two-workspace shape requires: both workspace columns are RESTRICT references, so a purge clearing only one direction would fail outright when the purged workspace sits on the other end. Tests cover insert-in-tx, forward lookup with multiple destinations ordered newest-first and scoped to one source, back lookup, rollback leaving no row, and both DR-2a criteria. The ordering and scoping tests use fixed row IDs whose lexical order contradicts the expected answer, so deleting the ordering term or the WHERE clause under test fails them on every run rather than half the time; verified by mutating the production query. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- internal/models/item_workspace_move.go | 43 ++ internal/store/item_workspace_moves.go | 190 ++++++ internal/store/item_workspace_moves_test.go | 616 ++++++++++++++++++ .../migrations/077_item_workspace_moves.sql | 91 +++ .../pgmigrations/055_item_workspace_moves.sql | 55 ++ internal/store/workspace_purge.go | 9 + 6 files changed, 1004 insertions(+) create mode 100644 internal/models/item_workspace_move.go create mode 100644 internal/store/item_workspace_moves.go create mode 100644 internal/store/item_workspace_moves_test.go create mode 100644 internal/store/migrations/077_item_workspace_moves.sql create mode 100644 internal/store/pgmigrations/055_item_workspace_moves.sql diff --git a/internal/models/item_workspace_move.go b/internal/models/item_workspace_move.go new file mode 100644 index 00000000..fd41e26b --- /dev/null +++ b/internal/models/item_workspace_move.go @@ -0,0 +1,43 @@ +package models + +// ItemWorkspaceMove is one durable record of an item being copied — or moved, +// which is a copy plus an archive of the source — from one workspace to +// another. Written in the SAME transaction as the copy, so the provenance can +// never disagree with the data (PLAN-2357 DR-2). +// +// It backs exactly two lookups: +// +// - forward, by SourceItemID: "where did this item go?" One source can be +// copied into several workspaces, so the forward lookup is a SET. +// - back, by TargetItemID: "where did this item come from?" A destination +// item is created by exactly one copy, so this is at most one row. +// +// ArchivedSource distinguishes a move from a plain copy, and only a move +// produces a "moved to" pointer on the archived source (DR-2a). A source +// copied three times and then moved has four rows, and only the archived one +// says where it *went*. +type ItemWorkspaceMove struct { + ID string `json:"id"` + SourceWorkspaceID string `json:"source_workspace_id"` + SourceItemID string `json:"source_item_id"` + TargetWorkspaceID string `json:"target_workspace_id"` + TargetItemID string `json:"target_item_id"` + + // ArchivedSource is true when the source was archived as part of the + // operation (a move) and false for a plain copy. + ArchivedSource bool `json:"archived_source"` + + // SourceSeq is the workspace-scoped `seq` the source workspace assigned + // when it archived the source. It exists only to order one source's + // moves deterministically: CreatedAt is second-precision RFC3339, so two + // moves inside the same second tie and the "moved to" lookup could + // otherwise return an arbitrary destination. + // + // nil for plain copies, which never archive. Since the "moved to" lookup + // reads only ArchivedSource rows, nil values never participate in the + // ordering. + SourceSeq *int64 `json:"source_seq,omitempty"` + + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` +} diff --git a/internal/store/item_workspace_moves.go b/internal/store/item_workspace_moves.go new file mode 100644 index 00000000..5c54ca09 --- /dev/null +++ b/internal/store/item_workspace_moves.go @@ -0,0 +1,190 @@ +package store + +import ( + "database/sql" + "fmt" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// item_workspace_moves accessors — cross-workspace copy/move provenance +// (PLAN-2357 DR-2). See migrations/077_item_workspace_moves.sql for the +// schema rationale, and models.ItemWorkspaceMove for the row semantics. + +// itemWorkspaceMoveColumns is the fixed select list scanItemWorkspaceMove +// expects, in order. +const itemWorkspaceMoveColumns = `id, source_workspace_id, source_item_id, + target_workspace_id, target_item_id, archived_source, source_seq, + created_by, created_at` + +// itemWorkspaceMoveOrder is the shared newest-first ordering. +// +// created_at leads because the forward lookup returns copies AND moves, and +// copies carry no source_seq at all — ordering by seq first would bury every +// copy behind every move regardless of recency. created_at is UTC RFC3339 +// TEXT, so lexicographic order is chronological order. +// +// source_seq breaks the ties created_at cannot: it is second-precision, and +// archive → restore → move again inside one second is legal (DR-2a). Within a +// single second the higher source_seq is unambiguously the later move. +// COALESCE rather than "NULLS LAST" because SQLite and Postgres disagree on +// default NULL placement in DESC order; -1 sorts below every real seq on both. +// +// id is a final tiebreak so the ordering is total and the result set is +// stable across calls. +const itemWorkspaceMoveOrder = ` ORDER BY created_at DESC, COALESCE(source_seq, -1) DESC, id DESC` + +// RecordItemWorkspaceMoveTx inserts one provenance row inside an existing +// transaction. It is tx-taking by design: the row must be written in the same +// transaction as the destination create (and, on a move, the source archive), +// so a rollback can never leave a pointer at an item that does not exist — +// or lose the pointer for a copy that committed (DR-9). There is deliberately +// no self-committing variant; nothing would legitimately call one. +// +// ID and CreatedAt are filled in when blank. Callers doing a move should pass +// the CreatedAt they used for the archive so both rows agree, and MUST set +// SourceSeq to the seq that archive assigned — without it two moves in the +// same second are unorderable (DR-2a). +// +// Returns the stored row, including any generated ID/CreatedAt. +func (s *Store) RecordItemWorkspaceMoveTx(tx *sql.Tx, m models.ItemWorkspaceMove) (*models.ItemWorkspaceMove, error) { + for _, required := range []struct{ name, value string }{ + {"source_workspace_id", m.SourceWorkspaceID}, + {"source_item_id", m.SourceItemID}, + {"target_workspace_id", m.TargetWorkspaceID}, + {"target_item_id", m.TargetItemID}, + {"created_by", m.CreatedBy}, + } { + if required.value == "" { + return nil, fmt.Errorf("record item workspace move: %s is required", required.name) + } + } + + // SourceSeq is required exactly when the source was archived, and + // forbidden otherwise. Both halves are load-bearing (DR-2a): + // + // archived without a seq — two moves of one source inside the same + // second become unorderable and the moved-to pointer picks arbitrarily, + // which is the precise failure this column exists to prevent. The + // ordering would silently fall through to the ID tiebreak, so the + // damage is invisible rather than loud. + // + // a copy WITH a seq — a plain copy never archives, so it has no source + // workspace seq to record; a value there is a caller bug (most likely a + // move/copy mix-up) and would put a copy row into the move ordering if + // archived_source were ever recomputed from it. + if m.ArchivedSource && m.SourceSeq == nil { + return nil, fmt.Errorf("record item workspace move: source_seq is required when archived_source is set") + } + if !m.ArchivedSource && m.SourceSeq != nil { + return nil, fmt.Errorf("record item workspace move: source_seq must be nil for a plain copy (archived_source is false)") + } + + if m.ID == "" { + m.ID = newID() + } + if m.CreatedAt == "" { + m.CreatedAt = now() + } + + var seq interface{} + if m.SourceSeq != nil { + seq = *m.SourceSeq + } + + if _, err := tx.Exec(s.q(` + INSERT INTO item_workspace_moves ( + id, source_workspace_id, source_item_id, + target_workspace_id, target_item_id, + archived_source, source_seq, created_by, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), m.ID, m.SourceWorkspaceID, m.SourceItemID, + m.TargetWorkspaceID, m.TargetItemID, + s.dialect.BoolToInt(m.ArchivedSource), seq, m.CreatedBy, m.CreatedAt, + ); err != nil { + return nil, fmt.Errorf("record item workspace move: %w", err) + } + + return &m, nil +} + +// ListItemWorkspaceMovesBySource returns every destination one source item was +// copied or moved to, newest first. +// +// A SET, not a row: one source can be copied into several workspaces, and can +// additionally be moved after being restored. The caller decides how to render +// multiples. A caller resolving the archived-source "moved to" pointer wants +// the first entry with ArchivedSource true — plain-copy rows never feed that +// pointer (DR-2a). +func (s *Store) ListItemWorkspaceMovesBySource(sourceItemID string) ([]models.ItemWorkspaceMove, error) { + rows, err := s.db.Query(s.q(` + SELECT `+itemWorkspaceMoveColumns+` + FROM item_workspace_moves + WHERE source_item_id = ?`+itemWorkspaceMoveOrder, + ), sourceItemID) + if err != nil { + return nil, fmt.Errorf("list item workspace moves by source: %w", err) + } + defer rows.Close() + + moves := []models.ItemWorkspaceMove{} + for rows.Next() { + m, err := scanItemWorkspaceMove(rows) + if err != nil { + return nil, fmt.Errorf("list item workspace moves by source: %w", err) + } + moves = append(moves, *m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list item workspace moves by source: %w", err) + } + return moves, nil +} + +// GetItemWorkspaceMoveByTarget returns where a destination item came from, or +// (nil, nil) when the item was not produced by a cross-workspace copy. +// +// One row, unlike the forward lookup, and that is enforced rather than +// assumed: a destination item is created by exactly one copy operation whose +// provenance row is written in the same transaction, and +// uq_item_workspace_moves_target makes a second row naming the same target +// impossible. No ordering or LIMIT is needed as a result. +func (s *Store) GetItemWorkspaceMoveByTarget(targetItemID string) (*models.ItemWorkspaceMove, error) { + row := s.db.QueryRow(s.q(` + SELECT `+itemWorkspaceMoveColumns+` + FROM item_workspace_moves + WHERE target_item_id = ?`, + ), targetItemID) + + m, err := scanItemWorkspaceMove(row) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get item workspace move by target: %w", err) + } + return m, nil +} + +// scanner (api_tokens.go) is satisfied by both *sql.Row and *sql.Rows. +func scanItemWorkspaceMove(sc scanner) (*models.ItemWorkspaceMove, error) { + var ( + m models.ItemWorkspaceMove + archived interface{} + sourceSeq sql.NullInt64 + ) + if err := sc.Scan( + &m.ID, &m.SourceWorkspaceID, &m.SourceItemID, + &m.TargetWorkspaceID, &m.TargetItemID, + &archived, &sourceSeq, &m.CreatedBy, &m.CreatedAt, + ); err != nil { + return nil, err + } + // SQLite hands back INTEGER 0/1, Postgres a native bool. + m.ArchivedSource = scanBool(archived) + if sourceSeq.Valid { + seq := sourceSeq.Int64 + m.SourceSeq = &seq + } + return &m, nil +} diff --git a/internal/store/item_workspace_moves_test.go b/internal/store/item_workspace_moves_test.go new file mode 100644 index 00000000..377f7a5d --- /dev/null +++ b/internal/store/item_workspace_moves_test.go @@ -0,0 +1,616 @@ +package store + +import ( + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// moveFixture is a source workspace plus two destination workspaces, each +// with a collection, so cross-workspace provenance rows can be written +// against real items (target_item_id carries a FK to items). +type moveFixture struct { + s *Store + srcWS *models.Workspace + dstWS *models.Workspace + dst2WS *models.Workspace + source *models.Item + actor string +} + +func newMoveFixture(t *testing.T, name string) moveFixture { + t.Helper() + s := testStore(t) + + srcWS := createTestWorkspace(t, s, name+" Source") + dstWS := createTestWorkspace(t, s, name+" Dest") + dst2WS := createTestWorkspace(t, s, name+" Dest Two") + + srcColl := createTestCollection(t, s, srcWS.ID, "Tasks") + source := createTestItem(t, s, srcWS.ID, srcColl.ID, "Original", "") + + return moveFixture{s: s, srcWS: srcWS, dstWS: dstWS, dst2WS: dst2WS, source: source, actor: "user-1"} +} + +// dest creates a destination item in ws and returns it. +func (f moveFixture) dest(t *testing.T, ws *models.Workspace, title string) *models.Item { + t.Helper() + coll, err := f.s.CreateCollection(ws.ID, models.CollectionCreate{ + Name: title + " Coll", + Schema: `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`, + }) + if err != nil { + t.Fatalf("create destination collection: %v", err) + } + return createTestItem(t, f.s, ws.ID, coll.ID, title, "") +} + +// record writes one provenance row through a committed transaction. +func (f moveFixture) record(t *testing.T, m models.ItemWorkspaceMove) *models.ItemWorkspaceMove { + t.Helper() + tx, err := f.s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + stored, err := f.s.RecordItemWorkspaceMoveTx(tx, m) + if err != nil { + t.Fatalf("RecordItemWorkspaceMoveTx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + return stored +} + +func seqPtr(v int64) *int64 { return &v } + +// TestRecordItemWorkspaceMoveTx_RoundTrip covers insert-in-tx plus both +// lookups, and asserts every column survives the dialect round-trip — +// notably archived_source (INTEGER on SQLite, BOOLEAN on Postgres) and the +// nullable source_seq. +func TestRecordItemWorkspaceMoveTx_RoundTrip(t *testing.T) { + f := newMoveFixture(t, "RoundTrip") + target := f.dest(t, f.dstWS, "Copy") + + stored := f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, + SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, + TargetItemID: target.ID, + ArchivedSource: true, + SourceSeq: seqPtr(42), + CreatedBy: f.actor, + }) + if stored.ID == "" { + t.Fatal("ID not generated") + } + if stored.CreatedAt == "" { + t.Fatal("CreatedAt not generated") + } + + forward, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("ListItemWorkspaceMovesBySource: %v", err) + } + if len(forward) != 1 { + t.Fatalf("forward lookup: got %d rows, want 1", len(forward)) + } + got := forward[0] + if got.ID != stored.ID { + t.Errorf("ID: got %q, want %q", got.ID, stored.ID) + } + if got.SourceWorkspaceID != f.srcWS.ID || got.SourceItemID != f.source.ID { + t.Errorf("source columns wrong: %+v", got) + } + if got.TargetWorkspaceID != f.dstWS.ID || got.TargetItemID != target.ID { + t.Errorf("target columns wrong: %+v", got) + } + if !got.ArchivedSource { + t.Error("ArchivedSource lost in round-trip; want true") + } + if got.SourceSeq == nil || *got.SourceSeq != 42 { + t.Errorf("SourceSeq: got %v, want 42", got.SourceSeq) + } + if got.CreatedBy != f.actor { + t.Errorf("CreatedBy: got %q, want %q", got.CreatedBy, f.actor) + } + if got.CreatedAt != stored.CreatedAt { + t.Errorf("CreatedAt: got %q, want %q", got.CreatedAt, stored.CreatedAt) + } + + // Back lookup finds the same row from the destination side. + back, err := f.s.GetItemWorkspaceMoveByTarget(target.ID) + if err != nil { + t.Fatalf("GetItemWorkspaceMoveByTarget: %v", err) + } + if back == nil { + t.Fatal("back lookup returned nil for a recorded target") + } + if back.ID != stored.ID || back.SourceItemID != f.source.ID { + t.Errorf("back lookup returned wrong row: %+v", back) + } + + // A target with no provenance is (nil, nil), not an error. + none, err := f.s.GetItemWorkspaceMoveByTarget(f.source.ID) + if err != nil { + t.Fatalf("GetItemWorkspaceMoveByTarget (absent): %v", err) + } + if none != nil { + t.Errorf("expected nil for an item that was never a copy target, got %+v", none) + } +} + +// TestRecordItemWorkspaceMoveTx_CopyLeavesSeqNull pins the copy-vs-move +// distinction: a plain copy never archives, so it has no source workspace seq +// and must store NULL. +func TestRecordItemWorkspaceMoveTx_CopyLeavesSeqNull(t *testing.T) { + f := newMoveFixture(t, "CopySeqNull") + target := f.dest(t, f.dstWS, "Copy") + + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, + SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, + TargetItemID: target.ID, + ArchivedSource: false, + CreatedBy: f.actor, + }) + + forward, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("forward lookup: %v", err) + } + if len(forward) != 1 { + t.Fatalf("got %d rows, want 1", len(forward)) + } + if forward[0].ArchivedSource { + t.Error("ArchivedSource: got true, want false for a plain copy") + } + if forward[0].SourceSeq != nil { + t.Errorf("SourceSeq: got %v, want nil for a plain copy", *forward[0].SourceSeq) + } +} + +// Fixed row IDs, used where a test must survive mutation of the ORDER BY. +// The ordering ends in `id DESC`, so a test that lets the helper mint random +// UUIDs would pick the right answer roughly half the time even with the +// meaningful ordering term deleted. Assigning IDs whose lexical order +// contradicts the expected answer makes the id tiebreak actively hostile: +// drop the term under test and the assertion fails every run, not sometimes. +const ( + lexHighMoveID = "ffffffff-ffff-ffff-ffff-ffffffffffff" + lexLowMoveID = "00000000-0000-0000-0000-000000000000" +) + +// TestListItemWorkspaceMovesBySource_MultipleDestinationsNewestFirst covers +// the "forward lookup returns a SET" contract: one source copied into several +// workspaces yields several rows, newest first — and only that source's rows. +func TestListItemWorkspaceMovesBySource_MultipleDestinationsNewestFirst(t *testing.T) { + f := newMoveFixture(t, "MultiDest") + first := f.dest(t, f.dstWS, "First") + second := f.dest(t, f.dst2WS, "Second") + + // The OLDER row gets the lexically HIGHER id, so the created_at ordering + // is the only thing that can put `second` in front. + f.record(t, models.ItemWorkspaceMove{ + ID: lexHighMoveID, + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: first.ID, + CreatedBy: f.actor, CreatedAt: "2026-01-01T00:00:00Z", + }) + f.record(t, models.ItemWorkspaceMove{ + ID: lexLowMoveID, + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dst2WS.ID, TargetItemID: second.ID, + CreatedBy: f.actor, CreatedAt: "2026-01-02T00:00:00Z", + }) + + // A row belonging to a DIFFERENT source in the same workspace. Without it + // the lookup's WHERE clause could be deleted entirely and every + // assertion here would still hold. + otherSource := createTestItem(t, f.s, f.srcWS.ID, + createTestCollection(t, f.s, f.srcWS.ID, "Other Src").ID, "Other Source", "") + otherTarget := f.dest(t, f.dstWS, "Other Target") + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: otherSource.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: otherTarget.ID, + CreatedBy: f.actor, CreatedAt: "2026-01-03T00:00:00Z", + }) + + forward, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("forward lookup: %v", err) + } + if len(forward) != 2 { + t.Fatalf("got %d rows, want 2 (the other source's row must be excluded)", len(forward)) + } + for _, m := range forward { + if m.SourceItemID != f.source.ID { + t.Errorf("forward lookup leaked a row for source %q", m.SourceItemID) + } + } + if forward[0].TargetItemID != second.ID { + t.Errorf("newest-first violated: first row targets %q, want the later copy %q", + forward[0].TargetItemID, second.ID) + } + if forward[1].TargetItemID != first.ID { + t.Errorf("second row targets %q, want the earlier copy %q", forward[1].TargetItemID, first.ID) + } + + // Each destination resolves back to its OWN row — asserting the target + // too, so a lookup ignoring its WHERE clause is caught here as well. + backCases := map[*models.Item]string{first: f.source.ID, second: f.source.ID, otherTarget: otherSource.ID} + for target, wantSource := range backCases { + back, err := f.s.GetItemWorkspaceMoveByTarget(target.ID) + if err != nil { + t.Fatalf("back lookup for %s: %v", target.ID, err) + } + if back == nil { + t.Errorf("back lookup for %s returned nil", target.ID) + continue + } + if back.TargetItemID != target.ID { + t.Errorf("back lookup for %s returned a row targeting %q", target.ID, back.TargetItemID) + } + if back.SourceItemID != wantSource { + t.Errorf("back lookup for %s resolved to source %q, want %q", target.ID, back.SourceItemID, wantSource) + } + } +} + +// TestRecordItemWorkspaceMoveTx_RollbackLeavesNoRow proves the row is bound to +// the caller's transaction: this is the whole reason the helper is tx-taking +// rather than self-committing (DR-9). +func TestRecordItemWorkspaceMoveTx_RollbackLeavesNoRow(t *testing.T) { + f := newMoveFixture(t, "Rollback") + target := f.dest(t, f.dstWS, "Copy") + + tx, err := f.s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + if _, err := f.s.RecordItemWorkspaceMoveTx(tx, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: target.ID, + ArchivedSource: true, SourceSeq: seqPtr(7), CreatedBy: f.actor, + }); err != nil { + t.Fatalf("RecordItemWorkspaceMoveTx: %v", err) + } + if err := tx.Rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + + forward, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("forward lookup: %v", err) + } + if len(forward) != 0 { + t.Errorf("rolled-back tx left %d provenance rows behind", len(forward)) + } + back, err := f.s.GetItemWorkspaceMoveByTarget(target.ID) + if err != nil { + t.Fatalf("back lookup: %v", err) + } + if back != nil { + t.Errorf("rolled-back tx left a back-pointer: %+v", back) + } +} + +// TestRecordItemWorkspaceMoveTx_RequiresIdentifiers guards the write boundary: +// source_item_id and target_item_id carry no workspace FK pair that would +// catch a blank, and a blank created_by would violate NOT NULL only at the +// driver layer with a far worse error. +func TestRecordItemWorkspaceMoveTx_RequiresIdentifiers(t *testing.T) { + f := newMoveFixture(t, "Validation") + target := f.dest(t, f.dstWS, "Copy") + + valid := models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: target.ID, + CreatedBy: f.actor, + } + + cases := map[string]func(*models.ItemWorkspaceMove){ + "source_workspace_id": func(m *models.ItemWorkspaceMove) { m.SourceWorkspaceID = "" }, + "source_item_id": func(m *models.ItemWorkspaceMove) { m.SourceItemID = "" }, + "target_workspace_id": func(m *models.ItemWorkspaceMove) { m.TargetWorkspaceID = "" }, + "target_item_id": func(m *models.ItemWorkspaceMove) { m.TargetItemID = "" }, + "created_by": func(m *models.ItemWorkspaceMove) { m.CreatedBy = "" }, + } + for name, blank := range cases { + t.Run(name, func(t *testing.T) { + tx, err := f.s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + m := valid + blank(&m) + if _, err := f.s.RecordItemWorkspaceMoveTx(tx, m); err == nil { + t.Errorf("blank %s was accepted", name) + } + }) + } +} + +// TestRecordItemWorkspaceMoveTx_SourceSeqMatchesArchivedSource pins the +// invariant that makes the same-second ordering below trustworthy: a move +// MUST carry the seq its archive assigned, and a copy — which never archives +// — must not carry one. An archived row with a nil seq would silently fall +// through to the ID tiebreak, which is exactly the arbitrary answer source_seq +// exists to prevent (DR-2a). +func TestRecordItemWorkspaceMoveTx_SourceSeqMatchesArchivedSource(t *testing.T) { + f := newMoveFixture(t, "SeqInvariant") + target := f.dest(t, f.dstWS, "Copy") + + base := models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: target.ID, + CreatedBy: f.actor, + } + + cases := []struct { + name string + archived bool + seq *int64 + }{ + {"move without seq", true, nil}, + {"copy with seq", false, seqPtr(5)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tx, err := f.s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + m := base + m.ArchivedSource = tc.archived + m.SourceSeq = tc.seq + if _, err := f.s.RecordItemWorkspaceMoveTx(tx, m); err == nil { + t.Errorf("%s was accepted; want an error", tc.name) + } + }) + } +} + +// TestItemWorkspaceMoves_TargetIsUnique proves the back-pointer cannot become +// ambiguous. Two rows naming one destination is a bug by construction (the +// row is written in the transaction that creates the target), and left +// unenforced it would silently change which source the back lookup names. +func TestItemWorkspaceMoves_TargetIsUnique(t *testing.T) { + f := newMoveFixture(t, "TargetUnique") + target := f.dest(t, f.dstWS, "Copy") + otherSource := createTestItem(t, f.s, f.srcWS.ID, + createTestCollection(t, f.s, f.srcWS.ID, "Other Src").ID, "Other Source", "") + + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: target.ID, + CreatedBy: f.actor, + }) + + tx, err := f.s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + if _, err := f.s.RecordItemWorkspaceMoveTx(tx, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: otherSource.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: target.ID, + CreatedBy: f.actor, + }); err == nil { + t.Error("a second provenance row for the same target was accepted") + } +} + +// TestItemWorkspaceMoves_MovedToIgnoresCopies is DR-2a's first acceptance +// criterion: copy twice, then move. The archived source advertises the MOVE +// target only — neither copy claims the source went anywhere. +func TestItemWorkspaceMoves_MovedToIgnoresCopies(t *testing.T) { + f := newMoveFixture(t, "MovedToIgnoresCopies") + copyA := f.dest(t, f.dstWS, "Copy A") + copyB := f.dest(t, f.dst2WS, "Copy B") + moveTarget := f.dest(t, f.dstWS, "Move Target") + + // Two plain copies FIRST, then the move — and the copies carry LATER + // created_at values than the move, so a naive "newest row wins" that + // forgets to filter on archived_source picks a copy and fails here. + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: copyA.ID, + ArchivedSource: false, CreatedBy: f.actor, CreatedAt: "2026-03-02T00:00:00Z", + }) + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dst2WS.ID, TargetItemID: copyB.ID, + ArchivedSource: false, CreatedBy: f.actor, CreatedAt: "2026-03-03T00:00:00Z", + }) + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: moveTarget.ID, + ArchivedSource: true, SourceSeq: seqPtr(10), + CreatedBy: f.actor, CreatedAt: "2026-03-01T00:00:00Z", + }) + + forward, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("forward lookup: %v", err) + } + if len(forward) != 3 { + t.Fatalf("got %d rows, want 3", len(forward)) + } + + movedTo := firstArchived(forward) + if movedTo == nil { + t.Fatal("no archived_source row found; the move produced no moved-to pointer") + } + if movedTo.TargetItemID != moveTarget.ID { + t.Errorf("moved-to resolved to %q, want the move target %q", movedTo.TargetItemID, moveTarget.ID) + } +} + +// TestItemWorkspaceMoves_MovedToSameSecondUsesSourceSeq is the criterion that +// justifies source_seq existing at all: two restore→move cycles inside the +// SAME second. created_at is second-precision RFC3339, so it ties, and only +// source_seq can say which destination is the later one. Without the seq +// ordering this test passes or fails by luck. +func TestItemWorkspaceMoves_MovedToSameSecondUsesSourceSeq(t *testing.T) { + f := newMoveFixture(t, "SameSecond") + earlier := f.dest(t, f.dstWS, "Earlier Move") + later := f.dest(t, f.dst2WS, "Later Move") + + const sameSecond = "2026-04-01T12:00:00Z" + + // Insert the LATER move first so row insertion order can't be what makes + // the assertion pass — and give it the lexically LOWEST id while the + // earlier move gets the highest. created_at is identical, so with the + // source_seq term removed from the ordering the query falls through to + // `id DESC` and returns `earlier` DETERMINISTICALLY. That is the point: + // with random UUIDs this test would still pass half the time against a + // query that had lost the very ordering it exists to prove. + f.record(t, models.ItemWorkspaceMove{ + ID: lexLowMoveID, + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dst2WS.ID, TargetItemID: later.ID, + ArchivedSource: true, SourceSeq: seqPtr(200), + CreatedBy: f.actor, CreatedAt: sameSecond, + }) + f.record(t, models.ItemWorkspaceMove{ + ID: lexHighMoveID, + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: earlier.ID, + ArchivedSource: true, SourceSeq: seqPtr(100), + CreatedBy: f.actor, CreatedAt: sameSecond, + }) + + forward, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("forward lookup: %v", err) + } + if len(forward) != 2 { + t.Fatalf("got %d rows, want 2 (restore-then-move-again must NOT be deduped)", len(forward)) + } + + movedTo := firstArchived(forward) + if movedTo == nil { + t.Fatal("no archived_source row found") + } + if movedTo.TargetItemID != later.ID { + t.Errorf("same-second tie resolved to %q, want the higher-seq destination %q", + movedTo.TargetItemID, later.ID) + } + if movedTo.SourceSeq == nil || *movedTo.SourceSeq != 200 { + t.Errorf("moved-to row carries seq %v, want 200", movedTo.SourceSeq) + } +} + +// firstArchived mirrors how the moved-to consumer (TASK-2359) reads the +// forward set: the newest row whose source was archived. +func firstArchived(moves []models.ItemWorkspaceMove) *models.ItemWorkspaceMove { + for i := range moves { + if moves[i].ArchivedSource { + return &moves[i] + } + } + return nil +} + +// TestPurgeWorkspaceData_ClearsItemWorkspaceMovesBothDirections covers the FK +// hazard the two-workspace shape introduces: item_workspace_moves references +// workspaces(id) twice with RESTRICT, so a purge that cleared only one +// direction would fail outright when the purged workspace sits on the other +// end. +func TestPurgeWorkspaceData_ClearsItemWorkspaceMovesBothDirections(t *testing.T) { + f := newMoveFixture(t, "Purge") + s := f.s + + // f.srcWS is the workspace we purge. One row where it is the SOURCE, one + // where it is the TARGET. + outbound := f.dest(t, f.dstWS, "Outbound") + inboundSource := createTestItem(t, s, f.dstWS.ID, + createTestCollection(t, s, f.dstWS.ID, "Inbound Src Coll").ID, "Inbound Source", "") + inboundTarget := createTestItem(t, s, f.srcWS.ID, + createTestCollection(t, s, f.srcWS.ID, "Inbound Dst Coll").ID, "Inbound Target", "") + + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: outbound.ID, + ArchivedSource: true, SourceSeq: seqPtr(1), CreatedBy: f.actor, + }) + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.dstWS.ID, SourceItemID: inboundSource.ID, + TargetWorkspaceID: f.srcWS.ID, TargetItemID: inboundTarget.ID, + CreatedBy: f.actor, + }) + + // A row entirely inside the surviving workspace must NOT be over-purged. + bystanderSrc := createTestItem(t, s, f.dstWS.ID, + createTestCollection(t, s, f.dstWS.ID, "Bystander Src").ID, "Bystander Source", "") + bystanderDst := createTestItem(t, s, f.dst2WS.ID, + createTestCollection(t, s, f.dst2WS.ID, "Bystander Dst").ID, "Bystander Target", "") + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.dstWS.ID, SourceItemID: bystanderSrc.ID, + TargetWorkspaceID: f.dst2WS.ID, TargetItemID: bystanderDst.ID, + CreatedBy: f.actor, + }) + + if got := s.countRows(t, `SELECT COUNT(*) FROM item_workspace_moves`); got != 3 { + t.Fatalf("seed: got %d provenance rows, want 3", got) + } + + if err := s.DeleteWorkspace(f.srcWS.Slug); err != nil { + t.Fatalf("soft-delete: %v", err) + } + if err := s.PurgeWorkspaceData(f.srcWS.ID); err != nil { + t.Fatalf("PurgeWorkspaceData: %v", err) + } + + if got := s.countRows(t, + `SELECT COUNT(*) FROM item_workspace_moves WHERE source_workspace_id = ? OR target_workspace_id = ?`, + f.srcWS.ID, f.srcWS.ID); got != 0 { + t.Errorf("purge left %d rows referencing the purged workspace", got) + } + if got := s.countRows(t, `SELECT COUNT(*) FROM item_workspace_moves`); got != 1 { + t.Errorf("after purge: got %d provenance rows, want 1 (the bystander pair)", got) + } +} + +// TestItemWorkspaceMoves_TargetDeleteCascades pins the asymmetric cascade +// choice: a deleted DESTINATION item takes its provenance row with it (a +// pointer at a vanished item is worse than none), while the SOURCE side +// carries no FK at all — the archived source is precisely the row whose +// pointer must survive. +func TestItemWorkspaceMoves_TargetDeleteCascades(t *testing.T) { + f := newMoveFixture(t, "Cascade") + target := f.dest(t, f.dstWS, "Copy") + + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: target.ID, + ArchivedSource: true, SourceSeq: seqPtr(3), CreatedBy: f.actor, + }) + + // Hard-deleting the SOURCE must leave the row intact. (Per-item hard + // delete has no product surface today — this asserts the schema choice, + // not a live code path.) + if _, err := f.s.db.Exec(f.s.q(`DELETE FROM items WHERE id = ?`), f.source.ID); err != nil { + t.Fatalf("hard-delete source: %v", err) + } + if got := f.s.countRows(t, `SELECT COUNT(*) FROM item_workspace_moves`); got != 1 { + t.Fatalf("source hard-delete removed the provenance row; got %d rows, want 1", got) + } + + // Hard-deleting the TARGET must cascade the row away. + if _, err := f.s.db.Exec(f.s.q(`DELETE FROM items WHERE id = ?`), target.ID); err != nil { + t.Fatalf("hard-delete target: %v", err) + } + if got := f.s.countRows(t, `SELECT COUNT(*) FROM item_workspace_moves`); got != 0 { + t.Errorf("target hard-delete did not cascade; got %d rows, want 0", got) + } +} diff --git a/internal/store/migrations/077_item_workspace_moves.sql b/internal/store/migrations/077_item_workspace_moves.sql new file mode 100644 index 00000000..692769bd --- /dev/null +++ b/internal/store/migrations/077_item_workspace_moves.sql @@ -0,0 +1,91 @@ +-- Migration 077: cross-workspace copy/move provenance (PLAN-2357 DR-2). +-- +-- Records "this item was copied (or moved) from workspace A to workspace B". +-- Written in the SAME transaction as the copy (and, on a move, the archive of +-- the source), so the pointer can never disagree with the data. +-- +-- Why a table rather than items.content or items.fields: content carries +-- collab/Yjs op-log semantics and mutating it reads as a lie after a restore; +-- fields is schema-validated per collection and has no reserved-key escape +-- hatch. Decisively, mutating resolution excludes archived items, so a +-- content- or field-based pointer would have to be written BEFORE the archive +-- with no way to patch it afterward. A row in the same tx has no such +-- ordering constraint. +-- +-- Two lookups, both hot, both indexed below: +-- forward — WHERE source_item_id = ? ("where did this go?") +-- back — WHERE target_item_id = ? ("where did this come from?") +-- +-- Shape precedent is item_collection_moves (migrations/066), but ONLY the +-- shape. That table's `seq` is a workspace delta-sync cursor; `source_seq` +-- here is a per-source move ordinal with an entirely different job (below). +CREATE TABLE IF NOT EXISTS item_workspace_moves ( + id TEXT PRIMARY KEY, + source_workspace_id TEXT NOT NULL REFERENCES workspaces(id), + source_item_id TEXT NOT NULL, + target_workspace_id TEXT NOT NULL REFERENCES workspaces(id), + target_item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + -- Move (true) vs plain copy (false). Only moves feed the archived-source + -- "moved to" banner; copy rows are back-pointer material only. Written + -- through dialect.BoolToInt, hence INTEGER here and BOOLEAN in the + -- Postgres counterpart. + archived_source INTEGER NOT NULL, + -- NULLABLE, and it exists for exactly one reason: deterministic ordering + -- of one source's moves. A source can be moved, restored, and moved + -- again, so the banner must pick the NEWEST archived_source row — and + -- created_at cannot break the tie because timestamps in this schema are + -- second-precision RFC3339. This stores the workspace-A `seq` that the + -- archive assigned: monotonic within A, and the source always lives in A, + -- so it totally orders that source's moves. Plain copies never archive + -- and so have no A seq; they are NULL, and since the banner reads only + -- archived_source rows, NULLs never participate in the ordering. + -- + -- This is NOT item_collection_moves' delta-sync cursor. Do not wire it to + -- any workspace cursor. + source_seq BIGINT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- FK / cascade choices, which deliberately differ from item_collection_moves: +-- +-- source_item_id has NO foreign key and therefore no cascade. 066 cascades +-- on item_id because a hard-deleted item has no meaningful move history; +-- here the inverse holds — the archived source is precisely the row whose +-- pointer must survive. Per-item hard delete does not currently exist (only +-- workspace purge), so this is future-proofing, but the direction matters. +-- +-- target_item_id DOES cascade: if the destination item is gone, a pointer +-- at it is worse than no pointer. +-- +-- Both workspace columns are plain RESTRICT references. Workspace purge +-- clears them explicitly (workspace_purge.go), in BOTH directions, so a +-- purge of either side removes the row. +-- +-- created_by is unconstrained (like item_links.created_by) so account +-- deletion has nothing to cascade or NULL here. + +-- The archived-source "moved to" lookup: newest archived row for one source. +-- PARTIAL, and deliberately NOT UNIQUE — archive → restore → move again +-- legitimately produces a second archived row for the same source, so a +-- uniqueness constraint would be wrong. +CREATE INDEX IF NOT EXISTS idx_item_workspace_moves_moved_to + ON item_workspace_moves(source_item_id, source_seq DESC) + WHERE archived_source = 1; + +-- Forward lookup over ALL rows (copies included); the partial index above +-- cannot serve it. +CREATE INDEX IF NOT EXISTS idx_item_workspace_moves_source + ON item_workspace_moves(source_item_id); + +-- Back lookup, and the index the target_item_id cascade needs. +-- +-- UNIQUE, unlike the forward index above, and the asymmetry is the point: a +-- destination item is created by exactly one copy, and its provenance row is +-- written in that same transaction, so two rows naming one target is a bug by +-- construction. Left unenforced, a duplicate would silently change which +-- source the back-pointer names. The forward direction has no such +-- constraint — one source legitimately fans out to many destinations, and +-- archive → restore → move again legitimately repeats. +CREATE UNIQUE INDEX IF NOT EXISTS uq_item_workspace_moves_target + ON item_workspace_moves(target_item_id); diff --git a/internal/store/pgmigrations/055_item_workspace_moves.sql b/internal/store/pgmigrations/055_item_workspace_moves.sql new file mode 100644 index 00000000..5e2e0ff6 --- /dev/null +++ b/internal/store/pgmigrations/055_item_workspace_moves.sql @@ -0,0 +1,55 @@ +-- Migration 055 (Postgres): cross-workspace copy/move provenance +-- (PLAN-2357 DR-2). Postgres counterpart to +-- internal/store/migrations/077_item_workspace_moves.sql — same intent, same +-- columns, same indexes; see that file for the full rationale. +-- +-- Two dialect differences, both mechanical: +-- * archived_source is BOOLEAN here and INTEGER in SQLite (the value is +-- written through dialect.BoolToInt, which yields a native bool on +-- Postgres and 0/1 on SQLite). +-- * the partial index predicate is `= TRUE` rather than `= 1`. +CREATE TABLE IF NOT EXISTS item_workspace_moves ( + id TEXT PRIMARY KEY, + source_workspace_id TEXT NOT NULL REFERENCES workspaces(id), + source_item_id TEXT NOT NULL, + target_workspace_id TEXT NOT NULL REFERENCES workspaces(id), + target_item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, + -- Move (true) vs plain copy (false). Only moves feed the archived-source + -- "moved to" banner; copy rows are back-pointer material only. + archived_source BOOLEAN NOT NULL, + -- NULLABLE. Deterministic ordering of one source's moves, and nothing + -- else: created_at is second-precision RFC3339 and cannot break a tie + -- between two moves in the same second. Holds the workspace-A `seq` the + -- archive assigned (monotonic within A). NULL for plain copies, which + -- never archive and never participate in the banner's ordering. + -- + -- NOT item_collection_moves' delta-sync cursor, despite the resemblance. + source_seq BIGINT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- source_item_id intentionally carries NO foreign key: the archived source is +-- exactly the row whose pointer must survive, so it must not cascade. +-- target_item_id does cascade — a pointer at a deleted destination is worse +-- than no pointer. Workspace purge clears both workspace directions +-- explicitly (workspace_purge.go). + +-- Archived-source "moved to" lookup. PARTIAL, deliberately NOT UNIQUE: +-- archive → restore → move again legitimately yields a second archived row. +CREATE INDEX IF NOT EXISTS idx_item_workspace_moves_moved_to + ON item_workspace_moves(source_item_id, source_seq DESC) + WHERE archived_source = TRUE; + +-- Forward lookup over ALL rows (copies included). +CREATE INDEX IF NOT EXISTS idx_item_workspace_moves_source + ON item_workspace_moves(source_item_id); + +-- Back lookup, and the index the target_item_id cascade needs. UNIQUE, +-- unlike the forward index: a destination item is created by exactly one +-- copy, in the same transaction that writes this row, so two rows naming one +-- target is a bug by construction and would silently change which source the +-- back-pointer names. The forward direction is deliberately non-unique — one +-- source fans out to many destinations. +CREATE UNIQUE INDEX IF NOT EXISTS uq_item_workspace_moves_target + ON item_workspace_moves(target_item_id); diff --git a/internal/store/workspace_purge.go b/internal/store/workspace_purge.go index 604e1662..9b9f5075 100644 --- a/internal/store/workspace_purge.go +++ b/internal/store/workspace_purge.go @@ -177,6 +177,15 @@ var purgeWorkspaceChildDeletes = []struct{ what, query string }{ {"item yjs op-log", `DELETE FROM item_yjs_updates WHERE item_id IN (SELECT id FROM items WHERE workspace_id = ?)`}, {"item wiki links", `DELETE FROM item_wiki_links WHERE source_item_id IN (SELECT id FROM items WHERE workspace_id = ?)`}, {"item collection moves", `DELETE FROM item_collection_moves WHERE workspace_id = ?`}, + // item_workspace_moves spans TWO workspaces, so it needs clearing from + // both directions — a row survives only while both endpoints do. Split + // into two single-placeholder statements because the loop below binds + // exactly one arg per entry. Purging the source workspace drops the + // destination's back-pointer; purging the destination drops the archived + // source's "moved to" pointer. Both are correct: a pointer at a purged + // workspace is worse than no pointer. (PLAN-2357 DR-2.) + {"item workspace moves (source side)", `DELETE FROM item_workspace_moves WHERE source_workspace_id = ?`}, + {"item workspace moves (target side)", `DELETE FROM item_workspace_moves WHERE target_workspace_id = ?`}, {"item grants", `DELETE FROM item_grants WHERE workspace_id = ?`}, {"status transitions", `DELETE FROM status_transitions WHERE workspace_id = ?`}, // --- items (self-ref parent_id NULLed by PurgeWorkspaceData first) --- From 5804c801466cee9dd55bc36884a5553decf6a3c9 Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 30 Jul 2026 01:11:38 +0000 Subject: [PATCH 2/6] feat(server): add cross-workspace authorization helper (TASK-2358) --- internal/server/authz_cross_workspace.go | 764 ++++++++++++ internal/server/authz_cross_workspace_test.go | 1067 +++++++++++++++++ internal/server/handlers_ref_resolver.go | 49 +- internal/server/server.go | 28 +- 4 files changed, 1883 insertions(+), 25 deletions(-) create mode 100644 internal/server/authz_cross_workspace.go create mode 100644 internal/server/authz_cross_workspace_test.go diff --git a/internal/server/authz_cross_workspace.go b/internal/server/authz_cross_workspace.go new file mode 100644 index 00000000..09ff3223 --- /dev/null +++ b/internal/server/authz_cross_workspace.go @@ -0,0 +1,764 @@ +package server + +import ( + "errors" + "net/http" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Cross-workspace authorization (PLAN-2357 / TASK-2358, DR-10). +// +// ############################################################ +// # requireEditPermission MUST NEVER be called with a # +// # workspace ID other than the one the current request's # +// # URL resolved to. Neither must requireItemVisible, # +// # requireMinRole, requireRole, or anything else that reads # +// # workspaceRole(r). # +// ############################################################ +// +// requireEditPermission (server.go) accepts `workspaceID` as a +// parameter, so it LOOKS reusable for a second workspace. It is not. +// Its editor/owner fast path is `role := workspaceRole(r)` — and +// RequireWorkspaceAccess (middleware_auth.go) only ever populates +// that context value for the workspace named in the request URL. +// Hand it workspace B's ID and it happily applies the caller's +// workspace A role to workspace B. An editor in A with no membership +// at all in B sails straight through the fast path. That is +// privilege escalation, not a rough edge. +// +// The second half of the same trap: the OAuth/MCP consent allow-list +// is applied AUTOMATICALLY in exactly one place — +// RequireWorkspaceAccess's call to tokenAllowedWorkspaceMatches +// (middleware_auth.go), on the workspace in the URL. Every other +// surface that touches a workspace the URL didn't name has to call it +// by hand, and the ones that do (handlers_search.go's cross-workspace +// search, handlers_workspaces.go's restore, handlers_collab.go's +// item-ID-addressed WebSocket upgrade) are the precedent. Miss it and +// a token the user consented to workspace A alone reads and writes +// workspace B unchecked. The comparison is always against the SECOND +// workspace's CANONICAL slug — consent persists slugs, and the caller +// may have addressed the workspace by UUID. +// +// Everything in this file exists so callers never have to remember +// either of those. Use AuthorizeCrossWorkspaceRead / +// AuthorizeCrossWorkspaceEdit for any workspace that is not the +// request's own. +// +// The closest prior art is Store.GetCrossWorkspaceBacklinks +// (internal/store/wiki_links.go), which hand-rolls the same two +// things: the bearer-vs-cookie admin split (BUG-1616/1617) and the +// manual allow-list test. +// +// handlers_ref_resolver.go is NOT a template. It replays the +// membership logic but skips the allow-list entirely, and it is +// bearer-reachable (the route sits inside the full middleware stack, +// so PATs and CLI session bearers hit it) — so its consent gap is a +// real one, mitigated only by the route disclosing nothing but a +// redirect. Do not copy it for anything that returns data. + +// CrossWorkspaceDenialReason explains why an authorization attempt +// against a second workspace failed. The empty value means "allowed". +// +// The reason exists so the CALLER decides the HTTP shape, deliberately +// — see the disclosure note on CrossWorkspaceAccess. Do not derive a +// status code from it ad hoc; use one of the Write* helpers, or add a +// new one here so the posture stays reviewable in one place. +type CrossWorkspaceDenialReason string + +const ( + // CrossWorkspaceAllowed is the zero value: no denial. + CrossWorkspaceAllowed CrossWorkspaceDenialReason = "" + + // CrossWorkspaceInvalidScope means the caller handed in a scope + // that names nothing (the zero CrossWorkspaceScope). Programmer + // error; treated as a denial so the failure mode is closed rather + // than "workspace-level check only". + CrossWorkspaceInvalidScope CrossWorkspaceDenialReason = "invalid_scope" + + // CrossWorkspaceWorkspaceNotFound means the slug/ID resolved to + // nothing the caller may address. Soft-deleted workspaces land + // here too — every resolveWorkspace path filters + // `deleted_at IS NULL`. + CrossWorkspaceWorkspaceNotFound CrossWorkspaceDenialReason = "workspace_not_found" + + // CrossWorkspaceNoWorkspaceAccess means the workspace exists but + // the caller has neither membership nor grants in it. + CrossWorkspaceNoWorkspaceAccess CrossWorkspaceDenialReason = "no_workspace_access" + + // CrossWorkspaceTokenNotAllowed means the caller is a genuine + // member of the target workspace, but the bearer token's consent + // allow-list does not include it. This is the denial a naive + // implementation misses. + CrossWorkspaceTokenNotAllowed CrossWorkspaceDenialReason = "token_not_allowed" + + // CrossWorkspaceScopeMismatch means the scope's item or collection + // does not belong to the resolved workspace. Confused-deputy + // guard; always fail closed. + CrossWorkspaceScopeMismatch CrossWorkspaceDenialReason = "scope_mismatch" + + // CrossWorkspaceCollectionNotVisible means the scope's collection + // is absent, soft-deleted, or hidden from the caller (DR-10a step + // 1). + CrossWorkspaceCollectionNotVisible CrossWorkspaceDenialReason = "collection_not_visible" + + // CrossWorkspaceItemNotVisible means the scope's item is hidden + // from the caller (DR-10b step 1). + CrossWorkspaceItemNotVisible CrossWorkspaceDenialReason = "item_not_visible" + + // CrossWorkspaceInsufficientPermission means the caller can see + // the scoped resource but may not write it (DR-10a step 2 / + // DR-10b step 2). + CrossWorkspaceInsufficientPermission CrossWorkspaceDenialReason = "insufficient_permission" + + // CrossWorkspaceLookupFailed means a store lookup errored. Always + // a denial — fail closed. + CrossWorkspaceLookupFailed CrossWorkspaceDenialReason = "lookup_failed" +) + +// CrossWorkspaceAccess is the verdict from AuthorizeCrossWorkspaceRead +// / AuthorizeCrossWorkspaceEdit. +// +// Disclosure posture (PLAN-2357). A caller with no access to workspace +// B must not be able to tell "B does not exist" from "B exists and you +// cannot see it". That is why Reason distinguishes them but the +// response writers do not: WriteHidden collapses every denial to an +// identical 404, and WriteDenied collapses absence and forbidden-ness +// to an identical 403. Read paths should use WriteHidden. Reason is +// for logs, metrics and tests — resist the urge to branch a response +// body on it. +// +// NEVER PUT THIS STRUCT IN A RESPONSE. On a denial it holds exactly +// the things the disclosure rule forbids surfacing: a resolved +// Workspace (with its ID, slug and name), a Role, and a Reason that +// separates "absent" from "forbidden". Every field carries `json:"-"` +// so a stray json.Marshal emits `{}` rather than the leak, but the tag +// is a backstop, not permission — render denials through WriteHidden +// or WriteDenied, and keep Workspace/Role/Reason for server-side logs. +type CrossWorkspaceAccess struct { + // Allowed is the only field a caller should gate behavior on. + Allowed bool `json:"-"` + + // Reason is CrossWorkspaceAllowed when Allowed, otherwise the + // specific denial. See the disclosure note above before using it + // to shape a response. + Reason CrossWorkspaceDenialReason `json:"-"` + + // Workspace is the resolved target workspace, or nil when + // resolution itself failed. Populated even on later denials so + // callers can log the canonical slug — server-side only. + Workspace *models.Workspace `json:"-"` + + // Role is the caller's effective role in the TARGET workspace + // ("owner" / "editor" / "viewer" / "guest"), derived fresh from + // membership + grants — never from workspaceRole(r). Empty when + // the caller has no role there. + Role string `json:"-"` + + // Err carries the underlying store error for + // CrossWorkspaceLookupFailed. Nil otherwise. + Err error `json:"-"` +} + +// WorkspaceID is a nil-safe accessor for the resolved workspace's ID. +func (a CrossWorkspaceAccess) WorkspaceID() string { + if a.Workspace == nil { + return "" + } + return a.Workspace.ID +} + +// WorkspaceSlug is a nil-safe accessor for the resolved workspace's +// canonical slug — the value the token allow-list was tested against. +func (a CrossWorkspaceAccess) WorkspaceSlug() string { + if a.Workspace == nil { + return "" + } + return a.Workspace.Slug +} + +// WriteHidden writes the non-disclosing denial: an identical 404 for +// every failure mode, so absence and forbidden-ness are +// indistinguishable. This is the default posture for read paths — +// notably the forward-pointer gate, where even a differently-shaped +// response reveals that a destination exists. +// +// subject names the thing the caller asked for ("Item", "Workspace", +// …) and MUST NOT be derived from the target workspace's state; pass a +// constant. +// +// Internal errors are reported as 404 too, matching +// refResolverNotFound: a 500 here would be a usable oracle only in +// contrived cases, but the contract is cheaper to keep total. The +// error is still returned in Err for the caller to log. +func (a CrossWorkspaceAccess) WriteHidden(w http.ResponseWriter, subject string) { + if subject == "" { + subject = "Resource" + } + writeError(w, http.StatusNotFound, "not_found", subject+" not found") +} + +// WriteDenied writes the acknowledging denial, for paths where the +// caller supplied the target slug themselves and a bare 404 would be +// unhelpful — the copy endpoint, primarily. +// +// It still refuses to distinguish "workspace absent" from "workspace +// forbidden": both are 403 with the same message. The only branch is +// the token allow-list, which reports itself because (a) it is the +// caller's OWN consent scope, not information about the target, (b) +// the middleware's primary path already emits exactly that message, +// and (c) it is only ever reported to a caller who genuinely has a +// role in the target — the authorize helpers rank +// CrossWorkspaceNoWorkspaceAccess ahead of it precisely so that +// message can never confirm the existence of a workspace the caller +// is a stranger to. +// +// Lookup failures are a 500 here rather than another 403. That is an +// observable difference, and it can only occur once resolution has +// already succeeded — so it does weakly imply the target exists. Two +// reasons it stays: an attacker cannot induce a store failure on +// demand, and silently laundering 500s into 403s would make a broken +// database look like a permissions problem. It is a real trade, so +// treat it as a PRECONDITION of choosing this writer: only call +// WriteDenied on a path where the caller supplied the target +// themselves. Everywhere else — anything a caller could use to probe +// for workspaces — use WriteHidden, which has no such variant. +func (a CrossWorkspaceAccess) WriteDenied(w http.ResponseWriter) { + switch a.Reason { + case CrossWorkspaceLookupFailed: + writeInternalError(w, a.Err) + case CrossWorkspaceTokenNotAllowed: + writeError(w, http.StatusForbidden, "permission_denied", + "Token is not authorized for this workspace") + default: + writeError(w, http.StatusForbidden, "forbidden", + "You do not have access to the requested workspace") + } +} + +// CrossWorkspaceScope names WHAT is being reached in the target +// workspace. It is a required argument, and its zero value is invalid. +// +// This is deliberate. Pad's role checks answer a WORKSPACE-level +// question, but almost every real authorization question is +// collection- or item-level (PLAN-2357 hit the same trap three times: +// DR-10a on the destination, DR-10b on the source, and again on the +// forward-pointer read). Requiring an explicit scope means a caller +// cannot fall into a workspace-level-only check by omission — they +// have to write CrossWorkspaceWorkspaceOnlyScope() and read its +// warning first. +// +// Construct with CrossWorkspaceItemScope, CrossWorkspaceCollectionScope +// or CrossWorkspaceWorkspaceOnlyScope. +type CrossWorkspaceScope struct { + item *models.Item + collectionID string + workspaceLevelOnly bool +} + +// CrossWorkspaceItemScope scopes the check to one already-loaded item. +// Collection visibility is checked first and item-level grants second, +// in that order, by checkItemVisible — the same rule set the +// middleware-gated handlers use. +// +// Pass the item you already resolved; the helper deliberately does not +// re-resolve it, so it cannot accidentally apply a different lookup +// path than the caller did. It MUST be a fully populated item — ID, +// WorkspaceID and CollectionID are all required and are checked +// against the resolved target workspace. Soft-deleted (archived) items +// are permitted: reading an archived source item is the point of the +// forward pointer. +func CrossWorkspaceItemScope(item *models.Item) CrossWorkspaceScope { + return CrossWorkspaceScope{item: item} +} + +// CrossWorkspaceCollectionScope scopes the check to one collection — +// creating into it, or disclosing its schema. +// +// Visibility here is FULL-collection-access semantics, deliberately +// stricter than the nav-lenient VisibleCollectionIDs set: a caller +// whose only claim on the collection is an item-level grant inside it +// does not qualify. That is the DR-10a hole — VisibleCollectionIDs +// folds item-grant collections in "so the collection appears in +// navigation", and ResolveUserPermission's own restricted-member gate +// consults that same lenient set, so a restricted editor with one +// stray item grant would otherwise be cleared to create into a +// collection deliberately hidden from them. +func CrossWorkspaceCollectionScope(collectionID string) CrossWorkspaceScope { + return CrossWorkspaceScope{collectionID: collectionID} +} + +// CrossWorkspaceWorkspaceOnlyScope requests a WORKSPACE-LEVEL CHECK +// ONLY, which is almost never sufficient on its own. +// +// It answers "does this caller have any role in workspace B, and does +// their token allow B" — nothing more. It does NOT answer whether they +// may see or write any particular collection or item there. A +// restricted member, or a guest holding one unrelated item grant, will +// pass this and still have no right to touch the resource you are +// about to touch. +// +// Legitimate uses are narrow: an early reject before the collection or +// item is known, or a check whose resource is genuinely the workspace +// itself. If you are about to read or write a collection or an item, +// you want CrossWorkspaceCollectionScope or CrossWorkspaceItemScope +// instead — and passing this and stopping is the exact mistake DR-10a, +// DR-10b and the forward-pointer leak all describe. +func CrossWorkspaceWorkspaceOnlyScope() CrossWorkspaceScope { + return CrossWorkspaceScope{workspaceLevelOnly: true} +} + +func (sc CrossWorkspaceScope) valid() bool { + switch { + case sc.workspaceLevelOnly: + return sc.item == nil && sc.collectionID == "" + case sc.item != nil: + return sc.collectionID == "" + case sc.collectionID != "": + return true + default: + return false + } +} + +// itemID / collectionID feed ResolveUserPermission's resolution order. +func (sc CrossWorkspaceScope) itemID() string { + if sc.item == nil { + return "" + } + return sc.item.ID +} + +func (sc CrossWorkspaceScope) permCollectionID() string { + if sc.item != nil { + return sc.item.CollectionID + } + return sc.collectionID +} + +// AuthorizeCrossWorkspaceRead decides whether the caller may READ the +// scoped resource in a workspace OTHER than the request's own. +// +// workspaceSlugOrID may be either form; it is resolved the same way +// RequireWorkspaceAccess resolves the URL slug, and the token +// allow-list is then tested against the resolved CANONICAL slug (the +// supplied value may have been a UUID). +// +// Order of evaluation, all of it fail-closed: +// +// 1. scope is well-formed; +// 2. workspace resolves and is not soft-deleted; +// 3. caller's role in THAT workspace, from membership and grants, +// honoring the bearer-vs-cookie admin split (BUG-1616/1617); +// 4. token consent allow-list against that workspace's slug; +// 5. the scoped visibility check — collection-then-item for an item +// scope, full-collection-access for a collection scope. +// +// NOT ATOMIC. The verdict describes the world at the instant it was +// computed, against the item you handed in. Nothing here takes a lock +// or a transaction, and every input can change underneath you: the +// item can be moved into a hidden collection or archived, the +// collection can be soft-deleted, the caller's membership or grants +// can be revoked, the workspace itself can be soft-deleted. A MUTATING +// caller must re-read both sides inside its write transaction and +// re-apply the check there (PLAN-2357 DR-9 requires exactly that of +// the copy path; see UpdateItemWithPreCheck / MoveItemWithPreCheck for +// the established shape). For a read path a stale verdict is a much +// smaller problem, but do not cache one across requests. +// +// See the file header for why requireEditPermission and friends cannot +// be used here. +func (s *Server) AuthorizeCrossWorkspaceRead(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope) CrossWorkspaceAccess { + return s.authorizeCrossWorkspace(r, workspaceSlugOrID, scope, false) +} + +// AuthorizeCrossWorkspaceEdit decides whether the caller may WRITE the +// scoped resource in a workspace OTHER than the request's own. +// +// It runs every step AuthorizeCrossWorkspaceRead runs, in the same +// order, and only then evaluates write permission — visibility first, +// permission second (DR-10a / DR-10b). Never reorder those: the +// permission check alone clears a restricted member for a collection +// they were never allowed to see. +// +// The four-step ordering DR-10b specifies for a cross-workspace copy +// composes out of two calls — and the SOURCE verdict must be handled +// before the destination is touched at all: +// +// src := s.AuthorizeCrossWorkspaceEdit(r, srcWS, CrossWorkspaceItemScope(item)) +// if !src.Allowed { +// src.WriteHidden(w, "Item") +// return +// } +// dst := s.AuthorizeCrossWorkspaceEdit(r, dstWS, CrossWorkspaceCollectionScope(collID)) +// if !dst.Allowed { +// dst.WriteDenied(w) +// return +// } +// +// which is source-item-visible → source-edit → destination-collection- +// visible → destination-collection-edit. The early return is part of +// the ordering, not style: evaluating the destination after a failed +// source check builds a verdict about workspace B for a caller who was +// not even allowed to read the source, and anything that verdict +// reaches — a log line, an error message, a timing difference — is a +// disclosure the source check was supposed to have prevented. +// +// Both calls apply to a dry-run as much as to the mutation; a preflight +// that confirms a hidden item exists, or echoes a hidden collection's +// schema, is itself the leak. +// +// NOT ATOMIC — see AuthorizeCrossWorkspaceRead. This is a PRE-check, +// not a write barrier. Treat an allow as "proceed to the transaction", +// never as "the write is now authorized": between this call and the +// insert, the destination collection can be soft-deleted (CreateItem +// does not reject that), the source item can be moved or archived, and +// the caller's membership or grants can be revoked. Re-read and +// re-check inside the write transaction. +// +// See the file header for why requireEditPermission cannot be used +// here. +func (s *Server) AuthorizeCrossWorkspaceEdit(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope) CrossWorkspaceAccess { + return s.authorizeCrossWorkspace(r, workspaceSlugOrID, scope, true) +} + +func crossWorkspaceDeny(reason CrossWorkspaceDenialReason, ws *models.Workspace, role string, err error) CrossWorkspaceAccess { + return CrossWorkspaceAccess{Reason: reason, Workspace: ws, Role: role, Err: err} +} + +func (s *Server) authorizeCrossWorkspace(r *http.Request, workspaceSlugOrID string, scope CrossWorkspaceScope, needEdit bool) CrossWorkspaceAccess { + // 1. Scope well-formedness. A zero CrossWorkspaceScope names + // nothing; refuse rather than silently degrade to a + // workspace-level check. + if !scope.valid() { + return crossWorkspaceDeny(CrossWorkspaceInvalidScope, nil, "", + errors.New("cross-workspace authorization called with an empty or ambiguous scope")) + } + if workspaceSlugOrID == "" { + return crossWorkspaceDeny(CrossWorkspaceWorkspaceNotFound, nil, "", nil) + } + + user := currentUser(r) + isBearer := isBearerAuth(r) + + // 2. Resolve the target. resolveWorkspace applies the same ACL + // scoping RequireWorkspaceAccess applies to the URL slug, and + // every path it takes (GetWorkspaceByID, GetWorkspaceBySlug, + // GetWorkspacesBySlugForUser) filters `deleted_at IS NULL`, so a + // soft-deleted target resolves to nil → denied. Note the + // UUID branch is NOT ACL-scoped; the role check below is what + // actually authorizes, exactly as in the middleware. + ws, err := s.resolveWorkspace(workspaceSlugOrID, user) + if err != nil { + return crossWorkspaceDeny(CrossWorkspaceLookupFailed, nil, "", err) + } + if ws == nil { + return crossWorkspaceDeny(CrossWorkspaceWorkspaceNotFound, nil, "", nil) + } + + // 3. Role in the TARGET workspace, computed fresh. Never + // workspaceRole(r) — that is workspace A's answer. + role, err := s.crossWorkspaceRole(r, ws, user, isBearer) + if err != nil { + return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, "", err) + } + + // 4. Token consent allow-list, against the CANONICAL slug. + // + // Evaluated here but reported AFTER the role check, so a + // "token not authorized" response can only ever reach a caller + // who actually has a role in the target. Otherwise a + // bearer-borne platform admin — for whom resolveWorkspace does a + // global slug lookup — could use the distinct message to + // confirm the existence of a workspace they never joined. + tokenAllowed := tokenAllowedWorkspaceMatches(r.Context(), ws.Slug) + + if role == "" { + s.recordMCPAuthzDenial(r, "not_a_member") + return crossWorkspaceDeny(CrossWorkspaceNoWorkspaceAccess, ws, "", nil) + } + if !tokenAllowed { + s.recordMCPAuthzDenial(r, "workspace_not_in_allowlist") + return crossWorkspaceDeny(CrossWorkspaceTokenNotAllowed, ws, role, nil) + } + + // 5. Scoped visibility. Workspace-level rights are not sufficient + // and this is where that is enforced. + switch { + case scope.item != nil: + item := scope.item + // Confused-deputy guard: the item must actually live in the + // workspace we just authorized, and it must be fully + // identified. Without this a caller could pass workspace B + // (which they can write) alongside an item from workspace C + // — and a sparse or hand-built models.Item with an empty + // WorkspaceID or CollectionID would sail through both the + // visibility filter (isCollectionVisible short-circuits on an + // unrestricted caller) and ResolveUserPermission (which falls + // back to the workspace membership role when collectionID is + // empty). Every field is required; no "unset means it's fine" + // branch (Codex round 1 P1). + if item.ID == "" || item.WorkspaceID != ws.ID || item.CollectionID == "" { + return crossWorkspaceDeny(CrossWorkspaceScopeMismatch, ws, role, nil) + } + // The item's collection must exist, live in the same + // workspace, and not be archived. checkItemVisible does NOT + // establish any of that — soft-deleting a collection leaves + // its items in place and neither GetItem nor + // VisibleCollectionIDs filters on the collection's deleted_at, + // so an item under an archived collection would otherwise pass + // (Codex round 2 P1). + // + // Only the caller-independent facts, though: the item scope + // deliberately does NOT apply the collection scope's + // full-collection-access rule. A guest whose sole claim is an + // item grant SHOULD be able to read that one item — that is + // what item grants mean — while still being barred from + // operating on the collection as a whole. checkItemVisible + // below is what draws that line. + if _, deny, ok := s.crossWorkspaceLiveCollection(ws, role, item.CollectionID); !ok { + return deny + } + visible, vErr := s.checkItemVisible(ws.ID, item, user, role, isBearer) + if vErr != nil { + return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, vErr) + } + if !visible { + return crossWorkspaceDeny(CrossWorkspaceItemNotVisible, ws, role, nil) + } + + case scope.collectionID != "": + coll, deny, ok := s.crossWorkspaceLiveCollection(ws, role, scope.collectionID) + if !ok { + return deny + } + visible, vErr := s.checkCollectionFullyVisible(r, ws.ID, coll.ID) + if vErr != nil { + return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, vErr) + } + if !visible { + return crossWorkspaceDeny(CrossWorkspaceCollectionNotVisible, ws, role, nil) + } + } + + if !needEdit { + return CrossWorkspaceAccess{Allowed: true, Workspace: ws, Role: role} + } + + // 6. Write permission, only after visibility passed. + allowed, pErr := s.crossWorkspaceEditAllowed(ws, user, isBearer, role, scope) + if pErr != nil { + return crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, pErr) + } + if !allowed { + return crossWorkspaceDeny(CrossWorkspaceInsufficientPermission, ws, role, nil) + } + return CrossWorkspaceAccess{Allowed: true, Workspace: ws, Role: role} +} + +// crossWorkspaceLiveCollection loads the scope's collection and +// establishes the caller-independent facts: it exists, it is not +// soft-deleted, and it belongs to the resolved workspace. +// +// Returns (verdict, false) on a denial and (coll, true) otherwise. +// Both scopes go through it, so neither can be satisfied by a +// collection that is archived or lives somewhere else. +func (s *Server) crossWorkspaceLiveCollection(ws *models.Workspace, role, collectionID string) (*models.Collection, CrossWorkspaceAccess, bool) { + coll, err := s.store.GetCollection(collectionID) + if err != nil { + return nil, crossWorkspaceDeny(CrossWorkspaceLookupFailed, ws, role, err), false + } + // GetCollection filters soft-deleted rows, so nil covers "absent" + // and "archived" alike — both are unreachable. + if coll == nil { + return nil, crossWorkspaceDeny(CrossWorkspaceCollectionNotVisible, ws, role, nil), false + } + if coll.WorkspaceID != ws.ID { + return nil, crossWorkspaceDeny(CrossWorkspaceScopeMismatch, ws, role, nil), false + } + return coll, CrossWorkspaceAccess{}, true +} + +// crossWorkspaceRole derives the caller's role in an arbitrary +// workspace, reproducing RequireWorkspaceAccess's rules without the +// request-scoped context value that middleware populates. Returns "" +// when the caller has no role there. Errors are real store failures — +// callers must treat them as denials. +// +// Governing rule: this must never grant MORE than the front door. If +// RequireWorkspaceAccess would 403 a caller at +// /api/v1/workspaces/{target}, reaching the same workspace sideways +// through a cross-workspace operation must fail too. The branches +// below therefore track middleware_auth.go's order exactly: +// +// 1. fresh install (UserCount == 0, no user) → "owner", because the +// whole instance is open until the first admin exists; +// 2. legacy workspace-scoped API token (no user, token pinned to a +// workspace) → "editor", but ONLY for the workspace it is pinned +// to. For any other workspace the token is a stranger — the +// correct answer for a cross-workspace reach; +// 3. platform admin over a COOKIE session → "owner" on every +// workspace. Over any bearer surface (CLI, PAT, MCP) the bypass is +// suppressed and the admin falls through to membership +// (BUG-1616/1617); +// 4. membership row → its role; +// 5. bearer-borne platform admin who is not a member → "", with NO +// grants fallback (BUG-1616/1617); +// 6. non-member holding any grant → "guest"; +// 7. otherwise "". +// +// Note this deliberately does NOT reuse resolverWorkspaceRole +// (handlers_ref_resolver.go), even though the two are close. That +// helper short-circuits to "owner" on ws.OwnerID for every auth +// surface (BUG-1618, a deliberate widening for the cookie-only +// redirect route). RequireWorkspaceAccess has no such exception — +// ownership and membership are separate persisted state, and the +// middleware requires the membership row — so honoring the shortcut +// here would let a bearer token reach a workspace the front door +// refuses. Codex round 2 P1. +func (s *Server) crossWorkspaceRole(r *http.Request, ws *models.Workspace, user *models.User, isBearer bool) (string, error) { + if user == nil { + count, err := s.store.UserCount() + if err != nil { + return "", err + } + if count == 0 { + return "owner", nil + } + if tokenWsID := tokenWorkspaceID(r); tokenWsID != "" && tokenWsID == ws.ID { + return "editor", nil + } + return "", nil + } + if user.Role == "admin" && !isBearer { + return "owner", nil + } + member, err := s.store.GetWorkspaceMember(ws.ID, user.ID) + if err != nil { + return "", err + } + if member != nil { + return member.Role, nil + } + // Bearer-borne platform admin who isn't a member gets NO + // grant-based fallback — the membership-only stance + // (BUG-1616/1617). Without this a single stray collection or item + // grant would yield "guest" below, and checkItemVisible's own + // admin bypass would then widen that back out. + if user.Role == "admin" && isBearer { + return "", nil + } + hasGrants, err := s.store.UserHasGrantsInWorkspace(ws.ID, user.ID) + if err != nil { + return "", err + } + if hasGrants { + return "guest", nil + } + return "", nil +} + +// crossWorkspaceEditAllowed answers the write half, and only ever runs +// after the scoped visibility check has already passed. +// +// It reproduces requireEditPermission's decision — editor/owner base +// role wins, otherwise fall back to ResolveUserPermission so grants can +// override an insufficient role — with exactly two substitutions, which +// are the whole point of this file: +// +// - `role` is derived for the TARGET workspace by crossWorkspaceRole, +// never read from workspaceRole(r); +// - it runs only AFTER the scoped visibility check. +// +// That ordering is what makes the base-role branch safe. DR-10a's +// escalation is the fast path used ALONE: an editor whose membership is +// collection_access="specific" passing the role check for a collection +// hidden from them. authorizeCrossWorkspace has already applied the +// collection- or item-scoped visibility filter by the time we get here, +// so the base role is being applied to a resource the caller is +// established to be able to see — the same composition handleMoveItem +// uses (requireItemVisible then requireEditPermission). +// +// Grants are treated as widening only, never narrowing. Delegating +// unconditionally to ResolveUserPermission would be tempting — it is +// the resource-scoped answer — but it resolves item and collection +// grants BEFORE membership, so an incidental `view` grant would demote +// a member whose base role is editor or owner and deny an edit the +// front door allows (Codex round 5). Fail-closed, but wrong, and +// divergence from requireEditPermission in either direction is how this +// helper eventually rots. +// +// Two bypasses ResolveUserPermission cannot express are handled here: +// +// - tokenized nil-user roles ("owner" on a fresh install, "editor" +// for a legacy workspace-pinned token) — crossWorkspaceRole only +// hands those out when the caller is genuinely authorized, and +// ResolveUserPermission has no user ID to work with; +// - the platform-admin bypass, which is cookie-session only. A +// bearer-borne admin falls through and is judged on their actual +// membership. +func (s *Server) crossWorkspaceEditAllowed(ws *models.Workspace, user *models.User, isBearer bool, role string, scope CrossWorkspaceScope) (bool, error) { + if user == nil { + // Only reachable for the synthesized roles above; a nil user + // with no role was already denied. + return role == "owner" || role == "editor", nil + } + if user.Role == "admin" && !isBearer { + return true, nil + } + // Base-role branch, mirroring requireEditPermission. "guest" is + // excluded: a guest has no role-based permission at all, only + // grants. + if role != "guest" && roleLevel(role) >= roleLevel("editor") { + return true, nil + } + perm, err := s.store.ResolveUserPermission(ws.ID, user.ID, scope.itemID(), scope.permCollectionID()) + if err != nil { + return false, err + } + return permissionLevel(perm) >= permissionLevel("edit"), nil +} + +// checkCollectionFullyVisible is the bool-returning core of +// requireCollectionFullyVisible — same rules, no response writing, and +// a workspace ID that need not be the request's own. See +// requireCollectionFullyVisible for why the full-access narrowing is +// stricter than plain visibleCollectionIDs. +// +// Both of its inputs are workspace-explicit: visibleCollectionIDs and +// guestResourceFilter read the request only for the current user and +// the bearer flag, never for a workspace-scoped context value. +func (s *Server) checkCollectionFullyVisible(r *http.Request, workspaceID, collectionID string) (bool, error) { + visibleIDs, err := s.visibleCollectionIDs(r, workspaceID) + if err != nil { + return false, err + } + if visibleIDs != nil { + // Restricted caller: when any item-level grant is in play, + // narrow from the nav-lenient set to full-access collections + // only, so an item-grant-only collection cannot qualify for a + // collection-wide operation. + fullCollIDs, grantedItemIDs, gErr := s.guestResourceFilter(r, workspaceID) + if gErr != nil { + return false, gErr + } + if len(grantedItemIDs) > 0 { + // isCollectionVisible reads nil as "unrestricted", so a + // caller whose full-access set is genuinely empty — a guest + // whose only claim is an item grant — must be handed an + // empty NON-NIL slice, not nil. ResolveBacklinksVisibility + // already guarantees that, but the guarantee lives two + // packages away and inverting it here would silently grant + // every collection in the workspace. Re-assert it locally + // (Codex round 3 P1: reported as a live bug on the + // assumption the guarantee wasn't there; it is, and + // TestCrossWorkspace_GuestItemGrantOnly pins the behavior — + // but the sentinel flip is too dangerous to leave implicit). + if fullCollIDs == nil { + fullCollIDs = []string{} + } + visibleIDs = fullCollIDs + } + } + return isCollectionVisible(collectionID, visibleIDs), nil +} diff --git a/internal/server/authz_cross_workspace_test.go b/internal/server/authz_cross_workspace_test.go new file mode 100644 index 00000000..c61f5761 --- /dev/null +++ b/internal/server/authz_cross_workspace_test.go @@ -0,0 +1,1067 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// contextWithWorkspaceRoleForTest / contextWithResolvedWorkspaceIDForTest +// stash the two values RequireWorkspaceAccess populates for the REQUEST's +// workspace. The cross-workspace helper must ignore both — these exist so +// the tests can poison the context the way a real handler would see it. +func contextWithWorkspaceRoleForTest(ctx context.Context, role string) context.Context { + return context.WithValue(ctx, ctxWorkspaceRole, role) +} + +func contextWithResolvedWorkspaceIDForTest(ctx context.Context, wsID string) context.Context { + return context.WithValue(ctx, ctxResolvedWorkspaceID, wsID) +} + +// Tests for the cross-workspace authorization helper (PLAN-2357 / +// TASK-2358, DR-10). Every case asserts a DENIAL as well as the happy +// path — the helper's whole job is the denial side. + +type crossWSFixture struct { + t *testing.T + srv *Server + + // Workspace A is the "request's own" workspace; workspace B is the + // second workspace every check in this file targets. + wsA *models.Workspace + wsB *models.Workspace + + // Collections/items in B. + collB *models.Collection + itemB *models.Item + hiddenB *models.Collection + hiddenIB *models.Item + collA *models.Collection + itemA *models.Item + ownerBoth *models.User +} + +func newCrossWSFixture(t *testing.T) *crossWSFixture { + t.Helper() + srv := testServer(t) + + owner := mustUser(t, srv, "owner@example.com", "ownerx", "") + wsA := mustWorkspace(t, srv, "Alpha", owner.ID) + wsB := mustWorkspace(t, srv, "Bravo", owner.ID) + + collA := mustCollection(t, srv, wsA.ID, "Tasks A") + itemA := mustItem(t, srv, wsA.ID, collA.ID, "Item A") + + collB := mustCollection(t, srv, wsB.ID, "Tasks B") + itemB := mustItem(t, srv, wsB.ID, collB.ID, "Item B") + hiddenB := mustCollection(t, srv, wsB.ID, "Secrets B") + hiddenIB := mustItem(t, srv, wsB.ID, hiddenB.ID, "Secret B") + + return &crossWSFixture{ + t: t, srv: srv, + wsA: wsA, wsB: wsB, + collA: collA, itemA: itemA, + collB: collB, itemB: itemB, + hiddenB: hiddenB, hiddenIB: hiddenIB, + ownerBoth: owner, + } +} + +func mustUser(t *testing.T, srv *Server, email, username, role string) *models.User { + t.Helper() + u, err := srv.store.CreateUser(models.UserCreate{ + Email: email, Name: username, Username: username, Password: "pw-test-12345", + }) + if err != nil { + t.Fatalf("CreateUser(%s): %v", email, err) + } + if role != "" { + if err := srv.store.SetUserRole(u.ID, role); err != nil { + t.Fatalf("SetUserRole(%s, %s): %v", email, role, err) + } + u.Role = role + } + return u +} + +func mustWorkspace(t *testing.T, srv *Server, name, ownerID string) *models.Workspace { + t.Helper() + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: name, OwnerID: ownerID}) + if err != nil { + t.Fatalf("CreateWorkspace(%s): %v", name, err) + } + if err := srv.store.AddWorkspaceMember(ws.ID, ownerID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember(%s): %v", name, err) + } + return ws +} + +func mustCollection(t *testing.T, srv *Server, wsID, name string) *models.Collection { + t.Helper() + c, err := srv.store.CreateCollection(wsID, models.CollectionCreate{Name: name}) + if err != nil { + t.Fatalf("CreateCollection(%s): %v", name, err) + } + return c +} + +func mustItem(t *testing.T, srv *Server, wsID, collID, title string) *models.Item { + t.Helper() + it, err := srv.store.CreateItem(wsID, collID, models.ItemCreate{Title: title}) + if err != nil { + t.Fatalf("CreateItem(%s): %v", title, err) + } + return it +} + +func (f *crossWSFixture) member(email, username, wsRole string, ws *models.Workspace) *models.User { + f.t.Helper() + u := mustUser(f.t, f.srv, email, username, "") + if err := f.srv.store.AddWorkspaceMember(ws.ID, u.ID, wsRole); err != nil { + f.t.Fatalf("AddWorkspaceMember: %v", err) + } + return u +} + +// reqOpts describes the auth surface the synthetic request presents. +type reqOpts struct { + bearer bool + // allowed is the OAuth/MCP consent allow-list. nil means "no + // allow-list stashed at all" (PAT / cookie), which is a distinct + // state from an empty slice. + allowed []string + setAllowed bool + // wsRoleCtx simulates RequireWorkspaceAccess having populated the + // role for workspace A — the poisoned context value the helper must + // ignore. + wsRoleCtx string + wsIDCtx string + // tokenWorkspaceID simulates a legacy workspace-scoped API token. + tokenWorkspaceID string + noUser bool +} + +func (f *crossWSFixture) request(user *models.User, o reqOpts) *http.Request { + f.t.Helper() + r := httptest.NewRequest("POST", "/api/v1/workspaces/"+f.wsA.Slug+"/items", nil) + ctx := r.Context() + if !o.noUser && user != nil { + ctx = WithCurrentUser(ctx, user) + } + if o.setAllowed { + ctx = WithTokenAllowedWorkspaces(ctx, o.allowed) + } + if o.tokenWorkspaceID != "" { + ctx = WithTokenWorkspaceID(ctx, o.tokenWorkspaceID) + } + if o.wsRoleCtx != "" { + ctx = contextWithWorkspaceRoleForTest(ctx, o.wsRoleCtx) + } + if o.wsIDCtx != "" { + ctx = contextWithResolvedWorkspaceIDForTest(ctx, o.wsIDCtx) + } + r = r.WithContext(ctx) + if o.bearer { + r.Header.Set("Authorization", "Bearer test-token") + } + return r +} + +func assertDenied(t *testing.T, got CrossWorkspaceAccess, want CrossWorkspaceDenialReason, label string) { + t.Helper() + if got.Allowed { + t.Fatalf("%s: expected denial (%s), got ALLOWED (role=%q)", label, want, got.Role) + } + if got.Reason != want { + t.Fatalf("%s: expected reason %q, got %q (err=%v)", label, want, got.Reason, got.Err) + } +} + +func assertAllowed(t *testing.T, got CrossWorkspaceAccess, label string) { + t.Helper() + if !got.Allowed { + t.Fatalf("%s: expected allowed, got denial %q (err=%v)", label, got.Reason, got.Err) + } + if got.Reason != CrossWorkspaceAllowed { + t.Fatalf("%s: allowed verdict carried reason %q", label, got.Reason) + } +} + +// --- Membership -------------------------------------------------------- + +// TestCrossWorkspace_EditorInAOnly is the headline case: a caller who is +// an editor in the REQUEST's workspace and a stranger to the target gets +// nothing there, even with the request context claiming "editor". +func TestCrossWorkspace_EditorInAOnly(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("a-editor@example.com", "aeditor", "editor", f.wsA) + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + // A non-admin stranger doesn't even get B to resolve — resolveWorkspace + // is ACL-scoped for them, so absence and forbidden-ness are conflated + // at the earliest possible point. Denied either way. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceWorkspaceNotFound, "edit into B collection") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceWorkspaceNotFound, "edit B item") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceWorkspaceNotFound, "read B item") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceWorkspaceNotFound, "workspace-only read of B") + // Addressing B by UUID skips resolveWorkspace's ACL scoping (it is a + // direct GetWorkspaceByID), so this is the path where the ROLE check is + // the only thing standing between the caller and workspace B. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.ID, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceNoWorkspaceAccess, "edit B item addressed by UUID") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.ID, CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceNoWorkspaceAccess, "workspace-only read of B by UUID") + + // Sanity: the same caller IS authorized in their own workspace, so + // the denials above aren't an artifact of a broken fixture. + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsA.Slug, CrossWorkspaceCollectionScope(f.collA.ID)), + "edit into A collection") +} + +// TestCrossWorkspace_RequireEditPermissionIsWrongForSecondWorkspace pins +// the reason this helper exists. requireEditPermission answers "yes" for +// workspace B purely because the request context says the caller is an +// editor in A. If this test ever starts failing because +// requireEditPermission got fixed, delete it — but do NOT start calling +// requireEditPermission cross-workspace. +func TestCrossWorkspace_RequireEditPermissionIsWrongForSecondWorkspace(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("trap@example.com", "trapuser", "editor", f.wsA) + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + rec := httptest.NewRecorder() + if !f.srv.requireEditPermission(rec, r, f.wsB.ID, f.itemB.ID, f.collB.ID) { + t.Skip("requireEditPermission no longer leaks the request workspace's role; " + + "the cross-workspace helper is still the only supported path") + } + // The trap fired, as documented. The helper must disagree — checked on + // the UUID form, which is the shape requireEditPermission takes and the + // one that bypasses resolveWorkspace's ACL scoping. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.ID, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceNoWorkspaceAccess, "helper disagrees with requireEditPermission") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceWorkspaceNotFound, "helper disagrees with requireEditPermission (slug form)") +} + +func TestCrossWorkspace_EditorInBoth(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("both@example.com", "bothuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + got := f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)) + assertAllowed(t, got, "edit into B collection") + if got.Role != "editor" { + t.Errorf("expected role editor in B, got %q", got.Role) + } + if got.WorkspaceID() != f.wsB.ID || got.WorkspaceSlug() != f.wsB.Slug { + t.Errorf("verdict points at the wrong workspace: %s/%s", got.WorkspaceID(), got.WorkspaceSlug()) + } + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "edit B item") +} + +func TestCrossWorkspace_ViewerInB(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("view@example.com", "viewuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "viewer reads B item") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceInsufficientPermission, "viewer edits B item") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceInsufficientPermission, "viewer creates into B collection") +} + +// --- Token consent allow-list ----------------------------------------- + +// TestCrossWorkspace_TokenAllowlist is the regression test that matters +// most: a naive implementation passes every membership case and fails +// this one, because tokenAllowedWorkspaceMatches runs in exactly one +// place (RequireWorkspaceAccess) and a second workspace never goes +// through it. +func TestCrossWorkspace_TokenAllowlist(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("tok@example.com", "tokuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + + cases := []struct { + name string + opts reqOpts + allowed bool + reason CrossWorkspaceDenialReason + }{ + { + name: "consented to A only, member of B", + opts: reqOpts{bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug}}, + reason: CrossWorkspaceTokenNotAllowed, + }, + { + name: "consented to A and B", + opts: reqOpts{bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug, f.wsB.Slug}}, + allowed: true, + }, + { + name: "wildcard consent", + opts: reqOpts{bearer: true, setAllowed: true, allowed: []string{"*"}}, + allowed: true, + }, + { + name: "no allow-list stashed (PAT)", + opts: reqOpts{bearer: true}, + allowed: true, + }, + { + name: "empty allow-list fails closed", + opts: reqOpts{bearer: true, setAllowed: true, allowed: []string{}}, + reason: CrossWorkspaceTokenNotAllowed, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := tc.opts + opts.wsRoleCtx = "editor" + opts.wsIDCtx = f.wsA.ID + r := f.request(u, opts) + for _, scope := range map[string]CrossWorkspaceScope{ + "collection": CrossWorkspaceCollectionScope(f.collB.ID), + "item": CrossWorkspaceItemScope(f.itemB), + "workspace": CrossWorkspaceWorkspaceOnlyScope(), + } { + got := f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, scope) + if tc.allowed { + assertAllowed(t, got, tc.name) + } else { + assertDenied(t, got, tc.reason, tc.name) + } + got = f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, scope) + if tc.allowed { + assertAllowed(t, got, tc.name+" (read)") + } else { + assertDenied(t, got, tc.reason, tc.name+" (read)") + } + } + }) + } +} + +// The allow-list is tested against the CANONICAL slug, so addressing the +// workspace by UUID must not bypass it. +func TestCrossWorkspace_TokenAllowlistAppliesToUUIDAddressing(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("uuid@example.com", "uuiduser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug}, + wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.ID, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceTokenNotAllowed, "UUID-addressed B with A-only consent") +} + +// A stranger to B must never receive the distinct "token not authorized" +// message, since that message confirms the workspace exists. The role +// check is ranked ahead of the allow-list check precisely for this. +func TestCrossWorkspace_TokenDenialNeverConfirmsExistenceToStranger(t *testing.T) { + f := newCrossWSFixture(t) + admin := mustUser(t, f.srv, "admin@example.com", "adminuser", "admin") + r := f.request(admin, reqOpts{bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug}}) + + // Bearer-borne admin is a stranger to B (membership-only stance) and + // resolveWorkspace does a GLOBAL slug lookup for admins — so the + // allow-list denial would leak existence if it were reported first. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceNoWorkspaceAccess, "bearer admin stranger to B") +} + +// --- Bearer-vs-cookie admin split (BUG-1616/1617) --------------------- + +func TestCrossWorkspace_AdminBearerVsCookieSplit(t *testing.T) { + f := newCrossWSFixture(t) + admin := mustUser(t, f.srv, "padmin@example.com", "padmin", "admin") + + cookieReq := f.request(admin, reqOpts{}) + bearerReq := f.request(admin, reqOpts{bearer: true}) + + // Cookie session: platform admin gets owner-equivalent access to a + // workspace they never joined. + got := f.srv.AuthorizeCrossWorkspaceEdit(cookieReq, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)) + assertAllowed(t, got, "cookie admin edits into B") + if got.Role != "owner" { + t.Errorf("cookie admin: expected owner-equivalent role, got %q", got.Role) + } + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(cookieReq, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)), + "cookie admin reads a B item") + + // Bearer: the same admin is a stranger to B. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(bearerReq, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceNoWorkspaceAccess, "bearer admin edits into B") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(bearerReq, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceNoWorkspaceAccess, "bearer admin reads B item") +} + +// A bearer-borne admin holding a stray grant in B still gets nothing: +// the membership-only stance skips the guest-grants fallback entirely. +func TestCrossWorkspace_BearerAdminWithStrayGrantStillDenied(t *testing.T) { + f := newCrossWSFixture(t) + admin := mustUser(t, f.srv, "strayadmin@example.com", "strayadmin", "admin") + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, admin.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + bearerReq := f.request(admin, reqOpts{bearer: true}) + + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(bearerReq, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceNoWorkspaceAccess, "bearer admin with stray grant") + + // A NON-admin with the same grant is a legitimate guest. + guest := f.member("guest@example.com", "guestuser", "editor", f.wsA) + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, guest.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant guest: %v", err) + } + gReq := f.request(guest, reqOpts{bearer: true, wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + got := f.srv.AuthorizeCrossWorkspaceEdit(gReq, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)) + assertAllowed(t, got, "guest with item grant edits the granted item") + if got.Role != "guest" { + t.Errorf("expected guest role, got %q", got.Role) + } +} + +// --- Soft-deleted target ---------------------------------------------- + +func TestCrossWorkspace_SoftDeletedWorkspaceDenied(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("sd@example.com", "sduser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + "pre-delete baseline") + + if err := f.srv.store.DeleteWorkspace(f.wsB.Slug); err != nil { + t.Fatalf("DeleteWorkspace: %v", err) + } + + for _, addr := range []string{f.wsB.Slug, f.wsB.ID} { + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, addr, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceWorkspaceNotFound, "soft-deleted B via "+addr) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, addr, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceWorkspaceNotFound, "soft-deleted B read via "+addr) + } + + // Even a cookie-session platform admin can't reach a soft-deleted + // workspace — resolution filters it before any role is computed. + admin := mustUser(t, f.srv, "sdadmin@example.com", "sdadmin", "admin") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(f.request(admin, reqOpts{}), f.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceWorkspaceNotFound, "cookie admin vs soft-deleted B") +} + +// --- Restricted members (collection_access = specific) ---------------- + +// DR-10a: a restricted editor in B must not be cleared to create into a +// collection hidden from them — including the case where their only +// claim on that collection is an item-level grant inside it, which the +// nav-lenient VisibleCollectionIDs set (and hence ResolveUserPermission's +// own gate) would wave through. +func TestCrossWorkspace_RestrictedEditorHiddenCollection(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("restricted@example.com", "restricteduser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + if err := f.srv.store.SetMemberCollectionAccess(f.wsB.ID, u.ID, "specific", []string{f.collB.ID}); err != nil { + t.Fatalf("SetMemberCollectionAccess: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + "restricted editor, permitted collection") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.hiddenB.ID)), + CrossWorkspaceCollectionNotVisible, "restricted editor, hidden collection") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.hiddenB.ID)), + CrossWorkspaceCollectionNotVisible, "restricted editor, hidden collection schema disclosure") + + // DR-10b: reading OUT of the hidden collection is the exfiltration + // direction and must refuse too. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)), + CrossWorkspaceItemNotVisible, "restricted editor, hidden item") + + // Now give them an item grant INSIDE the hidden collection. That makes + // the collection nav-visible, and ResolveUserPermission's restricted- + // member gate consults that same lenient set — so this is exactly the + // DR-10a escalation. The granted item becomes readable; the collection + // as a whole must not. + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.hiddenIB.ID, u.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)), + "restricted editor reads the specifically granted item") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.hiddenB.ID)), + CrossWorkspaceCollectionNotVisible, "restricted editor, item-grant-only collection") + + // And a sibling item in that collection, which the grant does not + // cover, stays invisible. + sibling := mustItem(t, f.srv, f.wsB.ID, f.hiddenB.ID, "Secret Sibling") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(sibling)), + CrossWorkspaceItemNotVisible, "restricted editor, ungranted sibling") + + // The workspace-level-only scope PASSES throughout — which is exactly + // why it is never sufficient on its own. + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()), + "workspace-only scope is not a substitute for the scoped check") +} + +// --- Guests holding only item grants ---------------------------------- + +func TestCrossWorkspace_GuestItemGrantOnly(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("g@example.com", "guser", "editor", f.wsA) + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, u.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "guest edits the granted item") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)), + CrossWorkspaceItemNotVisible, "guest reads an ungranted item") + // The grant makes itemB's collection nav-visible, but a guest whose + // only claim is one item must not be cleared to create into it. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceCollectionNotVisible, "guest creates into the item-grant collection") + + // A view-only item grant reads but does not write. + viewer := f.member("gv@example.com", "gvuser", "editor", f.wsA) + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, viewer.ID, "view", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant view: %v", err) + } + vr := f.request(viewer, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(vr, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "view-grant guest reads") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(vr, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceInsufficientPermission, "view-grant guest writes") +} + +// A guest with a full COLLECTION grant may create into that collection. +func TestCrossWorkspace_GuestCollectionGrant(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("gc@example.com", "gcuser", "editor", f.wsA) + if _, err := f.srv.store.CreateCollectionGrant(f.wsB.ID, f.collB.ID, u.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateCollectionGrant: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + "collection-grant guest creates into the granted collection") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.hiddenB.ID)), + CrossWorkspaceCollectionNotVisible, "collection-grant guest creates into another collection") +} + +// --- Scope hygiene ----------------------------------------------------- + +func TestCrossWorkspace_ScopeMismatch(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("sm@example.com", "smuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + // An item from A, authorized against B (which the caller owns). + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemA)), + CrossWorkspaceScopeMismatch, "item from A against B") + // A collection from A, likewise. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collA.ID)), + CrossWorkspaceScopeMismatch, "collection from A against B") + + // Codex round 1 P1: a sparse / hand-built item must NOT fail open. + // An unset WorkspaceID once meant "can't tell, assume fine", which + // let an item from a third workspace be laundered through a + // workspace the caller owns. Same for an unset CollectionID, which + // makes both isCollectionVisible and ResolveUserPermission degrade + // to a workspace-level answer. + sparse := []struct { + name string + item *models.Item + }{ + {"no workspace id", &models.Item{ID: f.itemA.ID, CollectionID: f.collA.ID}}, + {"no collection id", &models.Item{ID: f.itemB.ID, WorkspaceID: f.wsB.ID}}, + {"no item id", &models.Item{WorkspaceID: f.wsB.ID, CollectionID: f.collB.ID}}, + {"empty item", &models.Item{}}, + // Claims workspace B but points its collection at A. Only the + // PARENT-COLLECTION workspace check catches this one — the + // item's own WorkspaceID matches the target. + {"collection from another workspace", &models.Item{ID: f.itemB.ID, WorkspaceID: f.wsB.ID, CollectionID: f.collA.ID}}, + } + for _, tc := range sparse { + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(tc.item)), + CrossWorkspaceScopeMismatch, "sparse item scope: "+tc.name) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(tc.item)), + CrossWorkspaceScopeMismatch, "sparse item scope (read): "+tc.name) + } +} + +func TestCrossWorkspace_InvalidAndMissingInputs(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("iv@example.com", "ivuser", "owner", f.wsB) + r := f.request(u, reqOpts{}) + + // The zero scope names nothing and must never degrade to a + // workspace-level check. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceScope{}), + CrossWorkspaceInvalidScope, "zero scope") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceScope{}), + CrossWorkspaceInvalidScope, "zero scope (edit)") + // A nil item is not a scope either. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(nil)), + CrossWorkspaceInvalidScope, "nil item scope") + // Empty target. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, "", CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceWorkspaceNotFound, "empty target") + // Nonexistent target. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, "no-such-workspace", CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceWorkspaceNotFound, "nonexistent target") + // Nonexistent / soft-deleted collection in a workspace the caller owns. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope("00000000-0000-0000-0000-000000000000")), + CrossWorkspaceCollectionNotVisible, "nonexistent collection") +} + +// --- Grant / role precedence (Codex round 5) -------------------------- + +// Grants widen, never narrow. requireEditPermission lets an +// editor/owner base role win outright and only consults grants when the +// role is insufficient; the cross-workspace helper must do the same, or +// an incidental view grant silently demotes a member the front door +// would allow. +func TestCrossWorkspace_GrantsWidenNeverNarrow(t *testing.T) { + f := newCrossWSFixture(t) + + // Viewer in B plus an edit grant → the grant wins. + viewer := f.member("vg@example.com", "vguser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, viewer.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + vr := f.request(viewer, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(vr, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceInsufficientPermission, "viewer before the grant") + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, viewer.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(vr, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "viewer plus an edit grant") + + // Editor/owner in B plus a VIEW grant → still allowed. Resolving + // grants ahead of membership (ResolveUserPermission's own order) + // would demote them here. + for _, tc := range []struct{ email, name, role string }{ + {"eg@example.com", "eguser", "editor"}, + {"og@example.com", "oguser", "owner"}, + } { + u := f.member(tc.email, tc.name, "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, tc.role); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, u.ID, "view", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + tc.role+" in B is not demoted by a view grant") + } +} + +// --- System collections ------------------------------------------------ + +// Restricted members keep access to system collections (conventions, +// playbooks). That allowance survives the item-grant filtering branch, +// which is the shape that used to 404 them. +func TestCrossWorkspace_RestrictedMemberSystemCollection(t *testing.T) { + f := newCrossWSFixture(t) + sysColl, err := f.srv.store.CreateCollection(f.wsB.ID, models.CollectionCreate{Name: "Conventions B", IsSystem: true}) + if err != nil { + t.Fatalf("CreateCollection: %v", err) + } + sysItem := mustItem(t, f.srv, f.wsB.ID, sysColl.ID, "Convention B") + + u := f.member("sys@example.com", "sysuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + // Restricted to collB only — the system collection is NOT listed. + if err := f.srv.store.SetMemberCollectionAccess(f.wsB.ID, u.ID, "specific", []string{f.collB.ID}); err != nil { + t.Fatalf("SetMemberCollectionAccess: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(sysItem)), + "restricted member reads a system-collection item") + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(sysColl.ID)), + "restricted member creates into a system collection") + // The non-system collection they were NOT granted stays hidden — the + // system allowance must not blanket the workspace. + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)), + CrossWorkspaceItemNotVisible, "system allowance is not a blanket pass") + + // Activate the item-grant filtering branch with an unrelated grant. + // The system allowance must survive it. + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.hiddenIB.ID, u.ID, "view", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(sysItem)), + "system-collection item with item-grant filtering active") + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(sysColl.ID)), + "system collection scope with item-grant filtering active") +} + +// --- Grants on soft-deleted resources ---------------------------------- + +// A grant on a soft-deleted item is not access. UserHasGrantsInWorkspace +// filters archived resources, so the "guest" role must not be +// synthesized from one. +func TestCrossWorkspace_GrantOnArchivedItemGivesNoRole(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("ag@example.com", "aguser", "editor", f.wsA) + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.itemB.ID, u.ID, "edit", f.ownerBoth.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.ID, CrossWorkspaceItemScope(f.itemB)), + "live grant baseline") + + if err := f.srv.store.DeleteItem(f.itemB.ID); err != nil { + t.Fatalf("DeleteItem: %v", err) + } + // Addressed by UUID so resolveWorkspace's ACL scoping isn't what + // produces the denial — the role derivation is. + got := f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.ID, CrossWorkspaceItemScope(f.itemB)) + assertDenied(t, got, CrossWorkspaceNoWorkspaceAccess, "grant on an archived item") + if got.Role != "" { + t.Errorf("expected no role from an archived-item grant, got %q", got.Role) + } +} + +// --- Archived collections (Codex round 2 P1) -------------------------- + +// Soft-deleting a collection leaves its items in place, and neither +// GetItem nor VisibleCollectionIDs filters on the collection's +// deleted_at — so nothing downstream of the helper would notice. Both +// scopes must refuse. +func TestCrossWorkspace_ArchivedCollectionDenied(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("ac@example.com", "acuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "pre-archive baseline (item)") + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + "pre-archive baseline (collection)") + + if err := f.srv.store.DeleteCollection(f.collB.ID, ""); err != nil { + t.Fatalf("DeleteCollection: %v", err) + } + // The item row is untouched by the collection archive, which is + // exactly why the helper has to look. + if live, err := f.srv.store.GetItem(f.itemB.ID); err != nil || live == nil { + t.Fatalf("expected the item to survive its collection's archive (item=%v err=%v)", live, err) + } + + for label, scope := range map[string]CrossWorkspaceScope{ + "item": CrossWorkspaceItemScope(f.itemB), + "collection": CrossWorkspaceCollectionScope(f.collB.ID), + } { + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, scope), + CrossWorkspaceCollectionNotVisible, "archived collection, edit, "+label) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, scope), + CrossWorkspaceCollectionNotVisible, "archived collection, read, "+label) + } + + // A cookie-session platform admin doesn't get a pass either — the + // collection check runs before any per-caller visibility filter. + admin := mustUser(t, f.srv, "acadmin@example.com", "acadmin", "admin") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(f.request(admin, reqOpts{}), f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceCollectionNotVisible, "cookie admin vs archived collection") +} + +// --- Never grant more than the front door (Codex round 2 P1) ---------- + +// Ownership and membership are separate persisted state. +// RequireWorkspaceAccess has no ws.OwnerID exception — it requires the +// membership row — so a workspace owner with no member row is refused +// at /api/v1/workspaces/{slug} and must be refused sideways too. The +// ref resolver's resolverWorkspaceRole DOES take that shortcut +// (BUG-1618, a deliberate widening for its cookie-only redirect route); +// the cross-workspace helper deliberately does not reuse it. +func TestCrossWorkspace_OwnerWithoutMembershipRowMatchesFrontDoor(t *testing.T) { + f := newCrossWSFixture(t) + owner := mustUser(t, f.srv, "solo@example.com", "solouser", "") + ws, err := f.srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Unjoined", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + coll := mustCollection(t, f.srv, ws.ID, "Tasks") + if m, mErr := f.srv.store.GetWorkspaceMember(ws.ID, owner.ID); mErr != nil || m != nil { + t.Fatalf("fixture broken: expected no membership row (m=%v err=%v)", m, mErr) + } + + for _, tc := range []struct { + name string + opts reqOpts + }{ + {"bearer", reqOpts{bearer: true}}, + {"cookie", reqOpts{}}, + } { + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(f.request(owner, tc.opts), ws.Slug, CrossWorkspaceCollectionScope(coll.ID)), + CrossWorkspaceNoWorkspaceAccess, "member-less owner via "+tc.name) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(f.request(owner, tc.opts), ws.Slug, CrossWorkspaceWorkspaceOnlyScope()), + CrossWorkspaceNoWorkspaceAccess, "member-less owner read via "+tc.name) + } + + // Adding the membership row restores access on both surfaces. + if err := f.srv.store.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(f.request(owner, reqOpts{bearer: true}), ws.Slug, CrossWorkspaceCollectionScope(coll.ID)), + "owner with membership row, bearer") + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(f.request(owner, reqOpts{}), ws.Slug, CrossWorkspaceCollectionScope(coll.ID)), + "owner with membership row, cookie") +} + +// --- Tokenless surfaces ----------------------------------------------- + +// A legacy workspace-scoped API token is an editor in ITS workspace and a +// stranger everywhere else. +func TestCrossWorkspace_LegacyWorkspaceScopedToken(t *testing.T) { + f := newCrossWSFixture(t) + r := f.request(nil, reqOpts{noUser: true, bearer: true, tokenWorkspaceID: f.wsA.ID, + wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceNoWorkspaceAccess, "legacy token reaching B") + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsA.Slug, CrossWorkspaceCollectionScope(f.collA.ID)), + "legacy token in its own workspace") + + // An allow-list still applies on top of the pinned workspace — the + // two gates are independent and both must pass. + excluded := f.request(nil, reqOpts{noUser: true, bearer: true, tokenWorkspaceID: f.wsA.ID, + setAllowed: true, allowed: []string{f.wsB.Slug}, wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(excluded, f.wsA.Slug, CrossWorkspaceCollectionScope(f.collA.ID)), + CrossWorkspaceTokenNotAllowed, "legacy token, own workspace excluded by the allow-list") + included := f.request(nil, reqOpts{noUser: true, bearer: true, tokenWorkspaceID: f.wsA.ID, + setAllowed: true, allowed: []string{f.wsA.Slug}, wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(included, f.wsA.Slug, CrossWorkspaceCollectionScope(f.collA.ID)), + "legacy token, own workspace on the allow-list") +} + +// An anonymous caller on an initialized instance gets nothing. +func TestCrossWorkspace_AnonymousDenied(t *testing.T) { + f := newCrossWSFixture(t) + r := f.request(nil, reqOpts{noUser: true}) + assertDenied(t, f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + CrossWorkspaceNoWorkspaceAccess, "anonymous read") + assertDenied(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.collB.ID)), + CrossWorkspaceNoWorkspaceAccess, "anonymous edit") +} + +// Fresh install (no users yet): the instance is open, mirroring +// RequireWorkspaceAccess's UserCount == 0 bypass. +func TestCrossWorkspace_FreshInstall(t *testing.T) { + srv := testServer(t) + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Solo"}) + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + coll := mustCollection(t, srv, ws.ID, "Tasks") + r := httptest.NewRequest("GET", "/api/v1/workspaces/other/items", nil) + + got := srv.AuthorizeCrossWorkspaceEdit(r, ws.Slug, CrossWorkspaceCollectionScope(coll.ID)) + assertAllowed(t, got, "fresh install") + if got.Role != "owner" { + t.Errorf("fresh install: expected owner, got %q", got.Role) + } +} + +// --- Disclosure posture ------------------------------------------------- + +// "Absent" and "forbidden" must be byte-identical on the read posture. +func TestCrossWorkspace_WriteHiddenIsIndistinguishable(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("d@example.com", "duser", "editor", f.wsA) + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + // "no such workspace" vs "B exists, you are a stranger, and you + // addressed it by UUID so resolution succeeded" — two distinct internal + // reasons that must render identically. + absent := f.srv.AuthorizeCrossWorkspaceRead(r, "no-such-workspace", CrossWorkspaceWorkspaceOnlyScope()) + forbidden := f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.ID, CrossWorkspaceWorkspaceOnlyScope()) + if absent.Reason == forbidden.Reason { + t.Fatalf("fixture broken: expected distinct internal reasons, both %q", absent.Reason) + } + + render := func(a CrossWorkspaceAccess) (int, string) { + rec := httptest.NewRecorder() + a.WriteHidden(rec, "Item") + return rec.Code, rec.Body.String() + } + ac, ab := render(absent) + fc, fb := render(forbidden) + if ac != http.StatusNotFound || fc != http.StatusNotFound { + t.Fatalf("expected 404/404, got %d/%d", ac, fc) + } + if ab != fb { + t.Fatalf("response bodies differ — that difference is the leak:\nabsent: %s\nforbidden: %s", ab, fb) + } + + // The item-level denials must be indistinguishable from those too: + // a caller who can read B but not the destination item learns nothing. + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + if err := f.srv.store.SetMemberCollectionAccess(f.wsB.ID, u.ID, "specific", []string{f.collB.ID}); err != nil { + t.Fatalf("SetMemberCollectionAccess: %v", err) + } + hidden := f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)) + assertDenied(t, hidden, CrossWorkspaceItemNotVisible, "member of B, hidden destination item") + hc, hb := render(hidden) + if hc != ac || hb != ab { + t.Fatalf("item-hidden response differs from absent-workspace response:\n%d %s\n%d %s", hc, hb, ac, ab) + } +} + +// WriteDenied acknowledges, but still refuses to distinguish absent from +// forbidden. +func TestCrossWorkspace_WriteDeniedDoesNotDistinguishAbsence(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("wd@example.com", "wduser", "editor", f.wsA) + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + render := func(a CrossWorkspaceAccess) (int, string) { + rec := httptest.NewRecorder() + a.WriteDenied(rec) + return rec.Code, rec.Body.String() + } + ac, ab := render(f.srv.AuthorizeCrossWorkspaceEdit(r, "no-such-workspace", CrossWorkspaceWorkspaceOnlyScope())) + fc, fb := render(f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.ID, CrossWorkspaceWorkspaceOnlyScope())) + if ac != http.StatusForbidden || fc != http.StatusForbidden { + t.Fatalf("expected 403/403, got %d/%d", ac, fc) + } + if ab != fb { + t.Fatalf("WriteDenied distinguishes absence from forbidden-ness:\n%s\n%s", ab, fb) + } + + // The token allow-list is the one denial WriteDenied names — see its + // doc for why that is safe (it is only ever reported to a caller who + // already has a role in the target). + tc, _ := render(CrossWorkspaceAccess{Reason: CrossWorkspaceTokenNotAllowed}) + if tc != http.StatusForbidden { + t.Fatalf("token denial: expected 403, got %d", tc) + } + + // Lookup failures are the documented 500 variant, and the one + // observable difference WriteDenied carries. Pinning it here so the + // trade-off in its doc comment can't drift silently (Codex round 3 + // P2). WriteHidden must NOT have the same variant. + lc, _ := render(CrossWorkspaceAccess{Reason: CrossWorkspaceLookupFailed, Err: errAuthzTestLookup}) + if lc != http.StatusInternalServerError { + t.Fatalf("lookup failure via WriteDenied: expected 500, got %d", lc) + } + hidden := httptest.NewRecorder() + CrossWorkspaceAccess{Reason: CrossWorkspaceLookupFailed, Err: errAuthzTestLookup}.WriteHidden(hidden, "Item") + baseline := httptest.NewRecorder() + CrossWorkspaceAccess{Reason: CrossWorkspaceWorkspaceNotFound}.WriteHidden(baseline, "Item") + if hidden.Code != baseline.Code || hidden.Body.String() != baseline.Body.String() { + t.Fatalf("lookup failure via WriteHidden differs from every other reason: %d %s vs %d %s", + hidden.Code, hidden.Body.String(), baseline.Code, baseline.Body.String()) + } +} + +var errAuthzTestLookup = errors.New("synthetic store failure") + +// A denied verdict holds the resolved workspace, the caller's role and +// a reason that separates absence from forbidden-ness. Serializing it +// into a response would hand a caller everything the disclosure rule +// forbids, so every field carries json:"-" as a backstop against a +// stray json.Marshal (Codex round 7). +func TestCrossWorkspace_VerdictDoesNotSerialize(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("ser@example.com", "seruser", "editor", f.wsA) + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + + // Addressed by UUID so the verdict actually carries a resolved + // workspace — the shape with something to leak. + got := f.srv.AuthorizeCrossWorkspaceRead(r, f.wsB.ID, CrossWorkspaceWorkspaceOnlyScope()) + assertDenied(t, got, CrossWorkspaceNoWorkspaceAccess, "stranger to B by UUID") + if got.Workspace == nil { + t.Fatal("fixture broken: expected the denial to carry a resolved workspace") + } + + blob, err := json.Marshal(got) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if string(blob) != "{}" { + t.Fatalf("a denial verdict serialized to %s — every field must be json:\"-\"", blob) + } + for _, secret := range []string{f.wsB.ID, f.wsB.Slug, f.wsB.Name, string(got.Reason)} { + if secret != "" && strings.Contains(string(blob), secret) { + t.Fatalf("serialized verdict leaks %q: %s", secret, blob) + } + } +} + +// --- Fail-closed on lookup errors -------------------------------------- + +// With the store closed underneath it, every path must deny rather than +// allow. +func TestCrossWorkspace_FailsClosedOnStoreError(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("fc@example.com", "fcuser", "editor", f.wsA) + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + r := f.request(u, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + assertAllowed(t, f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemB)), + "pre-close baseline") + + if err := f.srv.store.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + for _, scope := range []CrossWorkspaceScope{ + CrossWorkspaceWorkspaceOnlyScope(), + CrossWorkspaceCollectionScope(f.collB.ID), + CrossWorkspaceItemScope(f.itemB), + } { + got := f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, scope) + if got.Allowed { + t.Fatalf("store error produced an ALLOW verdict for scope %+v", scope) + } + if got.Reason != CrossWorkspaceLookupFailed && got.Reason != CrossWorkspaceWorkspaceNotFound { + t.Fatalf("unexpected reason on store error: %q", got.Reason) + } + rec := httptest.NewRecorder() + got.WriteHidden(rec, "Item") + if rec.Code != http.StatusNotFound { + t.Fatalf("WriteHidden on a lookup failure returned %d, want 404", rec.Code) + } + } +} diff --git a/internal/server/handlers_ref_resolver.go b/internal/server/handlers_ref_resolver.go index 5904a1fb..a8d3feb5 100644 --- a/internal/server/handlers_ref_resolver.go +++ b/internal/server/handlers_ref_resolver.go @@ -143,6 +143,14 @@ func (s *Server) refResolverNotFound(w http.ResponseWriter, _ *http.Request) { // // The returned (false, err) pair is reserved for genuine DB errors; the // caller still maps both to a 404 to honor the no-leak contract. +// +// Note this route does NOT consult the OAuth/MCP token consent allow-list, +// and — because it is registered inside the full middleware stack — it IS +// reachable by PATs and CLI session bearers. What keeps that acceptable is +// that it discloses nothing but a 302 to a URL the caller can already read. +// Do not copy it as a template for a cross-workspace surface that returns +// data: use AuthorizeCrossWorkspaceRead (authz_cross_workspace.go), which +// checks the allow-list. func (s *Server) resolverItemVisible(r *http.Request, ws *models.Workspace, item *models.Item) (bool, error) { user := currentUser(r) @@ -171,7 +179,10 @@ func (s *Server) resolverItemVisible(r *http.Request, ws *models.Workspace, item // bearer signal so a bearer-borne platform admin doesn't get a // cross-workspace owner bypass (BUG-1618). authIsBearer := isBearerAuth(r) - role := s.resolverWorkspaceRole(ws, user, authIsBearer) + role, err := s.resolverWorkspaceRole(ws, user, authIsBearer) + if err != nil { + return false, err + } if role == "" { // Not a member, no grants, not admin/owner. Not visible. return false, nil @@ -199,13 +210,30 @@ func (s *Server) resolverItemVisible(r *http.Request, ws *models.Workspace, item // keep the owner bypass so the web-UI affordance is preserved. The // workspace owner check is unconditional regardless of auth surface // (BUG-1618). -func (s *Server) resolverWorkspaceRole(ws *models.Workspace, user *models.User, authIsBearer bool) string { +// +// A non-nil error is a genuine store failure, never "no role". Callers MUST +// fail closed on it. Pre-TASK-2358 this helper swallowed both lookup errors +// and silently continued to the next branch, which is how a transient DB +// blip could downgrade a member to the guest-grants path. The resolver route +// maps error and "" alike to a 404, so its visible behavior is unchanged. +// +// NOT reusable for a second workspace, despite the shape. The ws.OwnerID +// short-circuit below fires on every auth surface (BUG-1618, a deliberate +// widening for this cookie-only redirect route), whereas +// RequireWorkspaceAccess requires an actual workspace_members row for every +// bearer caller. Cross-workspace callers use Server.crossWorkspaceRole +// (authz_cross_workspace.go), which tracks the middleware instead so it can +// never grant more than the front door. +func (s *Server) resolverWorkspaceRole(ws *models.Workspace, user *models.User, authIsBearer bool) (string, error) { if ws.OwnerID == user.ID || (user.Role == "admin" && !authIsBearer) { - return "owner" + return "owner", nil } member, err := s.store.GetWorkspaceMember(ws.ID, user.ID) - if err == nil && member != nil { - return member.Role + if err != nil { + return "", err + } + if member != nil { + return member.Role, nil } // Bearer-borne platform admin who isn't a member gets NO grant-based // fallback — the membership-only stance (BUG-1616/1617/1618). Without @@ -215,14 +243,17 @@ func (s *Server) resolverWorkspaceRole(ws *models.Workspace, user *models.User, // for every item in the workspace. RequireWorkspaceAccess denies // bearer-admin non-members before checking grants for the same reason. if user.Role == "admin" && authIsBearer { - return "" + return "", nil } // Not a member — guest path requires at least one grant. hasGrants, err := s.store.UserHasGrantsInWorkspace(ws.ID, user.ID) - if err == nil && hasGrants { - return "guest" + if err != nil { + return "", err + } + if hasGrants { + return "guest", nil } - return "" + return "", nil } // resolverOwnerUsername returns the workspace owner's username — the diff --git a/internal/server/server.go b/internal/server/server.go index 9b9f9688..b987b0e4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1953,26 +1953,12 @@ func (s *Server) visibleCollectionIDs(r *http.Request, workspaceID string) ([]st // Writes a 404 and returns false if not visible; callers should invoke this // immediately after resolving a collection by slug/ID. func (s *Server) requireCollectionFullyVisible(w http.ResponseWriter, r *http.Request, workspaceID string, coll *models.Collection) bool { - visibleIDs, err := s.visibleCollectionIDs(r, workspaceID) + visible, err := s.checkCollectionFullyVisible(r, workspaceID, coll.ID) if err != nil { writeInternalError(w, err) return false } - if visibleIDs != nil { - // Restricted (non-nil visibleIDs): narrow to full-access - // collections only when the caller's restricted visibility - // includes any item-level grants, so an item-grant-only - // collection can't qualify for collection-wide operations. - fullCollIDs, grantedItemIDs, gErr := s.guestResourceFilter(r, workspaceID) - if gErr != nil { - writeInternalError(w, gErr) - return false - } - if len(grantedItemIDs) > 0 { - visibleIDs = fullCollIDs - } - } - if !isCollectionVisible(coll.ID, visibleIDs) { + if !visible { writeError(w, http.StatusNotFound, "not_found", "Collection not found") return false } @@ -2348,6 +2334,16 @@ func (s *Server) filterUserGrantsForCaller(r *http.Request, workspaceID string, // grant-based permissions so grants can override the base role. // For guests, it resolves the effective permission from grants directly. // Returns true if the request should continue, false if it was rejected with a 403. +// +// NEVER call this with a workspace ID other than the one the current +// request's URL resolved to. The `workspaceID` parameter makes it look +// reusable for a second workspace; it is not. The editor/owner fast path +// below reads workspaceRole(r), which RequireWorkspaceAccess populates only +// for the URL's workspace, so passing workspace B's ID applies workspace A's +// role — privilege escalation. Use AuthorizeCrossWorkspaceEdit +// (authz_cross_workspace.go) for any other workspace; it also checks the +// OAuth/MCP consent allow-list, which this helper does not (DR-10 of +// PLAN-2357). func (s *Server) requireEditPermission(w http.ResponseWriter, r *http.Request, workspaceID string, itemID, collectionID string) bool { role := workspaceRole(r) From 1eb1c9eda69e198de61030a5ae1ef4c7c3f369c7 Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 30 Jul 2026 01:53:10 +0000 Subject: [PATCH 3/6] feat(server): expose ACL-gated moved-to pointer on item GET (TASK-2359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An item MOVED to another workspace — copied, then archived — can now say where it went. GET on a single item gains an optional `moved_to` block naming each destination in displayable terms (workspace slug + item ref + title + collection slug), so a consumer can render a link without a second call. No HTTP redirect, no resolver change. The ACL gate is the point. A destination is revealed only after the caller independently passes AuthorizeCrossWorkspaceRead (TASK-2358) with an ITEM scope on the destination item itself. Workspace-level access is not sufficient: a restricted member of the destination workspace, or a guest holding one unrelated item grant there, has a role in that workspace while having no right to the copied item's collection. A caller who fails that check sees NO hint a destination exists. The key is omitted entirely — not a null, not an empty array, not a boolean — so the response is byte-identical to an archived item with no move record at all. A structurally distinguishable response is itself the leak. Restore decision: the block is OMITTED for a non-archived source. Restoring a moved-out source leaves two live items with the same content in two workspaces, which is legitimate, but at that instant the source has not moved anywhere and the response must stop asserting that it did. Past-tense provenance is the back-pointer question and applies equally to plain copies, which this field must never claim as moves. Also honored: DR-2a (only archived_source rows feed the pointer; plain copies are back-pointer material only), per-destination filtering over the forward lookup's SET with no short-circuit on the first hit or first denial, newest-first ordering, a scan bound on the per-GET authorization cost, and deliberate isolation of the hand-rolled public share-link DTO — pinned by an explicit negative test that freezes its key set. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- internal/models/item.go | 47 ++ internal/server/handlers_items.go | 8 + internal/server/handlers_share_links.go | 42 +- internal/server/item_moved_to.go | 380 +++++++++ internal/server/item_moved_to_test.go | 859 ++++++++++++++++++++ internal/store/item_workspace_moves.go | 80 +- internal/store/item_workspace_moves_test.go | 148 +++- web/src/lib/types/index.ts | 46 +- 8 files changed, 1593 insertions(+), 17 deletions(-) create mode 100644 internal/server/item_moved_to.go create mode 100644 internal/server/item_moved_to_test.go diff --git a/internal/models/item.go b/internal/models/item.go index fecbd227..bd5a5f63 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -81,6 +81,19 @@ type Item struct { // omitted for a restricted caller (nil). IsUnparented *bool `json:"is_unparented,omitempty"` + // MovedTo names the destination(s) an ARCHIVED item was moved to by a + // cross-workspace move (PLAN-2357 / TASK-2359). Populated on the single-item + // GET response only, and only for destinations the caller has independently + // been authorized to READ — see (*server.Server).movedToDestinations. + // + // `omitempty` is load-bearing, not cosmetic. A caller who may not read the + // destination must receive a response byte-identical to one for an archived + // item that was never moved: no key, no null, no empty array. A + // structurally distinguishable response is itself the disclosure the ACL + // gate exists to prevent. Never populate this from a list, search, activity + // or share-link path, and never emit a placeholder when the gate denies. + MovedTo []ItemMovedTo `json:"moved_to,omitempty"` + DerivedClosure *ItemDerivedClosure `json:"derived_closure,omitempty"` CodeContext *ItemCodeContext `json:"code_context,omitempty"` Convention *ItemConventionMetadata `json:"convention,omitempty"` @@ -96,6 +109,40 @@ func (item *Item) ComputeRef() { } } +// ItemMovedTo is one destination an archived item was moved to, rendered in +// DISPLAYABLE terms. It deliberately carries no UUIDs: the consumer must be +// able to render (and link to) the destination without a second call, and +// exposing internal IDs of a resource in another workspace buys nothing the +// slug/ref pair does not already give. +// +// Every field describes a resource the caller has already been authorized to +// read, and authorization is all-or-nothing per entry: an entry is never +// partially REDACTED, it is dropped. (Individual fields may still be empty for +// ordinary reasons — a workspace with no resolvable owner username, an item +// whose collection has no prefix — which is what the omitempty tags are for. +// Absence here never means "withheld".) +type ItemMovedTo struct { + // WorkspaceSlug is the destination workspace's CANONICAL slug — the same + // value the token consent allow-list was tested against. + WorkspaceSlug string `json:"workspace_slug"` + WorkspaceName string `json:"workspace_name,omitempty"` + // WorkspaceOwnerUsername completes the /{username}/{workspace}/... web + // route. Empty when the join did not resolve one; the consumer degrades to + // a non-linked label rather than guessing. + WorkspaceOwnerUsername string `json:"workspace_owner_username,omitempty"` + + CollectionSlug string `json:"collection_slug,omitempty"` + // Ref is the destination item's issue ID ("TASK-5"). Empty only for the + // rare item whose collection has no prefix or number. + Ref string `json:"ref,omitempty"` + ItemSlug string `json:"item_slug"` + Title string `json:"title"` + + // MovedAt is when the move was recorded (RFC3339 UTC), matching the + // provenance row's created_at. + MovedAt string `json:"moved_at,omitempty"` +} + type ItemRelationRef struct { ID string `json:"id"` Slug string `json:"slug,omitempty"` diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 6db39a45..cfc291c4 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -800,6 +800,14 @@ func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) { return } + // The archived-source "moved to" pointer (PLAN-2357 / TASK-2359). Wired + // HERE and nowhere else — deliberately not inside enrichItemForResponse, + // which also runs on create/update/restore/move. Returns nil unless the + // item is archived, was genuinely MOVED (not merely copied), and the + // caller independently passes a read check on the destination ITEM. + // See item_moved_to.go for the disclosure rule. + item.MovedTo = s.movedToDestinations(r, item) + writeJSON(w, http.StatusOK, item) } diff --git a/internal/server/handlers_share_links.go b/internal/server/handlers_share_links.go index c3738bab..085604ad 100644 --- a/internal/server/handlers_share_links.go +++ b/internal/server/handlers_share_links.go @@ -369,6 +369,37 @@ func (s *Server) requireShareLinkTargetVisible(w http.ResponseWriter, r *http.Re } } +// publicShareItemDTO projects an item down to the fields an ANONYMOUS visitor +// may see. It is an explicit allow-list and must stay one: this payload is +// served to whoever holds the link, with no membership, no grants and no +// consent scope behind it. +// +// It exists as a named function rather than an inline literal so the +// projection can be exercised directly — a test can hand it an item with +// privileged fields already populated and assert they do not come out the +// other side. Inline, the only reachable test was an end-to-end one against +// whatever the store happened to return, which passes vacuously whenever the +// fixture cannot populate the field under suspicion. +// +// NEVER replace this with the item itself. In particular it must never carry +// models.Item.MovedTo, the ACL-gated cross-workspace destination pointer +// (PLAN-2357 / TASK-2359): that block is revealed only after authorizing a +// specific caller's read access to the destination ITEM, and an anonymous +// share-link visitor has no identity to authorize. Swapping this for +// `writeJSON(w, ..., item)` would publish destinations on every public share +// link. TestMovedTo_ShareLinkNeverCarriesPointer pins both the key set and +// that specific omission. +func publicShareItemDTO(item *models.Item) map[string]interface{} { + return map[string]interface{}{ + "title": item.Title, + "content": item.Content, + "fields": item.Fields, + "ref": item.Ref, + "collection_name": item.CollectionName, + "collection_icon": item.CollectionIcon, + } +} + // handleResolveShareLink is the /s/{token} route. It resolves a share link // token and returns the shared content. Anonymous users are ALWAYS read-only (D8). func (s *Server) handleResolveShareLink(w http.ResponseWriter, r *http.Request) { @@ -497,15 +528,8 @@ func (s *Server) handleResolveShareLink(w http.ResponseWriter, r *http.Request) return } writeJSON(w, http.StatusOK, map[string]interface{}{ - "type": "item", - "item": map[string]interface{}{ - "title": item.Title, - "content": item.Content, - "fields": item.Fields, - "ref": item.Ref, - "collection_name": item.CollectionName, - "collection_icon": item.CollectionIcon, - }, + "type": "item", + "item": publicShareItemDTO(item), "permission": "view", "share_link": map[string]interface{}{ "target_type": link.TargetType, diff --git a/internal/server/item_moved_to.go b/internal/server/item_moved_to.go new file mode 100644 index 00000000..8a846701 --- /dev/null +++ b/internal/server/item_moved_to.go @@ -0,0 +1,380 @@ +package server + +import ( + "log/slog" + "net/http" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// The archived-source "moved to" pointer (PLAN-2357 / TASK-2359). +// +// An item that was MOVED to another workspace — copied, then archived — should +// be able to say where it went. This file is the read-side of that: a single +// optional block on the single-item GET response naming the destination in +// displayable terms (workspace slug + item ref), so the consumer can render a +// link without a second call. +// +// It is NOT a redirect and NOT a resolver change. GET on an archived item +// still returns the archived item; this only decorates it. +// +// ############################################################ +// # Populate MovedTo from handleGetItem ONLY. # +// ############################################################ +// +// Not from enrichItemForResponse — that runs on create, update, restore and +// move responses too, and every additional surface is another place the ACL +// gate below has to be re-proved. Not from any list, search, activity, +// backlink or delta path — those return many items and would multiply the +// per-destination authorization into an N×M cost while widening the +// disclosure surface for nothing. And explicitly not from the public +// share-link DTO (handlers_share_links.go), which is hand-rolled and +// therefore isolated by construction; TestMovedTo_ShareLinkNeverCarriesPointer +// pins that isolation so a future refactor to `writeJSON(w, ..., item)` there +// cannot silently start publishing destinations to anonymous visitors. +// +// THE DISCLOSURE RULE. A caller who may not read a destination must get a +// response byte-identical to the response for an archived item with no move +// record at all. Not a null, not an empty array, not a boolean, not a count. +// The whole key is absent. `omitempty` on a nil slice is what delivers that, +// so never assign an empty non-nil slice here. +// +// TIMING IS OUT OF SCOPE, DELIBERATELY. Status, headers and bytes are +// identical, but a withheld destination costs an extra item load and an extra +// cross-workspace authorization, so a caller who can repeatedly re-fetch an +// archived source could in principle distinguish the two cases by latency. +// Closing that would mean performing movedToScanLimit dummy authorizations on +// every archived-item GET — a real per-read cost paid against a channel that +// is noisy over a network, and one Pad already exposes wherever a response is +// conditionally enriched (visible-parent lookup, derived closure, guest +// resource filters). The contract this file enforces is the RESPONSE +// contract, matching CrossWorkspaceAccess.WriteHidden, which makes the same +// kind of trade explicitly for internal errors. If Pad ever adopts a +// constant-time posture it belongs as its own item across all of those +// surfaces; do not solve it here alone. + +// movedToScanLimit bounds how many MOVE rows one GET will consider. +// +// Each candidate costs a destination load plus a workspace resolve, a role +// derivation, a collection load and a visibility check +// (AuthorizeCrossWorkspaceRead is deliberately per-item and does not batch). +// Rows are only ever written by a copy/move operation — one row each — so +// reaching this many MOVES of a single source means archive → restore → move +// again, twenty-five times over. "Takes deliberate effort" is not a bound, +// though, and an unbounded per-read fan-out on a route any member can call is +// not something to leave to good manners. +// +// It is pushed down into the SQL (ListArchivedItemWorkspaceMovesBySource) +// rather than applied while looping, so it bounds rows RETURNED — scanned into +// structs and then authorized one by one — and not merely rows kept. A long +// tail of plain COPIES, which can never contribute to this block, is excluded +// by the same query. It is not a claim about the planner; see that method's +// doc for what the LIMIT does and does not promise. +// +// The cap is applied to the newest rows and BEFORE the ACL filter, which means +// a caller who could have read only destination #26 gets nothing. That is the +// honest shape of a cost bound: filtering first would require authorizing +// every row, which is the thing being bounded. Newest-first is the right +// truncation because the newest move is the one a banner most wants. +const movedToScanLimit = 25 + +// movedToDestinations returns the destinations an archived item was moved to +// that THIS caller is independently authorized to read, newest first, or nil. +// +// Six gates, ALL of which must hold for a destination to appear. They are +// numbered as conditions, not as an execution sequence: the code evaluates +// gate 4's provenance query before gate 3's source-collection check so it +// pays for a collection load only once there is actually something to +// disclose. All six are conjunctive and none is order-sensitive, so the +// reordering is a cost choice with no effect on the verdict — but do not read +// the list as a trace. +// +// 1. the SOURCE is archived (see the restore decision below); +// 2. the caller is not a grants-only GUEST in the source workspace (see below); +// 3. the source's own collection is live (see below); +// 4. the provenance row is a MOVE, not a plain copy (DR-2a: archived_source); +// 5. the destination item still exists and is live; +// 6. the caller passes AuthorizeCrossWorkspaceRead with an ITEM scope on the +// destination item itself. +// +// Gate 6 is the point of the whole function, and the ITEM scope is the point +// of gate 6. A workspace-level check (CrossWorkspaceWorkspaceOnlyScope) is NOT +// sufficient and using one here would be the bug: a restricted member of the +// destination workspace, or a guest holding one unrelated item grant there, +// has a role in that workspace while having no right whatsoever to see the +// copied item's collection. Naming the destination to them leaks both the +// existence and the location of an item they may not read. +// +// THE SET, NOT THE NEWEST ROW — and no "current" marker. PLAN-2357's DR-2a +// phrases the pointer as resolving to "the newest archived_source row, the +// older one is history", which reads as first-match. TASK-2359 refines it into +// the per-destination filter implemented here, and the refinement is the +// correct one for two reasons. First, filtering is per caller: with a +// first-match rule a caller who may read the older destination but not the +// newer gets nothing at all, and useful information is replaced by silence +// rather than by a smaller answer. Second, both destinations genuinely exist — +// move → restore → move again leaves real items in both workspaces — so +// naming both is accurate; only the claim "it currently lives at X" would be +// wrong, and this block never makes that claim. +// +// Which is also why there is deliberately NO per-entry `current` flag. It +// would be the obvious way to let Phase 3 word a banner precisely, and it is a +// disclosure vector: `current: false` on the sole visible entry announces that +// a NEWER destination exists and is being withheld — the exact inference the +// omit-entirely rule forbids. Phase 3 must therefore word the banner as +// provenance ("this item was moved; copies exist at …"), not as a current +// location, because an ACL-filtered list cannot promise its head is the +// newest move. +// +// PRECONDITION, ENFORCED RATHER THAN ASSUMED: the request must have resolved +// to the SOURCE ITEM'S OWN WORKSPACE. handleGetItem is the only caller and it +// satisfies this trivially, but the signature does not — hand this an item +// from workspace B on a request scoped to workspace A and gate 2 would test +// the caller's role in A while the source lives in B, so a grants-only guest +// of B who happens to be an owner of A would sail through the very gate that +// exists to stop them. That is the same confused-deputy shape +// CrossWorkspaceItemScope guards against on the destination side, and "only +// one caller today" is not a guard. Mismatch returns nil. +// +// GATE 2, GRANTS-ONLY GUESTS ON THE SOURCE. PLAN-2357 names this alongside +// share links: "a share link on the source, or a guest holding only a +// source-item grant, must never see moved_to". Both are narrow, delegated +// access — someone was handed one item, not that item's cross-workspace +// provenance — and the rule holds even when the guest could independently read +// the destination, because the question is what the SOURCE grant conveys. +// +// This is the one place workspaceRole(r) is the right thing to read: the +// source IS the request's own workspace, which is the only workspace that +// context value ever describes. It is emphatically not usable for the +// destination, and gate 6 derives that role fresh — see the header of +// authz_cross_workspace.go for what happens when the two are confused. +// +// GATE 3, THE SOURCE'S COLLECTION. handleGetItem has already run requireItemVisible on +// the source, which is what authorizes reading the item at all — but that check +// does NOT establish that the source's collection is live. Soft-deleting a +// collection leaves its items in place, and neither ResolveItemIncludeDeleted +// nor VisibleCollectionIDs filters on the collection's deleted_at, so an +// archived item under an archived collection is still fetchable today. That is +// pre-existing behavior for the item BODY and this function does not change it +// — widening or narrowing the item read is out of scope here. +// +// What it does do is refuse to ADD a cross-workspace disclosure on top of it. +// authorizeCrossWorkspace applies exactly this rule to the destination side +// (crossWorkspaceLiveCollection, "Codex round 2 P1" in that file), and the +// source deserves the symmetric treatment: an item in a collection the +// workspace has retired should not be the thing that reveals where a copy of +// it lives in another workspace. One extra collection load, paid only for an +// archived item that actually has move rows. +// +// RESTORE DECISION (TASK-2359). The block is OMITTED for a non-archived +// source, full stop. Restoring a moved-out source leaves two live items with +// the same content in two workspaces — legitimate, and the same end state a +// plain copy produces — but at that instant the source has not "moved" +// anywhere, so continuing to assert a move would be false. Past-tense +// provenance ("this was also copied to B") is real, but it is the BACK-pointer +// question and it applies equally to plain copies, which this block must never +// claim as moves; it belongs on a provenance surface of its own rather than +// smuggled through a field whose name asserts a move. Omitting also keeps the +// disclosure surface minimal: a live item's response shape is then identical +// for moved-and-restored and never-moved items, exactly as the denial case is. +// +// A store failure degrades to nil rather than failing the GET: the item read +// is the caller's actual request, and a provenance lookup error is not a +// reason to withhold it. Fail-closed on disclosure, fail-open on the read. +// +// NOT ATOMIC — inherited from AuthorizeCrossWorkspaceRead. The verdict +// describes the world at the instant it was computed; this is a read path, so +// a stale verdict is a narrow window, but do not cache one. +func (s *Server) movedToDestinations(r *http.Request, item *models.Item) []models.ItemMovedTo { + // Gate 1: only an archived source can have moved. See the restore + // decision above. + if item == nil || item.DeletedAt == nil || item.ID == "" { + return nil + } + itemID := item.ID + + // Precondition. workspaceRole(r) below describes the workspace the request + // URL resolved to and nothing else, so it is only an answer about the + // SOURCE while these two agree. See the note above. + resolvedWS, _ := r.Context().Value(ctxResolvedWorkspaceID).(string) + if resolvedWS == "" || resolvedWS != item.WorkspaceID { + slog.Warn("movedToDestinations: called outside the source item's own workspace context; omitting pointer", + "item_id", item.ID, "item_workspace_id", item.WorkspaceID, "request_workspace_id", resolvedWS) + return nil + } + + // Gate 2. Checked before the provenance query so a delegated-access + // caller never even causes the lookup. + if workspaceRole(r) == "guest" { + return movedToOmitted(itemID, "source_guest") + } + + // Gate 4 (DR-2a) is pushed into SQL: only archived_source rows, newest + // first, at most movedToScanLimit of them. A plain copy is back-pointer + // material only and must never claim the source moved, so it is excluded + // by the query rather than by the loop — that way a long tail of copies + // costs nothing on a read of the source. + moves, err := s.store.ListArchivedItemWorkspaceMovesBySource(item.ID, movedToScanLimit) + if err != nil { + slog.Warn("movedToDestinations: provenance lookup failed; omitting pointer", + "item_id", item.ID, "error", err) + return nil + } + if len(moves) == 0 { + return movedToOmitted(itemID, "no_move_rows") + } + + // Gate 3, paid only now that there is something to disclose. See the + // source-side note above for why requireItemVisible is not sufficient. + coll, cErr := s.store.GetCollection(item.CollectionID) + if cErr != nil { + slog.Warn("movedToDestinations: source collection lookup failed; omitting pointer", + "item_id", item.ID, "error", cErr) + return nil + } + // GetCollection filters soft-deleted rows, so nil covers "absent" and + // "archived" alike. + if coll == nil || coll.WorkspaceID != item.WorkspaceID { + return movedToOmitted(itemID, "source_collection_not_live") + } + + // Nil, never an empty slice — see the disclosure rule in the file header. + var out []models.ItemMovedTo + + for i := range moves { + m := moves[i] + + // Defense in depth: the query already filters this, but DR-2a is the + // invariant the whole block rests on and a consumer-side assert costs + // nothing. + if !m.ArchivedSource { + continue + } + + // Gate 5: the destination must still be there. GetItem filters + // soft-deleted rows, so an archived destination drops out — pointing a + // banner at an item that is itself archived is worse than saying + // nothing, and the caller loses no access they had. + target, terr := s.store.GetItem(m.TargetItemID) + if terr != nil { + slog.Warn("movedToDestinations: destination lookup failed; skipping", + "item_id", item.ID, "target_item_id", m.TargetItemID, "error", terr) + continue + } + if target == nil { + slog.Debug("movedToDestinations: destination item is gone or archived; skipping", + "item_id", itemID, "target_item_id", m.TargetItemID) + continue + } + // Fail closed if the row and the item disagree about where the + // destination lives. Authorizing workspace X while describing an item + // that now sits in workspace Y is the confused-deputy shape + // CrossWorkspaceItemScope guards against; catching it here means the + // mismatch is never even offered to the helper. + if target.WorkspaceID != m.TargetWorkspaceID { + slog.Warn("movedToDestinations: provenance row disagrees with destination item's workspace; skipping", + "item_id", item.ID, "target_item_id", m.TargetItemID, + "row_workspace_id", m.TargetWorkspaceID, "item_workspace_id", target.WorkspaceID) + continue + } + + // Gate 6. ITEM scope, never workspace-only. Addressing the workspace + // by ID is fine: the helper resolves it and tests the consent + // allow-list against the resolved CANONICAL slug. + access := s.AuthorizeCrossWorkspaceRead(r, m.TargetWorkspaceID, CrossWorkspaceItemScope(target)) + if !access.Allowed { + // Server-side only. Nothing about this denial — not its reason, + // not the workspace it names — may reach the response. The + // verdict struct is `json:"-"` throughout precisely so a stray + // marshal cannot undo that; these fields are for operators, which + // is the use CrossWorkspaceAccess's own contract reserves them + // for. + // + // A LOOKUP FAILURE IS NOT A DENIAL, though it is handled as one. + // The caller may well have been entitled to this destination and + // lost it to a broken store, so it is logged loudly and with the + // error, while an ordinary "you may not see this" stays at Debug + // where it belongs — those fire routinely and by design. + if access.Reason == CrossWorkspaceLookupFailed { + slog.Warn("movedToDestinations: destination authorization failed; withholding", + "item_id", item.ID, "target_item_id", m.TargetItemID, + "target_workspace", access.WorkspaceSlug(), "error", access.Err) + continue + } + slog.Debug("movedToDestinations: destination withheld", + "item_id", item.ID, "reason", string(access.Reason), + "target_workspace", access.WorkspaceSlug()) + continue + } + + ws := access.Workspace + if ws == nil { + // Unreachable for an allowed verdict; refuse rather than emit a + // half-populated entry. + continue + } + + // WorkspaceName and WorkspaceOwnerUsername go beyond the bare + // "slug + ref" locator the task asks for. That is a deliberate + // judgement — they turn a bare slug into "Moved to Pad Web" with a + // working /{username}/{workspace}/… link, which is the whole "no + // second call" requirement — and it holds for every caller gate 6 + // admits, though for two different reasons: + // + // - MEMBERS and GRANT-HOLDERS already enumerate both fields. + // GetUserWorkspaces (workspace_members.go) returns w.name and the + // owner's username for members, and its guest branch does the same + // for anyone holding a grant on a LIVE item in a LIVE collection — + // which is exactly what gate 6 admits, since gate 5 has already + // established the destination item is live. Nothing new. + // + // - The three SYNTHESIZED roles crossWorkspaceRole hands out are not + // in that enumeration, so for them this is genuinely new data — and + // harmless in each case. A cookie-session platform admin is "owner" + // everywhere and reaches any workspace through the admin surfaces + // regardless. A nil user on a fresh install predates the first + // account, when the whole instance is open by design. A legacy + // workspace-pinned token is only ever "editor" for the ONE + // workspace it is pinned to, so the destination is its own + // workspace. (A BEARER-borne admin is deliberately NOT on this + // list: crossWorkspaceRole suppresses that bypass, BUG-1616/1617.) + // + // If the guest enumeration ever narrows, or a fourth synthesized role + // appears, re-run this argument — and drop these two fields first if it + // no longer holds. The slug and ref alone satisfy the task. + out = append(out, models.ItemMovedTo{ + WorkspaceSlug: ws.Slug, + WorkspaceName: ws.Name, + WorkspaceOwnerUsername: ws.OwnerUsername, + CollectionSlug: target.CollectionSlug, + Ref: target.Ref, + ItemSlug: target.Slug, + Title: target.Title, + MovedAt: m.CreatedAt, + }) + } + + if out == nil { + return movedToOmitted(itemID, "all_destinations_filtered") + } + return out +} + +// movedToOmitted records WHY the block was omitted and returns nil. +// +// "The banner isn't showing" has half a dozen legitimate causes — no move +// rows, a plain-copy-only history, an archived destination, an archived source +// collection, a delegated-access caller, every destination filtered — and +// without this they are indistinguishable to an operator, because the whole +// point of the disclosure rule is that the RESPONSE cannot tell them apart +// either. So the distinction lives in the log instead. +// +// Debug, not Info: on a healthy instance the overwhelmingly common outcome is +// "no move rows", which would otherwise fire on every archived-item read. The +// reasons are fixed strings and the only identifier is the caller's OWN item — +// nothing here names a resource the caller was denied. +func movedToOmitted(itemID, reason string) []models.ItemMovedTo { + slog.Debug("movedToDestinations: no pointer emitted", + "item_id", itemID, "reason", reason) + return nil +} diff --git a/internal/server/item_moved_to_test.go b/internal/server/item_moved_to_test.go new file mode 100644 index 00000000..3ceb69b1 --- /dev/null +++ b/internal/server/item_moved_to_test.go @@ -0,0 +1,859 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Tests for the archived-source "moved to" pointer (PLAN-2357 / TASK-2359). +// +// The gate is the subject, not the decoration: nearly every case below asserts +// that a destination is WITHHELD, and the headline assertion +// (TestMovedTo_DeniedResponseIsByteIdenticalToNoMoveRecord) compares raw +// response BYTES rather than a parsed struct, because a structurally +// distinguishable response is itself the leak this task exists to prevent. + +// --- fixture ----------------------------------------------------------- + +type movedToFixture struct { + t *testing.T + srv *Server + + owner *models.User + + // A is the source workspace; B and C are destinations. + wsA, wsB, wsC *models.Workspace + + collA *models.Collection + source *models.Item + + collB *models.Collection + destB *models.Item + hiddenB *models.Collection + destHid *models.Item + + collC *models.Collection + destC *models.Item +} + +func newMovedToFixture(t *testing.T) *movedToFixture { + t.Helper() + srv := testServer(t) + + owner := mustUser(t, srv, "movedto-owner@example.com", "movedtoowner", "") + wsA := mustWorkspace(t, srv, "Source WS", owner.ID) + wsB := mustWorkspace(t, srv, "Dest WS", owner.ID) + wsC := mustWorkspace(t, srv, "Other Dest WS", owner.ID) + + collA := mustCollection(t, srv, wsA.ID, "Tasks A") + source := mustItem(t, srv, wsA.ID, collA.ID, "The Source Item") + + collB := mustCollection(t, srv, wsB.ID, "Tasks B") + destB := mustItem(t, srv, wsB.ID, collB.ID, "The Copy In B") + hiddenB := mustCollection(t, srv, wsB.ID, "Secrets B") + destHid := mustItem(t, srv, wsB.ID, hiddenB.ID, "The Copy In Hidden B") + + collC := mustCollection(t, srv, wsC.ID, "Tasks C") + destC := mustItem(t, srv, wsC.ID, collC.ID, "The Copy In C") + + return &movedToFixture{ + t: t, srv: srv, owner: owner, + wsA: wsA, wsB: wsB, wsC: wsC, + collA: collA, source: source, + collB: collB, destB: destB, hiddenB: hiddenB, destHid: destHid, + collC: collC, destC: destC, + } +} + +// record commits one provenance row. archivedSource=true makes it a MOVE +// (source_seq is then mandatory), false a plain copy. +func (f *movedToFixture) record(target *models.Item, archivedSource bool, seq int64) *models.ItemWorkspaceMove { + f.t.Helper() + m := models.ItemWorkspaceMove{ + SourceWorkspaceID: f.source.WorkspaceID, + SourceItemID: f.source.ID, + TargetWorkspaceID: target.WorkspaceID, + TargetItemID: target.ID, + ArchivedSource: archivedSource, + CreatedBy: f.owner.ID, + } + if archivedSource { + m.SourceSeq = &seq + } + tx, err := f.srv.store.DB().Begin() + if err != nil { + f.t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck // committed below; rollback is the failure path + stored, err := f.srv.store.RecordItemWorkspaceMoveTx(tx, m) + if err != nil { + f.t.Fatalf("RecordItemWorkspaceMoveTx: %v", err) + } + if err := tx.Commit(); err != nil { + f.t.Fatalf("commit: %v", err) + } + return stored +} + +func (f *movedToFixture) archiveSource() { + f.t.Helper() + if err := f.srv.store.DeleteItem(f.source.ID); err != nil { + f.t.Fatalf("DeleteItem(source): %v", err) + } +} + +func (f *movedToFixture) restoreSource() { + f.t.Helper() + if _, err := f.srv.store.RestoreItem(f.source.ID); err != nil { + f.t.Fatalf("RestoreItem(source): %v", err) + } +} + +// getSource performs GET on the source item exactly as the router would and +// requires a 200. Use rawGetSource when the status is what is under test. +func (f *movedToFixture) getSource(user *models.User, o reqOpts) *httptest.ResponseRecorder { + f.t.Helper() + rr := f.rawGetSource(user, o) + if rr.Code != http.StatusOK { + f.t.Fatalf("GET source: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + return rr +} + +func (f *movedToFixture) rawGetSource(user *models.User, o reqOpts) *httptest.ResponseRecorder { + f.t.Helper() + rr := httptest.NewRecorder() + f.srv.handleGetItem(rr, f.getSourceRequest(user, o)) + return rr +} + +// getSourceRequest builds the GET request the router would hand handleGetItem, +// with the caller's auth surface described by o. The workspace-A role and +// resolved ID are stashed the way RequireWorkspaceAccess stashes them; the +// cross-workspace gate must ignore both. +func (f *movedToFixture) getSourceRequest(user *models.User, o reqOpts) *http.Request { + f.t.Helper() + + r := httptest.NewRequest("GET", + "/api/v1/workspaces/"+f.wsA.Slug+"/items/"+f.source.Slug, nil) + ctx := r.Context() + if !o.noUser && user != nil { + ctx = WithCurrentUser(ctx, user) + } + if o.setAllowed { + ctx = WithTokenAllowedWorkspaces(ctx, o.allowed) + } + if o.tokenWorkspaceID != "" { + ctx = WithTokenWorkspaceID(ctx, o.tokenWorkspaceID) + } + role := o.wsRoleCtx + if role == "" { + role = "owner" + } + ctx = contextWithWorkspaceRoleForTest(ctx, role) + ctx = contextWithResolvedWorkspaceIDForTest(ctx, f.wsA.ID) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", f.wsA.Slug) + rctx.URLParams.Add("itemSlug", f.source.Slug) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + + r = r.WithContext(ctx) + if o.bearer { + r.Header.Set("Authorization", "Bearer test-token") + } + return r +} + +// movedTo parses the response and returns the moved_to block. It also asserts +// the disclosure rule's negative half: when the block is absent the KEY must +// be absent — never `"moved_to": null` and never `"moved_to": []`. +func (f *movedToFixture) movedTo(rr *httptest.ResponseRecorder) []models.ItemMovedTo { + f.t.Helper() + + var raw map[string]json.RawMessage + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + f.t.Fatalf("parse response: %v\nbody: %s", err, rr.Body.String()) + } + blob, present := raw["moved_to"] + if !present { + return nil + } + var out []models.ItemMovedTo + if err := json.Unmarshal(blob, &out); err != nil { + f.t.Fatalf("parse moved_to: %v", err) + } + if len(out) == 0 { + f.t.Fatalf("moved_to key present but empty (%s) — an empty marker is itself a disclosure; the key must be omitted", string(blob)) + } + return out +} + +func (f *movedToFixture) requireNoPointer(rr *httptest.ResponseRecorder, label string) { + f.t.Helper() + if got := f.movedTo(rr); got != nil { + f.t.Fatalf("%s: expected NO moved_to block, got %+v", label, got) + } +} + +// --- happy path -------------------------------------------------------- + +// TestMovedTo_ArchivedSourceCallerCanReadDestination is the affirmative case: +// the destination is named in displayable terms, with no UUIDs, so the +// consumer can render a link without a second call. +func TestMovedTo_ArchivedSourceCallerCanReadDestination(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, true, 10) + f.archiveSource() + + got := f.movedTo(f.getSource(f.owner, reqOpts{})) + if len(got) != 1 { + t.Fatalf("expected 1 destination, got %d (%+v)", len(got), got) + } + d := got[0] + if d.WorkspaceSlug != f.wsB.Slug { + t.Errorf("workspace_slug: got %q, want %q", d.WorkspaceSlug, f.wsB.Slug) + } + if d.Ref != f.destB.Ref || d.Ref == "" { + t.Errorf("ref: got %q, want %q", d.Ref, f.destB.Ref) + } + if d.ItemSlug != f.destB.Slug { + t.Errorf("item_slug: got %q, want %q", d.ItemSlug, f.destB.Slug) + } + if d.Title != f.destB.Title { + t.Errorf("title: got %q, want %q", d.Title, f.destB.Title) + } + if d.CollectionSlug != f.destB.CollectionSlug { + t.Errorf("collection_slug: got %q, want %q", d.CollectionSlug, f.destB.CollectionSlug) + } + if d.MovedAt == "" { + t.Error("moved_at empty") + } + + // No UUIDs anywhere in the block: the whole point of the displayable + // shape is that internal IDs of another workspace never travel. + blob, _ := json.Marshal(d) + for label, id := range map[string]string{ + "destination workspace ID": f.wsB.ID, + "destination item ID": f.destB.ID, + "destination collection": f.collB.ID, + } { + if id != "" && strings.Contains(string(blob), id) { + t.Errorf("moved_to leaked the %s (%s): %s", label, id, blob) + } + } +} + +// --- the disclosure rule ---------------------------------------------- + +// TestMovedTo_DeniedResponseIsByteIdenticalToNoMoveRecord is the acceptance +// criterion the whole task hangs on. A caller who cannot read the destination +// must not be able to tell a moved item from an ordinary archived one — so the +// comparison is on RAW BYTES, taken from the same caller and the same item, +// before and after the provenance row exists. Writing the row touches nothing +// on the item itself (no seq bump, no updated_at), so any difference between +// the two bodies is attributable to the pointer alone. A null, an empty array, +// a reordered key set or a differently-typed field would all fail here. +func TestMovedTo_DeniedResponseIsByteIdenticalToNoMoveRecord(t *testing.T) { + f := newMovedToFixture(t) + // Member of A only — a total stranger to B. + stranger := mustUser(t, f.srv, "stranger@example.com", "strangeruser", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, stranger.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + + f.archiveSource() + withoutRow := f.getSource(stranger, reqOpts{wsRoleCtx: "editor"}).Body.Bytes() + + f.record(f.destB, true, 10) + withRow := f.getSource(stranger, reqOpts{wsRoleCtx: "editor"}).Body.Bytes() + f.requireNoPointer(f.getSource(stranger, reqOpts{wsRoleCtx: "editor"}), "stranger to B") + + if string(withRow) != string(withoutRow) { + t.Fatalf("denied response is distinguishable from a never-moved item:\n with row: %s\nwithout row: %s", + withRow, withoutRow) + } + + // Control: the same item, same instant, read by someone who CAN see B — + // proving the bytes above were identical because the gate withheld the + // block, not because the fixture never had one to withhold. + if got := f.movedTo(f.getSource(f.owner, reqOpts{})); len(got) != 1 { + t.Fatalf("control: the owner should see the destination, got %+v", got) + } +} + +// TestMovedTo_WorkspaceAccessAloneIsNotEnough is the reason the gate uses an +// ITEM scope. A restricted member of the destination workspace can read +// workspace B generally while having no right to the copied item's collection; +// a workspace-level check would hand them the pointer anyway. +func TestMovedTo_WorkspaceAccessAloneIsNotEnough(t *testing.T) { + f := newMovedToFixture(t) + u := mustUser(t, f.srv, "restricted-b@example.com", "restrictedb", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + // A genuine editor in B — but only for collB, not the hidden collection + // the item was copied into. + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + if err := f.srv.store.SetMemberCollectionAccess(f.wsB.ID, u.ID, "specific", []string{f.collB.ID}); err != nil { + t.Fatalf("SetMemberCollectionAccess: %v", err) + } + + // Sanity: the workspace-only scope — the check a naive implementation + // would have made — passes for this caller. + probe := f.request(u) + if acc := f.srv.AuthorizeCrossWorkspaceRead(probe, f.wsB.Slug, CrossWorkspaceWorkspaceOnlyScope()); !acc.Allowed { + t.Fatalf("precondition: caller should have workspace-level access to B, got %q", acc.Reason) + } + + f.record(f.destHid, true, 10) + f.archiveSource() + f.requireNoPointer(f.getSource(u, reqOpts{wsRoleCtx: "editor"}), + "restricted member of B, destination in a hidden collection") +} + +// request builds a bare cross-workspace probe request for the fixture's caller. +func (f *movedToFixture) request(user *models.User) *http.Request { + f.t.Helper() + r := httptest.NewRequest("GET", "/api/v1/workspaces/"+f.wsA.Slug+"/items/x", nil) + ctx := WithCurrentUser(r.Context(), user) + ctx = contextWithWorkspaceRoleForTest(ctx, "editor") + ctx = contextWithResolvedWorkspaceIDForTest(ctx, f.wsA.ID) + return r.WithContext(ctx) +} + +// TestMovedTo_GuestWithUnrelatedItemGrantInDestination is the second half of +// the same trap: a guest holding one item grant in B has a role there +// ("guest") and passes a workspace-level check, but the granted item is not +// the copied one. +func TestMovedTo_GuestWithUnrelatedItemGrantInDestination(t *testing.T) { + f := newMovedToFixture(t) + u := mustUser(t, f.srv, "guest-b@example.com", "guestb", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + // One grant in B, on an item unrelated to the copy. + unrelated := mustItem(t, f.srv, f.wsB.ID, f.collB.ID, "Unrelated B Item") + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, unrelated.ID, u.ID, "view", f.owner.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + + f.record(f.destB, true, 10) + f.archiveSource() + f.requireNoPointer(f.getSource(u, reqOpts{wsRoleCtx: "editor"}), + "guest holding only an unrelated item grant in B") + + // ...and the grant on the ACTUAL destination does reveal it, so the + // denial above is the scope working rather than the guest role being + // blanket-refused. + if _, err := f.srv.store.CreateItemGrant(f.wsB.ID, f.destB.ID, u.ID, "view", f.owner.ID); err != nil { + t.Fatalf("CreateItemGrant on destination: %v", err) + } + if got := f.movedTo(f.getSource(u, reqOpts{wsRoleCtx: "editor"})); len(got) != 1 { + t.Fatalf("guest granted the destination item should see it, got %+v", got) + } +} + +// TestMovedTo_BearerTokenConsentedToSourceOnly: consent is enforced +// automatically only for the workspace in the URL. A token scoped to A must +// not learn about B even though its owner is a full member there. +func TestMovedTo_BearerTokenConsentedToSourceOnly(t *testing.T) { + f := newMovedToFixture(t) + u := mustUser(t, f.srv, "consent@example.com", "consentuser", "") + for _, ws := range []*models.Workspace{f.wsA, f.wsB} { + if err := f.srv.store.AddWorkspaceMember(ws.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + } + + f.record(f.destB, true, 10) + f.archiveSource() + + // Consent covers A only. + f.requireNoPointer( + f.getSource(u, reqOpts{ + wsRoleCtx: "editor", bearer: true, + setAllowed: true, allowed: []string{f.wsA.Slug}, + }), + "bearer consented to A only") + + // Same caller, same membership, consent widened to include B. + if got := f.movedTo(f.getSource(u, reqOpts{ + wsRoleCtx: "editor", bearer: true, + setAllowed: true, allowed: []string{f.wsA.Slug, f.wsB.Slug}, + })); len(got) != 1 { + t.Fatalf("bearer consented to A and B should see the destination, got %+v", got) + } +} + +// TestMovedTo_BearerPlatformAdminIsNotAShortcut: the cookie-vs-bearer admin +// split (BUG-1616/1617) must hold here too — a platform admin over a bearer +// surface who is not a member of B learns nothing. +func TestMovedTo_BearerPlatformAdminIsNotAShortcut(t *testing.T) { + f := newMovedToFixture(t) + admin := mustUser(t, f.srv, "admin-movedto@example.com", "adminmovedto", "admin") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, admin.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + + f.record(f.destB, true, 10) + f.archiveSource() + + f.requireNoPointer(f.getSource(admin, reqOpts{wsRoleCtx: "editor", bearer: true}), + "bearer-borne platform admin, not a member of B") + if got := f.movedTo(f.getSource(admin, reqOpts{wsRoleCtx: "editor"})); len(got) != 1 { + t.Fatalf("cookie-session platform admin should see the destination, got %+v", got) + } +} + +// --- multiples --------------------------------------------------------- + +// TestMovedTo_MultipleDestinationsFilteredPerDestination: the forward lookup is +// a SET, the filter is per destination, and the loop must not short-circuit on +// the first denial or the first hit. +// +// This is also the concrete case that rules out a "newest archived row wins" +// first-match implementation: the caller here may read the OLDER destination +// and not the newer one, so first-match would hand them nothing at all rather +// than the smaller true answer. See the SET note in item_moved_to.go. +func TestMovedTo_MultipleDestinationsFilteredPerDestination(t *testing.T) { + f := newMovedToFixture(t) + u := mustUser(t, f.srv, "multi@example.com", "multiuser", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + // Member of C only. C's row is recorded FIRST so the denied B row is + // encountered before it — a short-circuit on the first denial would drop + // the destination the caller may legitimately see. + if err := f.srv.store.AddWorkspaceMember(f.wsC.ID, u.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember C: %v", err) + } + + f.record(f.destC, true, 10) + f.record(f.destB, true, 11) // newest → first in the store's ordering + f.archiveSource() + + got := f.movedTo(f.getSource(u, reqOpts{wsRoleCtx: "editor"})) + if len(got) != 1 { + t.Fatalf("expected exactly the readable destination, got %d (%+v)", len(got), got) + } + if got[0].WorkspaceSlug != f.wsC.Slug { + t.Errorf("expected workspace C, got %q", got[0].WorkspaceSlug) + } + + // The owner sees both, newest first. + all := f.movedTo(f.getSource(f.owner, reqOpts{})) + if len(all) != 2 { + t.Fatalf("owner should see both destinations, got %d (%+v)", len(all), all) + } + if all[0].WorkspaceSlug != f.wsB.Slug || all[1].WorkspaceSlug != f.wsC.Slug { + t.Errorf("expected newest-first [B, C], got [%s, %s]", all[0].WorkspaceSlug, all[1].WorkspaceSlug) + } +} + +// --- DR-2a: copies are not moves --------------------------------------- + +func TestMovedTo_PlainCopyNeverClaimsAMove(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, false, 0) // plain copy: archived_source = false + f.archiveSource() + + f.requireNoPointer(f.getSource(f.owner, reqOpts{}), + "archived source whose only provenance row is a plain copy") +} + +// A source copied to C and then MOVED to B advertises only B. +func TestMovedTo_MoveRowWinsOverCopyRow(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destC, false, 0) + f.record(f.destB, true, 11) + f.archiveSource() + + got := f.movedTo(f.getSource(f.owner, reqOpts{})) + if len(got) != 1 || got[0].WorkspaceSlug != f.wsB.Slug { + t.Fatalf("expected only the move destination (B), got %+v", got) + } +} + +// --- the restore decision ---------------------------------------------- + +// TestMovedTo_RestoredSourceOmitsTheBlock pins TASK-2359's restore decision: +// the block is OMITTED for a non-archived source. Restoring leaves two live +// items with the same content in two workspaces — legitimate — but the source +// has not moved anywhere, so the response must stop asserting that it did. +// Past-tense provenance is the back-pointer question and applies equally to +// plain copies, which this field must never claim as moves. +func TestMovedTo_RestoredSourceOmitsTheBlock(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, true, 10) + f.archiveSource() + + if got := f.movedTo(f.getSource(f.owner, reqOpts{})); len(got) != 1 { + t.Fatalf("precondition: archived source should advertise the destination, got %+v", got) + } + + f.restoreSource() + f.requireNoPointer(f.getSource(f.owner, reqOpts{}), "restored (live) source") + + // Archiving again brings it back — the omission is a function of the + // source's current state, not a one-way latch. Archive → restore → move + // again is the legal sequence DR-2a's source_seq exists for, so the second + // move lands in a different workspace with its own destination item + // (uq_item_workspace_moves_target forbids reusing the first one). + f.record(f.destC, true, 12) + f.archiveSource() + got := f.movedTo(f.getSource(f.owner, reqOpts{})) + if len(got) != 2 { + t.Fatalf("re-archived source should advertise both moves, got %+v", got) + } + if got[0].WorkspaceSlug != f.wsC.Slug { + t.Errorf("newest move (C) should lead, got %q", got[0].WorkspaceSlug) + } +} + +// --- destination lifecycle --------------------------------------------- + +// A destination that has itself been archived is not advertised: pointing a +// banner at an archived item is worse than saying nothing. +func TestMovedTo_ArchivedDestinationIsNotAdvertised(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, true, 10) + f.archiveSource() + if err := f.srv.store.DeleteItem(f.destB.ID); err != nil { + t.Fatalf("DeleteItem(dest): %v", err) + } + + f.requireNoPointer(f.getSource(f.owner, reqOpts{}), "archived destination") +} + +// --- source-side gate -------------------------------------------------- + +// TestMovedTo_SourceItemGrantGuestNeverSeesPointer is the negative test +// PLAN-2357 names explicitly, alongside the share-link one: "a share link on +// the source, or a guest holding only a source-item grant, must never see +// moved_to". Delegated access to one item is not access to that item's +// cross-workspace provenance. +// +// The teeth are in the second half: the same guest is a full OWNER of the +// destination workspace, so the destination gate would happily clear them. The +// pointer is withheld anyway, because the question this gate answers is what +// the SOURCE grant conveys. +func TestMovedTo_SourceItemGrantGuestNeverSeesPointer(t *testing.T) { + f := newMovedToFixture(t) + guest := mustUser(t, f.srv, "source-guest@example.com", "sourceguest", "") + // No membership in A at all — the sole claim is one item grant on the + // source, which is what makes RequireWorkspaceAccess call them a guest. + if _, err := f.srv.store.CreateItemGrant(f.wsA.ID, f.source.ID, guest.ID, "view", f.owner.ID); err != nil { + t.Fatalf("CreateItemGrant on source: %v", err) + } + + // Make them an OWNER of the destination workspace too, so the destination + // gate would happily clear them. A gate that only asked "can this caller + // read the destination?" emits the pointer here. + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, guest.ID, "owner"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + f.record(f.destB, true, 10) + f.archiveSource() + + archived, err := f.srv.store.ResolveItemIncludeDeleted(f.wsA.ID, f.source.Slug) + if err != nil || archived == nil { + t.Fatalf("ResolveItemIncludeDeleted: %v", err) + } + if acc := f.srv.AuthorizeCrossWorkspaceRead( + f.request(guest), f.wsB.Slug, CrossWorkspaceItemScope(f.destB)); !acc.Allowed { + t.Fatalf("precondition: the guest should pass the DESTINATION gate, got %q", acc.Reason) + } + + // The gate itself, driven directly. This is the assertion with teeth: + // delete the workspaceRole(r) == "guest" check and it fails, because every + // later gate passes for this caller. + guestReq := f.getSourceRequest(guest, reqOpts{wsRoleCtx: "guest"}) + if got := f.srv.movedToDestinations(guestReq, archived); got != nil { + t.Fatalf("a guest holding only a source-item grant was handed the destination: %+v", got) + } + + // A real membership in workspace A — however minimal — restores it, so the + // refusal above is about what the delegated SOURCE claim conveys and not a + // blanket denial of this user. + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, guest.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if got := f.movedTo(f.getSource(guest, reqOpts{wsRoleCtx: "viewer"})); len(got) != 1 { + t.Fatalf("a genuine member of both workspaces should see the destination, got %+v", got) + } + + // Belt and braces, and worth recording: today the guest cannot reach the + // archived source over HTTP at all — grants on a soft-deleted item yield no + // visibility (the same rule TestCrossWorkspace_GrantOnArchivedItemGivesNoRole + // pins), so the route 404s before the pointer is ever computed. That makes + // the gate above defense in depth rather than the only thing standing + // between a source-grant guest and a destination — but it is exactly the + // kind of upstream behavior that a future change to grant semantics could + // relax without anyone thinking about this file. + revoked := mustUser(t, f.srv, "source-guest-2@example.com", "sourceguest2", "") + if _, err := f.srv.store.CreateItemGrant(f.wsA.ID, f.source.ID, revoked.ID, "view", f.owner.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + rr := f.rawGetSource(revoked, reqOpts{wsRoleCtx: "guest"}) + if rr.Code != http.StatusNotFound { + t.Errorf("grant-only guest reading an archived source: got %d, want 404 — "+ + "if this behavior changed, the guest gate in movedToDestinations is now load-bearing on its own", + rr.Code) + } +} + +// TestMovedTo_ArchivedSourceCollectionWithholdsPointer: requireItemVisible, +// which handleGetItem already ran, does NOT establish that the source's own +// collection is live — soft-deleting a collection leaves its items fetchable. +// That is pre-existing behavior for the item body and stays that way; what +// must not happen is a cross-workspace disclosure being layered on top of it. +// The destination side applies the identical rule +// (crossWorkspaceLiveCollection); this is its source-side mirror. +func TestMovedTo_ArchivedSourceCollectionWithholdsPointer(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, true, 10) + f.archiveSource() + + if got := f.movedTo(f.getSource(f.owner, reqOpts{})); len(got) != 1 { + t.Fatalf("precondition: the pointer should be visible before the collection is archived, got %+v", got) + } + + if err := f.srv.store.DeleteCollection(f.collA.ID, ""); err != nil { + t.Fatalf("DeleteCollection: %v", err) + } + + // The item body is still served — that is the pre-existing behavior this + // change deliberately leaves alone — but the pointer is gone. + f.requireNoPointer(f.getSource(f.owner, reqOpts{}), "source under a soft-deleted collection") +} + +// TestMovedTo_RefusesForeignWorkspaceRequestContext pins the precondition +// movedToDestinations enforces rather than assumes: the request must have +// resolved to the SOURCE item's own workspace. +// +// handleGetItem satisfies that trivially and is the only caller today, so this +// is a guard against the NEXT caller — an item-ID-addressed route, a resolver, +// a list handler. Given a request scoped to workspace B and a source from +// workspace A, the guest gate would test the caller's role in B while the +// source lives in A, and a grants-only guest of A who owns B would slip +// through the one gate written to stop them. +func TestMovedTo_RefusesForeignWorkspaceRequestContext(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, true, 10) + f.archiveSource() + + archived, err := f.srv.store.ResolveItemIncludeDeleted(f.wsA.ID, f.source.Slug) + if err != nil || archived == nil { + t.Fatalf("ResolveItemIncludeDeleted: %v", err) + } + + // The honest context: request resolved to A, source lives in A. + if got := f.srv.movedToDestinations(f.getSourceRequest(f.owner, reqOpts{}), archived); len(got) != 1 { + t.Fatalf("precondition: the correctly-scoped call should return the destination, got %+v", got) + } + + // The same everything, with the request claiming a different workspace. + r := f.getSourceRequest(f.owner, reqOpts{}) + r = r.WithContext(contextWithResolvedWorkspaceIDForTest(r.Context(), f.wsB.ID)) + if got := f.srv.movedToDestinations(r, archived); got != nil { + t.Fatalf("a request scoped to a foreign workspace was served the pointer: %+v", got) + } + + // And a request with no resolved workspace at all — a caller that skipped + // RequireWorkspaceAccess — fails closed rather than treating the empty + // role as "not a guest". + bare := httptest.NewRequest("GET", "/api/v1/items/"+f.source.Slug, nil) + bare = bare.WithContext(WithCurrentUser(bare.Context(), f.owner)) + if got := f.srv.movedToDestinations(bare, archived); got != nil { + t.Fatalf("a request with no resolved workspace was served the pointer: %+v", got) + } +} + +// --- surface isolation ------------------------------------------------- + +// TestMovedTo_ShareLinkNeverCarriesPointer guards the public share DTO's +// isolation DELIBERATELY. handlers_share_links.go hand-rolls its item payload, +// so it does not pick the block up today — but that is an accident of the +// current code, and a refactor to `writeJSON(w, ..., item)` would start +// publishing destinations to anonymous visitors on every share link. Pinning +// the exact key set makes that refactor fail loudly. +func TestMovedTo_ShareLinkNeverCarriesPointer(t *testing.T) { + f := newMovedToFixture(t) + + // The load-bearing half: hand the projection an item whose MovedTo is + // ALREADY populated and assert it does not survive. Driving this + // end-to-end instead would pass vacuously — the share route resolves only + // live items, and nothing on that path ever populates MovedTo, so a + // refactor to `writeJSON(w, ..., item)` would sail through an e2e-only + // test while publishing destinations on every public share link. + privileged := *f.destB + privileged.MovedTo = []models.ItemMovedTo{{ + WorkspaceSlug: f.wsC.Slug, + Ref: f.destC.Ref, + ItemSlug: f.destC.Slug, + Title: f.destC.Title, + }} + dto := publicShareItemDTO(&privileged) + if _, present := dto["moved_to"]; present { + t.Fatal("the public share DTO carries moved_to — a destination pointer is now visible to anonymous visitors") + } + // The DTO is an explicit allow-list; freeze it so an accidental wholesale + // swap to the full item model is caught rather than inferred. Any new key + // here is a disclosure decision and wants its own review. + want := map[string]bool{ + "title": true, "content": true, "fields": true, + "ref": true, "collection_name": true, "collection_icon": true, + } + for k := range dto { + if !want[k] { + t.Errorf("public share item DTO gained an unexpected key %q — re-review it for disclosure before widening this list", k) + } + } + for k := range want { + if _, present := dto[k]; !present { + t.Errorf("public share item DTO lost the expected key %q", k) + } + } + + // ...and the wired-up route really does use that projection, so the + // assertion above is about live code rather than a parallel helper. + link, err := f.srv.store.CreateShareLink(f.wsB.ID, "item", f.destB.ID, "view", f.owner.ID, nil) + if err != nil { + t.Fatalf("CreateShareLink: %v", err) + } + rr := doRequest(f.srv, "GET", "/api/v1/s/"+link.Token, nil) + if rr.Code != http.StatusOK { + t.Fatalf("resolve share link: got %d: %s", rr.Code, rr.Body.String()) + } + var resp struct { + Item map[string]json.RawMessage `json:"item"` + } + parseJSON(t, rr, &resp) + if len(resp.Item) != len(want) { + t.Errorf("share route payload has %d item keys, projection has %d — the route no longer uses publicShareItemDTO", + len(resp.Item), len(want)) + } + for k := range resp.Item { + if !want[k] { + t.Errorf("share route payload carries unexpected key %q", k) + } + } +} + +// The block belongs to the single-item GET alone. Mutation handlers reuse +// enrichItemForResponse and return a full item, so a future move of the +// population call into that helper — or a well-meaning copy-paste into another +// handler — would silently widen the surface. Driven through the real HTTP +// handlers, not through enrichItemForResponse directly, so it fails whichever +// way the widening arrives. +// +// RESTORE is the load-bearing one: it is the single mutation whose response +// describes an item that, one instant earlier, was an archived source WITH a +// live move row and therefore genuinely had a pointer to emit. +func TestMovedTo_MutationResponsesDoNotCarryPointer(t *testing.T) { + f := newMovedToFixture(t) + f.record(f.destB, true, 10) + f.archiveSource() + + if got := f.movedTo(f.getSource(f.owner, reqOpts{})); len(got) != 1 { + t.Fatalf("precondition: the archived source should have a pointer to withhold, got %+v", got) + } + + // POST .../restore — the response is an item that was moved-out a + // microsecond ago. + restoreRR := f.callItemHandler(f.srv.handleRestoreItem, "POST", "/restore", nil, f.owner) + if restoreRR.Code != http.StatusOK { + t.Fatalf("restore: got %d: %s", restoreRR.Code, restoreRR.Body.String()) + } + requireNoMovedToKey(t, restoreRR, "restore response") + + // PATCH the now-live item. + updateRR := f.callItemHandler(f.srv.handleUpdateItem, "PATCH", "", + map[string]interface{}{"title": "Renamed Source"}, f.owner) + if updateRR.Code != http.StatusOK { + t.Fatalf("update: got %d: %s", updateRR.Code, updateRR.Body.String()) + } + requireNoMovedToKey(t, updateRR, "update response") + + // And the helper every mutation shares stays clean, so the population + // cannot be smuggled in one level down. + fresh, err := f.srv.store.GetItem(f.source.ID) + if err != nil || fresh == nil { + t.Fatalf("GetItem: %v", err) + } + if fresh.MovedTo != nil { + t.Fatal("store-level item carries MovedTo; it must only ever be set by handleGetItem") + } + if err := f.srv.enrichItemForResponse(fresh, nil); err != nil { + t.Fatalf("enrichItemForResponse: %v", err) + } + if fresh.MovedTo != nil { + t.Fatal("enrichItemForResponse populated MovedTo — it runs on create/update/restore/move too; keep the population in handleGetItem") + } +} + +func requireNoMovedToKey(t *testing.T, rr *httptest.ResponseRecorder, label string) { + t.Helper() + var raw map[string]json.RawMessage + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatalf("%s: parse: %v\nbody: %s", label, err, rr.Body.String()) + } + // Guard against the assertion going vacuous: these handlers return the + // item at the TOP level, so if that ever changes to an envelope the + // moved_to check below would be looking in the wrong place and pass for + // the wrong reason. + if _, present := raw["id"]; !present { + t.Fatalf("%s is not a top-level item payload (%s); re-point this assertion before trusting it", + label, rr.Body.String()) + } + if blob, present := raw["moved_to"]; present { + t.Fatalf("%s carries moved_to (%s) — the pointer belongs to the single-item GET alone", label, string(blob)) + } +} + +// callItemHandler drives one item-scoped handler with the same synthetic +// request shape getSource builds, for a suffix under the item's route. +func (f *movedToFixture) callItemHandler( + h http.HandlerFunc, method, suffix string, body interface{}, user *models.User, +) *httptest.ResponseRecorder { + f.t.Helper() + + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + f.t.Fatalf("marshal body: %v", err) + } + reader = bytes.NewReader(data) + } + r := httptest.NewRequest(method, + "/api/v1/workspaces/"+f.wsA.Slug+"/items/"+f.source.Slug+suffix, reader) + if body != nil { + r.Header.Set("Content-Type", "application/json") + } + + ctx := WithCurrentUser(r.Context(), user) + ctx = contextWithWorkspaceRoleForTest(ctx, "owner") + ctx = contextWithResolvedWorkspaceIDForTest(ctx, f.wsA.ID) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", f.wsA.Slug) + rctx.URLParams.Add("itemSlug", f.source.Slug) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + + rr := httptest.NewRecorder() + h(rr, r.WithContext(ctx)) + return rr +} diff --git a/internal/store/item_workspace_moves.go b/internal/store/item_workspace_moves.go index 5c54ca09..b5d0341f 100644 --- a/internal/store/item_workspace_moves.go +++ b/internal/store/item_workspace_moves.go @@ -113,9 +113,13 @@ func (s *Store) RecordItemWorkspaceMoveTx(tx *sql.Tx, m models.ItemWorkspaceMove // // A SET, not a row: one source can be copied into several workspaces, and can // additionally be moved after being restored. The caller decides how to render -// multiples. A caller resolving the archived-source "moved to" pointer wants -// the first entry with ArchivedSource true — plain-copy rows never feed that -// pointer (DR-2a). +// multiples. +// +// This is the BROAD lookup — copies and moves alike — for callers that want +// the whole provenance picture. The archived-source "moved to" pointer is not +// one of them: it reads only ArchivedSource rows (DR-2a) and needs a bound on +// how many it will authorize, so it uses +// ListArchivedItemWorkspaceMovesBySource instead of filtering this result. func (s *Store) ListItemWorkspaceMovesBySource(sourceItemID string) ([]models.ItemWorkspaceMove, error) { rows, err := s.db.Query(s.q(` SELECT `+itemWorkspaceMoveColumns+` @@ -141,6 +145,76 @@ func (s *Store) ListItemWorkspaceMovesBySource(sourceItemID string) ([]models.It return moves, nil } +// ListArchivedItemWorkspaceMovesBySource returns at most `limit` MOVE rows for +// one source — rows with archived_source true — newest first. +// +// The narrow counterpart to ListItemWorkspaceMovesBySource, for the one +// consumer that wants moves and only moves: the archived-source "moved to" +// pointer (PLAN-2357 DR-2a / TASK-2359). Its reason to exist is that the +// caller bounds how many destinations it will AUTHORIZE, and that bound is +// only meaningful if the row set it authorizes over is bounded too. Filtering +// in Go instead would leave a source with a long tail of plain COPIES loading, +// scanning and allocating every one of them on every read of the source, while +// none of them can ever contribute to the result. +// +// What the LIMIT bounds is rows RETURNED — materialized, scanned into structs, +// and handed to the caller's per-row authorization. It is not a promise about +// the planner. idx_item_workspace_moves_moved_to is partial on +// `archived_source = 1` and ordered `(source_item_id, source_seq DESC)`, while +// the predicate here is a BOUND PARAMETER (dialect-dependent 1 vs TRUE) and +// the ordering leads with created_at, so neither engine is obliged to use it +// and the sort may well be materialized before the LIMIT applies. Rows per +// source are inherently tiny — one per copy or move of a single item — so the +// equality lookup on idx_item_workspace_moves_source is the part that matters. +// +// The ordering is itemWorkspaceMoveOrder, shared VERBATIM with the broad +// forward lookup, and that is a deliberate refusal to specialize. Ordering +// archived-only rows by `source_seq DESC` alone would match the partial +// index's columns and reads as strictly better — source_seq is NOT NULL for a +// move (RecordItemWorkspaceMoveTx enforces it) and is workspace-A-monotonic +// when the copy path supplies the seq the archive assigned. But the STORE +// cannot enforce that it is that seq: the column takes whatever the caller +// passes, there is no production writer yet to establish the habit, and +// imports, backfills and hand-repaired rows can carry a high seq with an old +// created_at. Under a seq-primary order such a row silently becomes the head — +// and with the cap, evicts the genuinely newest destination. Leading with +// created_at makes the pathological case merely mis-tiebroken instead of +// inverted, and keeps two queries over the same rows from disagreeing about +// what "newest" means. source_seq stays as the second term, which is the tie +// DR-2a actually needs it for. +// +// A non-positive limit returns no rows rather than every row: this is a bound, +// and a caller that forgot to set one should get the safe answer. +func (s *Store) ListArchivedItemWorkspaceMovesBySource(sourceItemID string, limit int) ([]models.ItemWorkspaceMove, error) { + moves := []models.ItemWorkspaceMove{} + if limit <= 0 { + return moves, nil + } + + rows, err := s.db.Query(s.q(` + SELECT `+itemWorkspaceMoveColumns+` + FROM item_workspace_moves + WHERE source_item_id = ? AND archived_source = ?`+itemWorkspaceMoveOrder+` + LIMIT ?`, + ), sourceItemID, s.dialect.BoolToInt(true), limit) + if err != nil { + return nil, fmt.Errorf("list archived item workspace moves by source: %w", err) + } + defer rows.Close() + + for rows.Next() { + m, err := scanItemWorkspaceMove(rows) + if err != nil { + return nil, fmt.Errorf("list archived item workspace moves by source: %w", err) + } + moves = append(moves, *m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list archived item workspace moves by source: %w", err) + } + return moves, nil +} + // GetItemWorkspaceMoveByTarget returns where a destination item came from, or // (nil, nil) when the item was not produced by a cross-workspace copy. // diff --git a/internal/store/item_workspace_moves_test.go b/internal/store/item_workspace_moves_test.go index 377f7a5d..22771dbb 100644 --- a/internal/store/item_workspace_moves_test.go +++ b/internal/store/item_workspace_moves_test.go @@ -1,6 +1,7 @@ package store import ( + "fmt" "testing" "github.com/PerpetualSoftware/pad/internal/models" @@ -510,8 +511,13 @@ func TestItemWorkspaceMoves_MovedToSameSecondUsesSourceSeq(t *testing.T) { } } -// firstArchived mirrors how the moved-to consumer (TASK-2359) reads the -// forward set: the newest row whose source was archived. +// firstArchived picks the newest row whose source was archived — the head of +// what the moved-to pointer considers. +// +// It is a TEST helper for exercising the broad forward lookup's ordering, not +// a mirror of the consumer: the real consumer (TASK-2359) queries +// ListArchivedItemWorkspaceMovesBySource and ACL-filters the whole bounded +// set per destination rather than taking the first entry. func firstArchived(moves []models.ItemWorkspaceMove) *models.ItemWorkspaceMove { for i := range moves { if moves[i].ArchivedSource { @@ -614,3 +620,141 @@ func TestItemWorkspaceMoves_TargetDeleteCascades(t *testing.T) { t.Errorf("target hard-delete did not cascade; got %d rows, want 0", got) } } + +// TestListArchivedItemWorkspaceMovesBySource covers the narrow, SQL-bounded +// query the moved-to pointer reads (TASK-2359): archived_source rows only, +// newest first, capped. The cap has to be real in SQL rather than applied by +// the caller — otherwise a source with a long tail of plain copies pays to +// load, sort, scan and allocate every one of them on every read of the source, +// and none of them can ever contribute to the result. +func TestListArchivedItemWorkspaceMovesBySource(t *testing.T) { + f := newMoveFixture(t, "ArchivedOnly") + + // Two plain copies with LATER timestamps than either move, so a query that + // forgets the archived_source predicate returns them first and the + // ordering assertions below fail loudly. + for i, ts := range []string{"2026-05-09T00:00:00Z", "2026-05-10T00:00:00Z"} { + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, + TargetItemID: f.dest(t, f.dstWS, fmt.Sprintf("Copy %d", i)).ID, + ArchivedSource: false, CreatedBy: f.actor, CreatedAt: ts, + }) + } + + older := f.dest(t, f.dstWS, "Older Move") + newer := f.dest(t, f.dst2WS, "Newer Move") + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: older.ID, + ArchivedSource: true, SourceSeq: seqPtr(10), + CreatedBy: f.actor, CreatedAt: "2026-05-01T00:00:00Z", + }) + f.record(t, models.ItemWorkspaceMove{ + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dst2WS.ID, TargetItemID: newer.ID, + ArchivedSource: true, SourceSeq: seqPtr(20), + CreatedBy: f.actor, CreatedAt: "2026-05-02T00:00:00Z", + }) + + got, err := f.s.ListArchivedItemWorkspaceMovesBySource(f.source.ID, 10) + if err != nil { + t.Fatalf("ListArchivedItemWorkspaceMovesBySource: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d rows, want the 2 moves (the 2 copies must be excluded)", len(got)) + } + if got[0].TargetItemID != newer.ID || got[1].TargetItemID != older.ID { + t.Errorf("expected newest-first [newer, older], got [%s, %s]", got[0].TargetItemID, got[1].TargetItemID) + } + for _, m := range got { + if !m.ArchivedSource { + t.Errorf("a plain copy leaked into the archived-only result: %+v", m) + } + if m.SourceSeq == nil { + t.Errorf("archived row lost its source_seq in the dialect round-trip: %+v", m) + } + } + + // The cap keeps the newest, which is what a banner most wants. + capped, err := f.s.ListArchivedItemWorkspaceMovesBySource(f.source.ID, 1) + if err != nil { + t.Fatalf("capped lookup: %v", err) + } + if len(capped) != 1 || capped[0].TargetItemID != newer.ID { + t.Fatalf("limit=1 should return the newest move, got %+v", capped) + } + + // A forgotten bound is the safe answer, not every row. + for _, limit := range []int{0, -1} { + none, err := f.s.ListArchivedItemWorkspaceMovesBySource(f.source.ID, limit) + if err != nil { + t.Fatalf("limit=%d: %v", limit, err) + } + if len(none) != 0 { + t.Errorf("limit=%d returned %d rows; a non-positive bound must return none", limit, len(none)) + } + } + + // And the broad forward lookup is untouched — it still sees everything. + all, err := f.s.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("forward lookup: %v", err) + } + if len(all) != 4 { + t.Errorf("the broad forward lookup should still return all 4 rows, got %d", len(all)) + } +} + +// TestListArchivedItemWorkspaceMovesBySource_SameSecondUsesSourceSeq is the +// archived-only query's copy of DR-2a's decisive case. Two +// archive→restore→move cycles inside one second tie on created_at — that tie +// is the entire reason source_seq exists — so without the seq term the +// ordering falls through to the id tiebreak and returns an arbitrary +// destination. The ids below are fixed so "arbitrary" is deterministic and the +// wrong answer is reproducible rather than a coin flip. +func TestListArchivedItemWorkspaceMovesBySource_SameSecondUsesSourceSeq(t *testing.T) { + f := newMoveFixture(t, "ArchivedOrder") + earlier := f.dest(t, f.dstWS, "Earlier Move") + later := f.dest(t, f.dst2WS, "Later Move") + + const sameSecond = "2026-06-01T12:00:00Z" + + // The LATER move gets the lexically LOWEST id, so an ordering that lost + // the source_seq term would return `earlier` first. + f.record(t, models.ItemWorkspaceMove{ + ID: lexLowMoveID, + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dst2WS.ID, TargetItemID: later.ID, + ArchivedSource: true, SourceSeq: seqPtr(200), + CreatedBy: f.actor, CreatedAt: sameSecond, + }) + f.record(t, models.ItemWorkspaceMove{ + ID: lexHighMoveID, + SourceWorkspaceID: f.srcWS.ID, SourceItemID: f.source.ID, + TargetWorkspaceID: f.dstWS.ID, TargetItemID: earlier.ID, + ArchivedSource: true, SourceSeq: seqPtr(100), + CreatedBy: f.actor, CreatedAt: sameSecond, + }) + + got, err := f.s.ListArchivedItemWorkspaceMovesBySource(f.source.ID, 10) + if err != nil { + t.Fatalf("ListArchivedItemWorkspaceMovesBySource: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d rows, want 2 (restore-then-move-again must NOT be deduped)", len(got)) + } + if got[0].TargetItemID != later.ID { + t.Errorf("head is %q, want the higher-seq move %q — the ordering is not seq-driven", + got[0].TargetItemID, later.ID) + } + // And the cap keeps the seq-newest one, which is the case the bound + // actually has to get right. + capped, err := f.s.ListArchivedItemWorkspaceMovesBySource(f.source.ID, 1) + if err != nil { + t.Fatalf("capped lookup: %v", err) + } + if len(capped) != 1 || capped[0].TargetItemID != later.ID { + t.Fatalf("limit=1 should keep the higher-seq move, got %+v", capped) + } +} diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 818613e5..2cd3ebd1 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -544,6 +544,32 @@ export interface TagCount { count: number; } +/** + * One destination an archived item was moved to (PLAN-2357 / TASK-2359). + * + * Displayable by construction — slugs and refs, never UUIDs — so a banner can + * link to the destination without a second request. + * + * The list is ACL-filtered per destination and newest-first, but it carries no + * "current" marker and cannot promise its head is the most recent move: such a + * marker would announce that a newer destination exists and is being withheld. + * Word any UI as provenance ("this item was moved; copies exist at …"), not as + * a current location. + */ +export interface ItemMovedTo { + workspace_slug: string; + workspace_name?: string; + /** Completes the /{username}/{workspace}/… route; may be absent. */ + workspace_owner_username?: string; + collection_slug?: string; + /** Issue ID, e.g. "TASK-14". */ + ref?: string; + item_slug: string; + title: string; + /** RFC3339 UTC timestamp of the move. */ + moved_at?: string; +} + export interface Item { id: string; workspace_id: string; @@ -594,6 +620,13 @@ export interface Item { // Structural local-first projection. Unrestricted index/delta responses // always include it; restricted callers omit it entirely. is_unparented?: boolean; + // Destination(s) an ARCHIVED item was moved to by a cross-workspace move + // (PLAN-2357 / TASK-2359). Present ONLY on the single-item GET response, + // and only for destinations the caller was independently authorized to + // read — the server omits the key entirely otherwise, so `undefined` + // means "nothing to show", never "there is one you may not see". Do not + // render a distinction between the two; there isn't one. + moved_to?: ItemMovedTo[]; derived_closure?: ItemDerivedClosure; code_context?: ItemCodeContext; convention?: ItemConventionMetadata; @@ -607,9 +640,16 @@ export interface Item { // the local-first read model (PLAN-1343) can hydrate a workspace-wide index // without paying the body cost on bootstrap. // -// Derived from `Item` via `Omit<…, 'content'>` so adding a new column to -// `Item` automatically flows into the index row without a second edit. -export type ItemIndexRow = Omit; +// Derived from `Item` via `Omit<…>` so adding a new column to `Item` +// automatically flows into the index row without a second edit. +// +// `moved_to` is omitted alongside `content` for a different reason: it is not +// a column at all but a per-caller, ACL-gated block the server populates on +// the single-item GET and nowhere else (PLAN-2357 / TASK-2359). Leaving it in +// would advertise a field the index and delta endpoints never emit, and invite +// a consumer to read it from a cached index row where its absence means +// nothing. +export type ItemIndexRow = Omit; export interface ItemIndexResponse { items: ItemIndexRow[]; From dbede59edf333692a551203f0096a32d73d8312e Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 30 Jul 2026 12:47:50 +0000 Subject: [PATCH 4/6] refactor(store): extract tx-taking item creation helper (TASK-2362) Implements PLAN-2357 DR-9a. CreateItem opens and commits its own transaction, so the cross-workspace copy path (create in B + attachment remap + provenance row + optional source archive, all atomic) cannot call it. A raw in-tx `INSERT INTO items` in its place would silently break version history, wiki-links, reporting, delta sync and slug uniqueness -- none of which fail loudly. Extracted, not duplicated, and CreateItem now goes through the same function so the two paths cannot drift: - `insertItemTx` is the write half, lifted verbatim out of the old tryCreateItem body: the items INSERT (item_number, workspace seq, content-flush watermarks), the initial item_versions row, wiki-link indexing + broken-title resolution, and the create-time status_transitions row. - `createItemTx(tx, workspaceID, collectionID, input) (*models.Item, error)` wraps it with the rest of CreateItem's pipeline -- defaults, assignment-scope validation, workspace-scoped unique slug allocation -- inside a caller-owned transaction. It returns the item read back in-tx so an orchestrator can consume its slug / item_number / seq (DR-14 fanout) without a post-COMMIT round-trip. - `tryCreateItem` is now a BEGIN/COMMIT wrapper around createItemTx, and CreateItem is the retry loop around that. - `uniqueSlug` and `validateAssignmentScope` gained rowQueryer- parameterized forms (`uniqueSlugQ` / `validateAssignmentScopeQ`) so both can run on the caller's transaction. The *sql.DB entry points delegate to them; behaviour is unchanged. Content must already be final: wiki-link indexing and the first version row are written from input.Content as given, so callers doing DR-11 attachment-ref rewriting must rewrite BEFORE calling. Trust boundary is documented on the function. collectionID/workspaceID consistency and ParentID scope stay the caller's job, matching the pre-extraction tryCreateItem -- DR-9 has the orchestrator re-read and row-lock both collections in-tx, so a check here would be a second, weaker read of an already-pinned row. Assignee and agent role ARE validated, as in CreateItem. No internal retry on unique violation: a failed statement poisons the caller's transaction and an internal retry would need a savepoint the caller can't see. Fixes a latent slug race in CreateItem along the way. It used to allocate the slug ONCE, outside the transaction, and re-submit that stale value on every retry -- so two concurrent creates of the same title had the loser burn all ten attempts on a slug the winner had already committed and then fail with a unique-constraint error. Slug allocation now happens inside the transaction under the workspace advisory lock, so each attempt sees the previous scan's outcome. Two Postgres-falsifiable concurrency tests cover it (createItemTx-only and mixed CreateItem + createItemTx). 24 tests: one per DR-9a parity-checklist line, a rollback test asserting no item / version / wiki-link / status transition / seq advance survives, an in-tx-visibility test, a slug-collision test, and the two concurrency tests. Every parity assertion verified falsifiable by mutating the production code. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- internal/store/items.go | 271 +++++++-- internal/store/items_create_tx_test.go | 790 +++++++++++++++++++++++++ internal/store/store.go | 12 +- 3 files changed, 1014 insertions(+), 59 deletions(-) create mode 100644 internal/store/items_create_tx_test.go diff --git a/internal/store/items.go b/internal/store/items.go index da753246..0692c17f 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -38,21 +38,41 @@ type ItemSearchResult struct { // validateAssignmentScope checks that the assigned user and agent role belong to the // same workspace as the item. This prevents cross-workspace assignment leaks. func (s *Store) validateAssignmentScope(workspaceID string, assignedUserID, agentRoleID *string) error { + return s.validateAssignmentScopeQ(s.db, workspaceID, assignedUserID, agentRoleID) +} + +// validateAssignmentScopeQ is validateAssignmentScope parameterized over the +// query surface so the same two checks can run inside a caller's transaction +// (createItemTx) instead of on an independent connection. Behaviour and error +// strings are identical to the *sql.DB form; only the connection the two +// existence probes run on differs. +// +// It deliberately re-issues the membership / agent-role probes as COUNT +// queries rather than calling IsWorkspaceMember / GetAgentRole, which are +// hard-wired to s.db. The predicates mirror those methods exactly, including +// GetAgentRole's `id = ? OR slug = ?` acceptance. +func (s *Store) validateAssignmentScopeQ(q rowQueryer, workspaceID string, assignedUserID, agentRoleID *string) error { if assignedUserID != nil && *assignedUserID != "" { - isMember, err := s.IsWorkspaceMember(workspaceID, *assignedUserID) - if err != nil { - return fmt.Errorf("validate assigned user: %w", err) - } - if !isMember { + var count int + if err := q.QueryRow( + s.q("SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ? AND user_id = ?"), + workspaceID, *assignedUserID, + ).Scan(&count); err != nil { + return fmt.Errorf("validate assigned user: %w", fmt.Errorf("check workspace membership: %w", err)) + } + if count == 0 { return fmt.Errorf("assigned user is not a member of this workspace") } } if agentRoleID != nil && *agentRoleID != "" { - role, err := s.GetAgentRole(workspaceID, *agentRoleID) - if err != nil { - return fmt.Errorf("validate agent role: %w", err) - } - if role == nil { + var count int + if err := q.QueryRow( + s.q("SELECT COUNT(*) FROM agent_roles WHERE workspace_id = ? AND (id = ? OR slug = ?)"), + workspaceID, *agentRoleID, *agentRoleID, + ).Scan(&count); err != nil { + return fmt.Errorf("validate agent role: %w", fmt.Errorf("get agent role: %w", err)) + } + if count == 0 { return fmt.Errorf("agent role does not belong to this workspace") } } @@ -154,67 +174,84 @@ func (s *Store) acquireWorkspaceParentLinkLock(tx *sql.Tx, workspaceID string) e } func (s *Store) CreateItem(workspaceID, collectionID string, input models.ItemCreate) (*models.Item, error) { - // Validate assignment scope before writing - if err := s.validateAssignmentScope(workspaceID, input.AssignedUserID, input.AgentRoleID); err != nil { - return nil, err - } - - id := newID() - ts := now() - - fields := input.Fields - if fields == "" { - fields = "{}" - } - tags := input.Tags - if tags == "" { - tags = "[]" - } - createdBy := input.CreatedBy - if createdBy == "" { - createdBy = "user" - } - source := input.Source - if source == "" { - source = "web" - } - - baseSlug := slugify(input.Title) - if baseSlug == "" { - baseSlug = "untitled" - } - slug, err := s.uniqueSlug("items", "workspace_id", workspaceID, baseSlug) - if err != nil { - return nil, fmt.Errorf("unique slug: %w", err) - } - - // Retry loop: if a concurrent insert claims the same item_number we - // roll back and re-read MAX(item_number) on the next attempt. + // Retry loop: if a concurrent insert claims the same item_number — or the + // same slug — we roll back and re-derive both on the next attempt. + // + // Re-deriving matters. Before TASK-2362 the slug was allocated ONCE, + // outside the transaction, and every retry re-submitted that same stale + // value: two concurrent creates of the same title had the loser burn all + // ten attempts on a slug the winner had already committed and then fail + // with a unique-constraint error. tryCreateItem now allocates inside the + // transaction, under the workspace lock, so each attempt sees the losing + // scan's outcome and picks the next free suffix. var lastErr error for attempt := 0; attempt < maxItemNumberRetries; attempt++ { - lastErr = s.tryCreateItem(id, workspaceID, collectionID, slug, ts, fields, tags, createdBy, source, input) - if lastErr == nil { - return s.GetItem(id) + item, err := s.tryCreateItem(workspaceID, collectionID, input) + if err == nil { + return item, nil } - // Only retry on unique-constraint violations (item_number conflict) - if !isUniqueViolation(lastErr) { - return nil, fmt.Errorf("insert item: %w", lastErr) + lastErr = err + // Only retry on unique-constraint violations (item_number / slug). + // Everything else — including assignment-scope rejections — is + // returned as-is. + if !isUniqueViolation(err) { + return nil, err } } return nil, fmt.Errorf("insert item after %d retries: %w", maxItemNumberRetries, lastErr) } // tryCreateItem attempts a single transactional insert of an item with the -// next available workspace-global item_number. The item_number is computed -// atomically via a subquery in the INSERT to avoid races between concurrent -// inserts reading the same MAX(item_number). -func (s *Store) tryCreateItem(id, workspaceID, collectionID, slug, ts, fields, tags, createdBy, source string, input models.ItemCreate) error { +// next available workspace-global item_number and a freshly-allocated unique +// slug. The item_number is computed atomically via a subquery in the INSERT to +// avoid races between concurrent inserts reading the same MAX(item_number). +// +// It is a thin BEGIN/COMMIT wrapper around createItemTx, which is also what +// the cross-workspace copy path calls with its own transaction — so the two +// creation paths share one implementation and cannot drift. +// +// Two deliberate behaviour changes from the pre-TASK-2362 shape, neither +// observable to any current caller (nothing in internal/server or cmd/pad +// string-matches these): +// +// - Each retry attempt now derives a fresh item id and timestamp, because +// both are generated inside createItemTx. Previously one id/timestamp was +// minted before the loop and re-submitted. The discarded id was never +// returned to anyone, and a retried attempt arguably SHOULD carry the +// time it actually succeeded. +// - The item is read back inside the transaction rather than after COMMIT. +// A read-back miss now rolls the create back with an error instead of +// returning (nil, nil) over a committed row — the old shape handed callers +// a nil item and a nil error for an item that existed. +func (s *Store) tryCreateItem(workspaceID, collectionID string, input models.ItemCreate) (*models.Item, error) { tx, err := s.db.Begin() if err != nil { - return err + return nil, fmt.Errorf("insert item: %w", err) } defer tx.Rollback() + item, err := s.createItemTx(tx, workspaceID, collectionID, input) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("insert item: %w", err) + } + return item, nil +} + +// insertItemTx is the write half of item creation: the items INSERT +// (item_number, workspace seq, content-flush watermarks), the initial +// item_versions row, wiki-link indexing + broken-title resolution, and the +// create-time status_transitions row. It neither begins nor commits — the +// caller owns the transaction boundary. +// +// Every creation side effect lives in this one place. Its only caller is +// createItemTx, which both CreateItem and the cross-workspace copy path +// (PLAN-2357 / DR-9a) go through, so the two paths cannot drift. +func (s *Store) insertItemTx(tx *sql.Tx, id, workspaceID, collectionID, slug, ts, fields, tags, createdBy, source string, input models.ItemCreate) error { + var err error + // PostgreSQL: take an advisory lock keyed on the workspace to serialize // item_number assignment. This eliminates the race between concurrent // transactions reading the same MAX(item_number). The lock is released @@ -322,7 +359,125 @@ func (s *Store) tryCreateItem(id, workspaceID, collectionID, slug, ts, fields, t } } - return tx.Commit() + return nil +} + +// createItemTx is the tx-taking form of item creation (PLAN-2357 / DR-9a) and +// the single implementation behind BOTH CreateItem and the cross-workspace +// copy path. It performs the ENTIRE create pipeline — defaults, +// assignment-scope validation, workspace-scoped unique slug allocation, +// item_number, workspace seq, content-flush watermarks, the initial +// item_versions row, wiki-link indexing plus broken-title resolution in the +// destination workspace, and the create-time status_transitions row — inside a +// transaction the caller owns. +// +// It exists because CreateItem opens and commits its own transaction, so the +// cross-workspace copy path (which must create in B, remap attachments, write +// provenance and optionally archive the source atomically) cannot call it. A +// raw `INSERT INTO items` in its place would silently break version history, +// wiki-links, reporting, delta sync and slug uniqueness — none of which fail +// loudly. Making CreateItem go through this same function rather than a +// parallel copy is what keeps the two from drifting. +// +// CONTENT MUST ALREADY BE FINAL. Wiki-link indexing and the initial version +// row are written from input.Content as given, so a caller doing attachment-ref +// rewriting (DR-11) must rewrite BEFORE calling — otherwise the indexed body +// and the first version both carry the source workspace's attachment UUIDs. +// +// TRUST BOUNDARY — this helper TRUSTS its caller, matching the pre-extraction +// tryCreateItem: +// - collectionID is NOT checked to belong to workspaceID, nor to be live. +// DR-9 puts that on the caller, which re-reads and row-locks the +// destination collection (FOR UPDATE on Postgres) inside the same tx; a +// check here would be a second, weaker read of an already-pinned row. +// - input.ParentID is NOT checked to belong to workspaceID, and no parent +// cycle walk runs. Callers that set it must validate it; the copy path +// scrubs it to nil (DR-17 — the copy is unparented). +// +// What it does NOT trust: input.AssignedUserID and input.AgentRoleID are +// validated against workspaceID, exactly as CreateItem does. +// +// NO RETRY ON UNIQUE VIOLATION. CreateItem wraps this in a retry loop by +// re-running the whole transaction; a caller-owned transaction can't do that, +// because a failed statement poisons it (Postgres) and an internal retry would +// need a savepoint the caller can't see. Retrying is near-redundant anyway: +// the workspace advisory lock (Postgres) / BEGIN IMMEDIATE (SQLite) serializes +// item_number and slug allocation per workspace for the transaction's whole +// lifetime. A caller-owned transaction that wants the retry must re-run its +// own transaction. +// +// Returns the created item read back inside the tx, so the caller can consume +// its committed slug / item_number / seq (DR-14 fanout) without a second +// round-trip after COMMIT. +func (s *Store) createItemTx(tx *sql.Tx, workspaceID, collectionID string, input models.ItemCreate) (*models.Item, error) { + // Validate assignment scope before writing — parity with CreateItem, but + // read through the tx so it sees the caller's uncommitted membership / + // role writes and is serialized with them. + // + // On SQLite this (and the slug scan below) now runs under the db-wide + // BEGIN IMMEDIATE write lock, where pre-extraction CreateItem ran it on + // the pool before opening its transaction. Both probes are single indexed + // COUNT lookups and only run when an assignee / agent role is actually + // set, and the widening is the same tradeoff store.go's DSN comment + // already accepts for UpdateItem's in-lock slug-collision check. + if err := s.validateAssignmentScopeQ(tx, workspaceID, input.AssignedUserID, input.AgentRoleID); err != nil { + return nil, err + } + + id := newID() + ts := now() + + fields := input.Fields + if fields == "" { + fields = "{}" + } + tags := input.Tags + if tags == "" { + tags = "[]" + } + createdBy := input.CreatedBy + if createdBy == "" { + createdBy = "user" + } + source := input.Source + if source == "" { + source = "web" + } + + // Take the workspace advisory lock BEFORE allocating the slug (Postgres; + // no-op on SQLite, where BEGIN IMMEDIATE already holds the write lock from + // the transaction's first statement). The slug scan is a read-modify-write + // on the workspace's slug space, so it must run under the same lock that + // serializes the workspace's creators — otherwise two concurrent creates of + // the same title both scan "foo" as free and one fails the unique + // constraint. Same lock key insertItemTx takes below; advisory xact locks + // are re-entrant within a transaction, so taking it twice — or a third time + // from an outer orchestrator holding both workspaces' locks — is harmless. + if err := s.acquireWorkspaceSeqLock(tx, workspaceID); err != nil { + return nil, err + } + + baseSlug := slugify(input.Title) + if baseSlug == "" { + baseSlug = "untitled" + } + slug, err := s.uniqueSlugQ(tx, "items", "workspace_id", workspaceID, baseSlug) + if err != nil { + return nil, fmt.Errorf("unique slug: %w", err) + } + + if err := s.insertItemTx(tx, id, workspaceID, collectionID, slug, ts, fields, tags, createdBy, source, input); err != nil { + return nil, fmt.Errorf("insert item: %w", err) + } + + item, err := s.getItemTx(tx, id) + if err != nil { + return nil, err + } + if item == nil { + return nil, fmt.Errorf("created item %s not readable in transaction", id) + } + return item, nil } // getCollectionSlugTx reads collections.slug for a collection_id diff --git a/internal/store/items_create_tx_test.go b/internal/store/items_create_tx_test.go new file mode 100644 index 00000000..8de22419 --- /dev/null +++ b/internal/store/items_create_tx_test.go @@ -0,0 +1,790 @@ +package store + +import ( + "database/sql" + "fmt" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Tests for createItemTx — the tx-taking counterpart of CreateItem +// (PLAN-2357 / DR-9a). Each test below pins one line of the DR-9a parity +// checklist: slug / item_number / seq, content-flush watermarks, the initial +// item_versions row, wiki-link indexing plus broken-title resolution, the +// create-time status_transitions row, CreateItem's defaults, and its +// assignment-scope validation. Plus the transaction-participation test: roll +// the caller's tx back and assert none of the above persisted. + +// createViaTx runs createItemTx inside a fresh transaction and commits it, +// which is what a caller that has nothing else to do in the tx looks like. +func createViaTx(t *testing.T, s *Store, workspaceID, collectionID string, input models.ItemCreate) *models.Item { + t.Helper() + tx, err := s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + item, err := s.createItemTx(tx, workspaceID, collectionID, input) + if err != nil { + t.Fatalf("createItemTx: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + return item +} + +// createViaTxErr runs createItemTx and rolls back, returning the error. +func createViaTxErr(t *testing.T, s *Store, workspaceID, collectionID string, input models.ItemCreate) error { + t.Helper() + tx, err := s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + _, err = s.createItemTx(tx, workspaceID, collectionID, input) + return err +} + +func scanCount(t *testing.T, s *Store, query string, args ...any) int { + t.Helper() + var n int + if err := s.db.QueryRow(s.q(query), args...).Scan(&n); err != nil { + t.Fatalf("count query %q: %v", query, err) + } + return n +} + +// itemNumber derefs models.Item.ItemNumber (*int), failing the test when the +// column came back NULL — which would itself be a parity break. +func itemNumber(t *testing.T, item *models.Item) int { + t.Helper() + if item.ItemNumber == nil { + t.Fatalf("item %s has a NULL item_number", item.ID) + } + return *item.ItemNumber +} + +func maxWorkspaceSeq(t *testing.T, s *Store, workspaceID string) int64 { + t.Helper() + var seq int64 + if err := s.db.QueryRow(s.q(`SELECT COALESCE(MAX(seq), 0) FROM items WHERE workspace_id = ?`), workspaceID).Scan(&seq); err != nil { + t.Fatalf("max seq: %v", err) + } + return seq +} + +// --- Parity: slug allocation, scoped to the destination workspace --- + +func TestCreateItemTx_AllocatesWorkspaceScopedSlug(t *testing.T) { + s := testStore(t) + wsA := createTestWorkspace(t, s, "Slug Source") + wsB := createTestWorkspace(t, s, "Slug Dest") + colA := createTestCollection(t, s, wsA.ID, "Tasks") + colB := createTestCollection(t, s, wsB.ID, "Tasks") + + // Same title exists in A — must NOT influence B's allocation. + createTestItem(t, s, wsA.ID, colA.ID, "Shared Title", "") + + item := createViaTx(t, s, wsB.ID, colB.ID, models.ItemCreate{Title: "Shared Title", Fields: `{"status":"open"}`}) + if item.Slug != "shared-title" { + t.Fatalf("expected base slug in destination workspace, got %q", item.Slug) + } +} + +func TestCreateItemTx_SlugCollisionResolvesToDistinctSlug(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Slug Collision") + col := createTestCollection(t, s, ws.ID, "Tasks") + + first := createTestItem(t, s, ws.ID, col.ID, "Collision Title", "") + second := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Collision Title", Fields: `{"status":"open"}`}) + third := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Collision Title", Fields: `{"status":"open"}`}) + + if first.Slug != "collision-title" { + t.Fatalf("first slug = %q", first.Slug) + } + if second.Slug != "collision-title-2" { + t.Fatalf("second slug = %q, want collision-title-2 (distinct, not an error or overwrite)", second.Slug) + } + if third.Slug != "collision-title-3" { + t.Fatalf("third slug = %q, want collision-title-3", third.Slug) + } + if first.ID == second.ID || second.ID == third.ID { + t.Fatalf("collision overwrote an existing item instead of creating a new one") + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); n != 3 { + t.Fatalf("expected 3 items, got %d", n) + } +} + +func TestCreateItemTx_UntitledFallbackSlug(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Untitled Slug") + col := createTestCollection(t, s, ws.ID, "Tasks") + + // A title that slugifies to "" must fall back to "untitled", same as CreateItem. + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "!!!", Fields: `{"status":"open"}`}) + if item.Slug != "untitled" { + t.Fatalf("slug = %q, want untitled", item.Slug) + } +} + +// --- Parity: item_number --- + +func TestCreateItemTx_AssignsNextItemNumber(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Item Numbers") + col := createTestCollection(t, s, ws.ID, "Tasks") + + first := createTestItem(t, s, ws.ID, col.ID, "One", "") + second := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Two", Fields: `{"status":"open"}`}) + third := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Three", Fields: `{"status":"open"}`}) + + if itemNumber(t, second) != itemNumber(t, first)+1 { + t.Fatalf("item_number = %d, want %d", itemNumber(t, second), itemNumber(t, first)+1) + } + if itemNumber(t, third) != itemNumber(t, second)+1 { + t.Fatalf("item_number = %d, want %d", itemNumber(t, third), itemNumber(t, second)+1) + } +} + +func TestCreateItemTx_ItemNumberIsWorkspaceScoped(t *testing.T) { + s := testStore(t) + wsA := createTestWorkspace(t, s, "Numbers A") + wsB := createTestWorkspace(t, s, "Numbers B") + colA := createTestCollection(t, s, wsA.ID, "Tasks") + colB := createTestCollection(t, s, wsB.ID, "Tasks") + + for i := 0; i < 3; i++ { + createTestItem(t, s, wsA.ID, colA.ID, "A item", "") + } + item := createViaTx(t, s, wsB.ID, colB.ID, models.ItemCreate{Title: "B item", Fields: `{"status":"open"}`}) + if got := itemNumber(t, item); got != 1 { + t.Fatalf("destination item_number = %d, want 1 (A's numbering must not leak)", got) + } +} + +// --- Parity: workspace seq (delta sync cursor) --- + +func TestCreateItemTx_AdvancesWorkspaceSeq(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Seq") + other := createTestWorkspace(t, s, "Seq Other") + col := createTestCollection(t, s, ws.ID, "Tasks") + otherCol := createTestCollection(t, s, other.ID, "Tasks") + + createTestItem(t, s, ws.ID, col.ID, "Existing", "") + before := maxWorkspaceSeq(t, s, ws.ID) + otherBefore := maxWorkspaceSeq(t, s, other.ID) + _ = otherCol + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Seq bump", Fields: `{"status":"open"}`}) + + if item.Seq <= before { + t.Fatalf("item seq = %d, want > %d", item.Seq, before) + } + if got := maxWorkspaceSeq(t, s, ws.ID); got != item.Seq { + t.Fatalf("workspace MAX(seq) = %d, want %d", got, item.Seq) + } + if got := maxWorkspaceSeq(t, s, other.ID); got != otherBefore { + t.Fatalf("unrelated workspace seq moved: %d -> %d", otherBefore, got) + } +} + +// --- Parity: content flush watermarks --- + +func TestCreateItemTx_ContentFlushWatermarks(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Watermarks") + col := createTestCollection(t, s, ws.ID, "Tasks") + + withContent := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Has content", Content: "body", Fields: `{"status":"open"}`}) + empty := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "No content", Fields: `{"status":"open"}`}) + + readWatermarks := func(id string) (*string, *int64) { + var at *string + var opLogID *int64 + if err := s.db.QueryRow(s.q(`SELECT content_flushed_at, content_flushed_op_log_id FROM items WHERE id = ?`), id). + Scan(&at, &opLogID); err != nil { + t.Fatalf("read watermarks: %v", err) + } + return at, opLogID + } + + at, opLogID := readWatermarks(withContent.ID) + if at == nil || *at == "" { + t.Fatalf("content_flushed_at must be set when content is non-empty") + } + if opLogID == nil || *opLogID != 0 { + t.Fatalf("content_flushed_op_log_id = %v, want 0", opLogID) + } + + at, opLogID = readWatermarks(empty.ID) + if at != nil || opLogID != nil { + t.Fatalf("empty content must leave both watermarks NULL, got (%v, %v)", at, opLogID) + } +} + +// --- Parity: initial item_versions row --- + +func TestCreateItemTx_WritesInitialVersionForNonEmptyContent(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Versions") + col := createTestCollection(t, s, ws.ID, "Tasks") + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{ + Title: "Versioned", + Content: "the rewritten body", + Fields: `{"status":"open"}`, + CreatedBy: "agent", + Source: "cli", + }) + + var content, createdBy, source string + var versionSeq int + var isDiff bool + if err := s.db.QueryRow(s.q(`SELECT content, created_by, source, version_seq, is_diff FROM item_versions WHERE item_id = ?`), item.ID). + Scan(&content, &createdBy, &source, &versionSeq, &isDiff); err != nil { + t.Fatalf("read initial version: %v", err) + } + if content != "the rewritten body" { + t.Fatalf("version content = %q", content) + } + if createdBy != "agent" || source != "cli" { + t.Fatalf("version attribution = (%q, %q), want (agent, cli)", createdBy, source) + } + if versionSeq != 1 { + t.Fatalf("version_seq = %d, want 1", versionSeq) + } + if isDiff { + t.Fatalf("initial version must not be a diff") + } +} + +func TestCreateItemTx_NoVersionForEmptyContent(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Versions Empty") + col := createTestCollection(t, s, ws.ID, "Tasks") + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Bodyless", Fields: `{"status":"open"}`}) + if n := scanCount(t, s, `SELECT COUNT(*) FROM item_versions WHERE item_id = ?`, item.ID); n != 0 { + t.Fatalf("expected no initial version for empty content, got %d", n) + } +} + +// --- Parity: wiki-link indexing --- + +func TestCreateItemTx_IndexesWikiLinksFromContent(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Wiki Index") + col := createTestCollection(t, s, ws.ID, "Tasks") + + target := createTestItem(t, s, ws.ID, col.ID, "Link Target", "") + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{ + Title: "Mentions target", + Content: "see [[Link Target]] for details", + Fields: `{"status":"open"}`, + }) + + var targetID sql.NullString + var targetTitle sql.NullString + if err := s.db.QueryRow(s.q(`SELECT target_item_id, target_title FROM item_wiki_links WHERE source_item_id = ?`), item.ID). + Scan(&targetID, &targetTitle); err != nil { + t.Fatalf("read wiki link row: %v", err) + } + if !targetID.Valid || targetID.String != target.ID { + t.Fatalf("wiki link target = %v, want %s", targetID, target.ID) + } + if !targetTitle.Valid || targetTitle.String != "Link Target" { + t.Fatalf("target_title = %v", targetTitle) + } +} + +func TestCreateItemTx_ResolvesBrokenTitleLinksInDestination(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Broken Titles") + col := createTestCollection(t, s, ws.ID, "Tasks") + + // A pre-existing item mentions a title that doesn't exist yet: the row + // lands broken (target_item_id NULL). + mentioner := createTestItem(t, s, ws.ID, col.ID, "Mentioner", "waiting on [[Arriving Later]]") + var pre sql.NullString + if err := s.db.QueryRow(s.q(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), mentioner.ID).Scan(&pre); err != nil { + t.Fatalf("read pre-arrival link: %v", err) + } + if pre.Valid { + t.Fatalf("expected a broken link row before arrival, got target %s", pre.String) + } + + arrival := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Arriving Later", Fields: `{"status":"open"}`}) + + var post sql.NullString + if err := s.db.QueryRow(s.q(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), mentioner.ID).Scan(&post); err != nil { + t.Fatalf("read post-arrival link: %v", err) + } + if !post.Valid || post.String != arrival.ID { + t.Fatalf("broken title link did not flip to the new item: %v", post) + } +} + +// --- Parity: create-time status_transitions row --- + +func TestCreateItemTx_SeedsCreateTimeStatusTransition(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Transitions") + col := createTestCollection(t, s, ws.ID, "Tasks") + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Born done", Fields: `{"status":"done"}`}) + + var id, fieldKey, from, to, collectionID, workspaceID string + if err := s.db.QueryRow(s.q(` + SELECT id, field_key, from_status, to_status, collection_id, workspace_id + FROM status_transitions WHERE item_id = ? + `), item.ID).Scan(&id, &fieldKey, &from, &to, &collectionID, &workspaceID); err != nil { + t.Fatalf("read create-time transition: %v", err) + } + if id != "create_"+item.ID { + t.Fatalf("transition id = %q, want create_%s (the idempotent backfill key)", id, item.ID) + } + if fieldKey != "status" || from != "" || to != "done" { + t.Fatalf("transition = (%q, %q -> %q), want (status, \"\" -> done)", fieldKey, from, to) + } + if collectionID != col.ID || workspaceID != ws.ID { + t.Fatalf("transition scoped to (%s, %s), want (%s, %s)", collectionID, workspaceID, col.ID, ws.ID) + } +} + +func TestCreateItemTx_NoStatusTransitionWhenDoneFieldUnset(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "No Transition") + col := createTestCollection(t, s, ws.ID, "Tasks") + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Statusless"}) + if n := scanCount(t, s, `SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`, item.ID); n != 0 { + t.Fatalf("expected no transition when the done field is unset, got %d", n) + } +} + +// --- Parity: CreateItem's defaults --- + +func TestCreateItemTx_AppliesCreateItemDefaults(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Defaults") + col := createTestCollection(t, s, ws.ID, "Tasks") + + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Bare"}) + + if item.Fields != "{}" { + t.Fatalf("fields default = %q, want {}", item.Fields) + } + if item.Tags != "[]" { + t.Fatalf("tags default = %q, want []", item.Tags) + } + if item.CreatedBy != "user" { + t.Fatalf("created_by default = %q, want user", item.CreatedBy) + } + if item.LastModifiedBy != "user" { + t.Fatalf("last_modified_by default = %q, want user", item.LastModifiedBy) + } + if item.Source != "web" { + t.Fatalf("source default = %q, want web", item.Source) + } +} + +func TestCreateItemTx_DefaultsMatchCreateItemExactly(t *testing.T) { + s := testStore(t) + wsA := createTestWorkspace(t, s, "Parity A") + wsB := createTestWorkspace(t, s, "Parity B") + colA := createTestCollection(t, s, wsA.ID, "Tasks") + colB := createTestCollection(t, s, wsB.ID, "Tasks") + + input := models.ItemCreate{Title: "Parity Subject", Content: "parity body [[Nowhere]]", Fields: `{"status":"done"}`} + + viaCreateItem, err := s.CreateItem(wsA.ID, colA.ID, input) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + viaTx := createViaTx(t, s, wsB.ID, colB.ID, input) + + if viaTx.Slug != viaCreateItem.Slug { + t.Fatalf("slug: tx=%q createItem=%q", viaTx.Slug, viaCreateItem.Slug) + } + if itemNumber(t, viaTx) != itemNumber(t, viaCreateItem) { + t.Fatalf("item_number: tx=%d createItem=%d", itemNumber(t, viaTx), itemNumber(t, viaCreateItem)) + } + if viaTx.Fields != viaCreateItem.Fields || viaTx.Tags != viaCreateItem.Tags { + t.Fatalf("fields/tags mismatch") + } + if viaTx.CreatedBy != viaCreateItem.CreatedBy || viaTx.LastModifiedBy != viaCreateItem.LastModifiedBy || viaTx.Source != viaCreateItem.Source { + t.Fatalf("attribution mismatch") + } + if viaTx.Content != viaCreateItem.Content { + t.Fatalf("content mismatch") + } + + // And the same side-effect row counts on both sides. + for _, q := range []string{ + `SELECT COUNT(*) FROM item_versions WHERE item_id = ?`, + `SELECT COUNT(*) FROM item_wiki_links WHERE source_item_id = ?`, + `SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`, + } { + a := scanCount(t, s, q, viaCreateItem.ID) + b := scanCount(t, s, q, viaTx.ID) + if a != b { + t.Fatalf("row-count parity broken for %q: createItem=%d createItemTx=%d", q, a, b) + } + if a == 0 { + t.Fatalf("test is vacuous: %q produced no rows on either path", q) + } + } +} + +// --- Parity: assignment-scope validation --- + +func TestCreateItemTx_RejectsForeignAssignedUser(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Assign Scope") + col := createTestCollection(t, s, ws.ID, "Tasks") + outsider := createTestUser(t, s, "outsider-createtx@example.com", "Outsider", "password123") + + outsiderID := outsider.ID + err := createViaTxErr(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Assigned out", AssignedUserID: &outsiderID}) + if err == nil || !strings.Contains(err.Error(), "not a member of this workspace") { + t.Fatalf("expected membership rejection, got %v", err) + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); n != 0 { + t.Fatalf("rejected create still wrote an item") + } +} + +func TestCreateItemTx_AcceptsMemberAssignedUser(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Assign Member") + col := createTestCollection(t, s, ws.ID, "Tasks") + member := createTestUser(t, s, "member-createtx@example.com", "Member", "password123") + if err := s.AddWorkspaceMember(ws.ID, member.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + + memberID := member.ID + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Assigned in", AssignedUserID: &memberID}) + if item.AssignedUserID == nil || *item.AssignedUserID != member.ID { + t.Fatalf("assigned user not persisted: %v", item.AssignedUserID) + } +} + +func TestCreateItemTx_RejectsForeignAgentRole(t *testing.T) { + s := testStore(t) + wsA := createTestWorkspace(t, s, "Role Owner") + wsB := createTestWorkspace(t, s, "Role Borrower") + colB := createTestCollection(t, s, wsB.ID, "Tasks") + + role, err := s.CreateAgentRole(wsA.ID, models.AgentRoleCreate{Name: "Backend"}) + if err != nil { + t.Fatalf("CreateAgentRole: %v", err) + } + + roleID := role.ID + err = createViaTxErr(t, s, wsB.ID, colB.ID, models.ItemCreate{Title: "Foreign role", AgentRoleID: &roleID}) + if err == nil || !strings.Contains(err.Error(), "agent role does not belong to this workspace") { + t.Fatalf("expected agent-role rejection, got %v", err) + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, wsB.ID); n != 0 { + t.Fatalf("rejected create still wrote an item") + } +} + +func TestCreateItemTx_AcceptsLocalAgentRole(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Role Local") + col := createTestCollection(t, s, ws.ID, "Tasks") + role, err := s.CreateAgentRole(ws.ID, models.AgentRoleCreate{Name: "Backend"}) + if err != nil { + t.Fatalf("CreateAgentRole: %v", err) + } + + roleID := role.ID + item := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "Local role", AgentRoleID: &roleID}) + if item.AgentRoleID == nil || *item.AgentRoleID != role.ID { + t.Fatalf("agent role not persisted: %v", item.AgentRoleID) + } +} + +// --- The transaction-participation test --- + +func TestCreateItemTx_RollbackPersistsNothing(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Rollback") + col := createTestCollection(t, s, ws.ID, "Tasks") + + // A pre-existing broken title link that the rolled-back create would + // otherwise have resolved. + mentioner := createTestItem(t, s, ws.ID, col.ID, "Rollback mentioner", "waits on [[Phantom Item]]") + seqBefore := maxWorkspaceSeq(t, s, ws.ID) + itemsBefore := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID) + + tx, err := s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + item, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{ + Title: "Phantom Item", + Content: "body that mentions [[Rollback mentioner]]", + Fields: `{"status":"done"}`, + }) + if err != nil { + tx.Rollback() + t.Fatalf("createItemTx: %v", err) + } + if item == nil { + tx.Rollback() + t.Fatalf("createItemTx returned nil item") + } + phantomID := item.ID + phantomSeq := item.Seq + if phantomSeq <= seqBefore { + tx.Rollback() + t.Fatalf("test is vacuous: in-tx seq %d did not advance past %d", phantomSeq, seqBefore) + } + if err := tx.Rollback(); err != nil { + t.Fatalf("rollback: %v", err) + } + + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE id = ?`, phantomID); n != 0 { + t.Fatalf("item survived rollback") + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); n != itemsBefore { + t.Fatalf("workspace item count changed across rollback: %d -> %d", itemsBefore, n) + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM item_versions WHERE item_id = ?`, phantomID); n != 0 { + t.Fatalf("item_versions row survived rollback") + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM item_wiki_links WHERE source_item_id = ?`, phantomID); n != 0 { + t.Fatalf("item_wiki_links row survived rollback") + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`, phantomID); n != 0 { + t.Fatalf("status_transitions row survived rollback") + } + if got := maxWorkspaceSeq(t, s, ws.ID); got != seqBefore { + t.Fatalf("workspace seq advanced across rollback: %d -> %d", seqBefore, got) + } + // The broken-title resolution must have rolled back too. + var target sql.NullString + if err := s.db.QueryRow(s.q(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), mentioner.ID).Scan(&target); err != nil { + t.Fatalf("read mentioner link: %v", err) + } + if target.Valid { + t.Fatalf("broken-title resolution survived rollback (target=%s)", target.String) + } + + // The next create reuses the seq the rolled-back one held — proof it was + // never committed. + next := createViaTx(t, s, ws.ID, col.ID, models.ItemCreate{Title: "After rollback", Fields: `{"status":"open"}`}) + if next.Seq != phantomSeq { + t.Fatalf("post-rollback seq = %d, want the rolled-back value %d", next.Seq, phantomSeq) + } +} + +// --- Concurrency: no retry loop, so the locks must carry it --- + +// CreateItem survives a concurrent item_number claim via its retry loop. +// createItemTx has no retry (a failed statement poisons the caller's tx), so +// the workspace advisory lock — taken before the slug scan and held for the +// whole transaction — is the only thing standing between concurrent copies +// and duplicate item_numbers or duplicate slugs. Runs on both dialects: +// Postgres exercises pg_advisory_xact_lock, SQLite exercises BEGIN IMMEDIATE. +func TestCreateItemTx_ConcurrentCreatesGetDistinctNumbersAndSlugs(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Concurrent Create") + col := createTestCollection(t, s, ws.ID, "Tasks") + + const n = 8 + type result struct { + slug string + number int + seq int64 + err error + } + results := make([]result, n) + start := make(chan struct{}) + done := make(chan int, n) + + for i := 0; i < n; i++ { + go func(i int) { + <-start + tx, err := s.db.Begin() + if err != nil { + results[i] = result{err: err} + done <- i + return + } + item, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{ + Title: "Racing Title", + Fields: `{"status":"open"}`, + }) + if err != nil { + tx.Rollback() + results[i] = result{err: err} + done <- i + return + } + if err := tx.Commit(); err != nil { + results[i] = result{err: err} + done <- i + return + } + if item.ItemNumber == nil { + results[i] = result{err: fmt.Errorf("item %s has a NULL item_number", item.ID)} + done <- i + return + } + results[i] = result{slug: item.Slug, number: *item.ItemNumber, seq: item.Seq} + done <- i + }(i) + } + close(start) + for i := 0; i < n; i++ { + <-done + } + + slugs := map[string]bool{} + numbers := map[int]bool{} + seqs := map[int64]bool{} + for i, r := range results { + if r.err != nil { + t.Fatalf("goroutine %d: %v", i, r.err) + } + if slugs[r.slug] { + t.Fatalf("duplicate slug %q across concurrent creates", r.slug) + } + if numbers[r.number] { + t.Fatalf("duplicate item_number %d across concurrent creates", r.number) + } + if seqs[r.seq] { + t.Fatalf("duplicate seq %d across concurrent creates", r.seq) + } + slugs[r.slug] = true + numbers[r.number] = true + seqs[r.seq] = true + } + if got := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE workspace_id = ?`, ws.ID); got != n { + t.Fatalf("committed %d items, want %d", got, n) + } + if got := scanCount(t, s, `SELECT COUNT(DISTINCT slug) FROM items WHERE workspace_id = ?`, ws.ID); got != n { + t.Fatalf("%d distinct slugs persisted, want %d", got, n) + } +} + +// The same race across the two entry points: half the writers go through the +// public CreateItem, half through createItemTx on their own transaction. Both +// now allocate the slug inside the transaction under the workspace lock, so +// neither can hand the other a stale scan. Before that change CreateItem +// allocated its slug before opening the transaction and re-submitted the same +// stale value on every retry, so a concurrent same-title create made it burn +// all ten attempts and fail with a unique-constraint error. +func TestCreateItemTx_MixedWithCreateItemConcurrently(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "Mixed Concurrent") + col := createTestCollection(t, s, ws.ID, "Tasks") + + const n = 8 + errs := make([]error, n) + slugs := make([]string, n) + start := make(chan struct{}) + done := make(chan struct{}, n) + + for i := 0; i < n; i++ { + go func(i int) { + defer func() { done <- struct{}{} }() + <-start + input := models.ItemCreate{Title: "Mixed Racing Title", Fields: `{"status":"open"}`} + if i%2 == 0 { + item, err := s.CreateItem(ws.ID, col.ID, input) + if err != nil { + errs[i] = err + return + } + slugs[i] = item.Slug + return + } + tx, err := s.db.Begin() + if err != nil { + errs[i] = err + return + } + item, err := s.createItemTx(tx, ws.ID, col.ID, input) + if err != nil { + tx.Rollback() + errs[i] = err + return + } + if err := tx.Commit(); err != nil { + errs[i] = err + return + } + slugs[i] = item.Slug + }(i) + } + close(start) + for i := 0; i < n; i++ { + <-done + } + + seen := map[string]bool{} + for i, err := range errs { + if err != nil { + t.Fatalf("writer %d (%s path) failed: %v", i, map[bool]string{true: "CreateItem", false: "createItemTx"}[i%2 == 0], err) + } + if seen[slugs[i]] { + t.Fatalf("duplicate slug %q across mixed-path concurrent creates", slugs[i]) + } + seen[slugs[i]] = true + } + if got := scanCount(t, s, `SELECT COUNT(DISTINCT slug) FROM items WHERE workspace_id = ?`, ws.ID); got != n { + t.Fatalf("%d distinct slugs persisted, want %d", got, n) + } +} + +// A caller doing more work in the same transaction sees the created item, and +// its own later failure takes the create down with it. +func TestCreateItemTx_VisibleToCallerBeforeCommit(t *testing.T) { + s := testStore(t) + ws := createTestWorkspace(t, s, "In-tx Visibility") + col := createTestCollection(t, s, ws.ID, "Tasks") + + tx, err := s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() + + item, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{Title: "Visible", Content: "x", Fields: `{"status":"open"}`}) + if err != nil { + t.Fatalf("createItemTx: %v", err) + } + + // Uncommitted, so invisible outside the tx... + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE id = ?`, item.ID); n != 0 { + t.Fatalf("uncommitted item visible outside the transaction") + } + // ...but readable inside it, which is what an orchestrator needs to write + // a provenance row referencing the new item in the same tx. + var n int + if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM items WHERE id = ?`), item.ID).Scan(&n); err != nil { + t.Fatalf("in-tx read: %v", err) + } + if n != 1 { + t.Fatalf("created item not visible inside its own transaction") + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit: %v", err) + } + if n := scanCount(t, s, `SELECT COUNT(*) FROM items WHERE id = ?`, item.ID); n != 1 { + t.Fatalf("item missing after commit") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 0c112762..b6b20959 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1125,12 +1125,22 @@ func (s *Store) backfillNullItemNumbers() error { // uniqueSlug generates a unique slug within a scope by appending -2, -3, etc. func (s *Store) uniqueSlug(table, scopeCol, scopeVal, baseSlug string) (string, error) { + return s.uniqueSlugQ(s.db, table, scopeCol, scopeVal, baseSlug) +} + +// uniqueSlugQ is uniqueSlug parameterized over the query surface (rowQueryer) +// so the collision scan can run either unlocked against *sql.DB or inside an +// in-flight *sql.Tx. The transactional form is what createItemTx uses: it +// allocates the slug under the same transaction — and the same workspace +// advisory lock — that performs the INSERT, so the scan is read-your-writes +// consistent and serialized against concurrent creators in that workspace. +func (s *Store) uniqueSlugQ(q rowQueryer, table, scopeCol, scopeVal, baseSlug string) (string, error) { slug := baseSlug for i := 2; ; i++ { var count int // Check all rows including soft-deleted to respect the DB UNIQUE constraint query := fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE %s = ? AND slug = ?", table, scopeCol) - err := s.db.QueryRow(s.q(query), scopeVal, slug).Scan(&count) + err := q.QueryRow(s.q(query), scopeVal, slug).Scan(&count) if err != nil { return "", err } From 60dd3e1a37ea8a5a0810e56683a425c7d9f38f35 Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 30 Jul 2026 13:04:09 +0000 Subject: [PATCH 5/6] feat(store): add attachment resolution planner for cross-workspace copy (TASK-2354) Implements PLAN-2357 DR-11 / DR-11a. PlanAttachmentCopy takes the copied content plus the FINAL destination fields and returns the old->new attachment UUID map, the rows to create (originals followed by their variants, parent_id remapped), the byte total, and the unresolvable-ref list. It writes nothing, takes no *sql.Tx, and is shared by the copy orchestration and the dry-run endpoint so their numbers cannot drift. DR-11a: every resolution is scoped to workspace_id = A AND deleted_at IS NULL, and the parent/variant traversal carries the identical scope. The reference set comes from user-controlled content, so an unscoped lookup would let a user clone another workspace's blob into a workspace they control, bypassing the download handler's workspace check. Refs that resolve to nothing under that scope -- dangling, soft-deleted, or foreign -- are never cloned and never fatal: they get no map entry, so the rewrite preserves the literal text and the copy renders exactly as broken as the source did. A cross-backend row emits an empty storage_key with the source key in SourceStorageKey, so the plan never contains a key the target backend cannot resolve. CreateAttachment now rejects an empty storage_key, which turns that contract into an enforced invariant: an orchestration that skips the Get/Put byte transfer fails at insert rather than creating a live attachment that 404s on download. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- internal/store/attachments.go | 11 + internal/store/attachments_copy_plan.go | 612 ++++++++++++ internal/store/attachments_copy_plan_test.go | 952 +++++++++++++++++++ 3 files changed, 1575 insertions(+) create mode 100644 internal/store/attachments_copy_plan.go create mode 100644 internal/store/attachments_copy_plan_test.go diff --git a/internal/store/attachments.go b/internal/store/attachments.go index 889d0bf7..87a85892 100644 --- a/internal/store/attachments.go +++ b/internal/store/attachments.go @@ -65,7 +65,18 @@ func scanAttachment(row interface { // generated by the store. The caller is expected to have already written // the blob via AttachmentStore.Put — store-level code does not touch the // filesystem. +// +// An empty storage_key is rejected. The column is NOT NULL but "" passes +// that constraint, and a row with no key is a live attachment the registry +// cannot resolve: it fails at download time, long after the insert, with +// nothing to point at. The cross-backend arm of PlanAttachmentCopy emits +// exactly that shape deliberately — StorageKey blank until the caller +// transfers the bytes and writes back Put's key — so this guard is what +// turns "the caller must Put first" from a comment into an invariant. func (s *Store) CreateAttachment(a *models.Attachment) error { + if a.StorageKey == "" { + return fmt.Errorf("create attachment: storage_key is required (write the blob via AttachmentStore.Put first)") + } if a.ID == "" { a.ID = newID() } diff --git a/internal/store/attachments_copy_plan.go b/internal/store/attachments_copy_plan.go new file mode 100644 index 00000000..b763582b --- /dev/null +++ b/internal/store/attachments_copy_plan.go @@ -0,0 +1,612 @@ +package store + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Attachment copy planner — PLAN-2357 DR-11 / DR-11a. +// +// PlanAttachmentCopy answers one question and writes nothing: given the +// markdown body and the FINAL destination fields of a cross-workspace item +// copy, which attachment rows must workspace B end up with, what are their +// new UUIDs, how many bytes does that add, and which references could not +// be resolved at all? +// +// Two callers share it: the copy orchestration (which performs the writes +// inside its own transaction) and the dry-run endpoint (which performs no +// writes whatsoever). That shared call is the entire reason the planner is +// a standalone, side-effect-free function rather than a step inside the +// orchestration — the dry-run's numbers are the numbers, by construction, +// not a second implementation that drifts. +// +// Note for the orchestration task: CreateAttachment is self-committing +// (s.db.Exec, no *sql.Tx parameter), so inserting these rows inside the +// copy transaction needs a tx-taking variant that does not exist yet — +// RecordItemWorkspaceMoveTx is the shape to follow. That belongs to the +// caller; it is not something this planner can or should provide. +// +// The planner takes no *sql.Tx and holds no lock. It reads in three +// bounded passes — referenced ids, any missing parents, then variants — +// each chunked, so the query count is proportional to the number of +// chunks, not to the number of references. It mutates nothing. If a future +// change appears to need a transaction here, the boundary has been drawn +// wrong: the writes belong to the caller. +// +// STALENESS CONTRACT — a plan is a snapshot, and it is only valid inside +// the critical section that produced it. Because the planner holds no +// lock, everything it read can change: the source attachment can be +// soft-deleted or reclaimed by the orphan GC, a variant can be added or +// removed, the source workspace can be deleted, and the actor's access to +// workspace A can be revoked. Inserting a stale plan would then create a +// live destination row for something the user is no longer entitled to +// copy — the DR-11a hole re-opened through time rather than through scope. +// +// PLAN-2357's pipeline is what closes it: fresh source under lock → +// migrate → overrides → validate → PLAN → rewrite + create, all inside the +// copy's transaction. The orchestration must call this INSIDE that +// section, immediately before the writes it feeds, and must never cache a +// plan across it. The dry-run endpoint is the deliberate exception: it +// writes nothing, so a snapshot that goes stale the moment it is returned +// costs nothing but a slightly out-of-date preview. +// +// attachments has no foreign key on parent_id (see SoftDeleteAttachment's +// note in attachments.go), so the originals-before-variants ordering of +// Rows is not enforced by the database — it is the caller's contract to +// honour, and the reason Rows is ordered rather than a bare map. + +// attachmentRefRE matches a "pad-attachment:" reference anywhere in a +// blob of text. Deliberately NOT the markdown-shaped regex used by +// internal/server/render (`![alt](pad-attachment:UUID)`), for two reasons: +// +// 1. Fields are matched as raw JSON, where a reference can sit in a bare +// string value with no markdown syntax around it at all. +// 2. The rewrite step the planner feeds (remapAttachmentRefs, the same +// helper the bundle importer uses) is a plain strings.ReplaceAll over +// "pad-attachment:". Enumerating with a NARROWER pattern than the +// rewriter uses would leave behind a reference the rewriter never +// remaps — the copy would keep pointing at workspace A's UUID, which +// 403s on download (handleGetAttachment, handlers_attachments.go). Matching the +// rewriter's reach exactly is the property that matters, so references +// inside fenced code blocks are enumerated too: they get rewritten +// either way, so they must be cloned either way. +// +// The id capture stops at anything that cannot appear in a UUID — +// whitespace, ')', '"', '.' — so `(pad-attachment:UUID)` and a +// JSON-encoded `"pad-attachment:UUID"` both yield the bare UUID. A +// reference with NO id at all (a bare `pad-attachment:`) does not match: +// there is nothing to resolve, so it is neither cloned nor counted. An id +// that is present but nonsense (`pad-attachment:not-a-uuid`, or a real +// UUID with a junk suffix) DOES match, fails to resolve, and is counted as +// unresolvable — which is the truth about that body, and is what the +// source rendered as too. +// +// The capture is deliberately not narrowed to the canonical 36-char UUID +// shape. Narrowing would trade a cosmetic improvement in the unresolvable +// count for a real failure mode: any attachment id that is not +// RFC4122-shaped would stop being enumerated, and a live attachment would +// silently fail to copy. Over-capturing costs a counted-but-uncloned +// reference on text that was already broken in workspace A. +var attachmentRefRE = regexp.MustCompile(`pad-attachment:([0-9A-Za-z][0-9A-Za-z_-]*)`) + +// attachmentRefPrefix is the literal every reference starts with. Shared +// by the regex above and the cheap Contains pre-filter. +const attachmentRefPrefix = "pad-attachment:" + +// attachmentPlanChunk bounds how many ids go into a single IN (...) list. +// Host-parameter limits are real and build-dependent — historically 999 on +// SQLite, higher on modern builds, 65535 on Postgres — and a copied item +// will not come close. The bound exists so that the question never has to +// be asked: a pathological body that blew the limit would fail the query, +// and this planner's failure mode reads as "no attachments to copy". +const attachmentPlanChunk = 400 + +// AttachmentCopyRequest is the planner's complete input. +// +// Content and Fields are the payloads that will actually be written to +// workspace B: the copied content, and the destination fields AFTER +// MigrateFields, AFTER overrides, and AFTER ValidateFields. The planner +// must never re-read the source item's raw fields, and deliberately has no +// parameter that would let it — enumerating raw fields would clone +// attachments referenced only by fields that MigrateFields DROPS. Those +// blobs would land in workspace B with nothing referencing them: invisible +// in the UI, and beyond the reach of the orphan sweep, whose live-row +// branch only considers rows with item_id IS NULL +// (Store.OrphanedAttachments) — a row with item_id set is reclaimed only +// once it is soft-deleted and past grace, which nothing will ever do to a +// row nobody can see. The dry-run's byte total would overstate the copy by +// the same amount. +type AttachmentCopyRequest struct { + // SourceWorkspaceID is workspace A. Every resolution in this plan is + // scoped to it (DR-11a) — the security boundary, not a hint. + SourceWorkspaceID string + + // TargetWorkspaceID is workspace B, stamped onto every emitted row. + TargetWorkspaceID string + + // TargetItemID is the destination item. Set on every emitted row so a + // cloned attachment is never transiently orphaned (DR-11). Required + // unless DryRun is set. + TargetItemID string + + // DryRun says the caller will not insert the returned rows — it only + // wants the totals. It is the ONLY way to get a plan without a + // TargetItemID, and the rows it produces carry item_id = NULL. + // + // The flag exists to make that distinction explicit rather than + // inferring it from an empty TargetItemID: a plan with NULL item_id + // rows, inserted, is a set of orphans the GC will never reclaim. They + // become sweep candidates (item_id IS NULL, past grace), but + // Store.AttachmentReferenced then finds the copied body pointing at + // them and protects them — so they stay, unattached and counting + // against the workspace's storage, indefinitely. Requiring the caller + // to say "dry run" out loud means the mistake cannot be made by + // omission. + DryRun bool + + // UploadedBy is the actor performing the copy. Never the source + // uploader, who may not be a member of workspace B at all (DR-11). + UploadedBy string + + // Content is the copied markdown body. + Content string + + // Fields are the FINAL destination fields (see the type comment). + Fields map[string]any + + // TargetBackend is the storage backend prefix the destination writes + // through ("fs", "s3", …) — the part of storage_key before the first + // ':'. Empty means "same backend as the source", the same-instance + // case, and disables cross-backend detection. + TargetBackend string +} + +// AttachmentCopyRow is one attachment row to create in workspace B, plus +// the source-side facts the caller needs in order to move bytes if it has +// to. +type AttachmentCopyRow struct { + // Attachment is the row to insert. ID and WorkspaceID are fresh; + // ContentHash, MimeType, SizeBytes, Filename, Width and Height are + // carried over verbatim; ItemID and UploadedBy come from the request; + // ParentID is remapped to the new original's ID. StorageKey is carried + // over ONLY when the target backend can resolve it — see + // NeedsByteTransfer. + // + // CreatedAt is deliberately the ZERO time, and DeletedAt is nil: the + // clone is a new row in workspace B, not a backdated one. + // CreateAttachment stamps now() when CreatedAt is zero, so the normal + // path needs no action — but a tx-taking insert written for the + // orchestration must stamp it too, or it will persist a zero timestamp + // with no error and every ordering that reads created_at will put the + // row in 0001. + Attachment models.Attachment + + // SourceID is the workspace-A attachment this row clones. + SourceID string + + // SourceStorageKey is the source row's key, always populated. For a + // same-backend copy it is also Attachment.StorageKey; for a + // cross-backend copy it is the key the caller passes to Get. + SourceStorageKey string + + // NeedsByteTransfer is true when the source blob lives in a backend + // the destination does not write to, so the shared-storage_key + // shortcut does not hold. + // + // When it is true, Attachment.StorageKey is deliberately EMPTY rather + // than the source key. The registry routes by key prefix, so a row + // carrying an "fs:" key in an s3-backed destination is a row whose + // bytes cannot be fetched — and it would look perfectly valid on + // insert, failing later at download time. Leaving it blank means the + // plan never contains a key the target backend cannot resolve: the + // caller Gets SourceStorageKey, Puts the bytes through the target + // backend, and writes the key Put returns into Attachment.StorageKey + // before inserting. + // + // False is the same-instance case: content-addressed storage means + // storage_key and content_hash are shared and AttachmentStore.Put is + // idempotent, so the copy is a row copy, not a byte copy. + NeedsByteTransfer bool +} + +// AttachmentCopyPlan is what the planner returns. Nothing in it has been +// written; the caller decides whether to act on it. +type AttachmentCopyPlan struct { + // IDMap maps every cloned source attachment UUID to its new UUID. + // It covers variant rows as well as originals — a superset of the + // references found, which is harmless for a ReplaceAll rewrite and + // makes the map a complete old→new record of the clone. + // + // Applying it is the caller's job, and remapAttachmentRefs (this + // package, already used by the bundle importer) is the helper to use: + // run it over the copied content, and over the fields' JSON encoding + // — the same representation the planner enumerated from and the same + // one items.fields stores — then unmarshal back if the caller needs a + // map. Rewriting the two payloads by any other route risks covering a + // different set of references than the plan does. + // + // Map iteration order is undefined, as always in Go, and applying the + // pairs in any order yields the same text — but only because no + // attachment id is a prefix of another. That holds because every id + // comes from newID() (CreateAttachment generates it; no handler or + // import path supplies one), so ids are fixed-length UUIDs. It is the + // same assumption RemapAttachmentReferencesInWorkspace already states + // and relies on for the bundle importer; if ids ever stop being + // UUID-shaped, both rewrites need a boundary-aware replacement, not + // just this one. + IDMap map[string]string + + // Rows are the rows to create, each original immediately followed by + // its variants, so a caller inserting in order never writes a + // parent_id ahead of its parent. + // + // The ORDER is stable across calls with identical input — references + // in first-appearance order, variants by (variant, id) — so a dry-run + // and the copy that follows it list the same rows in the same + // sequence. The generated destination IDs are of course fresh on every + // call; only a plan that is actually inserted has meaningful ones. + Rows []AttachmentCopyRow + + // TotalBytes is the sum of size_bytes over Rows — every distinct row + // counted exactly once, including thumbnail variants, and a reference + // appearing twice counted once because it produces one row. + // + // Two distinct rows sharing a content_hash ARE counted twice. That is + // deliberate: it matches what WorkspaceStorageUsage's SUM(size_bytes) + // will report after the copy, which already double-counts deduped + // blobs by existing design with a test asserting exactly that + // (TestStorageUsage_TracksUploads). Per DR-16 storage is reported, not + // enforced, so the honest number is the one the storage page will + // show — not a hash-deduped number that agrees with nothing. + TotalBytes int64 + + // UnresolvableRefs are the referenced UUIDs that resolved to nothing + // under the DR-11a scope: dangling, soft-deleted, or belonging to + // another workspace. Distinct, in first-appearance order. + // + // They are never cloned and never fatal. The literal text stays in + // the copied body (they get no IDMap entry, so the rewrite leaves + // them alone) and the copy renders exactly as broken as the source + // did. A stale reference is a pre-existing condition, not a reason to + // block a copy. + UnresolvableRefs []string + + // CrossBackend is true when any row needs a real byte transfer. See + // AttachmentCopyRow.NeedsByteTransfer. + CrossBackend bool +} + +// PlanAttachmentCopy plans the attachment side of a cross-workspace item +// copy. It writes nothing. +// +// DR-11a, restated because it is the whole point of the function: every +// lookup below carries `workspace_id = SourceWorkspaceID AND deleted_at IS +// NULL`, and so does the parent/variant traversal. The reference set comes +// from USER-CONTROLLED content, so an unscoped lookup is a confused-deputy +// hole — a user writes a foreign pad-attachment: into an item they +// own, copies it, and clones another workspace's blob into a workspace +// they control, bypassing the download handler's workspace check entirely. +// Dropping the scope from the variant query runs the same escalation one +// level down. +func (s *Store) PlanAttachmentCopy(req AttachmentCopyRequest) (*AttachmentCopyPlan, error) { + for _, required := range []struct{ name, value string }{ + {"source_workspace_id", req.SourceWorkspaceID}, + {"target_workspace_id", req.TargetWorkspaceID}, + {"uploaded_by", req.UploadedBy}, + } { + if required.value == "" { + return nil, fmt.Errorf("plan attachment copy: %s is required", required.name) + } + } + if req.TargetItemID == "" && !req.DryRun { + return nil, fmt.Errorf("plan attachment copy: target_item_id is required unless dry_run is set") + } + + plan := &AttachmentCopyPlan{IDMap: map[string]string{}} + + refs, err := attachmentRefsIn(req.Content, req.Fields) + if err != nil { + return nil, err + } + if len(refs) == 0 { + return plan, nil + } + + // Pass 1 — resolve the referenced ids, scoped. + resolved, err := s.attachmentsByIDInWorkspace(req.SourceWorkspaceID, refs) + if err != nil { + return nil, err + } + + // Pass 2 — a reference may name a variant row rather than an + // original (nothing in the editor produces that, but content is + // user-controlled). Cloning a variant without its parent would emit a + // row whose parent_id still points into workspace A, so the parent is + // resolved under the IDENTICAL scope and becomes the clone root. A + // parent that is missing, soft-deleted, or foreign makes the + // reference unresolvable — that is the one-level-down escalation + // DR-11a warns about. + var missingParents []string + wantParent := map[string]bool{} + for _, ref := range refs { + a, ok := resolved[ref] + if !ok || a.ParentID == nil || *a.ParentID == "" { + continue + } + if _, ok := resolved[*a.ParentID]; ok || wantParent[*a.ParentID] { + continue + } + // Deduped: several thumbnails of one original are a normal + // reference pattern, and repeating that parent would pad the IN + // list for nothing. + wantParent[*a.ParentID] = true + missingParents = append(missingParents, *a.ParentID) + } + if len(missingParents) > 0 { + parents, err := s.attachmentsByIDInWorkspace(req.SourceWorkspaceID, missingParents) + if err != nil { + return nil, err + } + for id, a := range parents { + resolved[id] = a + } + } + + // Classify every reference into "clone root" or "unresolvable". + var roots []models.Attachment + rootSeen := map[string]bool{} + for _, ref := range refs { + a, ok := resolved[ref] + if !ok { + plan.UnresolvableRefs = append(plan.UnresolvableRefs, ref) + continue + } + root := a + if a.ParentID != nil && *a.ParentID != "" { + parent, ok := resolved[*a.ParentID] + if !ok { + // Variant whose parent is out of scope. + plan.UnresolvableRefs = append(plan.UnresolvableRefs, ref) + continue + } + if parent.ParentID != nil && *parent.ParentID != "" { + // Variants are one level deep by construction; a + // deeper chain is corrupt data, and following it would + // mean an unbounded scoped traversal. Refuse rather + // than guess. + plan.UnresolvableRefs = append(plan.UnresolvableRefs, ref) + continue + } + root = parent + } + if rootSeen[root.ID] { + continue + } + rootSeen[root.ID] = true + roots = append(roots, root) + } + if len(roots) == 0 { + return plan, nil + } + + // Pass 3 — variants, under the same scope. A half-copied variant set + // is worse than neither. + rootIDs := make([]string, len(roots)) + for i, r := range roots { + rootIDs[i] = r.ID + } + variantsByParent, err := s.attachmentVariantsInWorkspace(req.SourceWorkspaceID, rootIDs) + if err != nil { + return nil, err + } + + for _, root := range roots { + newRootID := plan.appendRow(req, root, nil) + for _, v := range variantsByParent[root.ID] { + plan.appendRow(req, v, &newRootID) + } + } + return plan, nil +} + +// appendRow builds one destination row from a source attachment and adds +// it to the plan, updating the id map, the byte total and the +// cross-backend flag. newParentID is nil for an original and the new +// original's id for a variant. Returns the new row's id. +func (p *AttachmentCopyPlan) appendRow(req AttachmentCopyRequest, src models.Attachment, newParentID *string) string { + dst := src + dst.ID = newID() + dst.WorkspaceID = req.TargetWorkspaceID + dst.UploadedBy = req.UploadedBy + dst.ItemID = nil + if req.TargetItemID != "" { + itemID := req.TargetItemID + dst.ItemID = &itemID + } + dst.ParentID = nil + if newParentID != nil { + parentID := *newParentID + dst.ParentID = &parentID + } + // CreatedAt is left zero so CreateAttachment stamps now(): the clone + // is a new row in workspace B, not a backdated one. + dst.CreatedAt = time.Time{} + dst.DeletedAt = nil + + needsTransfer := req.TargetBackend != "" && storageKeyBackend(src.StorageKey) != req.TargetBackend + if needsTransfer { + // Never emit a key the target backend cannot resolve. The caller + // fills this in from Put's return value after transferring the + // bytes; SourceStorageKey below is where it Gets them from. + dst.StorageKey = "" + } + + p.Rows = append(p.Rows, AttachmentCopyRow{ + Attachment: dst, + SourceID: src.ID, + SourceStorageKey: src.StorageKey, + NeedsByteTransfer: needsTransfer, + }) + p.IDMap[src.ID] = dst.ID + p.TotalBytes += src.SizeBytes + if needsTransfer { + p.CrossBackend = true + } + return dst.ID +} + +// storageKeyBackend returns the backend prefix of a content-addressed +// storage key ("fs:" → "fs"). Keys without a prefix return "", which +// never equals a configured backend name, so an unrecognisable key is +// treated as needing a real byte transfer rather than being assumed +// resolvable. +func storageKeyBackend(key string) string { + prefix, _, ok := strings.Cut(key, ":") + if !ok { + return "" + } + return prefix +} + +// attachmentRefsIn returns the distinct attachment UUIDs referenced by the +// copied content and the final destination fields, in first-appearance +// order (content first, then fields) so a plan is deterministic. +// +// Fields are scanned as their JSON encoding — the same representation +// items.fields is stored and rewritten in — so a reference is found +// wherever it sits: a top-level string, an array element, a nested object. +func attachmentRefsIn(content string, fields map[string]any) ([]string, error) { + texts := []string{content} + if len(fields) > 0 { + encoded, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("plan attachment copy: encode fields: %w", err) + } + texts = append(texts, string(encoded)) + } + + var refs []string + seen := map[string]bool{} + for _, text := range texts { + if !strings.Contains(text, attachmentRefPrefix) { + continue + } + for _, m := range attachmentRefRE.FindAllStringSubmatch(text, -1) { + id := m[1] + if seen[id] { + continue + } + seen[id] = true + refs = append(refs, id) + } + } + return refs, nil +} + +// attachmentsByIDInWorkspace loads live attachment rows by id, scoped to +// one workspace (DR-11a). Rows outside the workspace and soft-deleted rows +// are simply absent from the result — the caller treats absence as +// "unresolvable", so a foreign id and a dangling id are indistinguishable +// by design. +func (s *Store) attachmentsByIDInWorkspace(workspaceID string, ids []string) (map[string]models.Attachment, error) { + out := make(map[string]models.Attachment, len(ids)) + for _, chunk := range chunkStrings(ids, attachmentPlanChunk) { + args := make([]any, 0, len(chunk)+1) + args = append(args, workspaceID) + for _, id := range chunk { + args = append(args, id) + } + query := `SELECT ` + attachmentColumns + ` FROM attachments + WHERE workspace_id = ? AND deleted_at IS NULL + AND id IN (` + sqlPlaceholderList(len(chunk)) + `)` + if err := s.scanAttachmentsInto(query, args, func(a models.Attachment) { + out[a.ID] = a + }); err != nil { + return nil, fmt.Errorf("plan attachment copy: resolve refs: %w", err) + } + } + return out, nil +} + +// attachmentVariantsInWorkspace loads the live variant rows of the given +// parents, keyed by parent id. Carries the IDENTICAL workspace + +// deleted_at scope as the reference resolution: without it, a parent in +// workspace A could pull in a "variant" row belonging to another +// workspace, which is the confused-deputy escalation one level down. +// +// COALESCE in the ORDER BY rather than a bare `variant` because the column +// is nullable and the two dialects disagree on default NULL placement — +// same reason itemWorkspaceMoveOrder coalesces source_seq. Real thumbnails +// always set variant, but a NULL one must not reorder the plan depending +// on which database is underneath. +func (s *Store) attachmentVariantsInWorkspace(workspaceID string, parentIDs []string) (map[string][]models.Attachment, error) { + out := map[string][]models.Attachment{} + for _, chunk := range chunkStrings(parentIDs, attachmentPlanChunk) { + args := make([]any, 0, len(chunk)+1) + args = append(args, workspaceID) + for _, id := range chunk { + args = append(args, id) + } + query := `SELECT ` + attachmentColumns + ` FROM attachments + WHERE workspace_id = ? AND deleted_at IS NULL + AND parent_id IN (` + sqlPlaceholderList(len(chunk)) + `) + ORDER BY COALESCE(variant, ''), id` + if err := s.scanAttachmentsInto(query, args, func(a models.Attachment) { + if a.ParentID == nil { + return + } + out[*a.ParentID] = append(out[*a.ParentID], a) + }); err != nil { + return nil, fmt.Errorf("plan attachment copy: resolve variants: %w", err) + } + } + return out, nil +} + +// scanAttachmentsInto runs a query returning attachmentColumns and hands +// each row to visit. +func (s *Store) scanAttachmentsInto(query string, args []any, visit func(models.Attachment)) error { + rows, err := s.db.Query(s.q(query), args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + a, err := scanAttachment(rows) + if err != nil { + return err + } + visit(*a) + } + return rows.Err() +} + +// sqlPlaceholderList returns "?, ?, ?" for n. s.q rebinds to $1…$n on +// Postgres. +func sqlPlaceholderList(n int) string { + if n <= 0 { + return "" + } + return strings.TrimSuffix(strings.Repeat("?, ", n), ", ") +} + +// chunkStrings splits ids into slices of at most size elements. +func chunkStrings(ids []string, size int) [][]string { + if len(ids) == 0 { + return nil + } + var out [][]string + for start := 0; start < len(ids); start += size { + end := start + size + if end > len(ids) { + end = len(ids) + } + out = append(out, ids[start:end]) + } + return out +} diff --git a/internal/store/attachments_copy_plan_test.go b/internal/store/attachments_copy_plan_test.go new file mode 100644 index 00000000..987e9072 --- /dev/null +++ b/internal/store/attachments_copy_plan_test.go @@ -0,0 +1,952 @@ +package store + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/items" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// planFixture is two source workspaces (A, and a third-party workspace C +// used for the confused-deputy tests) plus a destination workspace B. +type planFixture struct { + s *Store + wsA *models.Workspace + wsB *models.Workspace + wsC *models.Workspace + actor string +} + +func newPlanFixture(t *testing.T) planFixture { + t.Helper() + s := testStore(t) + return planFixture{ + s: s, + wsA: createTestWorkspace(t, s, "Plan Source"), + wsB: createTestWorkspace(t, s, "Plan Dest"), + wsC: createTestWorkspace(t, s, "Plan Third Party"), + actor: "copier-user", + } +} + +// attach creates an original attachment in ws and returns it. +func (f planFixture) attach(t *testing.T, ws *models.Workspace, filename string, size int64) *models.Attachment { + t.Helper() + return f.attachWith(t, ws, filename, size, "fs:"+newID(), newID()) +} + +func (f planFixture) attachWith(t *testing.T, ws *models.Workspace, filename string, size int64, storageKey, hash string) *models.Attachment { + t.Helper() + width, height := 800, 600 + a := &models.Attachment{ + WorkspaceID: ws.ID, + UploadedBy: "original-uploader", + StorageKey: storageKey, + ContentHash: hash, + MimeType: "image/png", + SizeBytes: size, + Filename: filename, + Width: &width, + Height: &height, + } + if err := f.s.CreateAttachment(a); err != nil { + t.Fatalf("CreateAttachment(%s): %v", filename, err) + } + return a +} + +// variant creates a derived thumbnail row for parent, in ws. +func (f planFixture) variant(t *testing.T, ws *models.Workspace, parent *models.Attachment, kind string, size int64) *models.Attachment { + t.Helper() + parentID := parent.ID + variant := kind + a := &models.Attachment{ + WorkspaceID: ws.ID, + UploadedBy: "original-uploader", + StorageKey: "fs:" + newID(), + ContentHash: newID(), + MimeType: "image/webp", + SizeBytes: size, + Filename: kind + "-" + parent.Filename, + ParentID: &parentID, + Variant: &variant, + } + if err := f.s.CreateAttachment(a); err != nil { + t.Fatalf("CreateAttachment(variant %s): %v", kind, err) + } + return a +} + +// req builds a request from A to B with the standard actor. +func (f planFixture) req(content string, fields map[string]any) AttachmentCopyRequest { + return AttachmentCopyRequest{ + SourceWorkspaceID: f.wsA.ID, + TargetWorkspaceID: f.wsB.ID, + TargetItemID: "dest-item-id", + UploadedBy: f.actor, + Content: content, + Fields: fields, + } +} + +func (f planFixture) plan(t *testing.T, req AttachmentCopyRequest) *AttachmentCopyPlan { + t.Helper() + plan, err := f.s.PlanAttachmentCopy(req) + if err != nil { + t.Fatalf("PlanAttachmentCopy: %v", err) + } + return plan +} + +func imageRef(id string) string { return fmt.Sprintf("![shot](pad-attachment:%s)", id) } +func fileRef(id string) string { return fmt.Sprintf("[spec](pad-attachment:%s)", id) } +func sourceIDs(p *AttachmentCopyPlan) []string { + out := make([]string, 0, len(p.Rows)) + for _, r := range p.Rows { + out = append(out, r.SourceID) + } + return out +} + +func assertSourceSet(t *testing.T, p *AttachmentCopyPlan, want ...string) { + t.Helper() + got := sourceIDs(p) + gotSorted := append([]string(nil), got...) + sort.Strings(gotSorted) + wantSorted := append([]string(nil), want...) + sort.Strings(wantSorted) + if strings.Join(gotSorted, ",") != strings.Join(wantSorted, ",") { + t.Fatalf("planned source ids = %v, want %v", got, want) + } +} + +// --- reference enumeration ------------------------------------------------- + +// TestPlanAttachmentCopy_RefsInContentOnly is the base case: one image +// reference in the body, one row, one map entry, the bytes counted. +func TestPlanAttachmentCopy_RefsInContentOnly(t *testing.T) { + f := newPlanFixture(t) + a := f.attach(t, f.wsA, "shot.png", 1234) + + plan := f.plan(t, f.req("intro\n\n"+imageRef(a.ID)+"\n", nil)) + + assertSourceSet(t, plan, a.ID) + if len(plan.IDMap) != 1 || plan.IDMap[a.ID] == "" { + t.Fatalf("IDMap = %v, want one entry keyed by %s", plan.IDMap, a.ID) + } + if plan.IDMap[a.ID] == a.ID { + t.Errorf("new id equals source id — the copy must get a fresh UUID") + } + if plan.TotalBytes != 1234 { + t.Errorf("TotalBytes = %d, want 1234", plan.TotalBytes) + } + if len(plan.UnresolvableRefs) != 0 { + t.Errorf("UnresolvableRefs = %v, want none", plan.UnresolvableRefs) + } +} + +// TestPlanAttachmentCopy_RefsInFieldsOnly proves fields are enumerated — +// a reference living only in a field value (no markdown around it, nested +// inside an array) must still be cloned. +func TestPlanAttachmentCopy_RefsInFieldsOnly(t *testing.T) { + f := newPlanFixture(t) + bare := f.attach(t, f.wsA, "bare.png", 10) + nested := f.attach(t, f.wsA, "nested.pdf", 20) + + plan := f.plan(t, f.req("no refs here", map[string]any{ + "cover": "pad-attachment:" + bare.ID, + "attachments": []any{"noise", fileRef(nested.ID)}, + })) + + assertSourceSet(t, plan, bare.ID, nested.ID) + if plan.TotalBytes != 30 { + t.Errorf("TotalBytes = %d, want 30", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_RefsInBoth covers the union, and pins that the +// two sources are merged rather than one shadowing the other. +func TestPlanAttachmentCopy_RefsInBoth(t *testing.T) { + f := newPlanFixture(t) + inBody := f.attach(t, f.wsA, "body.png", 5) + inField := f.attach(t, f.wsA, "field.png", 7) + + plan := f.plan(t, f.req(imageRef(inBody.ID), map[string]any{"cover": "pad-attachment:" + inField.ID})) + + assertSourceSet(t, plan, inBody.ID, inField.ID) + if plan.TotalBytes != 12 { + t.Errorf("TotalBytes = %d, want 12", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_SameRefTwice: the same attachment referenced in +// the body twice AND in a field gets ONE new UUID and is counted once. +// Two rows would mean the rewrite maps the old id to whichever new id won, +// and the dry-run would double the bytes. +func TestPlanAttachmentCopy_SameRefTwice(t *testing.T) { + f := newPlanFixture(t) + a := f.attach(t, f.wsA, "shot.png", 900) + + content := imageRef(a.ID) + "\n\nand again " + fileRef(a.ID) + plan := f.plan(t, f.req(content, map[string]any{"cover": "pad-attachment:" + a.ID})) + + if len(plan.Rows) != 1 { + t.Fatalf("len(Rows) = %d, want 1 (same blob referenced three times)", len(plan.Rows)) + } + if len(plan.IDMap) != 1 { + t.Fatalf("IDMap = %v, want exactly one entry", plan.IDMap) + } + if plan.TotalBytes != 900 { + t.Errorf("TotalBytes = %d, want 900 (counted once)", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_EmptyRefsIgnored: a bare prefix with no id +// resolves to nothing to clone and nothing to count — it is not a +// reference, so it is not an unresolvable reference either. +func TestPlanAttachmentCopy_EmptyRefsIgnored(t *testing.T) { + f := newPlanFixture(t) + + plan := f.plan(t, f.req("see pad-attachment: and pad-attachment:)", map[string]any{ + "note": "pad-attachment:", + })) + + if len(plan.Rows) != 0 || len(plan.UnresolvableRefs) != 0 { + t.Fatalf("rows=%v unresolvable=%v, want both empty", sourceIDs(plan), plan.UnresolvableRefs) + } +} + +// TestPlanAttachmentCopy_JunkSuffixIsUnresolvable pins the deliberate +// choice not to narrow the id capture to the canonical UUID shape. +// +// `pad-attachment:x` captures the whole token, resolves to nothing, +// and is counted. It is NOT partially matched back to and cloned. +// The alternative — matching only 36-char RFC4122 ids — would make any +// non-UUID attachment id silently un-copyable, trading a real failure for +// a cosmetic one: this body was already broken in workspace A (nothing +// resolves `x` there either), and it stays exactly as broken in B. +func TestPlanAttachmentCopy_JunkSuffixIsUnresolvable(t *testing.T) { + f := newPlanFixture(t) + a := f.attach(t, f.wsA, "shot.png", 77) + + plan := f.plan(t, f.req("![x](pad-attachment:"+a.ID+"x)", nil)) + + if len(plan.Rows) != 0 { + t.Fatalf("rows = %v, want none — a junk-suffixed id must not resolve to its prefix", sourceIDs(plan)) + } + if len(plan.UnresolvableRefs) != 1 || plan.UnresolvableRefs[0] != a.ID+"x" { + t.Errorf("UnresolvableRefs = %v, want [%sx]", plan.UnresolvableRefs, a.ID) + } +} + +// TestPlanAttachmentCopy_NonUUIDIdResolves is the other half of that +// choice: an attachment whose id is not RFC4122-shaped is still +// enumerated and still cloned. A canonical-UUID-only regex would drop it. +func TestPlanAttachmentCopy_NonUUIDIdResolves(t *testing.T) { + f := newPlanFixture(t) + a := &models.Attachment{ + ID: "legacy_img-42", + WorkspaceID: f.wsA.ID, + UploadedBy: "original-uploader", + StorageKey: "fs:" + newID(), + ContentHash: newID(), + MimeType: "image/png", + SizeBytes: 17, + Filename: "legacy.png", + } + if err := f.s.CreateAttachment(a); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + + plan := f.plan(t, f.req(imageRef(a.ID), nil)) + + assertSourceSet(t, plan, a.ID) +} + +// TestPlanAttachmentCopy_RefsInCodeFencesAreCloned pins a deliberate +// choice: enumeration matches the rewriter's reach (a plain ReplaceAll +// over "pad-attachment:"), which does not respect code fences. If the +// planner skipped fenced references the rewrite would leave workspace A's +// UUID in the copied body, and that UUID 403s on download from B. +func TestPlanAttachmentCopy_RefsInCodeFencesAreCloned(t *testing.T) { + f := newPlanFixture(t) + a := f.attach(t, f.wsA, "shot.png", 42) + + plan := f.plan(t, f.req("```\n"+imageRef(a.ID)+"\n```\n", nil)) + + assertSourceSet(t, plan, a.ID) +} + +// --- the ordering bug (DR-11) ---------------------------------------------- + +// TestPlanAttachmentCopy_DroppedFieldNotCloned is the ordering bug DR-11 +// calls out, driven through the real MigrateFields. +// +// The source item has a `screenshot` field the destination collection does +// not have, so MigrateFields drops it. The planner is handed the FINAL +// fields, so the attachment referenced only by that dropped field must not +// be cloned: cloning it would put a blob in workspace B with nothing +// referencing it — invisible, and un-GC-able because item_id is set — and +// would overstate the dry-run's byte total. +func TestPlanAttachmentCopy_DroppedFieldNotCloned(t *testing.T) { + f := newPlanFixture(t) + kept := f.attach(t, f.wsA, "kept.png", 100) + dropped := f.attach(t, f.wsA, "dropped.png", 999999) + + sourceSchema := []models.FieldDef{ + {Key: "cover", Type: "text", Label: "Cover"}, + {Key: "screenshot", Type: "text", Label: "Screenshot"}, + } + targetSchema := []models.FieldDef{ + {Key: "cover", Type: "text", Label: "Cover"}, + } + rawFields := map[string]any{ + "cover": "pad-attachment:" + kept.ID, + "screenshot": "pad-attachment:" + dropped.ID, + } + + migrated := items.MigrateFields(rawFields, sourceSchema, targetSchema) + if len(migrated.Dropped) != 1 || migrated.Dropped[0] != "screenshot" { + t.Fatalf("precondition: MigrateFields dropped %v, want [screenshot]", migrated.Dropped) + } + + plan := f.plan(t, f.req("body with no refs", migrated.Fields)) + + assertSourceSet(t, plan, kept.ID) + if _, ok := plan.IDMap[dropped.ID]; ok { + t.Errorf("dropped-field attachment %s was cloned — the planner re-read raw fields", dropped.ID) + } + if plan.TotalBytes != 100 { + t.Errorf("TotalBytes = %d, want 100 (the dropped blob must not inflate the dry-run)", plan.TotalBytes) + } + if len(plan.UnresolvableRefs) != 0 { + t.Errorf("UnresolvableRefs = %v — a dropped field is not an unresolvable reference, it is no reference at all", plan.UnresolvableRefs) + } +} + +// --- DR-11a: the confused deputy ------------------------------------------- + +// TestPlanAttachmentCopy_ForeignRefNotCloned is the security case. The +// user writes a reference to an attachment owned by a THIRD workspace into +// an item they control, then copies it. Resolution is scoped to +// workspace A, so the foreign row resolves to nothing: not cloned, no +// bytes, literal text preserved (no IDMap entry), counted as unresolvable. +// +// Cloning it would put another workspace's blob into a workspace the user +// controls, bypassing the download handler's workspace check +// (handleGetAttachment, handlers_attachments.go) entirely. +func TestPlanAttachmentCopy_ForeignRefNotCloned(t *testing.T) { + f := newPlanFixture(t) + mine := f.attach(t, f.wsA, "mine.png", 11) + theirs := f.attach(t, f.wsC, "theirs.png", 5_000_000) + + plan := f.plan(t, f.req(imageRef(mine.ID)+"\n"+imageRef(theirs.ID), nil)) + + assertSourceSet(t, plan, mine.ID) + if _, ok := plan.IDMap[theirs.ID]; ok { + t.Fatalf("foreign attachment %s was cloned — DR-11a scope is missing", theirs.ID) + } + if plan.TotalBytes != 11 { + t.Errorf("TotalBytes = %d, want 11", plan.TotalBytes) + } + if len(plan.UnresolvableRefs) != 1 || plan.UnresolvableRefs[0] != theirs.ID { + t.Errorf("UnresolvableRefs = %v, want [%s]", plan.UnresolvableRefs, theirs.ID) + } +} + +// TestPlanAttachmentCopy_SoftDeletedRefNotCloned: a tombstoned attachment +// is out of scope exactly like a foreign one. Cloning it would resurrect +// bytes the workspace already deleted. +func TestPlanAttachmentCopy_SoftDeletedRefNotCloned(t *testing.T) { + f := newPlanFixture(t) + gone := f.attach(t, f.wsA, "gone.png", 64) + if err := f.s.SoftDeleteAttachment(gone.ID); err != nil { + t.Fatalf("SoftDeleteAttachment: %v", err) + } + + plan := f.plan(t, f.req(imageRef(gone.ID), nil)) + + if len(plan.Rows) != 0 { + t.Fatalf("rows = %v, want none", sourceIDs(plan)) + } + if plan.TotalBytes != 0 { + t.Errorf("TotalBytes = %d, want 0", plan.TotalBytes) + } + if len(plan.UnresolvableRefs) != 1 || plan.UnresolvableRefs[0] != gone.ID { + t.Errorf("UnresolvableRefs = %v, want [%s]", plan.UnresolvableRefs, gone.ID) + } +} + +// TestPlanAttachmentCopy_DanglingRefNotFatal: a reference matching nothing +// at all is reported, never cloned, and never blocks the copy — a stale +// reference is a pre-existing condition. The resolvable siblings still +// plan normally. +func TestPlanAttachmentCopy_DanglingRefNotFatal(t *testing.T) { + f := newPlanFixture(t) + real := f.attach(t, f.wsA, "real.png", 8) + ghost := newID() + + plan := f.plan(t, f.req(imageRef(ghost)+"\n"+imageRef(real.ID), nil)) + + assertSourceSet(t, plan, real.ID) + if len(plan.UnresolvableRefs) != 1 || plan.UnresolvableRefs[0] != ghost { + t.Errorf("UnresolvableRefs = %v, want [%s]", plan.UnresolvableRefs, ghost) + } +} + +// --- variants --------------------------------------------------------------- + +// TestPlanAttachmentCopy_VariantsFollowParent: one referenced original +// with two thumbnails produces three rows, the thumbnails' parent_id +// remapped to the NEW original's id, and the original emitted first so a +// caller inserting in order never writes a parent_id ahead of its parent. +func TestPlanAttachmentCopy_VariantsFollowParent(t *testing.T) { + f := newPlanFixture(t) + orig := f.attach(t, f.wsA, "shot.png", 1000) + sm := f.variant(t, f.wsA, orig, models.AttachmentVariantThumbSm, 10) + md := f.variant(t, f.wsA, orig, models.AttachmentVariantThumbMd, 20) + + plan := f.plan(t, f.req(imageRef(orig.ID), nil)) + + assertSourceSet(t, plan, orig.ID, sm.ID, md.ID) + if plan.Rows[0].SourceID != orig.ID { + t.Fatalf("Rows[0].SourceID = %s, want the original %s", plan.Rows[0].SourceID, orig.ID) + } + newOriginalID := plan.Rows[0].Attachment.ID + for _, row := range plan.Rows[1:] { + if row.Attachment.ParentID == nil { + t.Fatalf("variant row %s lost its parent_id", row.SourceID) + } + if *row.Attachment.ParentID != newOriginalID { + t.Errorf("variant %s parent_id = %s, want the NEW original %s", + row.SourceID, *row.Attachment.ParentID, newOriginalID) + } + } + if plan.TotalBytes != 1030 { + t.Errorf("TotalBytes = %d, want 1030 (original + both thumbs)", plan.TotalBytes) + } + // The map covers derived rows too, so nothing referencing a thumbnail + // by id survives the rewrite pointing at workspace A. + if plan.IDMap[sm.ID] == "" || plan.IDMap[md.ID] == "" { + t.Errorf("IDMap = %v, want entries for both variants", plan.IDMap) + } +} + +// TestPlanAttachmentCopy_ForeignVariantNotFollowed is the one-level-down +// escalation: a variant row in a THIRD workspace whose parent_id points at +// workspace A's original must not be dragged in by the variant traversal. +// An unscoped `WHERE parent_id IN (...)` would clone it. +func TestPlanAttachmentCopy_ForeignVariantNotFollowed(t *testing.T) { + f := newPlanFixture(t) + orig := f.attach(t, f.wsA, "shot.png", 100) + ours := f.variant(t, f.wsA, orig, models.AttachmentVariantThumbSm, 5) + // Same parent_id, different workspace — user-controllable in the + // sense that any workspace can hold a row with an arbitrary parent_id. + theirs := f.variant(t, f.wsC, orig, models.AttachmentVariantThumbMd, 9_000) + + plan := f.plan(t, f.req(imageRef(orig.ID), nil)) + + assertSourceSet(t, plan, orig.ID, ours.ID) + if _, ok := plan.IDMap[theirs.ID]; ok { + t.Fatalf("foreign variant %s was cloned — the variant query lost the DR-11a scope", theirs.ID) + } + if plan.TotalBytes != 105 { + t.Errorf("TotalBytes = %d, want 105", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_SoftDeletedVariantNotFollowed: the variant +// traversal carries the deleted_at half of the scope too. +func TestPlanAttachmentCopy_SoftDeletedVariantNotFollowed(t *testing.T) { + f := newPlanFixture(t) + orig := f.attach(t, f.wsA, "shot.png", 100) + dead := f.variant(t, f.wsA, orig, models.AttachmentVariantThumbSm, 7) + if err := f.s.SoftDeleteAttachment(dead.ID); err != nil { + t.Fatalf("SoftDeleteAttachment: %v", err) + } + + plan := f.plan(t, f.req(imageRef(orig.ID), nil)) + + assertSourceSet(t, plan, orig.ID) + if plan.TotalBytes != 100 { + t.Errorf("TotalBytes = %d, want 100", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_RefToVariantPullsInParent: content is +// user-controlled, so a reference can name a thumbnail directly. Cloning +// it alone would emit a row whose parent_id still points into workspace A, +// so the in-scope parent is pulled in as the clone root and the whole +// variant set comes with it. +func TestPlanAttachmentCopy_RefToVariantPullsInParent(t *testing.T) { + f := newPlanFixture(t) + orig := f.attach(t, f.wsA, "shot.png", 100) + sm := f.variant(t, f.wsA, orig, models.AttachmentVariantThumbSm, 5) + + plan := f.plan(t, f.req(imageRef(sm.ID), nil)) + + assertSourceSet(t, plan, orig.ID, sm.ID) + if plan.Rows[0].SourceID != orig.ID { + t.Fatalf("Rows[0].SourceID = %s, want the parent %s first", plan.Rows[0].SourceID, orig.ID) + } + if got := plan.Rows[1].Attachment.ParentID; got == nil || *got != plan.Rows[0].Attachment.ID { + t.Errorf("variant parent_id = %v, want the new original %s", got, plan.Rows[0].Attachment.ID) + } + if len(plan.UnresolvableRefs) != 0 { + t.Errorf("UnresolvableRefs = %v, want none", plan.UnresolvableRefs) + } +} + +// TestPlanAttachmentCopy_RefToVariantWithForeignParent: the same +// escalation from the other direction. The referenced thumbnail lives in +// workspace A but its parent lives elsewhere, so following the parent +// would clone a foreign original. The reference is unresolvable instead. +func TestPlanAttachmentCopy_RefToVariantWithForeignParent(t *testing.T) { + f := newPlanFixture(t) + foreignOrig := f.attach(t, f.wsC, "theirs.png", 4_000) + localThumb := f.variant(t, f.wsA, foreignOrig, models.AttachmentVariantThumbSm, 6) + + plan := f.plan(t, f.req(imageRef(localThumb.ID), nil)) + + if len(plan.Rows) != 0 { + t.Fatalf("rows = %v, want none", sourceIDs(plan)) + } + if _, ok := plan.IDMap[foreignOrig.ID]; ok { + t.Fatalf("foreign parent %s was cloned via its in-workspace variant", foreignOrig.ID) + } + if plan.TotalBytes != 0 { + t.Errorf("TotalBytes = %d, want 0", plan.TotalBytes) + } + if len(plan.UnresolvableRefs) != 1 || plan.UnresolvableRefs[0] != localThumb.ID { + t.Errorf("UnresolvableRefs = %v, want [%s]", plan.UnresolvableRefs, localThumb.ID) + } +} + +// --- row shape -------------------------------------------------------------- + +// TestPlanAttachmentCopy_RowShape pins every column the caller inserts: +// fresh id, destination workspace, destination item from the outset (never +// transiently NULL), uploaded_by = the copying actor and NOT the source +// uploader (who may not be a member of B at all), and the +// content-addressed columns carried over verbatim so a same-instance copy +// is a row copy rather than a byte copy. +func TestPlanAttachmentCopy_RowShape(t *testing.T) { + f := newPlanFixture(t) + src := f.attach(t, f.wsA, "shot.png", 4096) + + plan := f.plan(t, f.req(imageRef(src.ID), nil)) + got := plan.Rows[0].Attachment + + if got.ID == src.ID || got.ID == "" { + t.Errorf("id = %q, want a fresh non-empty UUID", got.ID) + } + if got.WorkspaceID != f.wsB.ID { + t.Errorf("workspace_id = %s, want destination %s", got.WorkspaceID, f.wsB.ID) + } + if got.ItemID == nil || *got.ItemID != "dest-item-id" { + t.Errorf("item_id = %v, want dest-item-id (never transiently NULL)", got.ItemID) + } + if got.UploadedBy != f.actor { + t.Errorf("uploaded_by = %q, want the copying actor %q, not the source uploader %q", + got.UploadedBy, f.actor, src.UploadedBy) + } + if got.StorageKey != src.StorageKey || got.ContentHash != src.ContentHash { + t.Errorf("storage_key/content_hash = %q/%q, want them carried over (%q/%q)", + got.StorageKey, got.ContentHash, src.StorageKey, src.ContentHash) + } + if got.MimeType != src.MimeType || got.SizeBytes != src.SizeBytes || got.Filename != src.Filename { + t.Errorf("mime/size/filename not carried over: %+v", got) + } + if got.Width == nil || *got.Width != 800 || got.Height == nil || *got.Height != 600 { + t.Errorf("width/height not carried over: %v/%v", got.Width, got.Height) + } + if got.ParentID != nil { + t.Errorf("parent_id = %v, want nil for an original", got.ParentID) + } + if got.DeletedAt != nil { + t.Errorf("deleted_at = %v, want nil", got.DeletedAt) + } + if !got.CreatedAt.IsZero() { + t.Errorf("created_at = %v, want zero so CreateAttachment stamps now()", got.CreatedAt) + } + if plan.Rows[0].NeedsByteTransfer || plan.CrossBackend { + t.Errorf("same-backend copy flagged as needing a byte transfer") + } +} + +// TestPlanAttachmentCopy_DryRunAllowsEmptyTargetItem: a dry-run has no +// destination item yet and writes nothing, so an empty TargetItemID is +// legal there and simply leaves item_id nil. +func TestPlanAttachmentCopy_DryRunAllowsEmptyTargetItem(t *testing.T) { + f := newPlanFixture(t) + src := f.attach(t, f.wsA, "shot.png", 3) + + req := f.req(imageRef(src.ID), nil) + req.TargetItemID = "" + req.DryRun = true + plan := f.plan(t, req) + + if plan.Rows[0].Attachment.ItemID != nil { + t.Errorf("item_id = %v, want nil for a dry-run plan", plan.Rows[0].Attachment.ItemID) + } + if plan.TotalBytes != 3 { + t.Errorf("TotalBytes = %d, want 3 — a dry-run still reports the real total", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_InsertablePlanRequiresTargetItem: without the +// DryRun opt-in, an empty TargetItemID is an error rather than a plan full +// of item_id=NULL rows. Inserting those would permanently orphan the +// blobs — referenced by the copied body, so never GC'd, and invisible +// through every item-scoped surface. +func TestPlanAttachmentCopy_InsertablePlanRequiresTargetItem(t *testing.T) { + f := newPlanFixture(t) + src := f.attach(t, f.wsA, "shot.png", 3) + + req := f.req(imageRef(src.ID), nil) + req.TargetItemID = "" + _, err := f.s.PlanAttachmentCopy(req) + if err == nil || !strings.Contains(err.Error(), "target_item_id") { + t.Fatalf("err = %v, want one naming target_item_id", err) + } +} + +// --- cross-backend ---------------------------------------------------------- + +// TestPlanAttachmentCopy_CrossBackendDetected: when the destination writes +// to a different backend, the shared-storage_key shortcut does not hold. +// The plan must say so per row rather than emit a key the target backend +// cannot resolve. +func TestPlanAttachmentCopy_CrossBackendDetected(t *testing.T) { + f := newPlanFixture(t) + src := f.attachWith(t, f.wsA, "shot.png", 12, "fs:deadbeef", "deadbeef") + + req := f.req(imageRef(src.ID), nil) + req.TargetBackend = "s3" + plan := f.plan(t, req) + + if !plan.CrossBackend || !plan.Rows[0].NeedsByteTransfer { + t.Fatalf("cross-backend not detected: CrossBackend=%v row=%+v", plan.CrossBackend, plan.Rows[0]) + } + // The emitted row must NOT carry a key the s3 destination cannot + // resolve. An "fs:" key would insert cleanly and fail at download. + if plan.Rows[0].Attachment.StorageKey != "" { + t.Errorf("storage_key = %q, want empty — the caller fills it from Put after transferring bytes", + plan.Rows[0].Attachment.StorageKey) + } + if plan.Rows[0].SourceStorageKey != "fs:deadbeef" { + t.Errorf("SourceStorageKey = %q, want the source key so the caller can Get the bytes", + plan.Rows[0].SourceStorageKey) + } +} + +// TestPlanAttachmentCopy_CrossBackendRowUninsertable closes the loop on +// the sentinel: the blank StorageKey is not merely a convention the +// orchestration is asked to honour. CreateAttachment refuses it, so an +// orchestration that forgets the Get/Put step fails loudly at insert +// rather than quietly creating an attachment that 404s on download. +func TestPlanAttachmentCopy_CrossBackendRowUninsertable(t *testing.T) { + f := newPlanFixture(t) + src := f.attachWith(t, f.wsA, "shot.png", 12, "fs:deadbeef", "deadbeef") + + req := f.req(imageRef(src.ID), nil) + req.TargetBackend = "s3" + plan := f.plan(t, req) + + row := plan.Rows[0].Attachment + if err := f.s.CreateAttachment(&row); err == nil { + t.Fatal("CreateAttachment accepted a row with no storage_key") + } else if !strings.Contains(err.Error(), "storage_key") { + t.Errorf("err = %v, want one naming storage_key", err) + } + + // After the caller transfers the bytes, the same row inserts fine. + row.StorageKey = "s3:deadbeef" + row.ItemID = nil // no destination item exists in this unit fixture + if err := f.s.CreateAttachment(&row); err != nil { + t.Fatalf("CreateAttachment after byte transfer: %v", err) + } +} + +// TestPlanAttachmentCopy_SameBackendNoTransfer: naming the backend +// explicitly, when it matches, must NOT trigger a pointless byte copy. +func TestPlanAttachmentCopy_SameBackendNoTransfer(t *testing.T) { + f := newPlanFixture(t) + src := f.attachWith(t, f.wsA, "shot.png", 12, "fs:deadbeef", "deadbeef") + + req := f.req(imageRef(src.ID), nil) + req.TargetBackend = "fs" + plan := f.plan(t, req) + + if plan.CrossBackend || plan.Rows[0].NeedsByteTransfer { + t.Errorf("same backend flagged as cross-backend") + } + if plan.Rows[0].Attachment.StorageKey != "fs:deadbeef" { + t.Errorf("storage_key = %q, want the source key — a same-backend copy is a row copy", + plan.Rows[0].Attachment.StorageKey) + } + if plan.Rows[0].SourceStorageKey != "fs:deadbeef" { + t.Errorf("SourceStorageKey = %q, want it populated on every row", plan.Rows[0].SourceStorageKey) + } +} + +// TestPlanAttachmentCopy_PrefixlessKeyNeedsTransfer: a storage_key with no +// backend prefix cannot be routed by the registry, so it is treated as +// needing a real transfer rather than assumed resolvable. +func TestPlanAttachmentCopy_PrefixlessKeyNeedsTransfer(t *testing.T) { + f := newPlanFixture(t) + src := f.attachWith(t, f.wsA, "shot.png", 12, "legacy-key-no-prefix", "hash1") + + req := f.req(imageRef(src.ID), nil) + req.TargetBackend = "fs" + plan := f.plan(t, req) + + if !plan.CrossBackend || !plan.Rows[0].NeedsByteTransfer { + t.Errorf("prefixless storage_key must be flagged for byte transfer") + } +} + +// --- byte accounting -------------------------------------------------------- + +// TestPlanAttachmentCopy_DedupedBlobsCountedPerRow: two distinct +// attachment rows sharing a content_hash count twice, matching what +// WorkspaceStorageUsage's SUM(size_bytes) reports after the copy +// (TestStorageUsage_TracksUploads asserts that double-count is intentional). +// Per DR-16 storage is reported, not enforced — the dry-run's number must +// agree with the storage page, not with a hash-deduped fiction. +func TestPlanAttachmentCopy_DedupedBlobsCountedPerRow(t *testing.T) { + f := newPlanFixture(t) + a := f.attachWith(t, f.wsA, "one.png", 500, "fs:samehash", "samehash") + b := f.attachWith(t, f.wsA, "two.png", 500, "fs:samehash", "samehash") + + plan := f.plan(t, f.req(imageRef(a.ID)+imageRef(b.ID), nil)) + + if len(plan.Rows) != 2 { + t.Fatalf("len(Rows) = %d, want 2 distinct rows", len(plan.Rows)) + } + if plan.TotalBytes != 1000 { + t.Errorf("TotalBytes = %d, want 1000 (per row, matching SUM(size_bytes))", plan.TotalBytes) + } +} + +// TestPlanAttachmentCopy_DryRunTotalsMatchTheRealCopy is the acceptance +// criterion that the byte total "matches what the dry-run will display". +// The dry-run and the copy that follows it call the same function with the +// same inputs, so the guarantee is structural — this pins it against a +// future change that special-cases DryRun and lets the two drift. +func TestPlanAttachmentCopy_DryRunTotalsMatchTheRealCopy(t *testing.T) { + f := newPlanFixture(t) + orig := f.attach(t, f.wsA, "shot.png", 1000) + f.variant(t, f.wsA, orig, models.AttachmentVariantThumbSm, 10) + f.variant(t, f.wsA, orig, models.AttachmentVariantThumbMd, 20) + extra := f.attach(t, f.wsA, "doc.pdf", 300) + ghost := newID() + + content := imageRef(orig.ID) + "\n" + fileRef(extra.ID) + "\n" + imageRef(ghost) + fields := map[string]any{"cover": "pad-attachment:" + orig.ID} + + preview := f.req(content, fields) + preview.TargetItemID = "" + preview.DryRun = true + dry := f.plan(t, preview) + + real := f.plan(t, f.req(content, fields)) + + if dry.TotalBytes != real.TotalBytes { + t.Errorf("dry-run TotalBytes = %d, copy = %d", dry.TotalBytes, real.TotalBytes) + } + if dry.TotalBytes != 1330 { + t.Errorf("TotalBytes = %d, want 1330 (original + two thumbs + the pdf, each once)", dry.TotalBytes) + } + if strings.Join(sourceIDs(dry), ",") != strings.Join(sourceIDs(real), ",") { + t.Errorf("planned rows differ: dry-run %v, copy %v", sourceIDs(dry), sourceIDs(real)) + } + if strings.Join(dry.UnresolvableRefs, ",") != strings.Join(real.UnresolvableRefs, ",") { + t.Errorf("unresolvable refs differ: dry-run %v, copy %v", dry.UnresolvableRefs, real.UnresolvableRefs) + } + if len(dry.UnresolvableRefs) != 1 { + t.Errorf("UnresolvableRefs = %v, want the one dangling ref", dry.UnresolvableRefs) + } + + // The one thing that legitimately differs: the dry-run's rows are not + // attached to a destination item, because there is not one yet. + if dry.Rows[0].Attachment.ItemID != nil || real.Rows[0].Attachment.ItemID == nil { + t.Errorf("item_id: dry-run %v (want nil), copy %v (want set)", + dry.Rows[0].Attachment.ItemID, real.Rows[0].Attachment.ItemID) + } +} + +// --- determinism + validation ---------------------------------------------- + +// TestPlanAttachmentCopy_DeterministicOrder: references are planned in +// first-appearance order (content before fields), so a dry-run and the +// subsequent copy list the same rows in the same order. +func TestPlanAttachmentCopy_DeterministicOrder(t *testing.T) { + f := newPlanFixture(t) + first := f.attach(t, f.wsA, "first.png", 1) + second := f.attach(t, f.wsA, "second.png", 2) + third := f.attach(t, f.wsA, "third.png", 3) + + req := f.req(imageRef(second.ID)+imageRef(first.ID), map[string]any{"cover": "pad-attachment:" + third.ID}) + for i := 0; i < 5; i++ { + plan := f.plan(t, req) + got := sourceIDs(plan) + want := []string{second.ID, first.ID, third.ID} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("iteration %d: order = %v, want %v", i, got, want) + } + } +} + +// TestPlanAttachmentCopy_DoesNotMutateCallerFields: the planner reads the +// caller's final fields, it does not own them. The orchestration writes +// that same map to the destination item after planning, so a mutation +// here would corrupt the copy. +func TestPlanAttachmentCopy_DoesNotMutateCallerFields(t *testing.T) { + f := newPlanFixture(t) + a := f.attach(t, f.wsA, "shot.png", 5) + + fields := map[string]any{ + "cover": "pad-attachment:" + a.ID, + "status": "open", + "tags": []any{"one", "two"}, + } + before, err := json.Marshal(fields) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + f.plan(t, f.req("body", fields)) + + after, err := json.Marshal(fields) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(before) != string(after) { + t.Errorf("planner mutated caller fields:\n before %s\n after %s", before, after) + } +} + +// TestPlanAttachmentCopy_NoRefs: nothing referenced, nothing planned, and +// a non-nil map so callers can index it unconditionally. +func TestPlanAttachmentCopy_NoRefs(t *testing.T) { + f := newPlanFixture(t) + + plan := f.plan(t, f.req("just prose", map[string]any{"status": "open"})) + + if len(plan.Rows) != 0 || plan.TotalBytes != 0 || len(plan.UnresolvableRefs) != 0 { + t.Errorf("empty plan expected, got %+v", plan) + } + if plan.IDMap == nil { + t.Error("IDMap is nil; callers must be able to index it unconditionally") + } +} + +// TestPlanAttachmentCopy_RequiredInputs: the three identifiers that make +// the plan safe are mandatory. An empty SourceWorkspaceID in particular +// would turn the DR-11a scope into "any workspace". +func TestPlanAttachmentCopy_RequiredInputs(t *testing.T) { + f := newPlanFixture(t) + + for _, tc := range []struct { + name string + mutate func(*AttachmentCopyRequest) + want string + }{ + {"no source workspace", func(r *AttachmentCopyRequest) { r.SourceWorkspaceID = "" }, "source_workspace_id"}, + {"no target workspace", func(r *AttachmentCopyRequest) { r.TargetWorkspaceID = "" }, "target_workspace_id"}, + {"no actor", func(r *AttachmentCopyRequest) { r.UploadedBy = "" }, "uploaded_by"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := f.req("body", nil) + tc.mutate(&req) + _, err := f.s.PlanAttachmentCopy(req) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want one naming %s", err, tc.want) + } + }) + } +} + +// TestChunkStrings covers the split at and around the boundary. The +// integration test below cannot pin these cases cheaply (each id costs a +// row), and an off-by-one here silently drops a whole chunk of references +// — which the planner would report as "not referenced" rather than as an +// error. +func TestChunkStrings(t *testing.T) { + ids := func(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("id-%d", i) + } + return out + } + sizes := func(chunks [][]string) []int { + out := make([]int, len(chunks)) + for i, c := range chunks { + out[i] = len(c) + } + return out + } + + for _, tc := range []struct { + n int + size int + want []int + }{ + {0, 400, nil}, + {1, 400, []int{1}}, + {399, 400, []int{399}}, + {400, 400, []int{400}}, + {401, 400, []int{400, 1}}, + {800, 400, []int{400, 400}}, + {801, 400, []int{400, 400, 1}}, + {5, 2, []int{2, 2, 1}}, + } { + t.Run(fmt.Sprintf("n=%d/size=%d", tc.n, tc.size), func(t *testing.T) { + in := ids(tc.n) + chunks := chunkStrings(in, tc.size) + if fmt.Sprint(sizes(chunks)) != fmt.Sprint(tc.want) { + t.Fatalf("chunk sizes = %v, want %v", sizes(chunks), tc.want) + } + var flat []string + for _, c := range chunks { + flat = append(flat, c...) + } + if strings.Join(flat, ",") != strings.Join(in, ",") { + t.Errorf("chunks do not reassemble to the input") + } + }) + } +} + +// TestPlanAttachmentCopy_ManyRefsChunked drives real references through +// the multi-chunk path end to end: every reference must still resolve, +// exactly once, with the bytes counted once. It complements TestChunkStrings +// — that one pins the split arithmetic, this one pins that a plan built +// from more than one query is complete. +func TestPlanAttachmentCopy_ManyRefsChunked(t *testing.T) { + if testing.Short() { + t.Skip("creates attachmentPlanChunk+1 rows") + } + f := newPlanFixture(t) + + var body strings.Builder + want := make([]string, 0, attachmentPlanChunk+1) + for i := 0; i <= attachmentPlanChunk; i++ { + a := f.attach(t, f.wsA, fmt.Sprintf("shot-%d.png", i), 1) + want = append(want, a.ID) + body.WriteString(imageRef(a.ID)) + } + + plan := f.plan(t, f.req(body.String(), nil)) + + assertSourceSet(t, plan, want...) + if plan.TotalBytes != int64(len(want)) { + t.Errorf("TotalBytes = %d, want %d", plan.TotalBytes, len(want)) + } +} From 0fad869a28e9ea3f2609ce24ec03a162f80b2bde Mon Sep 17 00:00:00 2001 From: xarmian Date: Thu, 30 Jul 2026 15:19:00 +0000 Subject: [PATCH 6/6] feat(store): add CopyItemAcrossWorkspaces atomic orchestration (TASK-2363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN-2357 DR-9 / DR-9a / DR-11 / DR-12 / DR-14 / DR-16 / DR-17. One store operation, one transaction: create in B, clone attachments, archive A on a move, write provenance. Lock order (the whole point of DR-9): 1. Both workspaces' advisory locks, sorted and deduplicated by the hashtext LOCK KEY — sorting the ID strings does not order their hashes, so two opposing movers could still deadlock. 2. Both collection rows FOR UPDATE, sorted by collection ID — MigrateFields consumes both schemas. 3. Source item re-read under those locks; that snapshot is copied. Both primitives are dialect-gated: FOR UPDATE is a syntax error on SQLite, where BEGIN IMMEDIATE already serializes writers. Pipeline: migrate -> overrides -> validate (DR-12: MigrateFields' errors are stale once an override lands) -> quota -> PlanAttachmentCopy INSIDE the tx -> rewrite content AND fields via the plan's IDMap -> create in B -> attachment rows (originals before variants, item_id set from the outset, uploaded_by = the actor) -> archive A -> provenance. Seq (DR-14): B always advances; A advances only on ArchiveSource, and a plain copy leaves A completely untouched. Quota (DR-16) runs inside the transaction after the destination lock so two concurrent copies cannot jointly exceed the cap. Cross-backend attachment copies are REFUSED in v1 (ErrCopyCrossBackendAttachments): the store has no AttachmentStore handle, and a byte transfer under both workspaces' locks would block every writer in both workspaces on unbounded I/O with no rollback. Supporting changes: - CreateAttachmentTx: tx-taking insert (CreateAttachment is self-committing), sharing one body with the pool form. - CheckLimitTx: the feature COUNT reads through the caller's tx. - createItemTxWithID: createItemTx with a caller-supplied id, so the destination item id exists before the attachment plan is built. Tests: creation parity, seq on both sides, DR-12 ordering, DR-8/DR-17 scrubs, attachment clone + rewrite (including refs in code fences), DR-11a unresolvable refs, rollback at all four stages, quota. Postgres only: opposing A->B / B->A copies do not deadlock, concurrent copies cannot jointly exceed the cap, and colliding hashtext keys take one lock. All three verified falsifiable by mutating the production code. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- internal/store/attachments.go | 27 +- internal/store/items.go | 24 +- internal/store/items_cross_workspace_copy.go | 896 +++++++++++++++ .../items_cross_workspace_copy_pg_test.go | 348 ++++++ .../store/items_cross_workspace_copy_test.go | 1002 +++++++++++++++++ internal/store/limits.go | 39 +- 6 files changed, 2328 insertions(+), 8 deletions(-) create mode 100644 internal/store/items_cross_workspace_copy.go create mode 100644 internal/store/items_cross_workspace_copy_pg_test.go create mode 100644 internal/store/items_cross_workspace_copy_test.go diff --git a/internal/store/attachments.go b/internal/store/attachments.go index 87a85892..fa657686 100644 --- a/internal/store/attachments.go +++ b/internal/store/attachments.go @@ -74,6 +74,31 @@ func scanAttachment(row interface { // transfers the bytes and writes back Put's key — so this guard is what // turns "the caller must Put first" from a comment into an invariant. func (s *Store) CreateAttachment(a *models.Attachment) error { + return s.createAttachmentOn(s.db, a) +} + +// CreateAttachmentTx inserts an attachment row inside an existing transaction. +// +// It exists for the cross-workspace copy (PLAN-2357 / DR-9 / DR-11), which has +// to write the clone rows in the SAME transaction as the destination item — +// otherwise a rollback after the self-committing CreateAttachment leaves live +// attachment rows in workspace B pointing at an item that never existed, and a +// failure between the two leaves the copied body's rewritten refs dangling. +// RecordItemWorkspaceMoveTx is the shape this follows. +// +// Identical semantics to CreateAttachment, including the empty-storage_key +// refusal and the "stamp now() when CreatedAt is zero" rule that +// AttachmentCopyRow relies on (its rows carry a deliberately zero CreatedAt). +// Ordering is the caller's contract: attachments has no parent_id foreign key, +// so nothing stops a variant being inserted before its original — insert +// AttachmentCopyPlan.Rows in the order the planner emitted them. +func (s *Store) CreateAttachmentTx(tx *sql.Tx, a *models.Attachment) error { + return s.createAttachmentOn(tx, a) +} + +// createAttachmentOn is the shared insert body, parameterized over the pool or +// a transaction so CreateAttachment and CreateAttachmentTx cannot drift. +func (s *Store) createAttachmentOn(ex sqlExecer, a *models.Attachment) error { if a.StorageKey == "" { return fmt.Errorf("create attachment: storage_key is required (write the blob via AttachmentStore.Put first)") } @@ -87,7 +112,7 @@ func (s *Store) CreateAttachment(a *models.Attachment) error { ts = a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00") } - _, err := s.db.Exec(s.q(` + _, err := ex.Exec(s.q(` INSERT INTO attachments (`+attachmentColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), diff --git a/internal/store/items.go b/internal/store/items.go index 0692c17f..3a2bea65 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -410,6 +410,26 @@ func (s *Store) insertItemTx(tx *sql.Tx, id, workspaceID, collectionID, slug, ts // its committed slug / item_number / seq (DR-14 fanout) without a second // round-trip after COMMIT. func (s *Store) createItemTx(tx *sql.Tx, workspaceID, collectionID string, input models.ItemCreate) (*models.Item, error) { + return s.createItemTxWithID(tx, newID(), workspaceID, collectionID, input) +} + +// createItemTxWithID is createItemTx with the destination item's id supplied +// by the caller instead of minted inside. +// +// It exists for exactly one caller: CopyItemAcrossWorkspaces (PLAN-2357 / +// DR-9 / DR-11). The copy has to hand the attachment planner the destination +// item id BEFORE the item row exists, because every cloned attachment row must +// carry item_id from the outset — never transiently NULL, since a NULL-item_id +// row is a permanent un-reclaimable orphan (see AttachmentCopyRequest.DryRun's +// doc). Minting the id in the orchestration and passing it down is the only +// way to satisfy both that ordering and DR-9a's "the version row and the +// wiki-link index are built from the POST-rewrite content". +// +// An empty id is filled in, so a caller that has no opinion behaves exactly +// like createItemTx. The id is NOT validated for uniqueness here — the items +// primary key does that, and a collision (a caller re-using an id) surfaces as +// a unique violation that rolls the caller's transaction back. +func (s *Store) createItemTxWithID(tx *sql.Tx, id, workspaceID, collectionID string, input models.ItemCreate) (*models.Item, error) { // Validate assignment scope before writing — parity with CreateItem, but // read through the tx so it sees the caller's uncommitted membership / // role writes and is serialized with them. @@ -424,7 +444,9 @@ func (s *Store) createItemTx(tx *sql.Tx, workspaceID, collectionID string, input return nil, err } - id := newID() + if id == "" { + id = newID() + } ts := now() fields := input.Fields diff --git a/internal/store/items_cross_workspace_copy.go b/internal/store/items_cross_workspace_copy.go new file mode 100644 index 00000000..b2b1d31e --- /dev/null +++ b/internal/store/items_cross_workspace_copy.go @@ -0,0 +1,896 @@ +package store + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + + "github.com/PerpetualSoftware/pad/internal/items" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Cross-workspace item copy — PLAN-2357 DR-9 / DR-9a / DR-11 / DR-12 / DR-14 / +// DR-16 / DR-17. +// +// CopyItemAcrossWorkspaces is the one store operation that lands an item from +// workspace A into workspace B. It cannot be assembled from existing +// primitives: CreateItem, CreateAttachment and DeleteItem each open and commit +// their own transaction, so composing them would leave a window in which the +// destination item exists without its attachments, or the source is archived +// with no destination to point at. Everything below runs in ONE transaction. +// +// AUTHORIZATION IS NOT HERE. This is a store primitive; it enforces data +// invariants (scope, quota, seq, provenance) and nothing else. The four-step +// visibility/edit ladder of DR-10a / DR-10b — source item visible, source edit, +// destination collection visible, destination collection edit — belongs to the +// HTTP layer (TASK-2358 / TASK-2365) and MUST run before this is called. A +// caller that skips it has built an exfiltration path. +// +// FANOUT IS NOT HERE EITHER (DR-14). No activity row, no SSE publish, no +// webhook. Emitting inside the transaction would leak an event for a copy that +// then rolls back. The caller emits post-commit from CrossWorkspaceCopyResult. + +// ErrCopyCrossBackendAttachments is returned when the copy would have to move +// attachment BYTES between storage backends. +// +// v1 refuses rather than transferring. Two reasons, both structural: +// +// - The store has no handle on AttachmentStore. Blob backends are wired at +// the server layer and store-level code deliberately never touches the +// filesystem or object storage (see CreateAttachment's doc). Threading a +// backend registry into the store to serve one branch of one operation +// would invert that boundary for every other caller too. +// - Even with a handle, the Get/Put would run while this transaction holds +// BOTH workspaces' advisory locks. Every writer in both workspaces would +// block behind an unbounded network round-trip per attachment, and a +// partially-transferred set has no rollback (Put is not transactional). +// Doing it outside the transaction reintroduces exactly the atomicity hole +// DR-9 exists to close. +// +// Same-instance copies — the only shape that exists today, where source and +// destination resolve through the same backend — are unaffected: storage is +// content-addressed, so the clone is a row copy and NeedsByteTransfer is +// false for every row. Callers signal cross-backend detection by setting +// CrossWorkspaceCopyRequest.TargetBackend; leaving it empty disables the check +// entirely, which is correct for a single-backend deployment. +var ErrCopyCrossBackendAttachments = errors.New("copy item across workspaces: attachment bytes live in a different storage backend; cross-backend copy is not supported") + +// ItemLimitError reports a DR-16 item-count quota rejection. It carries the +// LimitResult so the HTTP layer can render the same plan-limit payload +// writePlanLimitError produces for handleCreateItem. +type ItemLimitError struct { + Result *LimitResult +} + +func (e *ItemLimitError) Error() string { + return fmt.Sprintf("copy item across workspaces: destination workspace is at its item limit (%d of %d)", + e.Result.Current, e.Result.Limit) +} + +// FieldValidationError reports a DR-12 validation failure — the destination +// fields, AFTER migration and AFTER overrides, do not satisfy the destination +// collection's schema. Distinguished from a generic error so the caller can +// answer with 400 rather than 500. +type FieldValidationError struct { + Err error +} + +func (e *FieldValidationError) Error() string { + return fmt.Sprintf("copy item across workspaces: %v", e.Err) +} + +func (e *FieldValidationError) Unwrap() error { return e.Err } + +// CrossWorkspaceCopyRequest is the complete input to CopyItemAcrossWorkspaces. +type CrossWorkspaceCopyRequest struct { + // SourceItemID is the item in workspace A. The source workspace is + // DERIVED from it rather than supplied — an item's workspace is not the + // caller's to assert. + SourceItemID string + + // TargetWorkspaceID and TargetCollectionID name the destination. The + // collection is re-read in-tx under `workspace_id = TargetWorkspaceID AND + // deleted_at IS NULL`, so a collection from another workspace is a + // not-found, not a cross-workspace write. + TargetWorkspaceID string + TargetCollectionID string + + // FieldOverrides are merged over the migrated fields and then validated + // (DR-12 — MigrateFields computes its Errors before any override exists, + // so those errors are stale the moment an override lands). + FieldOverrides map[string]any + + // Actor is the user performing the copy. It becomes every cloned + // attachment's uploaded_by (DR-11: never the source uploader, who may not + // be a member of B at all) and the provenance row's created_by. + Actor string + + // CreatedBy and Source are items.created_by / items.source for the new + // row, matching CreateItem's vocabulary ("user"/"agent", "web"/"cli"/…). + // Both default the same way CreateItem defaults them. + CreatedBy string + Source string + + // ArchiveSource turns the copy into a move (DR-1): the source is + // soft-deleted in the same transaction, workspace A's seq advances, and + // the provenance row records that seq. A plain copy leaves A completely + // untouched — no write, no seq bump, nothing for A's watchers to see. + ArchiveSource bool + + // TargetBackend is the storage-backend prefix workspace B writes through + // ("fs", "s3", …). Empty disables cross-backend detection — correct for a + // single-backend deployment. See ErrCopyCrossBackendAttachments. + TargetBackend string + + // EnforceItemLimit turns on the DR-16 items_per_workspace check against + // the DESTINATION workspace, inside the transaction. + // + // It is a caller flag rather than an unconditional check for parity with + // enforcePlanLimit, which self-hosted mode short-circuits before touching + // the store (`if !s.cloudMode { return true }`). Enforcing unconditionally + // here would apply free-tier caps to any self-hosted user whose plan row + // says "free" — a limit that path has never had. Cloud callers set it; + // self-hosted callers do not. + EnforceItemLimit bool + + // failAfterStage is a TEST-ONLY seam. It is unexported, so nothing outside + // internal/store can set it, and it is the only way to PROVE the rollback + // obligation the acceptance criteria state — "a failure at each stage + // leaves nothing in either workspace". Three of the four stages have no + // reachable natural failure once the lock protocol holds: the archive's + // row count and the provenance insert can only fail if something upstream + // is already broken. Without a seam those branches would be asserted by + // inspection rather than by a test that fails when the rollback breaks. + // + // See the copyStage* constants for the recognised values. + failAfterStage string +} + +// Stage names for CrossWorkspaceCopyRequest.failAfterStage. +const ( + copyStageCreateItem = "create_item" + copyStageAttachments = "attachments" + copyStageArchive = "archive" + copyStageProvenance = "provenance" +) + +// injectedStageFailure returns a synthetic error when the request asked to +// fail after the named stage. Always nil in production — failAfterStage is +// unexported and no production caller can set it. +func (req CrossWorkspaceCopyRequest) injectedStageFailure(stage string) error { + if req.failAfterStage == "" || req.failAfterStage != stage { + return nil + } + return fmt.Errorf("copy item across workspaces: injected failure after stage %q", stage) +} + +// CrossWorkspaceCopyResult is what a committed copy produced. Everything the +// post-commit fanout (TASK-2365) and the CLI/HTTP response need. +type CrossWorkspaceCopyResult struct { + // Item is the destination item, read back inside the transaction, so its + // Seq is exactly the one this copy assigned in workspace B. + Item *models.Item + + // Source is the source item as re-read UNDER LOCK — the snapshot that was + // actually copied, not the caller's pre-transaction read. + Source *models.Item + + // SourceWorkspaceID is workspace A, derived from the source item. + SourceWorkspaceID string + + // Move is the provenance row written in the same transaction. + Move *models.ItemWorkspaceMove + + // SourceSeq is the seq the archive assigned in workspace A, nil on a plain + // copy (which does not write in A at all). + SourceSeq *int64 + + // AttachmentsCopied / BytesCopied describe the cloned attachment rows. + // + // CALLER OBLIGATION: when AttachmentsCopied > 0 the destination + // workspace's storage usage changed, and internal/server memoizes that for + // 30 seconds. The caller MUST invalidate the destination's + // storageInfoCache entry after a successful copy — the store has no handle + // on it — or the storage page reports stale usage for the rest of the + // window, right after the user watched the bytes land. + AttachmentsCopied int + BytesCopied int64 + + // UnresolvableRefs are pad-attachment references in the copied body that + // resolved to nothing under the DR-11a scope. Never fatal; the literal + // text survives so the copy renders exactly as broken as the source did. + UnresolvableRefs []string + + // DroppedFields are field keys MigrateFields could not carry into the + // destination schema. DroppedAssignee / DroppedAgentRole record the DR-8 + // scrubs. + DroppedFields []string + DroppedAssignee bool + DroppedAgentRole bool +} + +// CopyItemAcrossWorkspaces copies one item from its workspace into another, +// atomically. +// +// THE LOCK ORDER. Changed only deliberately, and stated here because it is the +// only place it is true: +// +// 1. Workspace A and B advisory locks, sorted and deduplicated BY THE +// hashtext LOCK KEY. +// 2. Source and destination collection rows, sorted by collection ID, locked +// FOR UPDATE. +// 3. Source item re-read under those locks. +// +// Three things that look like nits and are not: +// +// - Sorting the workspace ID STRINGS does not order their hashes. Postgres +// locks hashtext(workspace_id), so two opposing movers sorting by ID could +// still take the two locks in opposite order and deadlock. Sort by the +// computed key. And deduplicate: two distinct workspaces can collide onto +// one key, in which case there is one lock to take. +// - BOTH collection rows, not just the destination. MigrateFields consumes +// both schemas, so pinning only the destination leaves half the input +// racy and lets a dry-run and the commit disagree about what carries. +// Sorted by collection ID for the same deadlock reason. +// - FOR UPDATE is not optional. A schema-only collection update does not +// necessarily take the workspace advisory lock, so merely READING the +// collections after the advisory locks leaves a window to reshape a schema +// before this transaction commits. +// +// pg_advisory_xact_lock and FOR UPDATE are dialect-gated. SQLite's DSN makes +// every db.Begin() a BEGIN IMMEDIATE, so all writers already serialize and the +// ordering concern is moot there — and FOR UPDATE is a syntax ERROR on SQLite, +// so emitting it unconditionally would fail on exactly one backend. +// +// THE PIPELINE, in the order DR-11 requires and no other: +// +// fresh source under lock -> migrate fields -> apply overrides -> validate +// -> quota -> PlanAttachmentCopy(source content + FINAL destination fields) +// -> rewrite content + fields via the plan's IDMap +// -> createItemTxWithID in B (version row + wiki-link index therefore see +// the POST-rewrite content, per DR-9a) +// -> insert attachment rows, originals before variants, item_id set from +// the outset +// -> archive in A if requested, advancing A's seq +// -> provenance row carrying that seq +// +// Enumerating attachment refs from the FINAL fields rather than the source's +// raw fields is load-bearing: raw enumeration clones blobs referenced only by +// fields MigrateFields DROPS, and those land in B invisible and beyond the +// reach of the orphan sweep (which only considers item_id IS NULL rows). +// +// SEQ (DR-14). B always advances, via createItemTxWithID. A advances ONLY on +// ArchiveSource — and a plain copy must not advance it at all, so A's cursor +// stays put and A's watchers see nothing. The archive reproduces DeleteItem's +// acquireWorkspaceSeqLock + nextWorkspaceSeqSubquery deliberately rather than +// calling it, because DeleteItem opens its own transaction. +// +// WHAT DOES NOT CARRY (DR-17). The copy is unparented — ParentID is scrubbed — +// and it has no item_links, no children, no comments, no versions beyond its +// own initial one, no stars and no grants. Tags DO carry: items.tags is a +// plain JSON array on the row with no workspace-scoped entity behind it. +// AgentRoleID always clears (role slugs are workspace-local); AssignedUserID +// carries only when the assignee is a member of the destination (DR-8). +func (s *Store) CopyItemAcrossWorkspaces(req CrossWorkspaceCopyRequest) (*CrossWorkspaceCopyResult, error) { + if req.SourceItemID == "" { + return nil, fmt.Errorf("copy item across workspaces: source_item_id is required") + } + if req.TargetWorkspaceID == "" { + return nil, fmt.Errorf("copy item across workspaces: target_workspace_id is required") + } + if req.TargetCollectionID == "" { + return nil, fmt.Errorf("copy item across workspaces: target_collection_id is required") + } + if req.Actor == "" { + return nil, fmt.Errorf("copy item across workspaces: actor is required") + } + + // Derive workspace A before opening the transaction. This read is used for + // NOTHING but the lock keys — every value the copy actually consumes is + // re-read under the locks below. An item cannot change workspace (no code + // path writes items.workspace_id), so a stale answer here is impossible in + // a way the in-tx re-read would not catch anyway. + sourceWorkspaceID, err := s.itemWorkspaceID(req.SourceItemID) + if err != nil { + return nil, err + } + + result, err := s.copyItemAcrossWorkspacesTx(req, sourceWorkspaceID) + if err != nil { + // DR-9's observability obligation: the lock ordering is the one failure + // mode nothing else surfaces, so an UNEXPECTED rollback — a deadlock + // especially — is logged with both workspaces and the item. + // + // Expected, caller-facing REJECTIONS are excluded, and deliberately so. + // A validation failure, a quota rejection, a missing source and a + // cross-backend refusal are all 4xx answers the caller renders; they are + // not incidents, and logging them here would (a) make the signal this + // log exists for unfindable under routine bad requests, and (b) copy + // user-controlled field values into the operator log, since + // ValidateFields quotes the offending value verbatim. The quota + // rejection gets its own line, with bounded fields and no user content, + // at the point it is decided. + if isExpectedCopyRejection(err) { + return nil, err + } + deadlock := isDeadlockError(err) + attrs := []any{ + "source_workspace_id", sourceWorkspaceID, + "target_workspace_id", req.TargetWorkspaceID, + "source_item_id", req.SourceItemID, + "archive_source", req.ArchiveSource, + "deadlock", deadlock, + "error", err, + } + // A deadlock is the one failure DR-9's lock ordering is meant to make + // impossible, so it is an ERROR: if the ordering is subtly wrong in + // production, nothing else surfaces it. + if deadlock { + slog.Error("cross-workspace item copy rolled back", attrs...) + } else { + slog.Warn("cross-workspace item copy rolled back", attrs...) + } + return nil, err + } + return result, nil +} + +// itemWorkspaceID reads an item's workspace, including soft-deleted rows so a +// copy of an already-archived source fails with a clear "not found" from the +// in-tx re-read rather than a confusing nil here. +func (s *Store) itemWorkspaceID(itemID string) (string, error) { + var workspaceID string + err := s.db.QueryRow(s.q(`SELECT workspace_id FROM items WHERE id = ?`), itemID).Scan(&workspaceID) + if err == sql.ErrNoRows { + return "", sql.ErrNoRows + } + if err != nil { + return "", fmt.Errorf("copy item across workspaces: resolve source workspace: %w", err) + } + return workspaceID, nil +} + +func (s *Store) copyItemAcrossWorkspacesTx(req CrossWorkspaceCopyRequest, sourceWorkspaceID string) (*CrossWorkspaceCopyResult, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, fmt.Errorf("copy item across workspaces: %w", err) + } + defer tx.Rollback() //nolint:errcheck // rollback after commit is a no-op + + // --- Step 1: both workspace advisory locks, ordered by lock KEY. --- + if _, err := s.acquireWorkspaceLocksOrdered(tx, sourceWorkspaceID, req.TargetWorkspaceID); err != nil { + return nil, err + } + + // --- Step 2: both collection rows, FOR UPDATE, ordered by collection ID. + // Read the source item's collection first — we need its ID to lock it, and + // the workspace lock is already held so nothing can move the item now. + var sourceCollectionID string + if err := tx.QueryRow(s.q(`SELECT collection_id FROM items WHERE id = ?`), req.SourceItemID).Scan(&sourceCollectionID); err != nil { + if err == sql.ErrNoRows { + return nil, sql.ErrNoRows + } + return nil, fmt.Errorf("copy item across workspaces: read source collection id: %w", err) + } + if err := s.lockCollectionRows(tx, sourceCollectionID, req.TargetCollectionID); err != nil { + return nil, err + } + + sourceColl, err := s.getCollectionInWorkspaceTx(tx, sourceCollectionID, sourceWorkspaceID) + if err != nil { + return nil, err + } + if sourceColl == nil { + return nil, fmt.Errorf("copy item across workspaces: source collection not found") + } + targetColl, err := s.getCollectionInWorkspaceTx(tx, req.TargetCollectionID, req.TargetWorkspaceID) + if err != nil { + return nil, err + } + if targetColl == nil { + return nil, fmt.Errorf("copy item across workspaces: target collection not found") + } + + // --- Step 3: re-read the source under lock. Copy THIS snapshot. --- + // Never the pre-transaction read: a concurrent edit or archive would + // otherwise race the copy and workspace B would get a torn — or + // already-archived — version. MoveItemWithPreCheck establishes the shape. + source, err := s.getItemTx(tx, req.SourceItemID) + if err != nil { + return nil, err + } + if source == nil { + // Deleted (or archived) between the pre-transaction read and the lock. + return nil, sql.ErrNoRows + } + + // --- Quota (DR-16), inside the transaction, before any insert. --- + if req.EnforceItemLimit { + limit, err := s.CheckLimitTx(tx, req.TargetWorkspaceID, "items_per_workspace") + if err != nil { + return nil, fmt.Errorf("copy item across workspaces: check item limit: %w", err) + } + if !limit.Allowed { + slog.Warn("cross-workspace item copy rejected by item quota", + "target_workspace_id", req.TargetWorkspaceID, + "source_item_id", req.SourceItemID, + "current", limit.Current, + "limit", limit.Limit, + "plan", limit.Plan) + return nil, &ItemLimitError{Result: limit} + } + } + + // --- Fields: migrate -> override -> validate (DR-12). --- + finalFields, dropped, err := migrateCopyFields(source.Fields, sourceColl.Schema, targetColl.Schema, req.FieldOverrides) + if err != nil { + return nil, err + } + + // --- DR-8 / DR-17 scrubs against the DESTINATION workspace. --- + assignedUserID, droppedAssignee, err := s.carryAssigneeTx(tx, req.TargetWorkspaceID, source.AssignedUserID) + if err != nil { + return nil, err + } + droppedAgentRole := source.AgentRoleID != nil && *source.AgentRoleID != "" + + // --- Attachments: plan INSIDE the transaction (staleness contract). --- + // The plan is a snapshot valid only in the critical section that produced + // it; caching one across the lock would let a soft-delete, an orphan-GC + // reclaim or a revoked membership slip between planning and inserting. + // + // The planner reads through s.db rather than this tx, and holds no lock on + // the attachment rows — its documented, deliberate shape (TASK-2354), so + // that the dry-run and the copy share ONE implementation and cannot drift. + // The residual window that leaves is bounded and harmless, and it is worth + // writing down why rather than re-litigating it: + // + // - On SQLite there is no window at all. BEGIN IMMEDIATE means this + // transaction holds the database's write lock, so a concurrent + // SoftDeleteAttachment / HardDeleteAttachment simply blocks until the + // copy commits. + // - On Postgres a concurrent soft-delete CAN land between planning and + // inserting. Routing the planner's reads through this tx would not + // change that: READ COMMITTED takes a fresh snapshot per statement, so + // the same committed delete would be just as visible. Only making every + // attachment writer take the workspace advisory lock would close it, + // which means putting a lock on the upload hot path for this. + // - And the outcome of losing that race is benign. Soft-delete never + // removes bytes, and the clone this transaction commits carries the + // same content_hash — so it is itself a protecting row for + // CountProtectingAttachmentsForHash, which is workspace-agnostic. The + // orphan GC therefore cannot reclaim the blob out from under the copy. + // What workspace B gets is a copy of something the user deleted in A a + // moment after asking for the copy, which is defensible on its own. + // + // The destination item id is minted HERE, before the item row exists, + // because every clone must carry item_id from the outset — a + // NULL-item_id row that the copied body then references is a permanent, + // un-reclaimable orphan (see AttachmentCopyRequest.DryRun). + targetItemID := newID() + plan, err := s.PlanAttachmentCopy(AttachmentCopyRequest{ + SourceWorkspaceID: sourceWorkspaceID, + TargetWorkspaceID: req.TargetWorkspaceID, + TargetItemID: targetItemID, + UploadedBy: req.Actor, + Content: source.Content, + Fields: finalFields, + TargetBackend: req.TargetBackend, + }) + if err != nil { + return nil, err + } + if plan.CrossBackend { + return nil, ErrCopyCrossBackendAttachments + } + if len(plan.UnresolvableRefs) > 0 { + // DR-11a observability: a spike means either a data-integrity problem + // or someone probing the confused-deputy path. Never fatal. + slog.Info("cross-workspace item copy has unresolvable attachment refs", + "source_workspace_id", sourceWorkspaceID, + "target_workspace_id", req.TargetWorkspaceID, + "source_item_id", req.SourceItemID, + "unresolvable_refs", len(plan.UnresolvableRefs)) + } + + // --- Rewrite content AND fields with the plan's IDMap. --- + // Both go through remapAttachmentRefs over the exact representations the + // planner enumerated from (raw content; the fields' JSON encoding), so the + // rewrite covers precisely the reference set the plan cloned. Any other + // route risks covering a different set. + newContent := remapAttachmentRefs(source.Content, plan.IDMap) + finalFieldsJSON, err := json.Marshal(finalFields) + if err != nil { + return nil, fmt.Errorf("copy item across workspaces: encode destination fields: %w", err) + } + newFieldsJSON := remapAttachmentRefs(string(finalFieldsJSON), plan.IDMap) + + // --- Create in B. Advances B's seq; writes the initial version row and + // the wiki-link index against the POST-rewrite content (DR-9a). --- + item, err := s.createItemTxWithID(tx, targetItemID, req.TargetWorkspaceID, req.TargetCollectionID, models.ItemCreate{ + Title: source.Title, + Content: newContent, + Fields: newFieldsJSON, + Tags: source.Tags, + // ParentID stays nil (DR-17): the source's parent lives in A, and + // DR-4 rules out dragging relatives along. + ParentID: nil, + AssignedUserID: assignedUserID, + AgentRoleID: nil, + CreatedBy: req.CreatedBy, + Source: req.Source, + }) + if err != nil { + return nil, err + } + if err := req.injectedStageFailure(copyStageCreateItem); err != nil { + return nil, err + } + + // --- Attachment rows, in the planner's order (originals before their + // variants). attachments has no parent_id foreign key, so this ordering is + // a caller contract the database will not enforce. --- + for i := range plan.Rows { + row := plan.Rows[i].Attachment + if err := s.CreateAttachmentTx(tx, &row); err != nil { + return nil, fmt.Errorf("copy item across workspaces: clone attachment %s: %w", plan.Rows[i].SourceID, err) + } + } + if err := req.injectedStageFailure(copyStageAttachments); err != nil { + return nil, err + } + + // --- Archive the source (move only), advancing A's seq (DR-14). --- + var sourceSeq *int64 + archiveTS := now() + if req.ArchiveSource { + seq, err := s.archiveItemForCopyTx(tx, sourceWorkspaceID, req.SourceItemID, archiveTS) + if err != nil { + return nil, err + } + sourceSeq = &seq + } + if err := req.injectedStageFailure(copyStageArchive); err != nil { + return nil, err + } + + // --- Provenance. Written last so it can carry the archive's seq, and in + // the same transaction so a rollback can never leave a pointer at an item + // that does not exist. --- + move, err := s.RecordItemWorkspaceMoveTx(tx, models.ItemWorkspaceMove{ + SourceWorkspaceID: sourceWorkspaceID, + SourceItemID: req.SourceItemID, + TargetWorkspaceID: req.TargetWorkspaceID, + TargetItemID: item.ID, + ArchivedSource: req.ArchiveSource, + SourceSeq: sourceSeq, + CreatedBy: req.Actor, + CreatedAt: archiveTS, + }) + if err != nil { + return nil, err + } + if err := req.injectedStageFailure(copyStageProvenance); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("copy item across workspaces: commit: %w", err) + } + + return &CrossWorkspaceCopyResult{ + Item: item, + Source: source, + SourceWorkspaceID: sourceWorkspaceID, + Move: move, + SourceSeq: sourceSeq, + AttachmentsCopied: len(plan.Rows), + BytesCopied: plan.TotalBytes, + UnresolvableRefs: plan.UnresolvableRefs, + DroppedFields: dropped, + DroppedAssignee: droppedAssignee, + DroppedAgentRole: droppedAgentRole, + }, nil +} + +// acquireWorkspaceLocksOrdered takes the workspace advisory locks for every +// supplied workspace, in ascending lock-KEY order, with duplicates collapsed. +// Returns the ordered key set actually locked (nil on SQLite). +// +// Sorting by lock key rather than by workspace ID is the entire point. Postgres +// locks hashtext(workspace_id) — the same key acquireWorkspaceSeqLock uses, so +// these acquisitions are re-entrant with the one createItemTxWithID takes later +// — and hashtext does not preserve string order. An A->B copy and a B->A copy +// sorting by ID would therefore be free to grab the two locks in opposite +// order and deadlock, which is the one failure DR-9 exists to prevent. +// +// Deduplicating is not cosmetic either: hashtext is a 32-bit hash, so two +// distinct workspaces CAN collide onto one key. When they do there is one lock +// to take, not two. (Taking it twice would in fact be harmless — advisory xact +// locks are re-entrant — but the returned key set is what tests assert on, and +// "how many distinct locks does this transaction hold" should be answerable.) +// +// SQLite is a no-op: BEGIN IMMEDIATE already serializes every writer, so there +// is no interleaving for an ordering to protect. +func (s *Store) acquireWorkspaceLocksOrdered(tx *sql.Tx, workspaceIDs ...string) ([]int64, error) { + if s.dialect.Driver() != DriverPostgres { + return nil, nil + } + + keys := make([]int64, 0, len(workspaceIDs)) + for _, id := range workspaceIDs { + if id == "" { + continue + } + var key int64 + // hashtext returns int4; the ::bigint cast makes the value we sort and + // lock on identical to the one pg_advisory_xact_lock(bigint) resolves + // to when acquireWorkspaceSeqLock passes hashtext($1) directly. + if err := tx.QueryRow("SELECT hashtext($1)::bigint", id).Scan(&key); err != nil { + return nil, fmt.Errorf("compute workspace lock key for %q: %w", id, err) + } + keys = append(keys, key) + } + + ordered := sortedDedupedLockKeys(keys) + for _, key := range ordered { + if _, err := tx.Exec("SELECT pg_advisory_xact_lock($1)", key); err != nil { + return nil, fmt.Errorf("acquire workspace lock %d: %w", key, err) + } + } + return ordered, nil +} + +// sortedDedupedLockKeys returns keys in ascending order with duplicates +// collapsed. Split out from the acquisition so the ordering contract is +// testable without a database. +func sortedDedupedLockKeys(keys []int64) []int64 { + if len(keys) == 0 { + return nil + } + sorted := append([]int64(nil), keys...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + out := sorted[:1] + for _, k := range sorted[1:] { + if k != out[len(out)-1] { + out = append(out, k) + } + } + return out +} + +// lockCollectionRows pins the source and destination collection rows FOR +// UPDATE, in ascending collection-ID order with duplicates collapsed. +// +// BOTH rows, because MigrateFields consumes both schemas: pinning only the +// destination leaves half the migration input free to change under the +// transaction, so a dry-run and the commit that follows it can disagree about +// which fields carry. +// +// FOR UPDATE rather than a plain read, because a schema-only collection update +// does not necessarily take the workspace advisory lock — reading after the +// advisory locks would still leave a window to reshape the schema before this +// transaction commits. +// +// Sorted for the same reason the workspace locks are: two copies whose +// collection sets overlap must take the overlap in one order. SQLite is a +// no-op, and FOR UPDATE is a SYNTAX ERROR there — the dialect gate is not an +// optimization. +func (s *Store) lockCollectionRows(tx *sql.Tx, collectionIDs ...string) error { + if s.dialect.Driver() != DriverPostgres { + return nil + } + seen := make(map[string]struct{}, len(collectionIDs)) + ids := make([]string, 0, len(collectionIDs)) + for _, id := range collectionIDs { + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + var got string + err := tx.QueryRow(s.q(`SELECT id FROM collections WHERE id = ? FOR UPDATE`), id).Scan(&got) + if err == sql.ErrNoRows { + // Not an error here — the scoped re-read below turns a missing or + // soft-deleted collection into the caller-facing "not found". + continue + } + if err != nil { + return fmt.Errorf("lock collection %q: %w", id, err) + } + } + return nil +} + +// getCollectionInWorkspaceTx reads a live collection inside the transaction, +// scoped to a workspace. The scope is the security boundary, not a hint: it is +// what makes "target collection in another workspace" a not-found instead of a +// cross-workspace write. Returns (nil, nil) when there is no such row. +// +// The IN-TX read is authoritative. A dry-run's schema snapshot is advisory — +// it was taken without the FOR UPDATE pin above, so it can be stale by the +// time the copy commits. +func (s *Store) getCollectionInWorkspaceTx(tx *sql.Tx, collectionID, workspaceID string) (*models.Collection, error) { + var c models.Collection + var createdAt, updatedAt string + var deletedAt *string + var isDefault bool + + err := tx.QueryRow(s.q(` + SELECT id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, is_system, created_at, updated_at, deleted_at + FROM collections + WHERE id = ? AND workspace_id = ? AND deleted_at IS NULL + `), collectionID, workspaceID).Scan( + &c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description, + &c.Schema, &c.Settings, &c.SortOrder, &isDefault, &c.IsSystem, + &createdAt, &updatedAt, &deletedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("copy item across workspaces: read collection: %w", err) + } + c.IsDefault = isDefault + c.CreatedAt = parseTime(createdAt) + c.UpdatedAt = parseTime(updatedAt) + c.DeletedAt = parseTimePtr(deletedAt) + return &c, nil +} + +// migrateCopyFields runs the DR-12 field pipeline: migrate the source fields +// into the destination schema, merge the caller's overrides, then validate. +// +// The ORDER is the decision. MigrateFields computes result.Errors before any +// override exists, so testing those errors after merging overrides in — which +// is what the existing single-workspace move path does — reports required +// fields an override has already satisfied, and never type-checks the override +// itself. ValidateFields is re-run over the merged map instead: it enforces +// required presence, applies schema defaults, and validates types and options. +// +// Returns the final field map (the planner's input, pre-rewrite) and the keys +// migration dropped. +func migrateCopyFields(sourceFieldsJSON, sourceSchemaJSON, targetSchemaJSON string, overrides map[string]any) (map[string]any, []string, error) { + var sourceSchema, targetSchema models.CollectionSchema + if err := json.Unmarshal([]byte(sourceSchemaJSON), &sourceSchema); err != nil { + return nil, nil, fmt.Errorf("copy item across workspaces: parse source schema: %w", err) + } + if err := json.Unmarshal([]byte(targetSchemaJSON), &targetSchema); err != nil { + return nil, nil, fmt.Errorf("copy item across workspaces: parse target schema: %w", err) + } + + currentFields := map[string]any{} + if strings.TrimSpace(sourceFieldsJSON) != "" { + if err := json.Unmarshal([]byte(sourceFieldsJSON), ¤tFields); err != nil { + // A source row with unparseable fields migrates as if it had none, + // matching handleMoveItem's tolerance. Refusing would strand the + // item in A with no way out. + currentFields = map[string]any{} + } + } + + migrated := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields) + for k, v := range overrides { + migrated.Fields[k] = v + } + if err := items.ValidateFields(migrated.Fields, targetSchema); err != nil { + return nil, nil, &FieldValidationError{Err: err} + } + return migrated.Fields, migrated.Dropped, nil +} + +// carryAssigneeTx implements DR-8's assignee rule: the source's assignee +// carries only when that user is a member of the DESTINATION workspace, and +// otherwise clears. Returns the value to write and whether it was dropped. +// +// createItemTxWithID would reject a non-member outright +// (validateAssignmentScopeQ), so this is not belt-and-braces — it is the +// difference between a copy that quietly drops an assignment and a copy that +// refuses because someone left workspace B. +func (s *Store) carryAssigneeTx(tx *sql.Tx, targetWorkspaceID string, sourceAssignee *string) (*string, bool, error) { + if sourceAssignee == nil || *sourceAssignee == "" { + return nil, false, nil + } + var count int + if err := tx.QueryRow( + s.q("SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ? AND user_id = ?"), + targetWorkspaceID, *sourceAssignee, + ).Scan(&count); err != nil { + return nil, false, fmt.Errorf("copy item across workspaces: check destination membership: %w", err) + } + if count == 0 { + return nil, true, nil + } + carried := *sourceAssignee + return &carried, false, nil +} + +// archiveItemForCopyTx soft-deletes the source inside the copy's transaction +// and returns the workspace-A seq the archive assigned. +// +// This REPRODUCES DeleteItem rather than calling it: DeleteItem opens and +// commits its own transaction, so calling it would put the archive outside the +// copy's atomic boundary — a crash between the two would strand a live source +// alongside a committed duplicate. The pieces that matter are the seq bump +// (nextWorkspaceSeqSubquery under the workspace advisory lock) and the +// `deleted_at IS NULL` guard that makes a concurrent archive a no-op rather +// than a second tombstone. +// +// The lock re-acquisition is a no-op — acquireWorkspaceLocksOrdered already +// holds workspace A's key and advisory xact locks are re-entrant — but taking +// it explicitly keeps this function correct on its own terms rather than by +// the grace of its only caller. +// +// The assigned seq is read back inside the transaction, under the still-held +// lock, so the value handed to the provenance row is exactly the one A's +// delta-sync clients will see on the tombstone. +func (s *Store) archiveItemForCopyTx(tx *sql.Tx, workspaceID, itemID, ts string) (int64, error) { + if err := s.acquireWorkspaceSeqLock(tx, workspaceID); err != nil { + return 0, err + } + res, err := tx.Exec(s.q(` + UPDATE items SET deleted_at = ?, updated_at = ?, seq = `+nextWorkspaceSeqSubquery+` + WHERE id = ? AND deleted_at IS NULL + `), ts, ts, workspaceID, itemID) + if err != nil { + return 0, fmt.Errorf("copy item across workspaces: archive source: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("copy item across workspaces: archive source: %w", err) + } + if affected == 0 { + // The source was re-read live under the lock moments ago, so this + // cannot happen without the lock protocol being broken. Fail loudly + // rather than record a provenance row claiming a move that did not + // happen. + return 0, fmt.Errorf("copy item across workspaces: source item %s was not archived", itemID) + } + var seq int64 + if err := tx.QueryRow(s.q(`SELECT seq FROM items WHERE id = ?`), itemID).Scan(&seq); err != nil { + return 0, fmt.Errorf("copy item across workspaces: read archive seq: %w", err) + } + return seq, nil +} + +// isExpectedCopyRejection reports whether err is a refusal the CALLER is meant +// to render — a 4xx — rather than an incident an operator should see. +// +// Kept as one predicate so the set is stated in a single place: field +// validation (DR-12), the item quota (DR-16, which logs its own bounded line), +// a source that is missing or already archived, and the v1 cross-backend +// attachment refusal. Everything else — a DB error, a constraint violation, a +// deadlock — is unexpected and gets logged. +func isExpectedCopyRejection(err error) bool { + var validation *FieldValidationError + var limit *ItemLimitError + return errors.As(err, &validation) || + errors.As(err, &limit) || + errors.Is(err, sql.ErrNoRows) || + errors.Is(err, ErrCopyCrossBackendAttachments) +} + +// isDeadlockError reports whether err is Postgres' serialization deadlock +// (SQLSTATE 40P01) or SQLite's equivalent lock timeout. String matching rather +// than a driver type assertion because internal/store is driver-agnostic and +// both drivers are behind database/sql here; the strings are stable parts of +// each engine's user-facing error text. +func isDeadlockError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "deadlock detected") || + strings.Contains(msg, "40p01") || + strings.Contains(msg, "database is locked") +} diff --git a/internal/store/items_cross_workspace_copy_pg_test.go b/internal/store/items_cross_workspace_copy_pg_test.go new file mode 100644 index 00000000..dd6dc93a --- /dev/null +++ b/internal/store/items_cross_workspace_copy_pg_test.go @@ -0,0 +1,348 @@ +package store + +import ( + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Postgres-only tests for CopyItemAcrossWorkspaces — PLAN-2357 / TASK-2363. +// +// These are the tests DR-9 says the lock ordering needs in order to be a +// decision rather than a comment. On SQLite they cannot fail: BEGIN IMMEDIATE +// serializes every writer, so two opposing copies never interleave and there +// is no advisory lock to order. `make test-pg` is where they run. + +// TestCopyItemAcrossWorkspaces_OpposingDirectionsDoNotDeadlock drives A->B and +// B->A copies simultaneously, repeatedly. +// +// This is the test that FAILS without the sorted lock acquisition. With an +// unordered (or ID-sorted, which does not order the hashes) acquisition, one +// goroutine holds hashtext(A) and waits on hashtext(B) while the other holds +// hashtext(B) and waits on hashtext(A); Postgres detects the cycle and aborts +// one transaction with SQLSTATE 40P01. With the keys sorted, both transactions +// request the same key first, so the cycle cannot form. +func TestCopyItemAcrossWorkspaces_OpposingDirectionsDoNotDeadlock(t *testing.T) { + requirePostgresForConcurrency(t) + + s := testStore(t) + wsA := createTestWorkspace(t, s, "Deadlock A") + wsB := createTestWorkspace(t, s, "Deadlock B") + colA := createTestCollection(t, s, wsA.ID, "Tasks A") + colB := createTestCollection(t, s, wsB.ID, "Tasks B") + + const rounds = 40 + sourcesA := make([]*models.Item, rounds) + sourcesB := make([]*models.Item, rounds) + for i := 0; i < rounds; i++ { + sourcesA[i] = createTestItem(t, s, wsA.ID, colA.ID, fmt.Sprintf("A-%d", i), "body") + sourcesB[i] = createTestItem(t, s, wsB.ID, colB.ID, fmt.Sprintf("B-%d", i), "body") + } + + var wg sync.WaitGroup + errs := make(chan error, rounds*2) + start := make(chan struct{}) + + run := func(src *models.Item, targetWS, targetCol string) { + defer wg.Done() + <-start + _, err := s.CopyItemAcrossWorkspaces(CrossWorkspaceCopyRequest{ + SourceItemID: src.ID, + TargetWorkspaceID: targetWS, + TargetCollectionID: targetCol, + Actor: "deadlock-actor", + }) + if err != nil { + errs <- err + } + } + + for i := 0; i < rounds; i++ { + wg.Add(2) + go run(sourcesA[i], wsB.ID, colB.ID) // A -> B + go run(sourcesB[i], wsA.ID, colA.ID) // B -> A + } + close(start) + wg.Wait() + close(errs) + + for err := range errs { + if isDeadlockError(err) { + t.Fatalf("opposing cross-workspace copies deadlocked: %v", err) + } + t.Fatalf("opposing cross-workspace copy failed: %v", err) + } + + // Every copy landed. A deadlock aborts a transaction, so a silent loss + // would show up here even if the error channel were somehow drained. + for _, ws := range []struct { + id string + want int + }{{wsA.ID, rounds * 2}, {wsB.ID, rounds * 2}} { + if got := countItemsIn(t, s, ws.id); got != ws.want { + t.Errorf("workspace %s has %d items, want %d", ws.id, got, ws.want) + } + } +} + +// TestCopyItemAcrossWorkspaces_OpposingMovesDoNotDeadlock is the sibling that +// fails when the outer workspace acquisition is REMOVED, rather than merely +// mis-ordered. +// +// Two things have to line up for that to be a real test, and getting either +// wrong makes it vacuous: +// +// - The copies must be MOVES. A plain copy writes in one workspace only, so +// its transaction naturally takes exactly one workspace advisory lock (the +// destination's, inside createItemTxWithID) and no AB/BA cycle can form +// without the outer acquisition. A move writes in both: the create takes +// B's key, the archive takes A's. +// - The two directions must use DISJOINT collection pairs. lockCollectionRows +// is a second, independent ordering guard — it takes both collection rows +// FOR UPDATE in sorted ID order, so two copies whose collection sets +// OVERLAP are already serialized by that alone and cannot deadlock however +// the workspace locks are taken. Verified empirically: with overlapping +// collections, deleting the outer acquisition does NOT deadlock. Give each +// direction its own source and destination collection and the collection +// locks stop overlapping, leaving the workspace keys as the only ordering. +// +// The two Postgres deadlock tests therefore prove different halves: this one +// proves the outer acquisition must EXIST, and the plain-copy one above proves +// it must be SORTED BY LOCK KEY (an unsorted acquisition deadlocks there even +// though every transaction takes both keys). +func TestCopyItemAcrossWorkspaces_OpposingMovesDoNotDeadlock(t *testing.T) { + requirePostgresForConcurrency(t) + + s := testStore(t) + wsA := createTestWorkspace(t, s, "Move Deadlock A") + wsB := createTestWorkspace(t, s, "Move Deadlock B") + // One collection pair per direction, so the FOR UPDATE collection locks of + // the two directions do not overlap. + colAOut := createTestCollection(t, s, wsA.ID, "A Outbound") + colBIn := createTestCollection(t, s, wsB.ID, "B Inbound") + colBOut := createTestCollection(t, s, wsB.ID, "B Outbound") + colAIn := createTestCollection(t, s, wsA.ID, "A Inbound") + + const rounds = 40 + sourcesA := make([]*models.Item, rounds) + sourcesB := make([]*models.Item, rounds) + for i := 0; i < rounds; i++ { + sourcesA[i] = createTestItem(t, s, wsA.ID, colAOut.ID, fmt.Sprintf("mA-%d", i), "body") + sourcesB[i] = createTestItem(t, s, wsB.ID, colBOut.ID, fmt.Sprintf("mB-%d", i), "body") + } + + var wg sync.WaitGroup + errs := make(chan error, rounds*2) + start := make(chan struct{}) + + run := func(src *models.Item, targetWS, targetCol string) { + defer wg.Done() + <-start + _, err := s.CopyItemAcrossWorkspaces(CrossWorkspaceCopyRequest{ + SourceItemID: src.ID, + TargetWorkspaceID: targetWS, + TargetCollectionID: targetCol, + Actor: "move-actor", + ArchiveSource: true, + }) + if err != nil { + errs <- err + } + } + + for i := 0; i < rounds; i++ { + wg.Add(2) + go run(sourcesA[i], wsB.ID, colBIn.ID) // A -> B, archiving in A + go run(sourcesB[i], wsA.ID, colAIn.ID) // B -> A, archiving in B + } + close(start) + wg.Wait() + close(errs) + + for err := range errs { + if isDeadlockError(err) { + t.Fatalf("opposing cross-workspace MOVES deadlocked: %v", err) + } + t.Fatalf("opposing cross-workspace move failed: %v", err) + } + + // Every move landed: each workspace archived its own `rounds` sources and + // received `rounds` arrivals, so the live count is unchanged. + for _, id := range []string{wsA.ID, wsB.ID} { + if got := countItemsIn(t, s, id); got != rounds { + t.Errorf("workspace %s has %d live items, want %d", id, got, rounds) + } + } +} + +// TestCopyItemAcrossWorkspaces_ConcurrentCopiesCannotJointlyExceedQuota is the +// DR-16 in-tx claim: two copies each individually under the cap but jointly +// over it must not both commit. +// +// What actually makes it hold is the DESTINATION WORKSPACE LOCK, taken before +// the check: the second copy's transaction blocks on it until the first +// commits, so its COUNT observes the committed row. Stated plainly because it +// bounds what this test proves — a pool-based CheckLimit would pass it too, +// under that lock. CheckLimitTx is the belt-and-braces half (it additionally +// sees the transaction's OWN uncommitted inserts, which matters the moment +// this operation ever creates more than one item), and +// TestCheckLimitTx_SeesUncommittedRowsInTheTransaction is what proves that +// property directly. +// +// The failure this test genuinely catches is the check moving OUTSIDE the +// transaction, or before the destination lock is acquired — either of which +// lets both copies read the same pre-copy count and both commit. +func TestCopyItemAcrossWorkspaces_ConcurrentCopiesCannotJointlyExceedQuota(t *testing.T) { + requirePostgresForConcurrency(t) + + s := testStore(t) + owner := createTestUser(t, s, "joint-quota@example.com", "Owner", "s3cret") + if err := s.SetUserPlan(owner.ID, "free", ""); err != nil { + t.Fatalf("SetUserPlan: %v", err) + } + if err := s.SetUserPlanOverrides(owner.ID, `{"items_per_workspace": 1}`); err != nil { + t.Fatalf("SetUserPlanOverrides: %v", err) + } + wsA, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Joint Source", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("CreateWorkspace(A): %v", err) + } + wsB, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Joint Dest", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("CreateWorkspace(B): %v", err) + } + colA := createTestCollection(t, s, wsA.ID, "Tasks A") + colB := createTestCollection(t, s, wsB.ID, "Tasks B") + + src1 := createTestItem(t, s, wsA.ID, colA.ID, "One", "body") + src2 := createTestItem(t, s, wsA.ID, colA.ID, "Two", "body") + + var wg sync.WaitGroup + results := make(chan error, 2) + start := make(chan struct{}) + for _, src := range []*models.Item{src1, src2} { + wg.Add(1) + go func(src *models.Item) { + defer wg.Done() + <-start + _, err := s.CopyItemAcrossWorkspaces(CrossWorkspaceCopyRequest{ + SourceItemID: src.ID, + TargetWorkspaceID: wsB.ID, + TargetCollectionID: colB.ID, + Actor: owner.ID, + EnforceItemLimit: true, + }) + results <- err + }(src) + } + close(start) + wg.Wait() + close(results) + + var ok, rejected int + for err := range results { + switch { + case err == nil: + ok++ + default: + var limitErr *ItemLimitError + if !errors.As(err, &limitErr) { + t.Fatalf("unexpected error: %v", err) + } + rejected++ + } + } + if ok != 1 || rejected != 1 { + t.Fatalf("got %d successes and %d quota rejections, want exactly 1 of each", ok, rejected) + } + if got := countItemsIn(t, s, wsB.ID); got != 1 { + t.Errorf("destination has %d items, want 1 (the cap)", got) + } +} + +// TestAcquireWorkspaceLocksOrdered_CollidingKeysTakeOneLock is the dedup half +// of the DR-9 contract, end to end. +// +// hashtext is a 32-bit hash, so two distinct workspace IDs CAN collide onto +// one lock key. The test finds a real colliding pair by brute force in the +// database (a birthday search over ~300k candidates; hashtext's own output is +// the only source of truth for what collides), creates two workspaces with +// those IDs, and asserts that acquiring both workspaces' locks yields ONE key. +func TestAcquireWorkspaceLocksOrdered_CollidingKeysTakeOneLock(t *testing.T) { + requirePostgresForConcurrency(t) + + s := testStore(t) + idA, idB := findHashtextCollision(t, s) + + // Two real workspaces carrying the colliding IDs. CreateWorkspace mints + // its own id, so these are inserted directly. + for i, id := range []string{idA, idB} { + if _, err := s.db.Exec(s.q(` + INSERT INTO workspaces (id, name, slug, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `), id, fmt.Sprintf("Collide %d", i), fmt.Sprintf("collide-%d-%s", i, newID()[:8]), now(), now()); err != nil { + t.Fatalf("insert colliding workspace: %v", err) + } + } + + tx, err := s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck // test cleanup + + keys, err := s.acquireWorkspaceLocksOrdered(tx, idA, idB) + if err != nil { + t.Fatalf("acquireWorkspaceLocksOrdered: %v", err) + } + if len(keys) != 1 { + t.Fatalf("colliding workspaces produced %d lock keys (%v), want 1", len(keys), keys) + } + + // Sanity: a NON-colliding pair still yields two. + other := createTestWorkspace(t, s, "Distinct") + keys2, err := s.acquireWorkspaceLocksOrdered(tx, idA, other.ID) + if err != nil { + t.Fatalf("acquireWorkspaceLocksOrdered (distinct): %v", err) + } + if len(keys2) != 2 { + t.Fatalf("distinct workspaces produced %d lock keys, want 2", len(keys2)) + } + if keys2[0] >= keys2[1] { + t.Errorf("lock keys %v are not in ascending order", keys2) + } +} + +// findHashtextCollision brute-forces two distinct UUID-shaped strings that +// hashtext maps to the same 32-bit value. Expected collisions over 300k +// candidates is ~10, so a miss is vanishingly unlikely; the test skips rather +// than fails if the search comes up empty, since an empty search proves +// nothing about the code under test. +func findHashtextCollision(t *testing.T, s *Store) (string, string) { + t.Helper() + var a, b string + err := s.db.QueryRow(` + WITH candidates AS ( + SELECT gen_random_uuid()::text AS id FROM generate_series(1, 300000) + ) + SELECT MIN(id), MAX(id) + FROM candidates + GROUP BY hashtext(id) + HAVING COUNT(*) > 1 AND MIN(id) <> MAX(id) + LIMIT 1 + `).Scan(&a, &b) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + t.Skip("no hashtext collision found in 300k candidates (expected ~10; retry)") + } + t.Fatalf("hashtext collision search: %v", err) + } + if a == b { + t.Skip("degenerate collision pair") + } + return a, b +} diff --git a/internal/store/items_cross_workspace_copy_test.go b/internal/store/items_cross_workspace_copy_test.go new file mode 100644 index 00000000..d8e56484 --- /dev/null +++ b/internal/store/items_cross_workspace_copy_test.go @@ -0,0 +1,1002 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Tests for CopyItemAcrossWorkspaces — PLAN-2357 / TASK-2363. +// +// The concurrency and lock-ordering tests live at the bottom and are +// Postgres-only by construction (see requirePostgresForConcurrency). + +// copyFixture is workspace A (source), workspace B (destination), and a third +// workspace C used for the confused-deputy negative cases. +type copyFixture struct { + s *Store + wsA *models.Workspace + wsB *models.Workspace + wsC *models.Workspace + colA *models.Collection + colB *models.Collection + colC *models.Collection + actor string +} + +func newCopyFixture(t *testing.T) copyFixture { + t.Helper() + s := testStore(t) + f := copyFixture{s: s, actor: "actor-user"} + f.wsA = createTestWorkspace(t, s, "Copy Source") + f.wsB = createTestWorkspace(t, s, "Copy Dest") + f.wsC = createTestWorkspace(t, s, "Copy Third Party") + f.colA = createTestCollection(t, s, f.wsA.ID, "Tasks A") + f.colB = createTestCollection(t, s, f.wsB.ID, "Tasks B") + f.colC = createTestCollection(t, s, f.wsC.ID, "Tasks C") + return f +} + +func (f copyFixture) req() CrossWorkspaceCopyRequest { + return CrossWorkspaceCopyRequest{ + TargetWorkspaceID: f.wsB.ID, + TargetCollectionID: f.colB.ID, + Actor: f.actor, + } +} + +func (f copyFixture) copy(t *testing.T, req CrossWorkspaceCopyRequest) *CrossWorkspaceCopyResult { + t.Helper() + res, err := f.s.CopyItemAcrossWorkspaces(req) + if err != nil { + t.Fatalf("CopyItemAcrossWorkspaces: %v", err) + } + return res +} + +// attachIn creates an original attachment in the given workspace. +func (f copyFixture) attachIn(t *testing.T, workspaceID, filename string, size int64) *models.Attachment { + t.Helper() + w, h := 640, 480 + a := &models.Attachment{ + WorkspaceID: workspaceID, + UploadedBy: "source-uploader", + StorageKey: "fs:" + newID(), + ContentHash: newID(), + MimeType: "image/png", + SizeBytes: size, + Filename: filename, + Width: &w, + Height: &h, + } + if err := f.s.CreateAttachment(a); err != nil { + t.Fatalf("CreateAttachment(%s): %v", filename, err) + } + return a +} + +func (f copyFixture) variantOf(t *testing.T, workspaceID string, parent *models.Attachment, kind string, size int64) *models.Attachment { + t.Helper() + pid, v := parent.ID, kind + a := &models.Attachment{ + WorkspaceID: workspaceID, + UploadedBy: "source-uploader", + StorageKey: "fs:" + newID(), + ContentHash: newID(), + MimeType: "image/webp", + SizeBytes: size, + Filename: kind + "-" + parent.Filename, + ParentID: &pid, + Variant: &v, + } + if err := f.s.CreateAttachment(a); err != nil { + t.Fatalf("CreateAttachment(variant): %v", err) + } + return a +} + +func countItemsIn(t *testing.T, s *Store, workspaceID string) int { + t.Helper() + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), workspaceID).Scan(&n); err != nil { + t.Fatalf("count items in %s: %v", workspaceID, err) + } + return n +} + +func countAttachmentsIn(t *testing.T, s *Store, workspaceID string) int { + t.Helper() + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM attachments WHERE workspace_id = ?`), workspaceID).Scan(&n); err != nil { + t.Fatalf("count attachments in %s: %v", workspaceID, err) + } + return n +} + +// attachmentsIn returns every attachment row in a workspace (including +// soft-deleted ones, of which the copy path creates none). +func attachmentsIn(t *testing.T, s *Store, workspaceID string) []models.Attachment { + t.Helper() + var out []models.Attachment + if err := s.scanAttachmentsInto( + `SELECT `+attachmentColumns+` FROM attachments WHERE workspace_id = ? ORDER BY created_at, id`, + []any{workspaceID}, + func(a models.Attachment) { out = append(out, a) }, + ); err != nil { + t.Fatalf("list attachments in %s: %v", workspaceID, err) + } + return out +} + +func countMoveRows(t *testing.T, s *Store) int { + t.Helper() + var n int + if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM item_workspace_moves`)).Scan(&n); err != nil { + t.Fatalf("count item_workspace_moves: %v", err) + } + return n +} + +func maxSeq(t *testing.T, s *Store, workspaceID string) int64 { + t.Helper() + seq, err := s.MaxItemSeq(workspaceID) + if err != nil { + t.Fatalf("MaxItemSeq(%s): %v", workspaceID, err) + } + return seq +} + +// --- Happy path ------------------------------------------------------------- + +func TestCopyItemAcrossWorkspaces_PlainCopyLandsInDestination(t *testing.T) { + f := newCopyFixture(t) + parent := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Parent", "") + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Ship the thing", + Content: "body with [[Parent]] link", + Fields: `{"status":"done"}`, + Tags: `["alpha","beta"]`, + ParentID: &parent.ID, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + res := f.copy(t, req) + + if res.Item.WorkspaceID != f.wsB.ID { + t.Errorf("copy landed in %s, want %s", res.Item.WorkspaceID, f.wsB.ID) + } + if res.Item.CollectionID != f.colB.ID { + t.Errorf("copy collection = %s, want %s", res.Item.CollectionID, f.colB.ID) + } + if res.Item.Title != src.Title { + t.Errorf("title = %q, want %q", res.Item.Title, src.Title) + } + if res.Item.Content != src.Content { + t.Errorf("content = %q, want %q", res.Item.Content, src.Content) + } + // DR-17: tags carry. Compared semantically because Postgres stores tags as + // jsonb and hands the array back re-serialized with spaces, while SQLite + // round-trips the literal TEXT. + var gotTags, wantTags []string + if err := json.Unmarshal([]byte(res.Item.Tags), &gotTags); err != nil { + t.Fatalf("decode copied tags %q: %v", res.Item.Tags, err) + } + if err := json.Unmarshal([]byte(src.Tags), &wantTags); err != nil { + t.Fatalf("decode source tags %q: %v", src.Tags, err) + } + if strings.Join(gotTags, ",") != strings.Join(wantTags, ",") { + t.Errorf("tags = %v, want the source's tags %v", gotTags, wantTags) + } + if len(gotTags) != 2 { + t.Errorf("tags = %v, want two entries", gotTags) + } + // DR-17: the copy is unparented. + if res.Item.ParentID != nil { + t.Errorf("copy has parent %v, want nil (DR-17 scrubs ParentID)", *res.Item.ParentID) + } + links, err := f.s.GetItemLinks(res.Item.ID) + if err != nil { + t.Fatalf("GetItemLinks: %v", err) + } + if len(links) != 0 { + t.Errorf("copy has %d links, want 0 (DR-17: item_links do not carry)", len(links)) + } + // The source is untouched. + stillThere, err := f.s.GetItem(src.ID) + if err != nil || stillThere == nil { + t.Fatalf("source item gone after a plain copy: %v", err) + } + if stillThere.Seq != src.Seq { + t.Errorf("plain copy bumped the source's seq: %d -> %d", src.Seq, stillThere.Seq) + } + // Provenance: a copy, not a move. + if res.Move == nil { + t.Fatal("no provenance row recorded") + } + if res.Move.ArchivedSource { + t.Error("provenance says archived_source=true for a plain copy") + } + if res.Move.SourceSeq != nil { + t.Errorf("plain copy recorded source_seq=%d, want nil", *res.Move.SourceSeq) + } + back, err := f.s.GetItemWorkspaceMoveByTarget(res.Item.ID) + if err != nil || back == nil { + t.Fatalf("back-pointer lookup failed: %v", err) + } + if back.SourceItemID != src.ID { + t.Errorf("back-pointer source = %s, want %s", back.SourceItemID, src.ID) + } +} + +// DR-9a parity: the copy is a REAL create, not a bare items INSERT. Version +// row, wiki-link index, status transition, slug, item_number and seq must all +// exist in the destination. +func TestCopyItemAcrossWorkspaces_CreationParity(t *testing.T) { + f := newCopyFixture(t) + // A destination item the copied body's [[...]] link resolves to. + createTestItem(t, f.s, f.wsB.ID, f.colB.ID, "Target Doc", "") + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Parity", "see [[Target Doc]]") + + req := f.req() + req.SourceItemID = src.ID + res := f.copy(t, req) + + if res.Item.Slug == "" { + t.Error("copy has no slug") + } + if res.Item.ItemNumber == nil || *res.Item.ItemNumber == 0 { + t.Error("copy has no item_number") + } + if res.Item.Seq == 0 { + t.Error("copy has no seq") + } + + for _, probe := range []struct { + name string + query string + }{ + {"item_versions", `SELECT COUNT(*) FROM item_versions WHERE item_id = ?`}, + {"item_wiki_links", `SELECT COUNT(*) FROM item_wiki_links WHERE source_item_id = ?`}, + {"status_transitions", `SELECT COUNT(*) FROM status_transitions WHERE item_id = ?`}, + } { + var n int + if err := f.s.db.QueryRow(f.s.q(probe.query), res.Item.ID).Scan(&n); err != nil { + t.Fatalf("%s probe: %v", probe.name, err) + } + if n == 0 { + t.Errorf("%s: 0 rows for the copied item, want >=1 (DR-9a creation parity)", probe.name) + } + } +} + +// --- Seq (DR-14) ------------------------------------------------------------ + +// A plain copy must not advance workspace A's cursor AT ALL — A's watchers +// have nothing to see, and a spurious bump would make them re-fetch an +// unchanged item. +func TestCopyItemAcrossWorkspaces_PlainCopyDoesNotAdvanceSourceSeq(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Untouched", "body") + + cursorA := maxSeq(t, f.s, f.wsA.ID) + cursorB := maxSeq(t, f.s, f.wsB.ID) + + req := f.req() + req.SourceItemID = src.ID + res := f.copy(t, req) + + if got := maxSeq(t, f.s, f.wsA.ID); got != cursorA { + t.Errorf("plain copy advanced workspace A's seq %d -> %d, want unchanged", cursorA, got) + } + changesA, err := f.s.ListItemsChangesSince(f.wsA.ID, ItemChangesParams{Since: cursorA}) + if err != nil { + t.Fatalf("ListItemsChangesSince(A): %v", err) + } + if len(changesA) != 0 { + t.Errorf("plain copy produced %d delta rows in A, want 0", len(changesA)) + } + + changesB, err := f.s.ListItemsChangesSince(f.wsB.ID, ItemChangesParams{Since: cursorB}) + if err != nil { + t.Fatalf("ListItemsChangesSince(B): %v", err) + } + var sawCreate bool + for _, it := range changesB { + if it.ID == res.Item.ID && it.DeletedAt == nil { + sawCreate = true + } + } + if !sawCreate { + t.Error("workspace B's delta from the pre-copy cursor does not contain the create") + } +} + +// On a MOVE, A must advance so its clients receive the tombstone — otherwise +// they keep rendering a source item that no longer exists. +func TestCopyItemAcrossWorkspaces_MoveEmitsTombstoneInSourceWorkspace(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Moving out", "body") + + cursorA := maxSeq(t, f.s, f.wsA.ID) + cursorB := maxSeq(t, f.s, f.wsB.ID) + + req := f.req() + req.SourceItemID = src.ID + req.ArchiveSource = true + res := f.copy(t, req) + + if got := maxSeq(t, f.s, f.wsA.ID); got <= cursorA { + t.Errorf("move did not advance workspace A's seq (%d -> %d)", cursorA, got) + } + changesA, err := f.s.ListItemsChangesSince(f.wsA.ID, ItemChangesParams{Since: cursorA}) + if err != nil { + t.Fatalf("ListItemsChangesSince(A): %v", err) + } + var sawTombstone bool + for _, it := range changesA { + if it.ID == src.ID && it.DeletedAt != nil { + sawTombstone = true + } + } + if !sawTombstone { + t.Error("workspace A's delta from the pre-copy cursor does not contain the tombstone") + } + if maxSeq(t, f.s, f.wsB.ID) <= cursorB { + t.Error("move did not advance workspace B's seq") + } + + // The source is archived, and the provenance row carries the seq that + // archive assigned (DR-2a: without it two moves in one second are + // unorderable). + if live, _ := f.s.GetItem(src.ID); live != nil { + t.Error("source is still live after a move") + } + if res.SourceSeq == nil { + t.Fatal("move recorded no source_seq") + } + if !res.Move.ArchivedSource { + t.Error("provenance says archived_source=false for a move") + } + archived, err := f.s.GetItemIncludeDeleted(src.ID) + if err != nil { + t.Fatalf("GetItemIncludeDeleted: %v", err) + } + if archived.Seq != *res.SourceSeq { + t.Errorf("provenance source_seq=%d but the archived row carries seq=%d", *res.SourceSeq, archived.Seq) + } +} + +// --- Fields (DR-12) --------------------------------------------------------- + +// MigrateFields computes result.Errors BEFORE any override exists, so testing +// those stale errors rejects a copy whose override already supplied the +// missing required field. Overrides must be applied first, then validated. +func TestCopyItemAcrossWorkspaces_OverrideSatisfiesRequiredField(t *testing.T) { + f := newCopyFixture(t) + // A destination collection with a required field the source does not have. + dest, err := f.s.CreateCollection(f.wsB.ID, models.CollectionCreate{ + Name: "Strict", + Schema: `{"fields":[{"key":"severity","label":"Severity","type":"select","options":["low","high"],"required":true}]}`, + }) + if err != nil { + t.Fatalf("CreateCollection: %v", err) + } + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Needs severity", "") + + req := f.req() + req.SourceItemID = src.ID + req.TargetCollectionID = dest.ID + + // Without the override the copy is refused … + if _, err := f.s.CopyItemAcrossWorkspaces(req); err == nil { + t.Fatal("copy succeeded with a required field unset, want a validation error") + } else { + var verr *FieldValidationError + if !errors.As(err, &verr) { + t.Errorf("error = %v, want a *FieldValidationError", err) + } + } + + // … and WITH it, the copy lands. This is the assertion that fails if the + // stale MigrateFields errors are consulted after overrides merge in. + req.FieldOverrides = map[string]any{"severity": "high"} + res := f.copy(t, req) + var fields map[string]any + if err := json.Unmarshal([]byte(res.Item.Fields), &fields); err != nil { + t.Fatalf("decode destination fields: %v", err) + } + if fields["severity"] != "high" { + t.Errorf("severity = %v, want \"high\"", fields["severity"]) + } +} + +// The mirror image: an override with a value the destination schema rejects +// must be type-checked. Pre-DR-12 the override was never validated at all. +func TestCopyItemAcrossWorkspaces_InvalidOverrideRejected(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Bad override", "") + + req := f.req() + req.SourceItemID = src.ID + req.FieldOverrides = map[string]any{"status": "not-an-option"} + + _, err := f.s.CopyItemAcrossWorkspaces(req) + if err == nil { + t.Fatal("copy accepted an override outside the select's options") + } + var verr *FieldValidationError + if !errors.As(err, &verr) { + t.Errorf("error = %v, want a *FieldValidationError", err) + } + if countItemsIn(t, f.s, f.wsB.ID) != 0 { + t.Error("a rejected copy left an item in the destination") + } +} + +func TestCopyItemAcrossWorkspaces_DroppedFieldsReported(t *testing.T) { + f := newCopyFixture(t) + dest, err := f.s.CreateCollection(f.wsB.ID, models.CollectionCreate{ + Name: "Narrow", + Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open"}]}`, + }) + if err != nil { + t.Fatalf("CreateCollection: %v", err) + } + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Extra fields", + Fields: `{"status":"open","nowhere_to_go":"x"}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + req.TargetCollectionID = dest.ID + res := f.copy(t, req) + + if len(res.DroppedFields) != 1 || res.DroppedFields[0] != "nowhere_to_go" { + t.Errorf("DroppedFields = %v, want [nowhere_to_go]", res.DroppedFields) + } +} + +// --- Assignment scrubs (DR-8) ----------------------------------------------- + +func TestCopyItemAcrossWorkspaces_AssigneeCarriesOnlyForDestinationMembers(t *testing.T) { + f := newCopyFixture(t) + member := createTestUser(t, f.s, "member@example.com", "Member", "s3cret") + stranger := createTestUser(t, f.s, "stranger@example.com", "Stranger", "s3cret") + for _, u := range []*models.User{member, stranger} { + if err := f.s.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember(A): %v", err) + } + } + if err := f.s.AddWorkspaceMember(f.wsB.ID, member.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember(B): %v", err) + } + + role, err := f.s.CreateAgentRole(f.wsA.ID, models.AgentRoleCreate{Name: "Builder"}) + if err != nil { + t.Fatalf("CreateAgentRole: %v", err) + } + + carried, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Assigned to a B member", + AssignedUserID: &member.ID, + AgentRoleID: &role.ID, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + req := f.req() + req.SourceItemID = carried.ID + res := f.copy(t, req) + if res.Item.AssignedUserID == nil || *res.Item.AssignedUserID != member.ID { + t.Errorf("assignee did not carry for a destination member: %v", res.Item.AssignedUserID) + } + if res.DroppedAssignee { + t.Error("DroppedAssignee=true for an assignee that carried") + } + // Agent role ALWAYS clears — role slugs are workspace-local. + if res.Item.AgentRoleID != nil { + t.Errorf("agent role carried (%v), want cleared", *res.Item.AgentRoleID) + } + if !res.DroppedAgentRole { + t.Error("DroppedAgentRole=false but the source had an agent role") + } + + dropped, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Assigned to a non-member", + AssignedUserID: &stranger.ID, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + req2 := f.req() + req2.SourceItemID = dropped.ID + res2 := f.copy(t, req2) + if res2.Item.AssignedUserID != nil { + t.Errorf("assignee carried for a non-member of B: %v", *res2.Item.AssignedUserID) + } + if !res2.DroppedAssignee { + t.Error("DroppedAssignee=false for an assignee that was cleared") + } +} + +// --- Attachments (DR-11 / DR-11a) ------------------------------------------- + +func TestCopyItemAcrossWorkspaces_AttachmentsClonedAndRefsRewritten(t *testing.T) { + f := newCopyFixture(t) + orig := f.attachIn(t, f.wsA.ID, "diagram.png", 4096) + thumb := f.variantOf(t, f.wsA.ID, orig, "thumb-md", 512) + + // Both collections declare `note` so a field-borne reference actually + // SURVIVES migration — with the default fixture schemas `note` is an + // unknown key that MigrateFields drops, and the fields rewrite would go + // untested. + withNote := `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open"},{"key":"note","label":"Note","type":"text"}]}` + srcColl, err := f.s.CreateCollection(f.wsA.ID, models.CollectionCreate{Name: "Noted A", Schema: withNote}) + if err != nil { + t.Fatalf("CreateCollection(A): %v", err) + } + dstColl, err := f.s.CreateCollection(f.wsB.ID, models.CollectionCreate{Name: "Noted B", Schema: withNote}) + if err != nil { + t.Fatalf("CreateCollection(B): %v", err) + } + + // A reference in the body, one inside a fenced code block (the rewriter + // is a plain ReplaceAll, so a fenced ref gets rewritten either way and + // must therefore be cloned either way), and one in the fields. + content := fmt.Sprintf("![d](pad-attachment:%s)\n\n```\nsee pad-attachment:%s\n```\n", orig.ID, thumb.ID) + src, err := f.s.CreateItem(f.wsA.ID, srcColl.ID, models.ItemCreate{ + Title: "Has attachments", + Content: content, + Fields: fmt.Sprintf(`{"status":"open","note":"pad-attachment:%s"}`, orig.ID), + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + req.TargetCollectionID = dstColl.ID + res := f.copy(t, req) + + // The field-borne reference survived migration — otherwise the assertions + // below about the fields rewrite would be vacuous. + if !strings.Contains(res.Item.Fields, "pad-attachment:") { + t.Fatal("the destination fields carry no attachment reference; the fields-rewrite assertions would be vacuous") + } + + if res.AttachmentsCopied != 2 { + t.Errorf("AttachmentsCopied = %d, want 2 (original + variant)", res.AttachmentsCopied) + } + if res.BytesCopied != 4096+512 { + t.Errorf("BytesCopied = %d, want %d", res.BytesCopied, 4096+512) + } + + // No workspace-A UUID survives the rewrite, anywhere — body or fields, + // fenced or not. A surviving ref renders broken AND 403s on download. + for _, oldID := range []string{orig.ID, thumb.ID} { + if strings.Contains(res.Item.Content, oldID) { + t.Errorf("copied content still references workspace-A attachment %s", oldID) + } + if strings.Contains(res.Item.Fields, oldID) { + t.Errorf("copied fields still reference workspace-A attachment %s", oldID) + } + } + + rows := attachmentsIn(t, f.s, f.wsB.ID) + if len(rows) != 2 { + t.Fatalf("workspace B has %d attachment rows, want 2", len(rows)) + } + var newOriginal *models.Attachment + for i := range rows { + a := rows[i] + // DR-11: item_id is set from the outset, never transiently NULL. + if a.ItemID == nil || *a.ItemID != res.Item.ID { + t.Errorf("clone %s has item_id %v, want %s", a.ID, a.ItemID, res.Item.ID) + } + // DR-11: uploaded_by is the ACTOR, not the source uploader. + if a.UploadedBy != f.actor { + t.Errorf("clone %s uploaded_by = %q, want the actor %q", a.ID, a.UploadedBy, f.actor) + } + if a.CreatedAt.IsZero() { + t.Errorf("clone %s has a zero created_at", a.ID) + } + if a.WorkspaceID != f.wsB.ID { + t.Errorf("clone %s landed in %s", a.ID, a.WorkspaceID) + } + if a.ParentID == nil { + cp := a + newOriginal = &cp + } + } + if newOriginal == nil { + t.Fatal("no cloned original in workspace B") + } + for i := range rows { + a := rows[i] + if a.ParentID == nil { + continue + } + // The variant's parent_id is remapped to the NEW original, not + // left pointing into workspace A. + if *a.ParentID != newOriginal.ID { + t.Errorf("cloned variant parent_id = %s, want the new original %s", *a.ParentID, newOriginal.ID) + } + } + + // The rewritten refs actually resolve in B. + for _, id := range []string{newOriginal.ID} { + if !strings.Contains(res.Item.Content, "pad-attachment:"+id) { + t.Errorf("copied content does not reference the new original %s", id) + } + } + + // Workspace A keeps its rows, untouched. + if countAttachmentsIn(t, f.s, f.wsA.ID) != 2 { + t.Error("the copy disturbed workspace A's attachment rows") + } +} + +// DR-11a: refs that resolve to nothing under `workspace_id = A AND deleted_at +// IS NULL` are never cloned, the literal text survives, and the copy is not +// blocked. The foreign-workspace case is the confused-deputy hole. +func TestCopyItemAcrossWorkspaces_UnresolvableRefsAreNeverCloned(t *testing.T) { + f := newCopyFixture(t) + foreign := f.attachIn(t, f.wsC.ID, "someone-elses.png", 9999) + dangling := newID() + + content := fmt.Sprintf("![a](pad-attachment:%s) ![b](pad-attachment:%s)", foreign.ID, dangling) + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Bad refs", + Content: content, + Fields: `{"status":"open"}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + res := f.copy(t, req) + + if res.AttachmentsCopied != 0 { + t.Errorf("cloned %d attachments from unresolvable refs, want 0", res.AttachmentsCopied) + } + if countAttachmentsIn(t, f.s, f.wsB.ID) != 0 { + t.Error("a foreign-workspace attachment was cloned into the destination") + } + if len(res.UnresolvableRefs) != 2 { + t.Errorf("UnresolvableRefs = %v, want both refs", res.UnresolvableRefs) + } + // The copy renders exactly as broken as the source did. + if res.Item.Content != content { + t.Errorf("content = %q, want the source text preserved verbatim", res.Item.Content) + } +} + +// v1 refuses a copy whose bytes live in a backend the destination does not +// write to, rather than silently inserting a row that 404s on download. +func TestCopyItemAcrossWorkspaces_CrossBackendRefused(t *testing.T) { + f := newCopyFixture(t) + orig := f.attachIn(t, f.wsA.ID, "on-disk.png", 100) // storage_key is "fs:…" + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Cross backend", + Content: fmt.Sprintf("![x](pad-attachment:%s)", orig.ID), + Fields: `{"status":"open"}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + req.TargetBackend = "s3" + + _, err = f.s.CopyItemAcrossWorkspaces(req) + if !errors.Is(err, ErrCopyCrossBackendAttachments) { + t.Fatalf("error = %v, want ErrCopyCrossBackendAttachments", err) + } + if countItemsIn(t, f.s, f.wsB.ID) != 0 || countAttachmentsIn(t, f.s, f.wsB.ID) != 0 { + t.Error("a refused cross-backend copy left rows in the destination") + } +} + +// --- Rollback --------------------------------------------------------------- + +// A failure at ANY stage must leave nothing behind in either workspace: no +// item, no attachment rows, no provenance row, and neither seq advanced. +func TestCopyItemAcrossWorkspaces_RollbackIsCompleteAtEveryStage(t *testing.T) { + stages := []string{copyStageCreateItem, copyStageAttachments, copyStageArchive, copyStageProvenance} + for _, stage := range stages { + t.Run(stage, func(t *testing.T) { + f := newCopyFixture(t) + orig := f.attachIn(t, f.wsA.ID, "shot.png", 128) + f.variantOf(t, f.wsA.ID, orig, "thumb-sm", 32) + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Rolls back", + Content: fmt.Sprintf("![x](pad-attachment:%s)", orig.ID), + Fields: `{"status":"open"}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + itemsB := countItemsIn(t, f.s, f.wsB.ID) + attachB := countAttachmentsIn(t, f.s, f.wsB.ID) + moves := countMoveRows(t, f.s) + cursorA := maxSeq(t, f.s, f.wsA.ID) + cursorB := maxSeq(t, f.s, f.wsB.ID) + + req := f.req() + req.SourceItemID = src.ID + req.ArchiveSource = true + req.failAfterStage = stage + + if _, err := f.s.CopyItemAcrossWorkspaces(req); err == nil { + t.Fatalf("copy succeeded despite an injected failure after %q", stage) + } + + if got := countItemsIn(t, f.s, f.wsB.ID); got != itemsB { + t.Errorf("destination item count %d -> %d after rollback", itemsB, got) + } + if got := countAttachmentsIn(t, f.s, f.wsB.ID); got != attachB { + t.Errorf("destination attachment count %d -> %d after rollback", attachB, got) + } + if got := countMoveRows(t, f.s); got != moves { + t.Errorf("provenance row count %d -> %d after rollback", moves, got) + } + if got := maxSeq(t, f.s, f.wsA.ID); got != cursorA { + t.Errorf("workspace A's seq advanced %d -> %d after rollback", cursorA, got) + } + if got := maxSeq(t, f.s, f.wsB.ID); got != cursorB { + t.Errorf("workspace B's seq advanced %d -> %d after rollback", cursorB, got) + } + if live, _ := f.s.GetItem(src.ID); live == nil { + t.Error("the source was archived despite the rollback") + } + }) + } +} + +// A cloned attachment whose storage_key is blank must abort the whole copy — +// CreateAttachmentTx refuses it, and that refusal has to unwind the item that +// was already inserted. (A row with no key is a live attachment the registry +// cannot resolve; it fails at download time with nothing to point at.) +func TestCopyItemAcrossWorkspaces_AttachmentInsertFailureRollsBackTheItem(t *testing.T) { + f := newCopyFixture(t) + // Insert an attachment with an empty storage_key directly — CreateAttachment + // refuses it, which is precisely the guard being exercised downstream. + id := newID() + if _, err := f.s.db.Exec(f.s.q(` + INSERT INTO attachments (`+attachmentColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), id, f.wsA.ID, nil, "uploader", "", newID(), "image/png", 10, "keyless.png", + nil, nil, nil, nil, now(), nil); err != nil { + t.Fatalf("insert keyless attachment: %v", err) + } + + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Keyless attachment", + Content: fmt.Sprintf("![x](pad-attachment:%s)", id), + Fields: `{"status":"open"}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + if _, err := f.s.CopyItemAcrossWorkspaces(req); err == nil { + t.Fatal("copy succeeded with a keyless source attachment") + } + if countItemsIn(t, f.s, f.wsB.ID) != 0 { + t.Error("the destination item survived a failed attachment insert") + } + if countAttachmentsIn(t, f.s, f.wsB.ID) != 0 { + t.Error("attachment rows survived a failed attachment insert") + } + if countMoveRows(t, f.s) != 0 { + t.Error("a provenance row survived a failed attachment insert") + } +} + +// --- Scope refusals --------------------------------------------------------- + +func TestCopyItemAcrossWorkspaces_TargetCollectionMustBelongToTargetWorkspace(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Scoped", "") + + req := f.req() + req.SourceItemID = src.ID + req.TargetCollectionID = f.colC.ID // lives in workspace C, not B + + if _, err := f.s.CopyItemAcrossWorkspaces(req); err == nil { + t.Fatal("copy accepted a collection from another workspace") + } + if countItemsIn(t, f.s, f.wsB.ID) != 0 || countItemsIn(t, f.s, f.wsC.ID) != 0 { + t.Error("a cross-workspace collection reference produced an item") + } +} + +func TestCopyItemAcrossWorkspaces_ArchivedSourceIsNotCopyable(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Already gone", "") + if err := f.s.DeleteItem(src.ID); err != nil { + t.Fatalf("DeleteItem: %v", err) + } + + req := f.req() + req.SourceItemID = src.ID + if _, err := f.s.CopyItemAcrossWorkspaces(req); err == nil { + t.Fatal("copy accepted an archived source") + } +} + +// --- Quota (DR-16) ---------------------------------------------------------- + +// quotaFixture wires a free-tier owner with an items_per_workspace override so +// the cap is reachable in a test. +func newQuotaFixture(t *testing.T, limit int) copyFixture { + t.Helper() + s := testStore(t) + owner := createTestUser(t, s, "quota-owner@example.com", "Owner", "s3cret") + if err := s.SetUserPlan(owner.ID, "free", ""); err != nil { + t.Fatalf("SetUserPlan: %v", err) + } + if err := s.SetUserPlanOverrides(owner.ID, fmt.Sprintf(`{"items_per_workspace": %d}`, limit)); err != nil { + t.Fatalf("SetUserPlanOverrides: %v", err) + } + wsA, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Quota Source", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("CreateWorkspace(A): %v", err) + } + wsB, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Quota Dest", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("CreateWorkspace(B): %v", err) + } + return copyFixture{ + s: s, + wsA: wsA, + wsB: wsB, + colA: createTestCollection(t, s, wsA.ID, "Tasks A"), + colB: createTestCollection(t, s, wsB.ID, "Tasks B"), + actor: owner.ID, + } +} + +func TestCopyItemAcrossWorkspaces_ItemQuota(t *testing.T) { + // The destination starts empty and the cap is 2: the first copy is + // "exactly at limit minus one" and lands; the second fills the cap and + // lands; the third is one-over and is refused. + f := newQuotaFixture(t, 2) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Copy me", "") + + for i := 0; i < 2; i++ { + req := f.req() + req.SourceItemID = src.ID + req.EnforceItemLimit = true + if _, err := f.s.CopyItemAcrossWorkspaces(req); err != nil { + t.Fatalf("copy %d under the cap failed: %v", i, err) + } + } + + req := f.req() + req.SourceItemID = src.ID + req.EnforceItemLimit = true + _, err := f.s.CopyItemAcrossWorkspaces(req) + if err == nil { + t.Fatal("copy past the item cap succeeded") + } + var limitErr *ItemLimitError + if !errors.As(err, &limitErr) { + t.Fatalf("error = %v, want an *ItemLimitError", err) + } + if limitErr.Result.Current != 2 || limitErr.Result.Limit != 2 { + t.Errorf("limit result = %+v, want current=2 limit=2", limitErr.Result) + } + if countItemsIn(t, f.s, f.wsB.ID) != 2 { + t.Errorf("destination has %d items, want 2 (the rejected copy must not land)", countItemsIn(t, f.s, f.wsB.ID)) + } + + // EnforceItemLimit=false is the self-hosted shape: the same copy lands. + req.EnforceItemLimit = false + if _, err := f.s.CopyItemAcrossWorkspaces(req); err != nil { + t.Fatalf("unenforced copy failed: %v", err) + } +} + +// TestCheckLimitTx_SeesUncommittedRowsInTheTransaction is the direct proof +// that the DR-16 quota count runs on the caller's transaction. +// +// The concurrent-copy test below cannot prove this on its own: the destination +// workspace's advisory lock already serializes the two copies, so a pool-based +// CheckLimit would produce the same one-success/one-rejection outcome. This +// one distinguishes them unambiguously — an uncommitted insert is visible to +// CheckLimitTx and invisible to CheckLimit, so swapping the call in the copy +// path back to the pool form makes this test fail. +func TestCheckLimitTx_SeesUncommittedRowsInTheTransaction(t *testing.T) { + s := testStore(t) + owner := createTestUser(t, s, "tx-count@example.com", "Owner", "s3cret") + if err := s.SetUserPlan(owner.ID, "free", ""); err != nil { + t.Fatalf("SetUserPlan: %v", err) + } + if err := s.SetUserPlanOverrides(owner.ID, `{"items_per_workspace": 1}`); err != nil { + t.Fatalf("SetUserPlanOverrides: %v", err) + } + ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Tx Count", OwnerID: owner.ID}) + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + col := createTestCollection(t, s, ws.ID, "Tasks") + + tx, err := s.db.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback() //nolint:errcheck // test cleanup + + if _, err := s.createItemTx(tx, ws.ID, col.ID, models.ItemCreate{ + Title: "Uncommitted", + Fields: `{"status":"open"}`, + }); err != nil { + t.Fatalf("createItemTx: %v", err) + } + + inTx, err := s.CheckLimitTx(tx, ws.ID, "items_per_workspace") + if err != nil { + t.Fatalf("CheckLimitTx: %v", err) + } + if inTx.Current != 1 { + t.Errorf("CheckLimitTx Current=%d, want 1 (the uncommitted insert)", inTx.Current) + } + if inTx.Allowed { + t.Error("CheckLimitTx Allowed=true at the cap; the uncommitted row was not counted") + } +} + +// --- Lock-key ordering ------------------------------------------------------ + +// The pure half of the DR-9 lock contract: ascending order, duplicates +// collapsed. Colliding keys (two workspaces hashing to one value) are ONE lock, +// not two. +func TestSortedDedupedLockKeys(t *testing.T) { + cases := []struct { + name string + in []int64 + want []int64 + }{ + {"empty", nil, nil}, + {"single", []int64{7}, []int64{7}}, + {"already ordered", []int64{-5, 3}, []int64{-5, 3}}, + {"reversed", []int64{3, -5}, []int64{-5, 3}}, + // hashtext returns a signed int4, so negative keys are ordinary and + // must sort numerically, not by string. + {"negatives sort numerically", []int64{-2147483648, 2147483647, 0}, []int64{-2147483648, 0, 2147483647}}, + {"collision collapses", []int64{42, 42}, []int64{42}}, + {"collision among others", []int64{9, 42, 42, 1}, []int64{1, 9, 42}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := sortedDedupedLockKeys(tc.in) + if len(got) != len(tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("got %v, want %v", got, tc.want) + } + } + }) + } +} diff --git a/internal/store/limits.go b/internal/store/limits.go index 8479d4c4..3e281a8d 100644 --- a/internal/store/limits.go +++ b/internal/store/limits.go @@ -1,6 +1,7 @@ package store import ( + "database/sql" "encoding/json" "fmt" "log/slog" @@ -61,6 +62,30 @@ type LimitResult struct { // 2. Platform plan_limits[plan][feature] — DB-stored defaults for the tier // 3. Hardcoded fallback — safety net if DB config is missing func (s *Store) CheckLimit(workspaceID, feature string) (*LimitResult, error) { + return s.checkLimitOn(s.db, workspaceID, feature) +} + +// CheckLimitTx is CheckLimit with the usage count read through the caller's +// transaction instead of an independent connection (PLAN-2357 / DR-16). +// +// The distinction is the whole point: a limit check that counts on the pool +// cannot see the caller's own uncommitted inserts, and — more importantly — +// it is not serialized with a concurrent transaction holding the workspace's +// advisory lock. Two copies into a workspace one item below its cap would then +// both read "under the limit" and both commit. Counting inside the transaction, +// after the destination workspace lock is held, makes the second copy's COUNT +// wait for the first to commit and observe it. +// +// Only the COUNT moves onto the tx. The workspace-owner and user lookups stay +// on the pool: neither is written by the copy path, and routing them through +// the tx would only widen what a failed probe poisons. +func (s *Store) CheckLimitTx(tx *sql.Tx, workspaceID, feature string) (*LimitResult, error) { + return s.checkLimitOn(tx, workspaceID, feature) +} + +// checkLimitOn is the shared body of CheckLimit / CheckLimitTx, parameterized +// over the surface the feature COUNT runs on. +func (s *Store) checkLimitOn(counter rowQueryer, workspaceID, feature string) (*LimitResult, error) { // 1. Look up workspace → owner_id var ownerID string err := s.db.QueryRow(s.q(`SELECT owner_id FROM workspaces WHERE id = ?`), workspaceID).Scan(&ownerID) @@ -95,7 +120,7 @@ func (s *Store) CheckLimit(workspaceID, feature string) (*LimitResult, error) { } // 4. Get current count for the feature - current, err := s.featureCount(workspaceID, ownerID, feature) + current, err := s.featureCountOn(counter, workspaceID, ownerID, feature) if err != nil { return nil, fmt.Errorf("check limit: count %s: %w", feature, err) } @@ -172,18 +197,20 @@ func (s *Store) resolveLimit(plan, feature, overridesJSON string) int { return hardcodedLimit(plan, feature) } -// featureCount returns the current count for a workspace-scoped feature. -func (s *Store) featureCount(workspaceID, ownerID, feature string) (int, error) { +// featureCountOn returns the current count for a workspace-scoped feature, +// parameterized over the query surface so the same COUNTs serve both +// CheckLimit (the pool) and CheckLimitTx (a caller's transaction). +func (s *Store) featureCountOn(q rowQueryer, workspaceID, ownerID, feature string) (int, error) { var count int var err error switch feature { case "items_per_workspace": - err = s.db.QueryRow(s.q(`SELECT COUNT(*) FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), workspaceID).Scan(&count) + err = q.QueryRow(s.q(`SELECT COUNT(*) FROM items WHERE workspace_id = ? AND deleted_at IS NULL`), workspaceID).Scan(&count) case "members_per_workspace": - err = s.db.QueryRow(s.q(`SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ?`), workspaceID).Scan(&count) + err = q.QueryRow(s.q(`SELECT COUNT(*) FROM workspace_members WHERE workspace_id = ?`), workspaceID).Scan(&count) case "webhooks": - err = s.db.QueryRow(s.q(`SELECT COUNT(*) FROM webhooks WHERE workspace_id = ?`), workspaceID).Scan(&count) + err = q.QueryRow(s.q(`SELECT COUNT(*) FROM webhooks WHERE workspace_id = ?`), workspaceID).Scan(&count) default: return 0, fmt.Errorf("unknown workspace feature: %s", feature) }