diff --git a/CLAUDE.md b/CLAUDE.md index 01914b5b..13e777bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,8 @@ REST API at `/api/v1/`. Key endpoints: - `GET/POST /workspaces/{ws}/collections` — collection CRUD - `GET/POST /workspaces/{ws}/collections/{coll}/items` — item CRUD - `GET/PATCH/DELETE /workspaces/{ws}/items/{slug}` — item by slug +- `POST /workspaces/{ws}/items/{slug}/copy/preflight` — cross-workspace copy dry run: what would carry / drop / need a value, plus the full warning set. Read-only and safe to call repeatedly (PLAN-2357) +- `POST /workspaces/{ws}/items/{slug}/copy` — cross-workspace copy; with `archive_source` it is the move. Same request shape as the preflight. **Never retry it automatically** — there is no idempotency key, so a retry duplicates the item - `GET /workspaces/{ws}/dashboard` — computed project overview (active items, plans, attention, blockers) - `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details) - `GET/POST/DELETE /workspaces/{ws}/webhooks` — webhook management @@ -165,6 +167,11 @@ pad item show # e.g. pad item show TASK-5 pad item update [--status X] [--priority X] pad item delete pad item move +pad item copy --to-workspace --collection [--dry-run] [--archive-source] [--field k=v] + # Cross-workspace copy; --archive-source makes it a move. + # --dry-run previews the field mapping + warnings. + # Refuses rather than guessing when a destination field needs a value, + # and NEVER retries the mutating call (no idempotency key — PLAN-2357 DR-13). pad item search "query" pad project dashboard # Project dashboard pad project next # Recommended next task diff --git a/cmd/pad/cmd_item.go b/cmd/pad/cmd_item.go index 92ded5ef..c18a31d8 100644 --- a/cmd/pad/cmd_item.go +++ b/cmd/pad/cmd_item.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strconv" "strings" @@ -1260,6 +1261,709 @@ Examples: return cmd } +// --- cross-workspace copy (PLAN-2357 / TASK-2366) --- + +// itemCopyOptions is everything `pad item copy` reads off its flags. +// Extracted from the cobra command so runItemCopy is testable without a +// live server, a config file or a workspace on disk. +type itemCopyOptions struct { + Ref string + TargetWorkspace string + TargetCollection string + DryRun bool + ArchiveSource bool + Fields []string + // Format is the effective --format value ("table" or "json"). + Format string +} + +// itemCopyDeps is the seam the command's logic is tested through. Copy is +// the MUTATING call; the refuse-to-guess test asserts it is never invoked. +type itemCopyDeps struct { + // Schema returns the destination collection's schema so --field values + // parse to their declared types. A lookup failure must degrade to an + // empty schema (every value stays a string) rather than fail the + // command — the server is the authority on the destination anyway. + Schema func(wsSlug, collSlug string) models.CollectionSchema + // Preflight runs the dry-run endpoint. Non-mutating. + Preflight func(cli.ItemCopyRequest) (*cli.ItemCopyPreflight, json.RawMessage, error) + // Copy runs the mutating endpoint. Never called on a dry run, and + // never called when the preflight reports unresolved needs_value. + Copy func(cli.ItemCopyRequest) (*cli.ItemCopyResult, json.RawMessage, error) +} + +func itemCopyCmd() *cobra.Command { + opts := itemCopyOptions{} + cmd := &cobra.Command{ + Use: "copy --to-workspace --collection ", + Short: "Copy an item into another workspace", + Long: `Copy an item into a different workspace, choosing the destination collection. + +Fields are migrated into the destination collection's schema: matching keys with +compatible types carry across, incompatible ones are dropped, and destination +fields that end up required-but-empty must be supplied with --field. + +With --archive-source the source is archived after a successful copy — that is +the MOVE path. The archived source records where it went. + +What never carries: child items, the parent link, dependency links (blocks / +blocked-by / related), the assignee when they are not a member of the +destination workspace, and the agent role (role slugs are workspace-local). +--dry-run reports all of it before anything happens. + +--dry-run calls a read-only preview endpoint and changes nothing. Without it, +the preview still runs first: if any destination field needs a value you have +not supplied, the copy is refused before any mutating request is sent. + +A copy is attempted at most once per invocation and is NEVER retried +automatically. There is no idempotency key, so a blind re-run after an +ambiguous failure would create a duplicate item — on such a failure the +command tells you to check the destination workspace instead. + +Items can be referenced by issue ID (e.g. TASK-5) or slug. + +Examples: + pad item copy IDEA-12 --to-workspace pad-web --collection tasks --dry-run + pad item copy IDEA-12 --to-workspace pad-web --collection tasks + pad item copy TASK-5 --to-workspace pad-web --collection tasks --archive-source + pad item copy TASK-5 --to-workspace pad-web --collection tasks --field priority=high`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, _ := getClient() + ws := getWorkspace() + + opts.Ref = args[0] + opts.Format = formatFlag + + deps := itemCopyDeps{ + Schema: func(wsSlug, collSlug string) models.CollectionSchema { + var schema models.CollectionSchema + if coll, err := client.GetCollection(wsSlug, collSlug); err == nil { + _ = json.Unmarshal([]byte(coll.Schema), &schema) + } + return schema + }, + Preflight: func(req cli.ItemCopyRequest) (*cli.ItemCopyPreflight, json.RawMessage, error) { + return client.CopyItemPreflight(ws, opts.Ref, req) + }, + Copy: func(req cli.ItemCopyRequest) (*cli.ItemCopyResult, json.RawMessage, error) { + return client.CopyItem(ws, opts.Ref, req) + }, + } + return runItemCopy(opts, deps, os.Stdout, os.Stderr) + }, + } + cmd.Flags().StringVar(&opts.TargetWorkspace, "to-workspace", "", "destination workspace slug (required)") + cmd.Flags().StringVar(&opts.TargetCollection, "collection", "", "destination collection slug (required)") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "report what the copy would do, without doing any of it") + cmd.Flags().BoolVar(&opts.ArchiveSource, "archive-source", false, "archive the source after a successful copy (the move path)") + cmd.Flags().StringArrayVarP(&opts.Fields, "field", "f", nil, "set a destination field value (repeatable): --field key=value") + _ = cmd.MarkFlagRequired("to-workspace") + _ = cmd.MarkFlagRequired("collection") + return cmd +} + +// runItemCopy is the whole command, minus flag binding. +// +// Order of operations is load-bearing: +// +// 1. Build the request (the SAME body both endpoints take). +// 2. Always run the preflight — it is read-only. +// 3. On --dry-run, render it and stop. +// 4. If the preflight reports unresolved needs_value, REFUSE. No mutating +// request is sent; the user is shown exactly which fields to supply. +// This mirrors the web dialog's disabled confirm button — nobody +// should round-trip into an error they could have been shown. +// 5. Otherwise perform the copy, exactly once (DR-13). +func runItemCopy(opts itemCopyOptions, deps itemCopyDeps, stdout, stderr io.Writer) error { + if strings.TrimSpace(opts.TargetWorkspace) == "" { + return fmt.Errorf("--to-workspace is required") + } + if strings.TrimSpace(opts.TargetCollection) == "" { + return fmt.Errorf("--collection is required") + } + + targetWorkspace := strings.TrimSpace(opts.TargetWorkspace) + targetCollection := normalizeCollectionSlug(strings.TrimSpace(opts.TargetCollection)) + + req := cli.ItemCopyRequest{ + TargetWorkspace: targetWorkspace, + TargetCollection: targetCollection, + ArchiveSource: opts.ArchiveSource, + } + + if len(opts.Fields) > 0 { + // Type the values against the DESTINATION collection's schema — + // the overrides are destination-schema keys, so the source's + // schema would be the wrong authority. + var schema models.CollectionSchema + if deps.Schema != nil { + schema = deps.Schema(targetWorkspace, targetCollection) + } + overrides := map[string]any{} + for _, kv := range opts.Fields { + idx := strings.Index(kv, "=") + if idx <= 0 { + // Unlike `pad item create`, a malformed --field here is a + // hard error rather than a silent skip: this command's + // whole refusal contract is "you were told what to + // supply", and silently dropping the thing the user + // supplied would make the refusal a lie. + return fmt.Errorf("invalid --field %q: expected key=value", kv) + } + overrides[kv[:idx]] = parseFieldFlag(schema, kv[:idx], kv[idx+1:]) + } + req.FieldOverrides = overrides + } + + pre, rawPre, err := deps.Preflight(req) + if err != nil { + return err + } + + if opts.DryRun { + // A write failure on the dry run IS the failure — nothing + // happened, and reporting nothing while exiting 0 would let a + // script believe it saw an empty preview. + if opts.Format == "json" { + return cli.PrintRawJSON(stdout, rawPre) + } + return renderItemCopyPreflight(stdout, pre) + } + + if len(pre.Fields.NeedsValue) > 0 { + // The preflight was sent WITH the overrides, so a non-empty + // needs_value here means the user supplied nothing that resolves + // these keys. Refuse — no mutating request. + if opts.Format == "json" { + // Still the endpoint's own contract: needs_value names the + // unresolved fields, so a script gets a machine-readable + // refusal rather than a bare exit code. + _ = cli.PrintRawJSON(stdout, rawPre) + } else { + // The returned write error is deliberately dropped: the + // command is already about to exit non-zero, and a stderr + // that cannot be written to has nowhere to report that. + _ = renderItemCopyNeedsValue(stderr, pre, req.FieldOverrides) + } + return fmt.Errorf("copy refused: %s in %s/%s %s a value (use --field key=value)", + pluralize(len(pre.Fields.NeedsValue), "field", "fields"), + targetWorkspace, targetCollection, + map[bool]string{true: "needs", false: "need"}[len(pre.Fields.NeedsValue) == 1]) + } + if !pre.Valid { + // needs_value is empty but the server still says the mapping is + // incomplete. That is a contract violation rather than a user + // error, and guessing past it is the one thing this command must + // not do — so refuse without naming fields we do not have. + return fmt.Errorf("copy refused: the destination reported the field mapping as incomplete without naming a field; re-run with --dry-run --format json and report this") + } + + res, rawRes, err := deps.Copy(req) + if err != nil { + if cli.CopyCommitted(err) { + // The server confirmed the copy and only the RESULT was lost + // (an unreadable or undecodable 2xx body). Same rule as the + // write-failure path below: this is a reporting failure, and + // a non-zero exit here would tell a script the copy did not + // happen — the DR-13 duplicate, one indirection removed. + fmt.Fprintf(stderr, "The copy SUCCEEDED, but its result could not be read: %v\n", err) + fmt.Fprintf(stderr, "Do not re-run — the item is in %q. Find it with:\n", targetWorkspace) + fmt.Fprintf(stderr, " pad item list %s --workspace %s\n", targetCollection, targetWorkspace) + if opts.Format == "json" && len(rawRes) > 0 { + // Whatever bytes did arrive are still the server's, and + // a script may be able to use them. + _ = cli.PrintRawJSON(stdout, rawRes) + } + return nil + } + if cli.CopyOutcomeUnknown(err) { + // DR-13. Do NOT suggest re-running. + fmt.Fprintf(stderr, "The copy failed with an UNKNOWN outcome: %v\n\n", err) + fmt.Fprintf(stderr, "The item may or may not have been created in workspace %q.\n", targetWorkspace) + if opts.ArchiveSource { + fmt.Fprintf(stderr, "The source may or may not have been archived.\n") + } + fmt.Fprintf(stderr, "This command never retries automatically: there is no idempotency key,\n") + fmt.Fprintf(stderr, "so a blind re-run would create a DUPLICATE item.\n\n") + fmt.Fprintf(stderr, "Check the destination first:\n") + fmt.Fprintf(stderr, " pad item list %s --workspace %s\n", targetCollection, targetWorkspace) + return fmt.Errorf("copy outcome unknown: check workspace %q before re-running", targetWorkspace) + } + return err + } + + // THE COPY HAS COMMITTED. From here on a failure to WRITE THE REPORT + // must not become a non-zero exit. + // + // This is the DR-13 hazard in its subtlest form: a script that sees a + // non-zero exit from `pad item copy` will reasonably conclude the copy + // did not happen, and the obvious recovery is to run it again — which + // duplicates the item. A full disk or a closed pipe on stdout says + // nothing whatsoever about the destination workspace. So the write + // error is reported on stderr and the exit code stays 0, which is the + // honest answer to "did the copy succeed?". + var writeErr error + if opts.Format == "json" { + writeErr = cli.PrintRawJSON(stdout, rawRes) + } else { + writeErr = renderItemCopyResult(stdout, res) + } + if writeErr != nil { + fmt.Fprintf(stderr, "The copy SUCCEEDED, but its report could not be written: %v\n", writeErr) + fmt.Fprintf(stderr, "Do not re-run — the item is in %q. Find it with:\n", targetWorkspace) + fmt.Fprintf(stderr, " pad item list %s --workspace %s\n", targetCollection, targetWorkspace) + } + + // PARTIAL OUTCOME. archive_source is what was ASKED for; source.archived + // is what HAPPENED, and the server's contract is that they agree on + // every successful response. When they do not, the requested operation + // did not fully complete, and exit 0 would tell automation that a MOVE + // finished while the source is still sitting there (Codex round 8). + // + // This is the opposite call from the write-failure branch above, and the + // difference is the whole distinction: there, the operation succeeded + // and only the report was lost, so a non-zero exit would have been a + // lie. Here the operation is genuinely incomplete. + // + // Exiting non-zero is safe with respect to DR-13 for the same reason the + // unknown-outcome branch is: the message says what exists and what to do + // instead of re-running, and the copy half must never be repeated. + if res.ArchiveSource != res.Source.Archived { + srcRef := itemCopyRef(res.Source.Ref, res.Source.Slug) + destRef := itemCopyRef(res.Destination.Ref, res.Destination.Slug) + fmt.Fprintf(stderr, "\nPARTIAL: the copy landed but the source's archive state is not what was asked for.\n") + fmt.Fprintf(stderr, " the copy EXISTS as %s in %q — do not re-run, that would duplicate it\n", destRef, res.Destination.WorkspaceSlug) + if res.ArchiveSource { + fmt.Fprintf(stderr, " --archive-source was given, but %s in %q is NOT archived\n", srcRef, res.Source.WorkspaceSlug) + fmt.Fprintf(stderr, " archive it separately:\n pad item delete %s --workspace %s\n", srcRef, res.Source.WorkspaceSlug) + } else { + fmt.Fprintf(stderr, " --archive-source was NOT given, but %s in %q was archived anyway\n", srcRef, res.Source.WorkspaceSlug) + fmt.Fprintf(stderr, " restore it:\n pad item restore %s --workspace %s\n", srcRef, res.Source.WorkspaceSlug) + } + return fmt.Errorf("copy completed but the source archive state is wrong: asked archive_source=%v, source.archived=%v", res.ArchiveSource, res.Source.Archived) + } + return nil +} + +// itemCopyWriter records the first write error and stops writing after it, +// so a renderer can stay a straight line of Fprintf calls and still report +// whether its output actually landed. +type itemCopyWriter struct { + w io.Writer + err error +} + +func (e *itemCopyWriter) Write(p []byte) (int, error) { + if e.err != nil { + return 0, e.err + } + n, err := e.w.Write(p) + if err != nil { + e.err = err + } + return n, err +} + +// pluralize renders "1 field" / "2 fields". +func pluralize(n int, singular, plural string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, singular) + } + return fmt.Sprintf("%d %s", n, plural) +} + +// itemCopyDroppedReasons / itemCopyNeedsValueReasons translate the wire +// vocabulary into a sentence. An UNKNOWN code falls through to the code +// itself rather than to a generic phrase — a reason the CLI does not know +// about must still reach the user verbatim. +var itemCopyDroppedReasons = map[string]string{ + "no_target_field": "the destination collection has no such field", + "incompatible_type": "the value cannot be converted to the destination's field", + "undeclared_source_field": "the source item's own collection schema no longer declares this key", + "assignee_not_a_member": "the assignee is not a member of the destination workspace", + "agent_role_not_portable": "agent roles are workspace-local and never carry", +} + +var itemCopyNeedsValueReasons = map[string]string{ + "missing_required": "required, with no value to carry and no default", + "invalid_value": "the carried value is not valid in the destination", +} + +func itemCopyReason(table map[string]string, code string) string { + if s, ok := table[code]; ok { + return s + } + if code == "" { + return "(no reason given)" + } + return code +} + +// renderItemCopyPreflight writes the human-readable dry run. +// +// EVERY bucket header and EVERY warning line is printed unconditionally, +// including the empty and zero cases. Omitting a zero would make "no +// attachments" indistinguishable from "this CLI does not report +// attachments", and the entire point of DR-17 is that none of this is +// silent. +func renderItemCopyPreflight(out io.Writer, p *cli.ItemCopyPreflight) error { + w := &itemCopyWriter{w: out} + fmt.Fprintf(w, "Copy preview — dry run. Nothing was changed.\n\n") + writeItemCopyHeader(w, p) + + fmt.Fprintf(w, "\nFields\n") + + fmt.Fprintf(w, " carried (%d)\n", len(p.Fields.Carried)) + if len(p.Fields.Carried) == 0 { + fmt.Fprintf(w, " (none)\n") + } + for _, f := range p.Fields.Carried { + fmt.Fprintf(w, " %-20s %s = %s [from %s]\n", + itemCopyLine(f.Key), itemCopyTypeLabel(f.Type, f.Label), itemCopyValue(f.Value), itemCopyLine(f.From)) + } + + fmt.Fprintf(w, " dropped (%d)\n", len(p.Fields.Dropped)) + if len(p.Fields.Dropped) == 0 { + fmt.Fprintf(w, " (none)\n") + } + for _, f := range p.Fields.Dropped { + fmt.Fprintf(w, " %-20s %s (%s) — %s\n", + itemCopyLine(f.Key), itemCopyTypeLabel("", f.Label), itemCopyLine(f.Kind), + itemCopyReason(itemCopyDroppedReasons, f.Reason)) + } + + fmt.Fprintf(w, " needs_value (%d)\n", len(p.Fields.NeedsValue)) + if len(p.Fields.NeedsValue) == 0 { + fmt.Fprintf(w, " (none)\n") + } + for _, f := range p.Fields.NeedsValue { + required := "optional" + if f.Required { + required = "required" + } + fmt.Fprintf(w, " %-20s %s %s — %s\n", + itemCopyLine(f.Key), itemCopyTypeLabel(f.Type, f.Label), required, + itemCopyReason(itemCopyNeedsValueReasons, f.Reason)) + if len(f.Options) > 0 { + fmt.Fprintf(w, " %-20s options: %s\n", "", itemCopyList(f.Options)) + } + if f.Message != "" { + fmt.Fprintf(w, " %-20s %s\n", "", itemCopyLine(f.Message)) + } + } + + fmt.Fprintf(w, "\nWarnings\n") + warn := p.Warnings + fmt.Fprintf(w, " %-32s %d\n", "child items (not copied)", warn.ChildCount) + fmt.Fprintf(w, " %-32s %s\n", "children orphaned by the move", yesNo(warn.ChildrenOrphaned)) + fmt.Fprintf(w, " %-32s %s\n", "parent dropped", yesNo(warn.DroppedParent)) + fmt.Fprintf(w, " %-32s %s\n", "outgoing links dropped", itemCopyLinkSummary(warn.OutgoingLinks)) + fmt.Fprintf(w, " %-32s %s\n", "incoming links dropped", itemCopyLinkSummary(warn.IncomingLinks)) + fmt.Fprintf(w, " %-32s %s\n", "assignee dropped", yesNo(warn.DroppedAssignee)) + fmt.Fprintf(w, " %-32s %s\n", "agent role dropped", yesNo(warn.DroppedAgentRole)) + fmt.Fprintf(w, " %-32s %s\n", "attachments cloned", itemCopyAttachmentSummary(warn.AttachmentCount, warn.AttachmentBytes)) + fmt.Fprintf(w, " %-32s %d\n", "unresolvable attachment refs", warn.UnresolvableRefCount) + + fmt.Fprintln(w) + if len(p.Fields.NeedsValue) > 0 { + fmt.Fprintf(w, "%s still %s a value. Supply with --field key=value, then re-run without --dry-run.\n", + pluralize(len(p.Fields.NeedsValue), "field", "fields"), + map[bool]string{true: "needs", false: "need"}[len(p.Fields.NeedsValue) == 1]) + return w.err + } + // `valid` is the server's own gate, and it means only that + // needs_value is empty — never restate it as "this will succeed". + fmt.Fprintf(w, "The field mapping is complete. Re-run without --dry-run to perform the %s.\n", + itemCopyModeNoun(p.ArchiveSource)) + return w.err +} + +// renderItemCopyNeedsValue is the refusal. It names every unresolved field +// and shows the exact flags to add. +// +// No MUTATING request was sent. The read-only preflight has of course +// already run — that is where this information came from — so do not read +// this as "the command touched nothing". +// +// Returns the first write error for symmetry with the other two renderers. +// The caller has nowhere better to send it (this IS the stderr path) and +// already exits non-zero, but a renderer that silently swallows write +// failures is the kind of thing that gets copied into one that matters. +// overrides is what the user actually supplied, so the refusal can tell the +// two cases apart. They read completely differently and demand different +// next actions (Codex round 8): a field nobody supplied needs a --field +// added, whereas a field the user DID supply and the destination rejected +// needs a DIFFERENT value — and telling that user "no --field supplied one" +// would send them looking for a bug in their own command line. +func renderItemCopyNeedsValue(out io.Writer, p *cli.ItemCopyPreflight, overrides map[string]any) error { + w := &itemCopyWriter{w: out} + + supplied := func(key string) (any, bool) { + if overrides == nil || strings.TrimSpace(key) == "" { + return nil, false + } + v, ok := overrides[key] + return v, ok + } + rejected := 0 + for _, f := range p.Fields.NeedsValue { + if _, ok := supplied(f.Key); ok { + rejected++ + } + } + + verb := map[bool]string{true: "needs", false: "need"}[len(p.Fields.NeedsValue) == 1] + switch { + case rejected == 0: + fmt.Fprintf(w, "Refusing to %s: %s in %s/%s %s a value, and no --field supplied one.\n", + itemCopyModeNoun(p.ArchiveSource), + pluralize(len(p.Fields.NeedsValue), "field", "fields"), + p.Destination.WorkspaceSlug, p.Destination.CollectionSlug, verb) + case rejected == len(p.Fields.NeedsValue): + fmt.Fprintf(w, "Refusing to %s: the --field %s you supplied %s rejected by %s/%s.\n", + itemCopyModeNoun(p.ArchiveSource), + map[bool]string{true: "value", false: "values"}[rejected == 1], + map[bool]string{true: "was", false: "were"}[rejected == 1], + p.Destination.WorkspaceSlug, p.Destination.CollectionSlug) + default: + fmt.Fprintf(w, "Refusing to %s: %s in %s/%s still %s a value (%d supplied with --field and rejected).\n", + itemCopyModeNoun(p.ArchiveSource), + pluralize(len(p.Fields.NeedsValue), "field", "fields"), + p.Destination.WorkspaceSlug, p.Destination.CollectionSlug, verb, rejected) + } + fmt.Fprintf(w, "No copy was attempted.\n\n") + for _, f := range p.Fields.NeedsValue { + required := "optional" + if f.Required { + required = "required" + } + fmt.Fprintf(w, " %-20s %s %s — %s\n", + itemCopyLine(f.Key), itemCopyTypeLabel(f.Type, f.Label), required, + itemCopyReason(itemCopyNeedsValueReasons, f.Reason)) + if v, ok := supplied(f.Key); ok { + fmt.Fprintf(w, " %-20s you supplied %s — the destination rejected it\n", "", itemCopyValue(v)) + } + if len(f.Options) > 0 { + fmt.Fprintf(w, " %-20s options: %s\n", "", itemCopyList(f.Options)) + } + if f.Message != "" { + fmt.Fprintf(w, " %-20s %s\n", "", itemCopyLine(f.Message)) + } + } + // An entry with an EMPTY key cannot be supplied: `--field =value` is + // rejected by this command's own parser, and the server has nothing to + // match it against either. Printing `--field =` would hand the + // user a command that cannot work, so say what is actually wrong + // instead (Codex round 6). Empty keys are not currently rejected by + // collection-schema validation, so this is reachable. + unnamed := 0 + var toAdd, toFix []string + for _, f := range p.Fields.NeedsValue { + if strings.TrimSpace(f.Key) == "" { + unnamed++ + continue + } + if _, ok := supplied(f.Key); ok { + toFix = append(toFix, itemCopyLine(f.Key)) + continue + } + toAdd = append(toAdd, itemCopyLine(f.Key)) + } + if len(toAdd) > 0 { + fmt.Fprintf(w, "\nAdd:") + for _, k := range toAdd { + fmt.Fprintf(w, " --field %s=", k) + } + fmt.Fprintln(w) + } + if len(toFix) > 0 { + // A different instruction on purpose — repeating "Add: --field + // size=" at a user who already passed --field size=xl is + // how a CLI teaches someone that it is not listening. + fmt.Fprintf(w, "\nCorrect:") + for _, k := range toFix { + fmt.Fprintf(w, " --field %s=", k) + } + fmt.Fprintln(w) + } + if unnamed > 0 { + fmt.Fprintf(w, "\n%s came back with an empty key and cannot be supplied with --field.\n", + pluralize(unnamed, "field", "fields")) + fmt.Fprintf(w, "That is a problem with the destination collection's schema, not something\nthis command can resolve — fix the schema in %s/%s.\n", + p.Destination.WorkspaceSlug, p.Destination.CollectionSlug) + } + return w.err +} + +// renderItemCopyResult writes the outcome of a completed copy. +func renderItemCopyResult(out io.Writer, r *cli.ItemCopyResult) error { + w := &itemCopyWriter{w: out} + verb := "Copied" + if r.Source.Archived { + verb = "Moved" + } + fmt.Fprintf(w, "%s %s → %s %s\n\n", + verb, + itemCopyRef(r.Source.Ref, r.Source.Slug), + r.Destination.WorkspaceSlug, + itemCopyRef(r.Destination.Ref, r.Destination.Slug)) + + fmt.Fprintf(w, " %-13s %s/%s %s %q\n", "Source", + r.Source.WorkspaceSlug, r.Source.CollectionSlug, + itemCopyRef(r.Source.Ref, r.Source.Slug), r.Source.Title) + // Archived is what HAPPENED; archive_source is what was ASKED for. + // Report the fact, and flag a disagreement rather than hiding it. + fmt.Fprintf(w, " %-13s %s\n", " archived", yesNo(r.Source.Archived)) + if r.ArchiveSource != r.Source.Archived { + fmt.Fprintf(w, " %-13s --archive-source was %s but the source is %sarchived\n", " WARNING", + yesNo(r.ArchiveSource), map[bool]string{true: "", false: "not "}[r.Source.Archived]) + } + fmt.Fprintf(w, " %-13s %s/%s %s %s\n", "Destination", + r.Destination.WorkspaceSlug, r.Destination.CollectionSlug, + itemCopyRef(r.Destination.Ref, r.Destination.Slug), r.Destination.Slug) + + fmt.Fprintf(w, "\nWarnings\n") + warn := r.Warnings + dropped := "(none)" + if len(warn.DroppedFields) > 0 { + dropped = itemCopyList(warn.DroppedFields) + } + fmt.Fprintf(w, " %-32s %s\n", "fields dropped", dropped) + fmt.Fprintf(w, " %-32s %s\n", "assignee dropped", yesNo(warn.DroppedAssignee)) + fmt.Fprintf(w, " %-32s %s\n", "agent role dropped", yesNo(warn.DroppedAgentRole)) + fmt.Fprintf(w, " %-32s %s\n", "attachments cloned", itemCopyAttachmentSummary(warn.AttachmentCount, warn.AttachmentBytes)) + fmt.Fprintf(w, " %-32s %d\n", "unresolvable attachment refs", warn.UnresolvableRefCount) + return w.err +} + +func writeItemCopyHeader(w io.Writer, p *cli.ItemCopyPreflight) { + fmt.Fprintf(w, " %-13s %s/%s %s %q\n", "Source", + p.Source.WorkspaceSlug, p.Source.CollectionSlug, + itemCopyRef(p.Source.Ref, p.Source.Slug), p.Source.Title) + fmt.Fprintf(w, " %-13s %s/%s (%s / %s)\n", "Destination", + p.Destination.WorkspaceSlug, p.Destination.CollectionSlug, + p.Destination.WorkspaceName, p.Destination.CollectionName) + mode := "copy — the source is left untouched" + if p.ArchiveSource { + mode = "move — copy, then archive the source" + } + fmt.Fprintf(w, " %-13s %s\n", "Mode", mode) +} + +func itemCopyModeNoun(archiveSource bool) string { + if archiveSource { + return "move" + } + return "copy" +} + +// itemCopyRef prefers the issue ID and falls back to the slug, which is +// always present. `ref` is omitempty on the wire. +func itemCopyRef(ref, slug string) string { + if ref != "" { + return ref + } + return slug +} + +// itemCopyTypeLabel renders the "(Label, type)" annotation, tolerating +// either or both being absent (both are omitempty on the wire). +func itemCopyTypeLabel(fieldType, label string) string { + label = itemCopyLine(label) + fieldType = itemCopyLine(fieldType) + switch { + case label != "" && fieldType != "": + return fmt.Sprintf("(%s, %s)", label, fieldType) + case label != "": + return fmt.Sprintf("(%s)", label) + case fieldType != "": + return fmt.Sprintf("(%s)", fieldType) + default: + return "()" + } +} + +// itemCopyLine makes a server-supplied string safe to drop into a table +// row. Schema keys, labels, option values, link types and validator +// messages are all user-authored and travel through the server verbatim, so +// a newline in one of them could forge a row and a control character could +// scramble the terminal. Anything carrying a control character is rendered +// in Go quoted form; everything else — overwhelmingly the common case — is +// passed through byte-for-byte, because quoting every string would make the +// ordinary output harder to read for no benefit. +func itemCopyLine(s string) string { + for _, r := range s { + if r < 0x20 || r == 0x7f { + return strconv.Quote(s) + } + } + return s +} + +// itemCopyList renders a list of server-supplied strings. +// +// Every element is quoted, unconditionally — unlike itemCopyLine, which +// quotes only when it must. The separator is the whole problem here: a +// select option or schema key may legitimately contain ", ", and joined +// bare it would read as two entries. `"ready, waiting"` has to be +// distinguishable from `ready`, `waiting`, because in a needs_value block +// the user is about to retype one of them into --field. +func itemCopyList(items []string) string { + parts := make([]string, len(items)) + for i, s := range items { + parts[i] = strconv.Quote(s) + } + return strings.Join(parts, ", ") +} + +// itemCopyValue renders a carried field value without lying about it: a +// JSON null renders as an explicit marker, strings are quoted so an empty +// string is visible, and structured values keep their JSON form. +func itemCopyValue(v any) string { + if v == nil { + return "(null)" + } + if s, ok := v.(string); ok { + return fmt.Sprintf("%q", s) + } + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return fmt.Sprintf("%v", v) +} + +// itemCopyLinkSummary renders a link-type→count map as "2 (blocks 1, +// related 1)". An empty or absent map renders "0", never a blank — a +// missing count and a zero count must not look the same. +func itemCopyLinkSummary(m map[string]int) string { + total := 0 + keys := make([]string, 0, len(m)) + for k, v := range m { + total += v + keys = append(keys, k) + } + if len(keys) == 0 { + return "0" + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s %d", itemCopyLine(k), m[k])) + } + return fmt.Sprintf("%d (%s)", total, strings.Join(parts, ", ")) +} + +// itemCopyAttachmentSummary renders count + bytes. The exact byte count is +// always shown alongside the human-readable form so the rounding in +// "1.2 MiB" can never be mistaken for the real number. +func itemCopyAttachmentSummary(count int, bytes int64) string { + if count == 0 && bytes == 0 { + return "0" + } + return fmt.Sprintf("%d (%s, %d bytes)", count, humanBytes(bytes), bytes) +} + // --- artifact export / import --- func itemExportCmd() *cobra.Command { diff --git a/cmd/pad/groups.go b/cmd/pad/groups.go index c2e824e8..9ae153e1 100644 --- a/cmd/pad/groups.go +++ b/cmd/pad/groups.go @@ -116,6 +116,7 @@ func itemCmd() *cobra.Command { deleteCmd(), restoreCmd(), moveCmd(), + itemCopyCmd(), itemExportCmd(), itemImportCmd(), editCmd(), diff --git a/cmd/pad/item_copy_test.go b/cmd/pad/item_copy_test.go new file mode 100644 index 00000000..6fe4c434 --- /dev/null +++ b/cmd/pad/item_copy_test.go @@ -0,0 +1,1319 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/cli" + "github.com/PerpetualSoftware/pad/internal/models" +) + +// ── fixtures ───────────────────────────────────────────────────────────── + +// fullPreflight is a preview where EVERY bucket and EVERY warning is +// non-trivial. The rendering tests assert against it so a dropped line is a +// failure rather than an omission nobody notices. +func fullPreflight() *cli.ItemCopyPreflight { + return &cli.ItemCopyPreflight{ + Source: cli.ItemCopyPreflightSource{ + WorkspaceSlug: "docapp", CollectionSlug: "ideas", + Ref: "IDEA-12", Slug: "cross-workspace-copy", Title: "Cross-workspace copy", + }, + Destination: cli.ItemCopyPreflightDestination{ + WorkspaceSlug: "pad-web", WorkspaceName: "Pad Web", + CollectionSlug: "tasks", CollectionName: "Tasks", + }, + ArchiveSource: true, + Valid: false, + Fields: cli.ItemCopyPreflightFields{ + Carried: []cli.ItemCopyPreflightCarried{ + {Key: "status", Label: "Status", Type: "select", Value: "todo", From: "default"}, + {Key: "points", Label: "Points", Type: "number", Value: 3.0, From: "migrated"}, + {Key: "notes", Type: "text", Value: "", From: "override"}, + }, + Dropped: []cli.ItemCopyPreflightDropped{ + {Key: "impact", Label: "Impact", Kind: "field", Reason: "no_target_field"}, + {Key: "assignee", Label: "Assignee", Kind: "assignment", Reason: "assignee_not_a_member"}, + {Key: "agent_role", Label: "Agent role", Kind: "assignment", Reason: "agent_role_not_portable"}, + }, + NeedsValue: []cli.ItemCopyPreflightNeedsValue{ + {Key: "priority", Label: "Priority", Type: "select", Options: []string{"low", "high"}, Required: true, Reason: "missing_required"}, + {Key: "size", Label: "Size", Type: "select", Required: false, Reason: "invalid_value", Message: `"xl" is not one of s, m, l`}, + }, + }, + Warnings: cli.ItemCopyPreflightWarnings{ + ChildCount: 2, + ChildrenOrphaned: true, + DroppedParent: true, + OutgoingLinks: map[string]int{"related": 1, "blocks": 2}, + IncomingLinks: map[string]int{"blocked-by": 1}, + DroppedAssignee: true, + DroppedAgentRole: true, + AttachmentCount: 3, + AttachmentBytes: 1258291, + UnresolvableRefCount: 1, + }, + } +} + +// emptyPreflight is the everything-is-zero case. Its whole job is to prove +// "nothing to report" renders as an explicit zero rather than as silence. +func emptyPreflight() *cli.ItemCopyPreflight { + return &cli.ItemCopyPreflight{ + Source: cli.ItemCopyPreflightSource{ + WorkspaceSlug: "docapp", CollectionSlug: "ideas", Ref: "IDEA-12", Slug: "x", Title: "X", + }, + Destination: cli.ItemCopyPreflightDestination{ + WorkspaceSlug: "pad-web", WorkspaceName: "Pad Web", CollectionSlug: "tasks", CollectionName: "Tasks", + }, + Valid: true, + Fields: cli.ItemCopyPreflightFields{ + Carried: []cli.ItemCopyPreflightCarried{}, + Dropped: []cli.ItemCopyPreflightDropped{}, + NeedsValue: []cli.ItemCopyPreflightNeedsValue{}, + }, + Warnings: cli.ItemCopyPreflightWarnings{ + OutgoingLinks: map[string]int{}, + IncomingLinks: map[string]int{}, + }, + } +} + +// recordingDeps captures what the command sent where, and fails the test if +// the mutating call happens when it must not. +type recordingDeps struct { + t *testing.T + preflightCalls []cli.ItemCopyRequest + copyCalls []cli.ItemCopyRequest + schemaCalls [][2]string + + preflight *cli.ItemCopyPreflight + preflightRaw json.RawMessage + preflightErr error + // preflightFn, when set, computes the preview FROM the request — the + // way the real endpoint does. Takes precedence over preflight. + preflightFn func(cli.ItemCopyRequest) *cli.ItemCopyPreflight + + result *cli.ItemCopyResult + resultRaw json.RawMessage + copyErr error + + schema models.CollectionSchema + // forbidCopy makes any mutating call a test failure. + forbidCopy bool +} + +func (d *recordingDeps) deps() itemCopyDeps { + return itemCopyDeps{ + Schema: func(ws, coll string) models.CollectionSchema { + d.schemaCalls = append(d.schemaCalls, [2]string{ws, coll}) + return d.schema + }, + Preflight: func(req cli.ItemCopyRequest) (*cli.ItemCopyPreflight, json.RawMessage, error) { + d.preflightCalls = append(d.preflightCalls, req) + pre := d.preflight + if d.preflightFn != nil { + pre = d.preflightFn(req) + } + raw := d.preflightRaw + if raw == nil && pre != nil { + b, _ := json.Marshal(pre) + raw = b + } + return pre, raw, d.preflightErr + }, + Copy: func(req cli.ItemCopyRequest) (*cli.ItemCopyResult, json.RawMessage, error) { + if d.forbidCopy { + d.t.Errorf("MUTATING COPY SENT — this path must send no mutating request. req=%+v", req) + } + d.copyCalls = append(d.copyCalls, req) + return d.result, d.resultRaw, d.copyErr + }, + } +} + +func baseOpts() itemCopyOptions { + return itemCopyOptions{ + Ref: "IDEA-12", TargetWorkspace: "pad-web", TargetCollection: "tasks", Format: "table", + } +} + +// ── dry-run rendering: every bucket, every warning ─────────────────────── + +func TestRunItemCopy_DryRunRendersEveryBucketAndWarning(t *testing.T) { + d := &recordingDeps{t: t, preflight: fullPreflight(), forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + opts.ArchiveSource = true + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + got := out.String() + + want := []string{ + // header + "Copy preview — dry run. Nothing was changed.", + "docapp/ideas IDEA-12 \"Cross-workspace copy\"", + "pad-web/tasks (Pad Web / Tasks)", + "move — copy, then archive the source", + + // the three contract bucket names, with counts + "carried (3)", + "dropped (3)", + "needs_value (2)", + + // carried rows, including the provenance of each value + `status (Status, select) = "todo" [from default]`, + "points (Points, number) = 3 [from migrated]", + `notes (text) = "" [from override]`, + + // dropped rows: kind AND reason, for schema fields and the DR-8 pair + "impact (Impact) (field) — the destination collection has no such field", + "assignee (Assignee) (assignment) — the assignee is not a member of the destination workspace", + "agent_role (Agent role) (assignment) — agent roles are workspace-local and never carry", + + // needs_value rows: required-ness, reason, options and the + // validator's own message + "priority (Priority, select) required — required, with no value to carry and no default", + `options: "low", "high"`, + "size (Size, select) optional — the carried value is not valid in the destination", + `"xl" is not one of s, m, l`, + + // DR-15's full warning set — all nine lines + "child items (not copied) 2", + "children orphaned by the move yes", + "parent dropped yes", + "outgoing links dropped 3 (blocks 2, related 1)", + "incoming links dropped 1 (blocked-by 1)", + "assignee dropped yes", + "agent role dropped yes", + "attachments cloned 3 (1.2 MiB, 1258291 bytes)", + "unresolvable attachment refs 1", + + "2 fields still need a value", + } + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("dry-run output missing %q\n--- got ---\n%s", w, got) + } + } + if errOut.Len() != 0 { + t.Errorf("dry run should write nothing to stderr; got %q", errOut.String()) + } + if len(d.preflightCalls) != 1 { + t.Fatalf("expected exactly one preflight call; got %d", len(d.preflightCalls)) + } + if !d.preflightCalls[0].ArchiveSource { + t.Error("--archive-source must reach the preflight so it can weight children_orphaned") + } +} + +// The zero case is the one a careless renderer gets wrong: an omitted line +// makes "no attachments" look identical to "this CLI does not report +// attachments". +func TestRunItemCopy_DryRunRendersZeroAndEmptyExplicitly(t *testing.T) { + d := &recordingDeps{t: t, preflight: emptyPreflight(), forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + got := out.String() + + want := []string{ + "copy — the source is left untouched", + "carried (0)", + "dropped (0)", + "needs_value (0)", + "child items (not copied) 0", + "children orphaned by the move no", + "parent dropped no", + "outgoing links dropped 0", + "incoming links dropped 0", + "assignee dropped no", + "agent role dropped no", + "attachments cloned 0", + "unresolvable attachment refs 0", + "The field mapping is complete. Re-run without --dry-run to perform the copy.", + } + for _, w := range want { + if !strings.Contains(got, w) { + t.Errorf("output missing %q\n--- got ---\n%s", w, got) + } + } + // Each empty bucket says so out loud. + if n := strings.Count(got, "(none)"); n != 3 { + t.Errorf("expected each of the three empty buckets to render (none); got %d\n%s", n, got) + } +} + +// A nil link map (a server that omitted the key entirely) must still render +// a zero, never a blank. +func TestItemCopyLinkSummary(t *testing.T) { + cases := []struct { + name string + in map[string]int + want string + }{ + {"nil", nil, "0"}, + {"empty", map[string]int{}, "0"}, + {"one", map[string]int{"blocks": 2}, "2 (blocks 2)"}, + {"sorted", map[string]int{"related": 1, "blocks": 2, "a": 3}, "6 (a 3, blocks 2, related 1)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := itemCopyLinkSummary(tc.in); got != tc.want { + t.Errorf("itemCopyLinkSummary(%v) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +// Codex round 2. Schema keys, labels and option values are user-authored +// and travel through the server verbatim, so the renderer must not let them +// forge structure: an option containing ", " has to stay ONE option (the +// user is about to retype it into --field), and a newline anywhere must not +// be able to invent a table row. +func TestRunItemCopy_HostileSchemaStringsCannotForgeStructure(t *testing.T) { + pre := emptyPreflight() + pre.Valid = false + pre.Fields.NeedsValue = []cli.ItemCopyPreflightNeedsValue{ + {Key: "state", Label: "State", Type: "select", Required: true, Reason: "missing_required", + Options: []string{"ready, waiting", "done"}}, + {Key: "forged\n fake_key (Text) required — invented", Required: true, Reason: "missing_required"}, + } + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + got := out.String() + + // Two options, one of which contains a comma — they must be + // distinguishable from three plain options. + if !strings.Contains(got, `options: "ready, waiting", "done"`) { + t.Errorf("a comma inside an option must not read as a separator:\n%s", got) + } + // The forged key must be escaped onto a single line — no output line + // may BEGIN with the invented row. + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "fake_key") { + t.Errorf("a newline in a schema key forged a table row: %q\n%s", line, got) + } + } + if !strings.Contains(got, `"forged\n fake_key`) { + t.Errorf("expected the control character to be escaped:\n%s", got) + } + // needs_value still says 2, not 3. + if !strings.Contains(got, "needs_value (2)") { + t.Errorf("bucket count should be the server's:\n%s", got) + } +} + +// Codex round 6. A needs_value entry with an empty key cannot be supplied +// with --field (the parser rejects `--field =value`), so the refusal must +// not print a command that cannot work. +func TestRunItemCopy_RefusalHandlesAnEmptyFieldKey(t *testing.T) { + pre := emptyPreflight() + pre.Valid = false + pre.Fields.NeedsValue = []cli.ItemCopyPreflightNeedsValue{ + {Key: "", Label: "Nameless", Required: true, Reason: "missing_required"}, + {Key: "priority", Required: true, Reason: "missing_required"}, + } + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + + var out, errOut bytes.Buffer + if err := runItemCopy(baseOpts(), d.deps(), &out, &errOut); err == nil { + t.Fatal("expected a refusal") + } + stderr := errOut.String() + if strings.Contains(stderr, "--field =") { + t.Errorf("must not print a --field the parser would reject:\n%s", stderr) + } + if !strings.Contains(stderr, "--field priority=") { + t.Errorf("the resolvable field must still be offered:\n%s", stderr) + } + if !strings.Contains(stderr, "empty key") { + t.Errorf("the unresolvable entry must be called out:\n%s", stderr) + } +} + +// When EVERY entry is unnamed there is nothing to add, so the "Add:" line +// must not be printed at all. +func TestRunItemCopy_RefusalWithOnlyEmptyKeys(t *testing.T) { + pre := emptyPreflight() + pre.Valid = false + pre.Fields.NeedsValue = []cli.ItemCopyPreflightNeedsValue{{Key: " ", Required: true, Reason: "missing_required"}} + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + + var out, errOut bytes.Buffer + if err := runItemCopy(baseOpts(), d.deps(), &out, &errOut); err == nil { + t.Fatal("expected a refusal") + } + if strings.Contains(errOut.String(), "Add:") { + t.Errorf("nothing can be added; the Add line must be omitted:\n%s", errOut.String()) + } +} + +func TestItemCopyLineAndList(t *testing.T) { + // The common case is passed through untouched — quoting everything + // would make ordinary output worse for no gain. + if got := itemCopyLine("priority"); got != "priority" { + t.Errorf("itemCopyLine(%q) = %q", "priority", got) + } + if got := itemCopyLine("Due date"); got != "Due date" { + t.Errorf("itemCopyLine kept a space wrongly: %q", got) + } + // Control characters force the quoted form. + for _, s := range []string{"a\nb", "a\tb", "a\rb", "a\x1b[2Jb", "a\x7fb"} { + if got := itemCopyLine(s); !strings.HasPrefix(got, `"`) { + t.Errorf("itemCopyLine(%q) = %q, want it quoted", s, got) + } + } + // Lists always quote, because the separator is the ambiguity. + if got := itemCopyList([]string{"a, b", "c"}); got != `"a, b", "c"` { + t.Errorf("itemCopyList = %q", got) + } + if got := itemCopyList(nil); got != "" { + t.Errorf("itemCopyList(nil) = %q", got) + } +} + +// A reason code this CLI has never heard of must reach the user verbatim +// rather than being flattened into a generic phrase or dropped. +func TestItemCopyReason_UnknownCodeSurfacesVerbatim(t *testing.T) { + if got := itemCopyReason(itemCopyDroppedReasons, "some_future_reason"); got != "some_future_reason" { + t.Errorf("unknown reason rendered as %q", got) + } + if got := itemCopyReason(itemCopyNeedsValueReasons, ""); got != "(no reason given)" { + t.Errorf("empty reason rendered as %q", got) + } + if got := itemCopyReason(itemCopyDroppedReasons, "incompatible_type"); got == "incompatible_type" { + t.Error("known reason should be translated to a sentence") + } +} + +// --archive-source on a dry run reports the consequence and performs none +// of it. +func TestRunItemCopy_DryRunArchiveSourceReportsWithoutPerforming(t *testing.T) { + pre := fullPreflight() + pre.Fields.NeedsValue = nil // otherwise the refusal message dominates + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + opts.ArchiveSource = true + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + got := out.String() + for _, w := range []string{ + "move — copy, then archive the source", + "children orphaned by the move yes", + "Re-run without --dry-run to perform the move.", + } { + if !strings.Contains(got, w) { + t.Errorf("output missing %q\n--- got ---\n%s", w, got) + } + } + if len(d.copyCalls) != 0 { + t.Fatalf("a dry run must perform no copy; got %d", len(d.copyCalls)) + } +} + +// ── --format json fidelity ─────────────────────────────────────────────── + +func TestRunItemCopy_DryRunJSONIsTheServerResponse(t *testing.T) { + const payload = `{"valid":false,"fields":{"carried":[],"dropped":[],"needs_value":[{"key":"priority","required":true,"reason":"missing_required"}]},` + + `"warnings":{"attachment_bytes":9007199254740993,"outgoing_links":{},"incoming_links":{}},"future_field":"kept"}` + var pre cli.ItemCopyPreflight + if err := json.Unmarshal([]byte(payload), &pre); err != nil { + t.Fatalf("fixture: %v", err) + } + d := &recordingDeps{t: t, preflight: &pre, preflightRaw: json.RawMessage(payload), forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + opts.Format = "json" + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, out.Bytes()); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if compact.String() != payload { + t.Errorf("--format json altered the server's response.\n got: %s\nwant: %s", compact.String(), payload) + } +} + +// The acceptance criterion asks for each bucket and each warning to be +// asserted in --format json as well as in the table, so this walks the SAME +// full fixture as TestRunItemCopy_DryRunRendersEveryBucketAndWarning and +// checks every field arrives with the right value and the right JSON name. +// The test above pins byte-fidelity; this one pins coverage. +func TestRunItemCopy_DryRunJSONCarriesEveryBucketAndWarning(t *testing.T) { + pre := fullPreflight() + pre.ArchiveSource = true + raw, err := json.Marshal(pre) + if err != nil { + t.Fatalf("fixture: %v", err) + } + d := &recordingDeps{t: t, preflight: pre, preflightRaw: raw, forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + opts.ArchiveSource = true + opts.Format = "json" + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + + var doc struct { + ArchiveSource bool `json:"archive_source"` + Valid bool `json:"valid"` + Fields struct { + Carried []struct { + Key string `json:"key"` + From string `json:"from"` + Value any `json:"value"` + } `json:"carried"` + Dropped []struct { + Key string `json:"key"` + Kind string `json:"kind"` + Reason string `json:"reason"` + } `json:"dropped"` + NeedsValue []struct { + Key string `json:"key"` + Required bool `json:"required"` + Reason string `json:"reason"` + Options []string `json:"options"` + Message string `json:"message"` + } `json:"needs_value"` + } `json:"fields"` + Warnings struct { + ChildCount *int `json:"child_count"` + ChildrenOrphaned *bool `json:"children_orphaned"` + DroppedParent *bool `json:"dropped_parent"` + OutgoingLinks map[string]int `json:"outgoing_links"` + IncomingLinks map[string]int `json:"incoming_links"` + DroppedAssignee *bool `json:"dropped_assignee"` + DroppedAgentRole *bool `json:"dropped_agent_role"` + AttachmentCount *int `json:"attachment_count"` + AttachmentBytes *int64 `json:"attachment_bytes"` + UnresolvableRefCount *int `json:"unresolvable_ref_count"` + } `json:"warnings"` + } + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("stdout is not the preflight JSON: %v\n%s", err, out.String()) + } + + // The move flag and the gate. + if !doc.ArchiveSource { + t.Error("archive_source did not survive to JSON") + } + if doc.Valid { + t.Error("valid should be false on this fixture") + } + + // All three contract buckets, with their contents. + if len(doc.Fields.Carried) != 3 || doc.Fields.Carried[0].Key != "status" || doc.Fields.Carried[0].From != "default" { + t.Errorf("carried bucket wrong: %+v", doc.Fields.Carried) + } + if len(doc.Fields.Dropped) != 3 { + t.Fatalf("dropped bucket wrong: %+v", doc.Fields.Dropped) + } + for i, want := range []struct{ key, kind, reason string }{ + {"impact", "field", "no_target_field"}, + {"assignee", "assignment", "assignee_not_a_member"}, + {"agent_role", "assignment", "agent_role_not_portable"}, + } { + got := doc.Fields.Dropped[i] + if got.Key != want.key || got.Kind != want.kind || got.Reason != want.reason { + t.Errorf("dropped[%d] = %+v, want %+v", i, got, want) + } + } + if len(doc.Fields.NeedsValue) != 2 { + t.Fatalf("needs_value bucket wrong: %+v", doc.Fields.NeedsValue) + } + if nv := doc.Fields.NeedsValue[0]; nv.Key != "priority" || !nv.Required || nv.Reason != "missing_required" || + len(nv.Options) != 2 { + t.Errorf("needs_value[0] = %+v", nv) + } + if nv := doc.Fields.NeedsValue[1]; nv.Key != "size" || nv.Required || nv.Reason != "invalid_value" || nv.Message == "" { + t.Errorf("needs_value[1] = %+v", nv) + } + + // DR-15's full warning set — pointers so an ABSENT key fails rather + // than silently decoding as the zero value. + // Independent checks, not a switch: every missing warning should be + // named in one run rather than one per fix cycle. + warn := doc.Warnings + if warn.ChildCount == nil || *warn.ChildCount != 2 { + t.Errorf("child_count = %v", warn.ChildCount) + } + if warn.ChildrenOrphaned == nil || !*warn.ChildrenOrphaned { + t.Errorf("children_orphaned = %v", warn.ChildrenOrphaned) + } + if warn.DroppedParent == nil || !*warn.DroppedParent { + t.Errorf("dropped_parent = %v", warn.DroppedParent) + } + if warn.DroppedAssignee == nil || !*warn.DroppedAssignee { + t.Errorf("dropped_assignee = %v", warn.DroppedAssignee) + } + if warn.DroppedAgentRole == nil || !*warn.DroppedAgentRole { + t.Errorf("dropped_agent_role = %v", warn.DroppedAgentRole) + } + if warn.AttachmentCount == nil || *warn.AttachmentCount != 3 { + t.Errorf("attachment_count = %v", warn.AttachmentCount) + } + if warn.AttachmentBytes == nil || *warn.AttachmentBytes != 1258291 { + t.Errorf("attachment_bytes = %v", warn.AttachmentBytes) + } + if warn.UnresolvableRefCount == nil || *warn.UnresolvableRefCount != 1 { + t.Errorf("unresolvable_ref_count = %v", warn.UnresolvableRefCount) + } + if warn.OutgoingLinks["blocks"] != 2 || warn.OutgoingLinks["related"] != 1 { + t.Errorf("outgoing_links = %v", warn.OutgoingLinks) + } + if warn.IncomingLinks["blocked-by"] != 1 { + t.Errorf("incoming_links = %v", warn.IncomingLinks) + } + + // And the empty case: every warning key must still be PRESENT, so a + // script cannot confuse "zero" with "this CLI dropped the field". + d2 := &recordingDeps{t: t, preflight: emptyPreflight(), forbidCopy: true} + var out2, errOut2 bytes.Buffer + opts2 := baseOpts() + opts2.DryRun = true + opts2.Format = "json" + if err := runItemCopy(opts2, d2.deps(), &out2, &errOut2); err != nil { + t.Fatalf("runItemCopy (empty): %v", err) + } + var empty struct { + Fields map[string]json.RawMessage `json:"fields"` + Warnings map[string]json.RawMessage `json:"warnings"` + } + if err := json.Unmarshal(out2.Bytes(), &empty); err != nil { + t.Fatalf("empty preflight JSON: %v", err) + } + for _, k := range []string{"carried", "dropped", "needs_value"} { + if _, ok := empty.Fields[k]; !ok { + t.Errorf("fields.%s missing from the zero-case JSON", k) + } + } + for _, k := range []string{ + "child_count", "children_orphaned", "dropped_parent", "outgoing_links", "incoming_links", + "dropped_assignee", "dropped_agent_role", "attachment_count", "attachment_bytes", "unresolvable_ref_count", + } { + if _, ok := empty.Warnings[k]; !ok { + t.Errorf("warnings.%s missing from the zero-case JSON", k) + } + } +} + +func TestRunItemCopy_CopyJSONIsTheServerResponse(t *testing.T) { + const payload = `{"source":{"slug":"a","archived":true,"seq":9007199254740993},"destination":{"slug":"b"},"archive_source":true,"item":null,"warnings":{"dropped_fields":[]},"future_field":1}` + var res cli.ItemCopyResult + if err := json.Unmarshal([]byte(payload), &res); err != nil { + t.Fatalf("fixture: %v", err) + } + pre := emptyPreflight() + d := &recordingDeps{t: t, preflight: pre, result: &res, resultRaw: json.RawMessage(payload)} + opts := baseOpts() + opts.Format = "json" + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + var compact bytes.Buffer + if err := json.Compact(&compact, out.Bytes()); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, out.String()) + } + if compact.String() != payload { + t.Errorf("--format json altered the server's response.\n got: %s\nwant: %s", compact.String(), payload) + } +} + +// ── refuse to guess ────────────────────────────────────────────────────── + +func TestRunItemCopy_RefusesWhenNeedsValueAndSendsNoMutatingRequest(t *testing.T) { + d := &recordingDeps{t: t, preflight: fullPreflight(), forbidCopy: true} + + var out, errOut bytes.Buffer + err := runItemCopy(baseOpts(), d.deps(), &out, &errOut) + if err == nil { + t.Fatal("expected a non-nil error so the command exits non-zero") + } + if !strings.Contains(err.Error(), "copy refused") { + t.Errorf("error should say the copy was refused; got %v", err) + } + if len(d.copyCalls) != 0 { + t.Fatalf("no mutating request may be sent; got %d", len(d.copyCalls)) + } + + stderr := errOut.String() + for _, w := range []string{ + "No copy was attempted.", + "priority", + "size", + `options: "low", "high"`, + `"xl" is not one of s, m, l`, + "--field priority=", + "--field size=", + } { + if !strings.Contains(stderr, w) { + t.Errorf("refusal missing %q\n--- stderr ---\n%s", w, stderr) + } + } + if out.Len() != 0 { + t.Errorf("the refusal belongs on stderr; stdout got %q", out.String()) + } +} + +// On --format json the refusal still emits the endpoint's own response, so +// a script gets machine-readable needs_value rather than a bare exit code. +func TestRunItemCopy_RefusalUnderJSONEmitsThePreflight(t *testing.T) { + pre := fullPreflight() + raw, _ := json.Marshal(pre) + d := &recordingDeps{t: t, preflight: pre, preflightRaw: raw, forbidCopy: true} + opts := baseOpts() + opts.Format = "json" + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err == nil { + t.Fatal("expected a non-zero exit") + } + var decoded cli.ItemCopyPreflight + if err := json.Unmarshal(out.Bytes(), &decoded); err != nil { + t.Fatalf("stdout is not the preflight JSON: %v\n%s", err, out.String()) + } + if len(decoded.Fields.NeedsValue) != 2 { + t.Errorf("needs_value should survive to the script; got %d entries", len(decoded.Fields.NeedsValue)) + } +} + +// The mirror of the refusal: the SAME command, against the SAME server +// behaviour, refuses without --field and proceeds with it. +// +// The fake preflight here is responsive rather than canned (Codex round 3): +// it reports needs_value=[priority] unless the request carries a priority +// override, exactly as the real endpoint does. A canned empty preflight +// would have made this a test of argument forwarding wearing the label of a +// test of the refuse-to-guess transition. +func TestRunItemCopy_RefusesWithoutOverrideAndProceedsWithIt(t *testing.T) { + newDeps := func(t *testing.T, forbidCopy bool) *recordingDeps { + d := &recordingDeps{ + t: t, + forbidCopy: forbidCopy, + result: &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{WorkspaceSlug: "docapp", CollectionSlug: "ideas", Ref: "IDEA-12", Slug: "x", Title: "X"}, + Destination: cli.ItemCopyResultDestination{WorkspaceSlug: "pad-web", CollectionSlug: "tasks", Ref: "TASK-9", Slug: "x"}, + Warnings: cli.ItemCopyResultWarnings{DroppedFields: []string{}}, + }, + } + d.preflightFn = func(req cli.ItemCopyRequest) *cli.ItemCopyPreflight { + pre := emptyPreflight() + if _, ok := req.FieldOverrides["priority"]; !ok { + pre.Valid = false + pre.Fields.NeedsValue = []cli.ItemCopyPreflightNeedsValue{ + {Key: "priority", Label: "Priority", Type: "select", Required: true, Reason: "missing_required"}, + } + } + return pre + } + return d + } + + t.Run("without --field: refuses, no mutation", func(t *testing.T) { + d := newDeps(t, true) + var out, errOut bytes.Buffer + err := runItemCopy(baseOpts(), d.deps(), &out, &errOut) + if err == nil { + t.Fatal("expected a refusal") + } + if len(d.copyCalls) != 0 { + t.Fatalf("no mutating request may be sent; got %d", len(d.copyCalls)) + } + if !strings.Contains(errOut.String(), "--field priority=") { + t.Errorf("the refusal must name the flag to add:\n%s", errOut.String()) + } + }) + + t.Run("with --field: proceeds", func(t *testing.T) { + d := newDeps(t, false) + opts := baseOpts() + opts.Fields = []string{"priority=high"} + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + if len(d.copyCalls) != 1 { + t.Fatalf("expected exactly one mutating call; got %d", len(d.copyCalls)) + } + if got := d.copyCalls[0].FieldOverrides["priority"]; got != "high" { + t.Errorf("override did not reach the copy: %v", got) + } + if got := d.preflightCalls[0].FieldOverrides["priority"]; got != "high" { + t.Errorf("override did not reach the preflight: %v", got) + } + if !strings.Contains(out.String(), "Copied IDEA-12 → pad-web TASK-9") { + t.Errorf("unexpected output:\n%s", out.String()) + } + }) +} + +// failingWriter is a stdout that stops working partway through. +type failingWriter struct { + after int + n int +} + +func (f *failingWriter) Write(p []byte) (int, error) { + f.n++ + if f.n > f.after { + return 0, errors.New("no space left on device") + } + return len(p), nil +} + +// A write failure AFTER the copy committed must not become a non-zero +// exit. A script that sees a non-zero exit will reasonably conclude the +// copy did not happen, and the obvious recovery — running it again — is +// exactly the DR-13 duplicate. The user is told on stderr instead. +func TestRunItemCopy_WriteFailureAfterCommitDoesNotFailTheCommand(t *testing.T) { + for _, format := range []string{"table", "json"} { + t.Run(format, func(t *testing.T) { + raw := json.RawMessage(`{"source":{"slug":"x"},"destination":{"slug":"y"},"warnings":{"dropped_fields":[]}}`) + d := &recordingDeps{ + t: t, + preflight: emptyPreflight(), + result: &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{WorkspaceSlug: "docapp", CollectionSlug: "ideas", Slug: "x"}, + Destination: cli.ItemCopyResultDestination{WorkspaceSlug: "pad-web", CollectionSlug: "tasks", Slug: "y"}, + Warnings: cli.ItemCopyResultWarnings{}, + }, + resultRaw: raw, + } + opts := baseOpts() + opts.Format = format + + var errOut bytes.Buffer + err := runItemCopy(opts, d.deps(), &failingWriter{}, &errOut) + if err != nil { + t.Fatalf("a committed copy must exit 0 even when its report cannot be written; got %v", err) + } + stderr := errOut.String() + for _, w := range []string{"copy SUCCEEDED", "Do not re-run", "pad item list tasks --workspace pad-web"} { + if !strings.Contains(stderr, w) { + t.Errorf("stderr missing %q\n%s", w, stderr) + } + } + }) + } +} + +// The dry run has the opposite obligation: nothing happened, so failing to +// print the preview IS the failure and must exit non-zero. +func TestRunItemCopy_WriteFailureOnDryRunIsAnError(t *testing.T) { + for _, format := range []string{"table", "json"} { + t.Run(format, func(t *testing.T) { + d := &recordingDeps{t: t, preflight: fullPreflight(), forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + opts.Format = format + var errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &failingWriter{}, &errOut); err == nil { + t.Fatal("a dry run that could not be printed must exit non-zero") + } + }) + } +} + +// `valid: false` with an empty needs_value is a server contract violation. +// Guessing past it is the one thing this command must not do. +func TestRunItemCopy_RefusesOnInvalidWithoutNamedFields(t *testing.T) { + pre := emptyPreflight() + pre.Valid = false + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + + var out, errOut bytes.Buffer + err := runItemCopy(baseOpts(), d.deps(), &out, &errOut) + if err == nil { + t.Fatal("expected a refusal") + } + if !strings.Contains(err.Error(), "without naming a field") { + t.Errorf("unexpected error: %v", err) + } +} + +// ── DR-13 at the command layer ─────────────────────────────────────────── + +func TestRunItemCopy_AmbiguousFailureNeverSuggestsARetry(t *testing.T) { + d := &recordingDeps{ + t: t, + preflight: emptyPreflight(), + copyErr: fmt.Errorf("%w: request failed: EOF", cli.ErrCopyOutcomeUnknown), + } + opts := baseOpts() + opts.ArchiveSource = true + + var out, errOut bytes.Buffer + err := runItemCopy(opts, d.deps(), &out, &errOut) + if err == nil { + t.Fatal("expected a non-zero exit") + } + if len(d.copyCalls) != 1 { + t.Fatalf("the copy must be attempted exactly once; got %d", len(d.copyCalls)) + } + stderr := errOut.String() + for _, w := range []string{ + "UNKNOWN outcome", + "may or may not have been created in workspace \"pad-web\"", + "The source may or may not have been archived.", + "never retries automatically", + "DUPLICATE item", + "pad item list tasks --workspace pad-web", + } { + if !strings.Contains(stderr, w) { + t.Errorf("ambiguous-failure message missing %q\n--- stderr ---\n%s", w, stderr) + } + } + // The whole point: nothing here may read as "try again". + for _, forbidden := range []string{"retry the command", "run it again", "re-run the copy"} { + if strings.Contains(strings.ToLower(stderr), forbidden) { + t.Errorf("ambiguous-failure message must not suggest a retry; found %q", forbidden) + } + } + if !strings.Contains(err.Error(), "check workspace") { + t.Errorf("the returned error should point at the destination; got %v", err) + } +} + +// Codex round 4. A 2xx whose body could not be read or decoded means the +// copy COMMITTED. Returning that as a normal error would exit non-zero and +// tell a script the copy did not happen — the DR-13 duplicate, arrived at +// through the reporting layer instead of the network layer. +func TestRunItemCopy_CommittedButUnreportedExitsZero(t *testing.T) { + for _, format := range []string{"table", "json"} { + t.Run(format, func(t *testing.T) { + raw := json.RawMessage(`{"source":`) + d := &recordingDeps{ + t: t, + preflight: emptyPreflight(), + copyErr: fmt.Errorf("%w: decoding the response: unexpected EOF", cli.ErrCopyCommitted), + resultRaw: raw, + } + opts := baseOpts() + opts.Format = format + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("a committed copy must exit 0; got %v", err) + } + if len(d.copyCalls) != 1 { + t.Fatalf("expected exactly one copy attempt; got %d", len(d.copyCalls)) + } + stderr := errOut.String() + for _, w := range []string{"copy SUCCEEDED", "Do not re-run", "pad item list tasks --workspace pad-web"} { + if !strings.Contains(stderr, w) { + t.Errorf("stderr missing %q\n%s", w, stderr) + } + } + if strings.Contains(stderr, "UNKNOWN") { + t.Errorf("a committed copy is not an unknown outcome:\n%s", stderr) + } + if format == "json" && !strings.Contains(out.String(), `"source"`) { + t.Errorf("the bytes that DID arrive should still reach stdout:\n%s", out.String()) + } + }) + } +} + +// Codex round 8. archive_source is what was ASKED for and source.archived +// is what HAPPENED; the server's contract is that they agree. When they do +// not, a MOVE did not complete, and exiting 0 would tell automation it did. +func TestRunItemCopy_PartialMoveExitsNonZeroAndSaysWhatExists(t *testing.T) { + cases := []struct { + name string + archiveSource bool + archived bool + wantStderr []string + }{ + { + name: "asked to archive, source survived", archiveSource: true, archived: false, + wantStderr: []string{"PARTIAL", "do not re-run", "is NOT archived", "pad item delete IDEA-12 --workspace docapp"}, + }, + { + name: "did not ask, source archived anyway", archiveSource: false, archived: true, + wantStderr: []string{"PARTIAL", "was archived anyway", "pad item restore IDEA-12 --workspace docapp"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &recordingDeps{ + t: t, + preflight: emptyPreflight(), + result: &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{ + WorkspaceSlug: "docapp", CollectionSlug: "ideas", Ref: "IDEA-12", Slug: "x", Archived: tc.archived, + }, + Destination: cli.ItemCopyResultDestination{ + WorkspaceSlug: "pad-web", CollectionSlug: "tasks", Ref: "TASK-9", Slug: "y", + }, + ArchiveSource: tc.archiveSource, + Warnings: cli.ItemCopyResultWarnings{}, + }, + } + opts := baseOpts() + opts.ArchiveSource = tc.archiveSource + + var out, errOut bytes.Buffer + err := runItemCopy(opts, d.deps(), &out, &errOut) + if err == nil { + t.Fatal("a partial outcome must exit non-zero") + } + for _, w := range tc.wantStderr { + if !strings.Contains(errOut.String(), w) { + t.Errorf("stderr missing %q\n%s", w, errOut.String()) + } + } + // The copy itself is still not repeatable. + if len(d.copyCalls) != 1 { + t.Fatalf("expected exactly one copy attempt; got %d", len(d.copyCalls)) + } + }) + } +} + +// The agreeing case — every successful response — stays exit 0. +func TestRunItemCopy_MoveThatArchivedExitsZero(t *testing.T) { + d := &recordingDeps{ + t: t, + preflight: emptyPreflight(), + result: &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{WorkspaceSlug: "docapp", CollectionSlug: "ideas", Ref: "IDEA-12", Slug: "x", Archived: true}, + Destination: cli.ItemCopyResultDestination{WorkspaceSlug: "pad-web", CollectionSlug: "tasks", Ref: "TASK-9", Slug: "y"}, + ArchiveSource: true, + Warnings: cli.ItemCopyResultWarnings{}, + }, + } + opts := baseOpts() + opts.ArchiveSource = true + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("a complete move must exit 0; got %v", err) + } + if strings.Contains(errOut.String(), "PARTIAL") { + t.Errorf("no partial warning expected:\n%s", errOut.String()) + } + if !strings.Contains(out.String(), "Moved IDEA-12 → pad-web TASK-9") { + t.Errorf("unexpected output:\n%s", out.String()) + } +} + +// Codex round 8. The preflight receives the overrides, so a --field the +// destination REJECTED comes back in needs_value. Telling that user "no +// --field supplied one" sends them hunting for a bug in their own command +// line, and repeating the same flag as the fix is worse. +func TestRunItemCopy_RejectedOverrideIsNotReportedAsMissing(t *testing.T) { + pre := emptyPreflight() + pre.Valid = false + pre.Fields.NeedsValue = []cli.ItemCopyPreflightNeedsValue{ + {Key: "size", Label: "Size", Type: "select", Required: true, Reason: "invalid_value", + Options: []string{"s", "m"}, Message: `"xl" is not one of s, m`}, + {Key: "priority", Required: true, Reason: "missing_required"}, + } + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + opts := baseOpts() + opts.Fields = []string{"size=xl"} + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err == nil { + t.Fatal("expected a refusal") + } + stderr := errOut.String() + if strings.Contains(stderr, "no --field supplied one") { + t.Errorf("the user DID supply --field size=xl:\n%s", stderr) + } + for _, w := range []string{ + `you supplied "xl" — the destination rejected it`, + "1 supplied with --field and rejected", + "Add: --field priority=", + "Correct: --field size=", + } { + if !strings.Contains(stderr, w) { + t.Errorf("stderr missing %q\n%s", w, stderr) + } + } + // The rejected key must NOT be offered as something to "add". + if strings.Contains(stderr, "--field size=") { + t.Errorf("repeating the same flag is not the fix:\n%s", stderr) + } +} + +// When EVERY unresolved field was supplied and rejected, the headline says +// so outright. +func TestRunItemCopy_AllOverridesRejected(t *testing.T) { + pre := emptyPreflight() + pre.Valid = false + pre.Fields.NeedsValue = []cli.ItemCopyPreflightNeedsValue{ + {Key: "size", Required: true, Reason: "invalid_value"}, + } + d := &recordingDeps{t: t, preflight: pre, forbidCopy: true} + opts := baseOpts() + opts.Fields = []string{"size=xl"} + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err == nil { + t.Fatal("expected a refusal") + } + stderr := errOut.String() + if !strings.Contains(stderr, "the --field value you supplied was rejected") { + t.Errorf("unexpected headline:\n%s", stderr) + } + if strings.Contains(stderr, "Add:") { + t.Errorf("nothing to add — everything was supplied already:\n%s", stderr) + } +} + +// A refusal the server made before writing anything is passed straight +// through — no unknown-outcome scare text. +func TestRunItemCopy_DeterministicFailureIsReportedPlainly(t *testing.T) { + apiErr := &cli.APIError{Code: "plan_limit_exceeded", Message: "workspace is at its item limit"} + d := &recordingDeps{t: t, preflight: emptyPreflight(), copyErr: apiErr} + + var out, errOut bytes.Buffer + err := runItemCopy(baseOpts(), d.deps(), &out, &errOut) + if !errors.Is(err, error(apiErr)) { + t.Fatalf("expected the API error to pass through; got %v", err) + } + if strings.Contains(errOut.String(), "UNKNOWN") { + t.Errorf("a 4xx is not an unknown outcome:\n%s", errOut.String()) + } +} + +// ── flag validation ────────────────────────────────────────────────────── + +func TestRunItemCopy_FlagValidationSendsNothing(t *testing.T) { + cases := []struct { + name string + mutate func(*itemCopyOptions) + wantErr string + }{ + {"no workspace", func(o *itemCopyOptions) { o.TargetWorkspace = "" }, "--to-workspace is required"}, + {"blank workspace", func(o *itemCopyOptions) { o.TargetWorkspace = " " }, "--to-workspace is required"}, + {"no collection", func(o *itemCopyOptions) { o.TargetCollection = "" }, "--collection is required"}, + {"field without =", func(o *itemCopyOptions) { o.Fields = []string{"priority"} }, `invalid --field "priority"`}, + {"field with empty key", func(o *itemCopyOptions) { o.Fields = []string{"=high"} }, `invalid --field "=high"`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + d := &recordingDeps{t: t, preflight: emptyPreflight(), forbidCopy: true} + opts := baseOpts() + tc.mutate(&opts) + var out, errOut bytes.Buffer + err := runItemCopy(opts, d.deps(), &out, &errOut) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err = %v, want it to contain %q", err, tc.wantErr) + } + if len(d.preflightCalls) != 0 { + t.Errorf("a rejected flag must not reach the server; got %d preflight calls", len(d.preflightCalls)) + } + }) + } +} + +// `--field key=` is a legitimate empty string, not a malformed flag. +func TestRunItemCopy_EmptyFieldValueIsAllowed(t *testing.T) { + d := &recordingDeps{t: t, preflight: emptyPreflight(), forbidCopy: true} + opts := baseOpts() + opts.DryRun = true + opts.Fields = []string{"notes="} + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + if got, ok := d.preflightCalls[0].FieldOverrides["notes"]; !ok || got != "" { + t.Errorf("expected notes to be the empty string; got %v (present=%v)", got, ok) + } +} + +// --field values are typed against the DESTINATION collection's schema — +// the source's would be the wrong authority, and a number sent as a string +// fails the destination's validator. +func TestRunItemCopy_FieldsAreTypedAgainstTheDestinationSchema(t *testing.T) { + d := &recordingDeps{ + t: t, + preflight: emptyPreflight(), + forbidCopy: true, + schema: models.CollectionSchema{Fields: []models.FieldDef{ + {Key: "points", Type: "number"}, + {Key: "done", Type: "checkbox"}, + {Key: "title", Type: "text"}, + }}, + } + opts := baseOpts() + opts.DryRun = true + opts.TargetCollection = "task" // singular; normalized on the way out + opts.Fields = []string{"points=3", "done=true", "title=5"} + + var out, errOut bytes.Buffer + if err := runItemCopy(opts, d.deps(), &out, &errOut); err != nil { + t.Fatalf("runItemCopy: %v", err) + } + ov := d.preflightCalls[0].FieldOverrides + if ov["points"] != 3.0 { + t.Errorf("points = %#v, want float64(3)", ov["points"]) + } + if ov["done"] != true { + t.Errorf("done = %#v, want bool true", ov["done"]) + } + if ov["title"] != "5" { + t.Errorf("title = %#v, want the string \"5\"", ov["title"]) + } + if len(d.schemaCalls) != 1 || d.schemaCalls[0] != [2]string{"pad-web", "tasks"} { + t.Errorf("schema should be fetched from the destination as the normalized slug; got %v", d.schemaCalls) + } + if d.preflightCalls[0].TargetCollection != "tasks" { + t.Errorf("target_collection = %q, want the normalized %q", d.preflightCalls[0].TargetCollection, "tasks") + } +} + +// A preflight failure aborts before the mutation. The dry run is the only +// thing that can tell us whether a copy is safe to send. +func TestRunItemCopy_PreflightFailureAbortsBeforeMutating(t *testing.T) { + d := &recordingDeps{ + t: t, + preflightErr: &cli.APIError{Code: "collection_not_found", Message: "no such collection"}, + forbidCopy: true, + } + var out, errOut bytes.Buffer + if err := runItemCopy(baseOpts(), d.deps(), &out, &errOut); err == nil { + t.Fatal("expected the preflight error to propagate") + } + if len(d.copyCalls) != 0 { + t.Fatalf("got %d mutating calls", len(d.copyCalls)) + } +} + +// ── result rendering ───────────────────────────────────────────────────── + +func TestRenderItemCopyResult(t *testing.T) { + res := &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{ + WorkspaceSlug: "docapp", CollectionSlug: "ideas", Ref: "IDEA-12", Slug: "x", Title: "Cross-workspace copy", + Archived: true, Seq: 412, + }, + Destination: cli.ItemCopyResultDestination{ + WorkspaceSlug: "pad-web", WorkspaceName: "Pad Web", CollectionSlug: "tasks", + Ref: "TASK-9", Slug: "cross-workspace-copy", Seq: 88, + }, + ArchiveSource: true, + Warnings: cli.ItemCopyResultWarnings{ + DroppedFields: []string{"impact", "effort"}, + DroppedAssignee: true, + AttachmentCount: 3, + AttachmentBytes: 1258291, + UnresolvableRefCount: 1, + }, + } + var buf bytes.Buffer + renderItemCopyResult(&buf, res) + got := buf.String() + for _, w := range []string{ + "Moved IDEA-12 → pad-web TASK-9", + "archived yes", + `fields dropped "impact", "effort"`, + "assignee dropped yes", + "agent role dropped no", + "attachments cloned 3 (1.2 MiB, 1258291 bytes)", + "unresolvable attachment refs 1", + } { + if !strings.Contains(got, w) { + t.Errorf("result output missing %q\n--- got ---\n%s", w, got) + } + } +} + +// A plain copy says "Copied", and an empty dropped_fields list says so +// rather than rendering a blank. +func TestRenderItemCopyResult_PlainCopyAndEmptyDropList(t *testing.T) { + res := &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{WorkspaceSlug: "docapp", CollectionSlug: "ideas", Slug: "x", Title: "X"}, + Destination: cli.ItemCopyResultDestination{WorkspaceSlug: "pad-web", CollectionSlug: "tasks", Slug: "y"}, + Warnings: cli.ItemCopyResultWarnings{}, + } + var buf bytes.Buffer + renderItemCopyResult(&buf, res) + got := buf.String() + if !strings.Contains(got, "Copied x → pad-web y") { + t.Errorf("a plain copy should say Copied and fall back to the slug:\n%s", got) + } + if !strings.Contains(got, "fields dropped (none)") { + t.Errorf("an empty dropped list must say (none):\n%s", got) + } + if !strings.Contains(got, "archived no") { + t.Errorf("archived must be reported even when false:\n%s", got) + } +} + +// archive_source and source.archived agree on every successful response. +// If they ever do not, say so rather than silently believing one. +func TestRenderItemCopyResult_FlagsDisagreement(t *testing.T) { + res := &cli.ItemCopyResult{ + Source: cli.ItemCopyResultSource{Slug: "x", Archived: false}, + Destination: cli.ItemCopyResultDestination{Slug: "y"}, + ArchiveSource: true, + Warnings: cli.ItemCopyResultWarnings{}, + } + var buf bytes.Buffer + renderItemCopyResult(&buf, res) + if !strings.Contains(buf.String(), "WARNING") { + t.Errorf("a disagreement between archive_source and source.archived must be surfaced:\n%s", buf.String()) + } +} + +// ── wiring ─────────────────────────────────────────────────────────────── + +func TestItemCopyCmd_IsRegisteredWithEveryFlag(t *testing.T) { + group := itemCmd() + var found bool + for _, sub := range group.Commands() { + if sub.Name() != "copy" { + continue + } + found = true + for _, flag := range []string{"to-workspace", "collection", "dry-run", "archive-source", "field"} { + if sub.Flags().Lookup(flag) == nil { + t.Errorf("pad item copy is missing the --%s flag", flag) + } + } + if sub.Short == "" || sub.Long == "" { + t.Error("pad item copy needs both a Short and a Long description") + } + // The no-retry contract has to be discoverable from --help; it is + // the single most surprising thing about this command. + if !strings.Contains(sub.Long, "NEVER retried") { + t.Error("--help must document that the copy is never retried automatically") + } + } + if !found { + t.Fatal("pad item copy is not registered in the item group") + } +} diff --git a/internal/cli/client.go b/internal/cli/client.go index 479754a9..7c935b6b 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -1599,12 +1599,20 @@ func (c *Client) handleResponse(resp *http.Response, result interface{}) error { } func (c *Client) parseError(resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + return parseErrorBody(resp.StatusCode, body) +} + +// parseErrorBody decodes an error response whose body has already been +// read. Split out of parseError so callers that need the raw bytes for +// their own purposes (see client_items_copy.go) produce byte-identical +// error values rather than a second, subtly different vocabulary. +func parseErrorBody(status int, body []byte) error { var errResp struct { Error APIError `json:"error"` } - body, _ := io.ReadAll(resp.Body) if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" { return &errResp.Error } - return fmt.Errorf("API error: %d %s", resp.StatusCode, string(body)) + return fmt.Errorf("API error: %d %s", status, string(body)) } diff --git a/internal/cli/client_items_copy.go b/internal/cli/client_items_copy.go new file mode 100644 index 00000000..088a8e30 --- /dev/null +++ b/internal/cli/client_items_copy.go @@ -0,0 +1,483 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// Cross-workspace item copy — the CLI client half of PLAN-2357 / TASK-2366. +// +// Two endpoints, one request shape (deliberately — see the server's +// handlers_items_copy_preflight.go header): +// +// POST /workspaces/{ws}/items/{ref}/copy/preflight → ItemCopyPreflight +// POST /workspaces/{ws}/items/{ref}/copy → ItemCopyResult +// +// The response types below MIRROR internal/server's. To be clear about why, +// because the obvious guess is wrong: this is NOT an import cycle. Nothing +// in internal/server imports internal/cli, and this package could import +// server tomorrow — the mirror test below does exactly that. +// +// It is a deliberate layering choice, the same one internal/cli/bootstrap.go +// already records ("otherwise has no dependency on internal/server"): this +// package is the HTTP client, and a client that reaches into the server's +// package for its wire types stops being separable from it and starts +// linking the store, the migrations and the router into anything that wants +// to talk to a Pad API. Mirroring is also what cli.DeletedWorkspace and the +// bootstrap constants already do, so this follows the house pattern rather +// than inventing one. +// +// The cost of mirroring is drift, and that is paid for: +// TestItemCopyMirrorsMatchServerShapes lives in the external cli_test +// package (which CAN import server) and walks both shapes, so a server-side +// rename is a red build rather than a field that silently stops rendering. +// +// ── DR-13: THE MUTATING COPY IS NEVER RETRIED ──────────────────────────── +// +// v1 has no idempotency key. A retry after a request that already committed +// creates a DUPLICATE item, and a caller who lost the response cannot tell +// which happened. CopyItem therefore: +// +// 1. runs on a DEDICATED *http.Client AND a dedicated transport +// (copyHTTPClient / copyTransport), so a retrying http.Client OR a +// retrying RoundTripper installed on the shared client cannot reach it. +// The transport half matters most: retry in Go is almost always a +// RoundTripper wrapper, which a merely-dedicated *http.Client would +// inherit; +// 2. sends a body net/http itself cannot replay — the reader is wrapped so +// Request.GetBody stays nil, which disables the transport's own +// "nothing was written, try the next connection" retry; +// 3. refuses redirects rather than re-issuing the POST at a new location; +// 4. reports an unrecoverable-outcome failure as ErrCopyOutcomeUnknown so +// the command layer can tell the user to CHECK the destination instead +// of re-running. +// +// TestCopyItem_* in client_items_copy_test.go guard all four. Do not route +// CopyItem through the shared post()/httpClient helpers. + +// copyRequestTimeout bounds the mutating copy. It is deliberately longer +// than the shared client's 10s: a copy that clones many attachment rows and +// fans out activity/webhook writes can legitimately outlast a normal API +// call, and a client-side timeout is exactly the ambiguous outcome DR-13 +// wants to make rare. It is still bounded — a hung connection must not +// wedge the CLI forever. +const copyRequestTimeout = 60 * time.Second + +// ErrCopyOutcomeUnknown marks a mutating-copy failure where the copy may or +// may not have committed: any transport-level failure (timeout, reset, EOF) +// and the server's own 500 `copy_failed`. Test with CopyOutcomeUnknown. +var ErrCopyOutcomeUnknown = errors.New("the copy's outcome is unknown") + +// ErrCopyCommitted marks a failure that happened AFTER the server confirmed +// the copy: a 2xx whose body could not be read, or could not be decoded. +// The copy HAPPENED; only the report is missing. +// +// It exists so the command layer can keep these off the non-zero exit path. +// A script that sees a failing exit code from `pad item copy` will +// reasonably conclude the copy did not happen, and the obvious recovery — +// running it again — is precisely the DR-13 duplicate. Test with +// CopyCommitted. +var ErrCopyCommitted = errors.New("the copy committed but its result could not be read") + +// ItemCopyRequest is the wire body for BOTH endpoints. +type ItemCopyRequest struct { + // TargetWorkspace is the destination workspace slug (a UUID is also + // accepted server-side). Required. + TargetWorkspace string `json:"target_workspace"` + // TargetCollection is the destination collection slug. Required. + TargetCollection string `json:"target_collection"` + // FieldOverrides maps destination-schema field key → value. A key the + // destination schema does not declare is a 400; a null value UNSETS + // the key rather than persisting a literal null. + FieldOverrides map[string]any `json:"field_overrides,omitempty"` + // ArchiveSource is the MOVE path: copy, then archive the source. + ArchiveSource bool `json:"archive_source"` +} + +// ItemCopyPreflightSource identifies the item being copied. +type ItemCopyPreflightSource struct { + WorkspaceSlug string `json:"workspace_slug"` + CollectionSlug string `json:"collection_slug"` + Ref string `json:"ref,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` +} + +// ItemCopyPreflightDestination identifies where the copy would land. +type ItemCopyPreflightDestination struct { + WorkspaceSlug string `json:"workspace_slug"` + WorkspaceName string `json:"workspace_name"` + CollectionSlug string `json:"collection_slug"` + CollectionName string `json:"collection_name"` +} + +// ItemCopyPreflightCarried is one field that survives to the destination. +type ItemCopyPreflightCarried struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + Value any `json:"value"` + // From is "migrated", "override" or "default". + From string `json:"from"` +} + +// ItemCopyPreflightDropped is one value that will not be copied. +type ItemCopyPreflightDropped struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + // Kind is "field" or "assignment". + Kind string `json:"kind"` + // Reason is one of no_target_field / incompatible_type / + // undeclared_source_field / assignee_not_a_member / + // agent_role_not_portable. + Reason string `json:"reason"` +} + +// ItemCopyPreflightNeedsValue is one destination field the caller must +// resolve with an override before the copy can proceed. +type ItemCopyPreflightNeedsValue struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + Options []string `json:"options,omitempty"` + Required bool `json:"required"` + // Reason is "missing_required" or "invalid_value". + Reason string `json:"reason"` + Message string `json:"message,omitempty"` +} + +// ItemCopyPreflightFields is DR-15's bucketing. The three names are the +// contract; all three slices are always present server-side. +type ItemCopyPreflightFields struct { + Carried []ItemCopyPreflightCarried `json:"carried"` + Dropped []ItemCopyPreflightDropped `json:"dropped"` + NeedsValue []ItemCopyPreflightNeedsValue `json:"needs_value"` +} + +// ItemCopyPreflightWarnings is DR-15's full warning set. +type ItemCopyPreflightWarnings struct { + ChildCount int `json:"child_count"` + ChildrenOrphaned bool `json:"children_orphaned"` + DroppedParent bool `json:"dropped_parent"` + OutgoingLinks map[string]int `json:"outgoing_links"` + IncomingLinks map[string]int `json:"incoming_links"` + DroppedAssignee bool `json:"dropped_assignee"` + DroppedAgentRole bool `json:"dropped_agent_role"` + AttachmentCount int `json:"attachment_count"` + AttachmentBytes int64 `json:"attachment_bytes"` + UnresolvableRefCount int `json:"unresolvable_ref_count"` +} + +// ItemCopyPreflight is the dry-run's 200 response. +type ItemCopyPreflight struct { + Source ItemCopyPreflightSource `json:"source"` + Destination ItemCopyPreflightDestination `json:"destination"` + ArchiveSource bool `json:"archive_source"` + // Valid means EXACTLY ONE THING: NeedsValue is empty. It is not a + // prediction that the copy will succeed. + Valid bool `json:"valid"` + Fields ItemCopyPreflightFields `json:"fields"` + Warnings ItemCopyPreflightWarnings `json:"warnings"` +} + +// ItemCopyResultSource identifies what was copied, and whether it survived. +type ItemCopyResultSource struct { + WorkspaceSlug string `json:"workspace_slug"` + CollectionSlug string `json:"collection_slug"` + Ref string `json:"ref,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` + Archived bool `json:"archived"` + Seq int64 `json:"seq,omitempty"` +} + +// ItemCopyResultDestination is where the copy landed. +type ItemCopyResultDestination struct { + WorkspaceSlug string `json:"workspace_slug"` + WorkspaceName string `json:"workspace_name"` + CollectionSlug string `json:"collection_slug"` + CollectionName string `json:"collection_name"` + Ref string `json:"ref,omitempty"` + Slug string `json:"slug"` + Seq int64 `json:"seq,omitempty"` +} + +// ItemCopyResultWarnings is the after-the-fact counterpart to the +// preflight's warning block. Deliberately narrower — the relationship +// counters are preview-only. +type ItemCopyResultWarnings struct { + DroppedFields []string `json:"dropped_fields"` + DroppedAssignee bool `json:"dropped_assignee"` + DroppedAgentRole bool `json:"dropped_agent_role"` + AttachmentCount int `json:"attachment_count"` + AttachmentBytes int64 `json:"attachment_bytes"` + UnresolvableRefCount int `json:"unresolvable_ref_count"` +} + +// ItemCopyResult is the mutating copy's 201 response. +type ItemCopyResult struct { + Source ItemCopyResultSource `json:"source"` + Destination ItemCopyResultDestination `json:"destination"` + ArchiveSource bool `json:"archive_source"` + Item *models.Item `json:"item"` + Warnings ItemCopyResultWarnings `json:"warnings"` +} + +// CopyItemPreflight runs the DRY RUN. It mutates nothing in the copy's own +// domain, so it is safe to call repeatedly and safe to retry. +// +// Returns the decoded preview AND the server's raw response bytes, so +// `--format json` can hand a script the endpoint's own contract rather than +// a CLI-shaped re-encoding of it. +func (c *Client) CopyItemPreflight(wsSlug, itemRef string, req ItemCopyRequest) (*ItemCopyPreflight, json.RawMessage, error) { + raw, err := c.postCopyJSON(c.httpClient, itemCopyPath(wsSlug, itemRef)+"/preflight", req, false) + if err != nil { + return nil, nil, err + } + var out ItemCopyPreflight + if err := json.Unmarshal(raw, &out); err != nil { + return nil, raw, fmt.Errorf("decode copy preflight response: %w", err) + } + return &out, raw, nil +} + +// CopyItem performs the MUTATING cross-workspace copy. +// +// NEVER RETRY THIS CALL (DR-13). See the file header for the four +// mechanisms that enforce it. On failure, check CopyOutcomeUnknown(err) +// before saying anything to the user about what happened. +func (c *Client) CopyItem(wsSlug, itemRef string, req ItemCopyRequest) (*ItemCopyResult, json.RawMessage, error) { + hc := c.copyHTTPClient() + // The transport is this call's own, so its idle connections have nobody + // to serve afterwards. + defer hc.CloseIdleConnections() + + raw, err := c.postCopyJSON(hc, itemCopyPath(wsSlug, itemRef), req, true) + if err != nil { + return nil, nil, err + } + var out ItemCopyResult + if err := json.Unmarshal(raw, &out); err != nil { + // The server answered 2xx, so the copy COMMITTED — we simply + // cannot render it. Not ErrCopyOutcomeUnknown: the outcome is + // known and it is "succeeded". + return nil, raw, fmt.Errorf("%w: decoding the response: %v", ErrCopyCommitted, err) + } + return &out, raw, nil +} + +// itemCopyPath builds the copy endpoint's path with each dynamic segment +// escaped exactly once. +// +// Most of this client concatenates slugs into paths bare, and for the usual +// kebab-case slug or `TASK-5` ref that is indistinguishable from this. It is +// not good enough HERE: `..`, `/`, `?` and `#` in a ref would silently +// re-route or truncate the request, and this is the one endpoint in the CLI +// where landing on a DIFFERENT URL than intended could mutate the wrong +// thing. Escaping is a no-op for every legitimate ref, so it costs nothing +// and removes the class. +func itemCopyPath(wsSlug, itemRef string) string { + return "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemRef) + "/copy" +} + +// CopyCommitted reports whether err is a post-commit reporting failure — +// the copy happened, only its result was lost. Callers must not present +// these as failures of the copy, and must not exit non-zero on them. +func CopyCommitted(err error) bool { + return err != nil && errors.Is(err, ErrCopyCommitted) +} + +// CopyOutcomeUnknown reports whether err leaves it genuinely unknown +// whether the copy committed. True for the server's 500 `copy_failed` and +// for any transport-level failure on the mutating call; false for every +// 4xx, which is a refusal the server made BEFORE writing anything. +func CopyOutcomeUnknown(err error) bool { + if err == nil { + return false + } + if errors.Is(err, ErrCopyOutcomeUnknown) { + return true + } + var apiErr *APIError + if errors.As(err, &apiErr) { + return apiErr.Code == "copy_failed" + } + return false +} + +// copyHTTPClient is the dedicated client for the mutating copy (DR-13 +// mechanism 1): its own transport, its own timeout, and no redirect +// following. +// +// The caller must CloseIdleConnections when done — the transport is not +// shared, so its pool would otherwise outlive the call. +func (c *Client) copyHTTPClient() *http.Client { + return &http.Client{ + Transport: c.copyTransport(), + Timeout: copyRequestTimeout, + // DR-13 mechanism 3. A 307/308 would otherwise re-send the POST + // body at a new URL — a retry by another name. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// copyTransport picks the RoundTripper the mutating copy runs on. +// +// A dedicated *http.Client is NOT enough on its own: retry behaviour in Go +// is usually implemented as a RoundTripper wrapper, and a wrapper installed +// on the shared client's Transport would be inherited by any client that +// reuses it. So the choice is made here, by type: +// +// - a plain *http.Transport is CLONED. Its only retry is the narrow +// nothing-written replay that mechanism 2 already disables, and cloning +// preserves whatever proxy, TLS and dialer configuration it carries. +// The clone brings its own connection pool, which costs one extra +// handshake per copy — an acceptable price, and the reason CopyItem +// closes idle connections afterwards. +// +// - anything else is a WRAPPER of unknown behaviour, which is exactly the +// shape a retrying RoundTripper takes. It is not used. +// +// The second branch has a real cost: a future auth- or tracing-wrapping +// RoundTripper would be bypassed here too, and this is the one call in the +// CLI where that could look like a mysterious connection failure. That is +// deliberate. DR-13's hazard is a duplicated item nobody can detect after +// the fact; a copy that visibly fails to connect is the better failure, and +// there is no way to tell the two kinds of wrapper apart from the outside. +// If a wrapper ever becomes load-bearing for this client, the fix is to +// give Client an explicit non-retrying transport field — not to start +// trusting c.httpClient.Transport here. +func (c *Client) copyTransport() http.RoundTripper { + base := c.httpClient.Transport + if base == nil { + base = http.DefaultTransport + } + if t, ok := base.(*http.Transport); ok { + return t.Clone() + } + if def, ok := http.DefaultTransport.(*http.Transport); ok { + return def.Clone() + } + return &http.Transport{} +} + +// postCopyJSON posts body to path and returns the response's raw JSON. +// +// mutating selects the DR-13 posture: a mutating call gets a body net/http +// cannot replay and reports transport failures as ErrCopyOutcomeUnknown. +func (c *Client) postCopyJSON(hc *http.Client, path string, body any, mutating bool) (json.RawMessage, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := c.newCopyRequest(path, data, mutating) + if err != nil { + return nil, err + } + + resp, err := hc.Do(req) + if err != nil { + if mutating { + // DR-13 mechanism 4: no response reached us, so the copy may + // have committed anyway. + return nil, fmt.Errorf("%w: request failed: %v", ErrCopyOutcomeUnknown, err) + } + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + raw, readErr := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, parseErrorBody(resp.StatusCode, raw) + } + if resp.StatusCode >= 300 { + // Only reachable on the mutating path, where CheckRedirect hands + // the 3xx back instead of following it. + return nil, fmt.Errorf("unexpected redirect (%d) to %q; refusing to re-send the request", resp.StatusCode, resp.Header.Get("Location")) + } + if readErr != nil { + if mutating { + // A 2xx header already arrived, so the server committed. Only + // the body was lost — a reporting failure, not a copy failure. + return nil, fmt.Errorf("%w: reading the response body: %v", ErrCopyCommitted, readErr) + } + return nil, fmt.Errorf("read response: %w", readErr) + } + return json.RawMessage(raw), nil +} + +// newCopyRequest builds the POST for either copy endpoint. +// +// mutating is DR-13 mechanism 2. net/http will REPLAY a request whose +// connection died before any byte was written — but only when it can rebuild +// the body, which means only when Request.GetBody is set. http.NewRequest +// sets GetBody automatically for the readers it recognises, *bytes.Reader +// among them. +// +// That replay is narrow and usually harmless. It is still not acceptable +// here: "nothing was written" is the TRANSPORT's belief about one connection +// attempt, and this command's contract to the user is that a copy leaves the +// process at most once. Hiding the reader behind an opaque type leaves +// GetBody nil, so the decision belongs to us rather than to a heuristic. +// +// The preflight is left replayable — it is read-only, and a lost connection +// there costs nothing. +// +// Extracted from postCopyJSON so TestCopyItem_MutatingRequestIsNotReplayable +// can assert the property directly. Asserting it end-to-end is not possible: +// provoking the transport's nothing-written path requires winning a race +// against an idle-connection close, so a network-level test would pass for +// the wrong reason. +func (c *Client) newCopyRequest(path string, data []byte, mutating bool) (*http.Request, error) { + var reader io.Reader = bytes.NewReader(data) + if mutating { + reader = &opaqueReader{r: reader} + } + req, err := c.newRequest(http.MethodPost, path, reader) + if err != nil { + return nil, err + } + // opaqueReader defeats net/http's length sniffing too, so restore the + // Content-Length the server would otherwise not see. + req.ContentLength = int64(len(data)) + req.Header.Set("Content-Type", "application/json") + return req, nil +} + +// opaqueReader hides a reader's concrete type from http.NewRequest so that +// Request.GetBody stays nil. See newCopyRequest. +type opaqueReader struct{ r io.Reader } + +func (o *opaqueReader) Read(p []byte) (int, error) { return o.r.Read(p) } + +// PrintRawJSON writes a server response verbatim except for indentation. +// +// json.Indent is a LEXICAL transform: it does not decode, so key order, +// numeric literals (int64 byte counts and seq values especially) and every +// field the CLI does not model survive byte-for-byte. Re-encoding through a +// Go value would silently drop unknown fields and could round large +// integers through float64 — which is exactly what "the endpoint's response +// unchanged" is there to prevent. +func PrintRawJSON(w io.Writer, raw json.RawMessage) error { + var buf bytes.Buffer + if err := json.Indent(&buf, raw, "", " "); err != nil { + // Not valid JSON to indent — emit the bytes exactly as received + // rather than inventing a shape. + buf.Reset() + buf.Write(raw) + } + buf.WriteByte('\n') + _, err := w.Write(buf.Bytes()) + return err +} diff --git a/internal/cli/client_items_copy_mirror_test.go b/internal/cli/client_items_copy_mirror_test.go new file mode 100644 index 00000000..f80758ae --- /dev/null +++ b/internal/cli/client_items_copy_mirror_test.go @@ -0,0 +1,158 @@ +package cli_test + +// The copy RESPONSE types in client_items_copy.go are hand-written mirrors +// of internal/server's, kept separate by the layering choice documented +// there. Nothing in the compiler stops them drifting. This test walks both +// shapes and fails on any difference in JSON field names, omitempty, field +// sets, or kinds — so a server-side rename is a red build here rather than a +// field that silently stops rendering in `pad item copy`. +// +// SCOPE, precisely: the two RESPONSE types, ItemCopyPreflight and +// ItemCopyResult, recursively. The REQUEST type is deliberately not here — +// the server's (itemCopyPreflightRequest) is unexported, so no test outside +// package server can name it. What covers the request instead is +// TestCopyItem_RequestShapeIsTheDocumentedOne in client_items_copy_test.go, +// which asserts the exact key set that goes out on the wire, and +// TestCopyPreflightAndCopySendIdenticalBodies, which pins the server's +// "both endpoints take the same body" contract. A request-side rename on the +// server is therefore caught by those two, not by this file. +// +// This file lives in the EXTERNAL test package (cli_test) because it imports +// internal/server, and package cli itself deliberately does not. + +import ( + "fmt" + "reflect" + "sort" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/cli" + "github.com/PerpetualSoftware/pad/internal/server" +) + +func TestItemCopyMirrorsMatchServerShapes(t *testing.T) { + cases := []struct { + name string + mirror any + origin any + }{ + {"ItemCopyPreflight", cli.ItemCopyPreflight{}, server.ItemCopyPreflight{}}, + {"ItemCopyResult", cli.ItemCopyResult{}, server.ItemCopyResult{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var findings []string + compareJSONShape(reflect.TypeOf(tc.mirror), reflect.TypeOf(tc.origin), tc.name, &findings, map[string]bool{}) + for _, f := range findings { + t.Errorf("%s", f) + } + }) + } +} + +// compareJSONShape recurses over two struct types, comparing the JSON +// contract they express. mirror is the internal/cli copy; origin is +// internal/server's authority. +func compareJSONShape(mirror, origin reflect.Type, path string, findings *[]string, seen map[string]bool) { + mirror = deref(mirror) + origin = deref(origin) + + if mirror == origin { + // Same type on both sides (e.g. *models.Item) — nothing to drift. + return + } + + key := path + "|" + mirror.String() + "|" + origin.String() + if seen[key] { + return + } + seen[key] = true + + if mirror.Kind() != origin.Kind() { + *findings = append(*findings, fmt.Sprintf("%s: kind %s (cli) != %s (server)", path, mirror.Kind(), origin.Kind())) + return + } + + switch mirror.Kind() { + case reflect.Struct: + mf := jsonFields(mirror) + of := jsonFields(origin) + for _, name := range sortedUnion(mf, of) { + m, inMirror := mf[name] + o, inOrigin := of[name] + switch { + case !inMirror: + *findings = append(*findings, fmt.Sprintf("%s.%s: present on the server type, MISSING from the cli mirror", path, name)) + case !inOrigin: + *findings = append(*findings, fmt.Sprintf("%s.%s: present on the cli mirror, MISSING from the server type", path, name)) + default: + if m.omitempty != o.omitempty { + *findings = append(*findings, fmt.Sprintf("%s.%s: omitempty %v (cli) != %v (server)", path, name, m.omitempty, o.omitempty)) + } + compareJSONShape(m.typ, o.typ, path+"."+name, findings, seen) + } + } + case reflect.Slice, reflect.Array, reflect.Map: + compareJSONShape(mirror.Elem(), origin.Elem(), path+"[]", findings, seen) + default: + if mirror.Kind() != origin.Kind() { + *findings = append(*findings, fmt.Sprintf("%s: %s (cli) != %s (server)", path, mirror, origin)) + } + } +} + +type jsonField struct { + typ reflect.Type + omitempty bool +} + +func jsonFields(t reflect.Type) map[string]jsonField { + out := map[string]jsonField{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" { + continue // unexported: not on the wire + } + tag := f.Tag.Get("json") + if tag == "-" { + continue + } + parts := strings.Split(tag, ",") + name := parts[0] + if name == "" { + name = f.Name + } + omit := false + for _, p := range parts[1:] { + if p == "omitempty" { + omit = true + } + } + out[name] = jsonField{typ: f.Type, omitempty: omit} + } + return out +} + +func sortedUnion(a, b map[string]jsonField) []string { + set := map[string]bool{} + for k := range a { + set[k] = true + } + for k := range b { + set[k] = true + } + names := make([]string, 0, len(set)) + for k := range set { + names = append(names, k) + } + sort.Strings(names) + return names +} + +func deref(t reflect.Type) reflect.Type { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + return t +} diff --git a/internal/cli/client_items_copy_test.go b/internal/cli/client_items_copy_test.go new file mode 100644 index 00000000..c4efb33a --- /dev/null +++ b/internal/cli/client_items_copy_test.go @@ -0,0 +1,633 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// newCopyTestClient points a Client at ts with a HOME that has no saved +// credentials, so the test never picks up the developer's real token. +func newCopyTestClient(t *testing.T, ts *httptest.Server) *Client { + t.Helper() + t.Setenv("HOME", t.TempDir()) + return NewClientFromURL(ts.URL) +} + +// ── DR-13: the mutating copy is attempted EXACTLY ONCE ─────────────────── +// +// These are the guard the task asks for: a test, not a comment. If someone +// later installs a retrying RoundTripper on the shared client, swaps +// copyHTTPClient() for c.httpClient, or "simplifies" postCopyJSON's opaque +// reader back to a *bytes.Reader, one of these fails. + +func TestCopyItem_500CopyFailedIsAttemptedOnce(t *testing.T) { + var attempts int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/copy") { + t.Errorf("unexpected path %q", r.URL.Path) + } + atomic.AddInt32(&attempts, 1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"code":"copy_failed","message":"the copy may or may not have committed"}}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err == nil { + t.Fatal("expected an error from a 500 copy_failed") + } + if got := atomic.LoadInt32(&attempts); got != 1 { + t.Fatalf("the mutating copy must be attempted exactly once; server saw %d attempts", got) + } + if !CopyOutcomeUnknown(err) { + t.Errorf("500 copy_failed must be reported as an unknown outcome; got %v", err) + } +} + +// TestCopyItem_MutatingRequestIsNotReplayable pins DR-13 mechanism 2 +// directly: net/http replays a request whose connection died before any +// byte was written, and it can only do that when Request.GetBody is set. +// +// This is asserted on the constructed request rather than over a socket on +// purpose. The transport's nothing-written path needs the client to lose a +// race against an idle-connection close, so a network-level test would pass +// whether or not GetBody were nil — it would look like a guard and be none. +// The preflight case is included as the control: the difference between the +// two calls has to be deliberate and visible, not an accident. +func TestCopyItem_MutatingRequestIsNotReplayable(t *testing.T) { + c := &Client{baseURL: "http://example.invalid/api/v1", httpClient: &http.Client{}} + body := []byte(`{"target_workspace":"b","target_collection":"tasks","archive_source":false}`) + + mutating, err := c.newCopyRequest("/workspaces/a/items/TASK-1/copy", body, true) + if err != nil { + t.Fatalf("newCopyRequest(mutating): %v", err) + } + if mutating.GetBody != nil { + t.Error("the mutating copy's request must not carry GetBody — that is what lets net/http replay it") + } + if mutating.ContentLength != int64(len(body)) { + t.Errorf("ContentLength = %d, want %d", mutating.ContentLength, len(body)) + } + if mutating.Header.Get("Content-Type") != "application/json" { + t.Errorf("Content-Type = %q", mutating.Header.Get("Content-Type")) + } + + preflight, err := c.newCopyRequest("/workspaces/a/items/TASK-1/copy/preflight", body, false) + if err != nil { + t.Fatalf("newCopyRequest(preflight): %v", err) + } + if preflight.GetBody == nil { + t.Error("the read-only preflight should stay replayable; if it does not, the mutating assertion above proves nothing") + } +} + +// TestCopyItem_LostResponseIsAttemptedOnce covers the transport-failure +// path: the request reaches the server and the RESPONSE is lost, so the +// outcome is genuinely unknown. The CLI must send it once and must not +// claim to know what happened. +// +// Scope, stated so nobody mistakes this for more than it is (Codex round +// 3): this is NOT the net/http nothing-written replay case. That one needs +// the connection to die BEFORE the request bytes go out, on a pooled +// connection, which is a race no test can schedule — and the copy now runs +// on its own transport with its own empty pool, so it cannot arise here at +// all. The replay guarantee itself is pinned by +// TestCopyItem_MutatingRequestIsNotReplayable. +func TestCopyItem_LostResponseIsAttemptedOnce(t *testing.T) { + var copyAttempts int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(©Attempts, 1) + // Take the connection and drop it without answering. + hj, ok := w.(http.Hijacker) + if !ok { + t.Error("test server does not support hijacking") + return + } + conn, _, err := hj.Hijack() + if err != nil { + t.Errorf("hijack: %v", err) + return + } + _ = conn.Close() + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err == nil { + t.Fatal("expected a transport error when the connection dies") + } + if got := atomic.LoadInt32(©Attempts); got != 1 { + t.Fatalf("the copy request must not be replayed; server saw %d attempts", got) + } + if !CopyOutcomeUnknown(err) { + t.Errorf("a lost connection leaves the outcome unknown; got %v", err) + } + if !errors.Is(err, ErrCopyOutcomeUnknown) { + t.Errorf("expected ErrCopyOutcomeUnknown in the chain; got %v", err) + } +} + +// TestCopyItem_RedirectIsNotFollowed — a 307/308 would re-send the POST +// body at a new URL. That is a retry wearing a hat. +func TestCopyItem_RedirectIsNotFollowed(t *testing.T) { + var copyAttempts, elsewhereAttempts int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/elsewhere") { + atomic.AddInt32(&elsewhereAttempts, 1) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{}`)) + return + } + atomic.AddInt32(©Attempts, 1) + http.Redirect(w, r, "/api/v1/elsewhere", http.StatusTemporaryRedirect) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err == nil { + t.Fatal("expected an error rather than a followed redirect") + } + if !strings.Contains(err.Error(), "redirect") { + t.Errorf("error should name the redirect; got %v", err) + } + if got := atomic.LoadInt32(&elsewhereAttempts); got != 0 { + t.Fatalf("the POST body must not be re-sent at the redirect target; got %d requests", got) + } + if got := atomic.LoadInt32(©Attempts); got != 1 { + t.Fatalf("expected exactly one copy attempt; got %d", got) + } + // A redirect tells us nothing about whether anything committed, but it + // is not the ambiguous-outcome class either: the server answered. + if CopyOutcomeUnknown(err) { + t.Errorf("a redirect is a misconfiguration, not an unknown copy outcome") + } +} + +// TestCopyItem_MutatingClientIsNotTheSharedOne pins mechanism 1: even if +// the shared client is swapped for something with retry behaviour, the +// copy gets its own. +func TestCopyItem_MutatingClientIsNotTheSharedOne(t *testing.T) { + c := &Client{httpClient: &http.Client{}} + got := c.copyHTTPClient() + if got == c.httpClient { + t.Fatal("the mutating copy must not run on the shared http.Client") + } + if got.Transport == c.httpClient.Transport { + t.Fatal("the mutating copy must not inherit the shared client's RoundTripper") + } + if got.CheckRedirect == nil { + t.Fatal("the mutating copy's client must refuse redirects") + } + if err := got.CheckRedirect(nil, nil); !errors.Is(err, http.ErrUseLastResponse) { + t.Fatalf("CheckRedirect must return ErrUseLastResponse; got %v", err) + } + if got.Timeout != copyRequestTimeout { + t.Fatalf("copy timeout = %v, want %v", got.Timeout, copyRequestTimeout) + } +} + +// retryingTransport is the hazard DR-13 names, in the shape it actually +// takes in Go: a RoundTripper wrapper, not an http.Client setting. A +// dedicated *http.Client would inherit it through Transport. +type retryingTransport struct { + base http.RoundTripper + tries int + roundTr int32 +} + +func (rt *retryingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&rt.roundTr, 1) + var resp *http.Response + var err error + for i := 0; i < rt.tries; i++ { + if req.GetBody != nil && i > 0 { + b, gerr := req.GetBody() + if gerr != nil { + return nil, gerr + } + req.Body = b + } + resp, err = rt.base.RoundTrip(req) + if err == nil && resp.StatusCode < 500 { + return resp, nil + } + if resp != nil { + resp.Body.Close() + } + } + return resp, err +} + +// TestCopyItem_RetryingSharedTransportIsNotInherited is Codex round 1's P1. +// A dedicated http.Client is not sufficient on its own: retry behaviour +// lives in the RoundTripper, and inheriting c.httpClient.Transport would +// have inherited the retry with it. +func TestCopyItem_RetryingSharedTransportIsNotInherited(t *testing.T) { + var serverHits int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&serverHits, 1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"code":"copy_failed","message":"unknown outcome"}}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + rt := &retryingTransport{base: http.DefaultTransport, tries: 3} + c.httpClient.Transport = rt + + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err == nil { + t.Fatal("expected an error") + } + if got := atomic.LoadInt32(&serverHits); got != 1 { + t.Fatalf("a retrying transport on the SHARED client must not reach the copy; server saw %d requests", got) + } + if got := atomic.LoadInt32(&rt.roundTr); got != 0 { + t.Fatalf("the mutating copy must not run through the shared transport at all; it was entered %d times", got) + } + + // Control: the read-only preflight DOES use the shared transport, so + // the assertion above is about the copy's isolation and not about the + // wrapper being inert. + if _, _, err := c.CopyItemPreflight("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}); err == nil { + t.Fatal("expected the preflight to surface the 500 too") + } + if got := atomic.LoadInt32(&rt.roundTr); got == 0 { + t.Fatal("the preflight should have gone through the shared transport; the control proves nothing otherwise") + } +} + +// copyTransport must preserve a plain *http.Transport's configuration — +// dropping proxy/TLS settings would turn DR-13 compliance into a +// connection bug. +func TestCopyTransport_ClonesAPlainTransportButNotAWrapper(t *testing.T) { + shared := &http.Transport{MaxIdleConnsPerHost: 42, DisableCompression: true} + c := &Client{httpClient: &http.Client{Transport: shared}} + + got, ok := c.copyTransport().(*http.Transport) + if !ok { + t.Fatalf("expected an *http.Transport; got %T", c.copyTransport()) + } + if got == shared { + t.Error("the copy must not share the connection pool it was cloned from") + } + if got.MaxIdleConnsPerHost != 42 || !got.DisableCompression { + t.Errorf("clone lost configuration: %+v", got) + } + + // A wrapper is discarded outright — see copyTransport's doc comment for + // the trade-off this encodes. + c.httpClient.Transport = &retryingTransport{base: shared, tries: 2} + if _, ok := c.copyTransport().(*http.Transport); !ok { + t.Error("a wrapping RoundTripper must be replaced by a plain transport") + } + + // A nil Transport means net/http's default, and must still be cloned + // rather than shared. + c.httpClient.Transport = nil + def, ok := c.copyTransport().(*http.Transport) + if !ok { + t.Fatal("nil Transport should yield a plain *http.Transport") + } + if def == http.DefaultTransport { + t.Error("must not hand out DefaultTransport itself") + } +} + +// The three outcome classes are mutually exclusive, and each one drives a +// different thing the CLI is allowed to tell the user: +// +// unknown — may or may not have committed; check the destination +// committed — definitely committed; do NOT re-run +// neither — a refusal made before any write; safe to fix and re-run +func TestCopyOutcomeClassification(t *testing.T) { + cases := []struct { + name string + err error + wantUnknown bool + wantCommitted bool + }{ + {"nil", nil, false, false}, + {"copy_failed", &APIError{Code: "copy_failed", Message: "x"}, true, false}, + {"validation_error", &APIError{Code: "validation_error", Message: "x"}, false, false}, + {"plan_limit_exceeded", &APIError{Code: "plan_limit_exceeded", Message: "x"}, false, false}, + {"conflict", &APIError{Code: "conflict", Message: "x"}, false, false}, + {"transport", fmt.Errorf("%w: request failed: EOF", ErrCopyOutcomeUnknown), true, false}, + {"undecodable 2xx", fmt.Errorf("%w: decoding the response: x", ErrCopyCommitted), false, true}, + {"unrelated", errors.New("boom"), false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := CopyOutcomeUnknown(tc.err); got != tc.wantUnknown { + t.Errorf("CopyOutcomeUnknown(%v) = %v, want %v", tc.err, got, tc.wantUnknown) + } + if got := CopyCommitted(tc.err); got != tc.wantCommitted { + t.Errorf("CopyCommitted(%v) = %v, want %v", tc.err, got, tc.wantCommitted) + } + if tc.wantUnknown && tc.wantCommitted { + t.Fatal("fixture claims both classes; they are mutually exclusive") + } + }) + } +} + +// A body that dies mid-read after a 2xx header is the same class as an +// undecodable one: the server already committed. +func TestCopyItem_TruncatedSuccessBodyIsCommittedNotUnknown(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "500") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"source":`)) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + hj, ok := w.(http.Hijacker) + if !ok { + t.Error("no hijacker") + return + } + conn, _, err := hj.Hijack() + if err != nil { + t.Errorf("hijack: %v", err) + return + } + _ = conn.Close() + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err == nil { + t.Fatal("expected an error from a truncated body") + } + if CopyOutcomeUnknown(err) { + t.Errorf("a 2xx header already arrived; the copy committed. got %v", err) + } + if !CopyCommitted(err) { + t.Errorf("expected the committed-but-unreported class; got %v", err) + } +} + +// A 4xx is a refusal made BEFORE anything was written, so it must be +// distinguishable from the ambiguous class — and it must not be retried +// either. +func TestCopyItem_4xxIsARefusalNotAnUnknownOutcome(t *testing.T) { + var attempts int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attempts, 1) + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"code":"validation_error","message":"priority is required"}}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected an *APIError; got %v", err) + } + if apiErr.Code != "validation_error" || apiErr.Message != "priority is required" { + t.Errorf("unexpected APIError %+v", apiErr) + } + if CopyOutcomeUnknown(err) { + t.Error("a 400 is a refusal, not an unknown outcome") + } + if got := atomic.LoadInt32(&attempts); got != 1 { + t.Fatalf("expected exactly one attempt; got %d", got) + } +} + +// ── request/response fidelity ──────────────────────────────────────────── + +// Codex round 6. A hostile ref must not be able to re-route the one call +// in this CLI that mutates two workspaces. +func TestCopyItemPath_EscapesEachSegmentOnce(t *testing.T) { + // The ordinary case is byte-identical to bare concatenation — that is + // what makes the escaping free. + if got, want := itemCopyPath("pad-web", "TASK-5"), "/workspaces/pad-web/items/TASK-5/copy"; got != want { + t.Errorf("itemCopyPath = %q, want %q", got, want) + } + for _, ref := range []string{"../../admin", "a/b", "a?x=1", "a#frag", "a%2Fb"} { + got := itemCopyPath("ws", ref) + if strings.Count(got, "/") != 5 { + t.Errorf("itemCopyPath(%q) = %q — a hostile ref changed the path shape", ref, got) + } + for _, bad := range []string{"?", "#"} { + if strings.Contains(got, bad) { + t.Errorf("itemCopyPath(%q) = %q — %q survived unescaped", ref, got, bad) + } + } + } +} + +// The escaped path must survive to the wire, not just to the string. +func TestCopyItem_HostileRefDoesNotEscapeTheRoute(t *testing.T) { + var seen string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.URL.EscapedPath() + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "../../admin", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err != nil { + t.Fatalf("CopyItem: %v", err) + } + if want := "/api/v1/workspaces/ws/items/..%2F..%2Fadmin/copy"; seen != want { + t.Errorf("server saw %q, want %q", seen, want) + } +} + +func TestCopyItem_RequestShapeIsTheDocumentedOne(t *testing.T) { + var body []byte + var contentType string + var contentLength int64 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(r.Body) + body = buf.Bytes() + contentType = r.Header.Get("Content-Type") + contentLength = r.ContentLength + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"source":{},"destination":{},"warnings":{"dropped_fields":[]}}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, _, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{ + TargetWorkspace: "pad-web", + TargetCollection: "tasks", + FieldOverrides: map[string]any{"priority": "high"}, + ArchiveSource: true, + }) + if err != nil { + t.Fatalf("CopyItem: %v", err) + } + if contentType != "application/json" { + t.Errorf("Content-Type = %q", contentType) + } + // The opaque reader defeats net/http's length sniffing, so Content-Length + // is restored by hand. A regression there would make the server read a + // chunked body it does not expect. + if contentLength != int64(len(body)) { + t.Errorf("Content-Length = %d, body is %d bytes", contentLength, len(body)) + } + + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("request body is not JSON: %v", err) + } + want := map[string]any{ + "target_workspace": "pad-web", + "target_collection": "tasks", + "field_overrides": map[string]any{"priority": "high"}, + "archive_source": true, + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("request body =\n %v\nwant\n %v", got, want) + } +} + +// The preflight and the copy take a BYTE-IDENTICAL body. That is the +// server's stated contract and the reason a client can preview then commit +// without rebuilding the request. +func TestCopyPreflightAndCopySendIdenticalBodies(t *testing.T) { + bodies := map[string][]byte{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(r.Body) + if strings.HasSuffix(r.URL.Path, "/copy/preflight") { + bodies["preflight"] = buf.Bytes() + _, _ = w.Write([]byte(`{"valid":true}`)) + return + } + bodies["copy"] = buf.Bytes() + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + req := ItemCopyRequest{ + TargetWorkspace: "pad-web", + TargetCollection: "tasks", + FieldOverrides: map[string]any{"priority": "high", "points": 3.0}, + ArchiveSource: true, + } + if _, _, err := c.CopyItemPreflight("ws", "TASK-1", req); err != nil { + t.Fatalf("preflight: %v", err) + } + if _, _, err := c.CopyItem("ws", "TASK-1", req); err != nil { + t.Fatalf("copy: %v", err) + } + if !bytes.Equal(bodies["preflight"], bodies["copy"]) { + t.Errorf("bodies differ:\npreflight: %s\ncopy: %s", bodies["preflight"], bodies["copy"]) + } +} + +// The raw bytes handed to --format json must be the SERVER's, unmodelled +// fields and full int64 precision included. +func TestCopyItemPreflight_ReturnsServerBytesVerbatim(t *testing.T) { + const payload = `{"valid":true,"archive_source":false,` + + `"fields":{"carried":[],"dropped":[],"needs_value":[]},` + + `"warnings":{"attachment_bytes":9007199254740993,"outgoing_links":{},"incoming_links":{}},` + + `"a_field_this_cli_does_not_model":"kept"}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(payload)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + pre, raw, err := c.CopyItemPreflight("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err != nil { + t.Fatalf("preflight: %v", err) + } + if string(raw) != payload { + t.Errorf("raw bytes were altered.\n got: %s\nwant: %s", raw, payload) + } + if !pre.Valid { + t.Error("decoded preflight lost valid=true") + } + if pre.Warnings.AttachmentBytes != 9007199254740993 { + t.Errorf("int64 attachment_bytes decoded as %d", pre.Warnings.AttachmentBytes) + } +} + +func TestPrintRawJSON_PreservesShapeAndPrecision(t *testing.T) { + const payload = `{"warnings":{"attachment_bytes":9007199254740993},"unmodelled":{"z":1,"a":2}}` + var buf bytes.Buffer + if err := PrintRawJSON(&buf, json.RawMessage(payload)); err != nil { + t.Fatalf("PrintRawJSON: %v", err) + } + out := buf.String() + if !strings.HasSuffix(out, "\n") { + t.Error("output should end with a newline") + } + // json.Indent is lexical: the big integer survives as a literal and the + // unmodelled object keeps its original key order. + if !strings.Contains(out, "9007199254740993") { + t.Errorf("large integer lost precision:\n%s", out) + } + if strings.Index(out, `"z"`) > strings.Index(out, `"a"`) { + t.Errorf("key order was not preserved:\n%s", out) + } + // Compacting the output must reproduce the input byte-for-byte. + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(out)); err != nil { + t.Fatalf("compact: %v", err) + } + if compact.String() != payload { + t.Errorf("round trip changed the document.\n got: %s\nwant: %s", compact.String(), payload) + } +} + +func TestPrintRawJSON_NonJSONIsEmittedVerbatim(t *testing.T) { + var buf bytes.Buffer + if err := PrintRawJSON(&buf, json.RawMessage("not json")); err != nil { + t.Fatalf("PrintRawJSON: %v", err) + } + if buf.String() != "not json\n" { + t.Errorf("got %q", buf.String()) + } +} + +// A 2xx whose body will not decode means the copy COMMITTED — that is a +// known outcome, and must not be reported as ambiguous or the user will be +// told to go hunting for something that is definitely there. +func TestCopyItem_UndecodableSuccessIsNotAmbiguous(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"item": "not-an-object"}`)) + })) + defer ts.Close() + + c := newCopyTestClient(t, ts) + _, raw, err := c.CopyItem("ws", "TASK-1", ItemCopyRequest{TargetWorkspace: "b", TargetCollection: "tasks"}) + if err == nil { + t.Fatal("expected a decode error") + } + if CopyOutcomeUnknown(err) { + t.Error("a 2xx means the copy committed; the outcome is known") + } + if !CopyCommitted(err) { + t.Errorf("an undecodable 2xx must be classified as committed-but-unreported; got %v", err) + } + if !errors.Is(err, ErrCopyCommitted) { + t.Errorf("expected ErrCopyCommitted in the chain; got %v", err) + } + if len(raw) == 0 { + t.Error("the raw bytes should still be returned so a caller can show them") + } +} diff --git a/internal/items/overrides.go b/internal/items/overrides.go new file mode 100644 index 00000000..ec429837 --- /dev/null +++ b/internal/items/overrides.go @@ -0,0 +1,47 @@ +package items + +import ( + "sort" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// UndeclaredOverrideKeys returns the keys in an override map that the target +// schema does not declare, sorted so a caller's error message is stable for a +// given input. A nil or empty override map returns nil. +// +// It lives HERE, in the package both consumers already import, because two +// implementations of it is exactly the divergence PLAN-2357's DR-6 exists to +// prevent (Codex round 17). The cross-workspace copy PREFLIGHT refuses an +// undeclared override with a 400, and the COPY refuses it in +// Store.migrateCopyFields; if the two ever disagreed about which keys count as +// undeclared, the preview would promise something the copy refuses, or worse +// permit something the copy silently persists as an orphan key. +// +// The rule is a straight key-set difference with no exemptions. There is no +// reserved-key escape hatch in items.fields — PLAN-2357 DR-2 records why the +// cross-workspace provenance pointer needed its own table rather than a fields +// key — so every key the schema does not declare is an orphan. +// +// Why refusing is the right answer rather than dropping the key silently: +// ValidateFields ignores keys the schema does not declare, so an undeclared +// override that is merged is WRITTEN, invisible to every schema-driven +// surface. Dropping it instead would be no better — a client that asked for a +// value would get an item without it and no way to tell. +func UndeclaredOverrideKeys(overrides map[string]any, targetFields []models.FieldDef) []string { + if len(overrides) == 0 { + return nil + } + declared := make(map[string]struct{}, len(targetFields)) + for _, f := range targetFields { + declared[f.Key] = struct{}{} + } + var bad []string + for k := range overrides { + if _, ok := declared[k]; !ok { + bad = append(bad, k) + } + } + sort.Strings(bad) + return bad +} diff --git a/internal/items/overrides_test.go b/internal/items/overrides_test.go new file mode 100644 index 00000000..1708ddd6 --- /dev/null +++ b/internal/items/overrides_test.go @@ -0,0 +1,61 @@ +package items + +import ( + "reflect" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// TestUndeclaredOverrideKeys pins the shared rule both cross-workspace-copy +// consumers depend on (PLAN-2357 / TASK-2365). The SORT is part of the +// contract, not a convenience: both callers put the keys straight into a 400's +// message, and the copy preflight is specified to return identical bytes for +// identical input — a map-iteration-ordered list would break that on every +// other call. +func TestUndeclaredOverrideKeys(t *testing.T) { + defs := []models.FieldDef{ + {Key: "status", Type: "select"}, + {Key: "priority", Type: "select"}, + } + + cases := []struct { + name string + overrides map[string]any + want []string + }{ + {"nil map", nil, nil}, + {"empty map", map[string]any{}, nil}, + {"all declared", map[string]any{"status": "open", "priority": "low"}, nil}, + {"one undeclared", map[string]any{"nope": 1}, []string{"nope"}}, + { + "several undeclared come back sorted", + map[string]any{"zeta": 1, "alpha": 2, "status": "open", "mid": 3}, + []string{"alpha", "mid", "zeta"}, + }, + { + // A nil VALUE still names a real key; only the key set matters + // here. Whether a nil unsets or assigns is the merge loop's + // business, and it never gets to decide for an undeclared key. + "a nil value is still judged by its key", + map[string]any{"status": nil, "nope": nil}, + []string{"nope"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := UndeclaredOverrideKeys(tc.overrides, defs); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("got %v, want %v", got, tc.want) + } + }) + } + + // A schema with no fields declares nothing, so every override is an + // orphan. The empty-schema case is reachable — a collection created with + // no fields is legal. + got := UndeclaredOverrideKeys(map[string]any{"b": 1, "a": 2}, nil) + if !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("empty schema: got %v, want [a b]", got) + } +} diff --git a/internal/items/validate.go b/internal/items/validate.go index 50549d99..8d29a47b 100644 --- a/internal/items/validate.go +++ b/internal/items/validate.go @@ -34,12 +34,75 @@ func compilePattern(pat string) (*regexp.Regexp, error) { return re, nil } +// FieldIssueKind classifies a single field-level validation failure. +// +// The two kinds want different treatment from a caller that is building a +// user-facing form rather than just rejecting a request: a IssueRequired +// field needs a value supplied, while a IssueInvalid field HAS a value and +// it is wrong. PLAN-2357's copy preflight buckets them separately, and the +// distinction is impossible to recover from ValidateFields' joined error +// string without parsing English. +type FieldIssueKind string + +const ( + // IssueRequired — the schema declares the field required, it is absent + // (or nil) in the map, and the schema supplies no default to fill it. + IssueRequired FieldIssueKind = "required" + + // IssueInvalid — the field HAS a value and that value fails the + // schema's type / options / pattern / range rules. + IssueInvalid FieldIssueKind = "invalid" +) + +// FieldIssue is one field-level validation failure, attributable to a key. +type FieldIssue struct { + // Key is the schema field key the failure belongs to. Always set — + // that is the point of this type. + Key string + // Kind is IssueRequired or IssueInvalid. + Kind FieldIssueKind + // Message is the human-readable explanation. For IssueInvalid it is + // validateFieldType's error text verbatim, so it stays in step with + // the single-error path. + Message string +} + // ValidateFields checks field values against the collection schema. // It validates required fields are present, types are correct, and select // values are within the allowed options. It applies defaults for missing // optional fields, mutating the fields map in place. +// +// It is a thin wrapper over ValidateFieldsDetailed that joins the issues +// into the historical single-error string. Both share one traversal so the +// two surfaces can never disagree about what is valid. func ValidateFields(fields map[string]any, schema models.CollectionSchema) error { - var errs []string + issues := ValidateFieldsDetailed(fields, schema) + if len(issues) == 0 { + return nil + } + errs := make([]string, 0, len(issues)) + for _, iss := range issues { + errs = append(errs, iss.Message) + } + return fmt.Errorf("field validation failed: %s", strings.Join(errs, "; ")) +} + +// ValidateFieldsDetailed is ValidateFields with per-field attribution: +// it returns one FieldIssue per failure instead of a joined error, and +// applies schema defaults to `fields` in place exactly as ValidateFields +// does (the mutation is the same traversal, not a second pass). +// +// Issues come back in SCHEMA ORDER, which makes the result deterministic +// for a given (fields, schema) pair — repeated calls with equal input +// produce byte-identical output. PLAN-2357's dry-run preflight leans on +// that: it is specified to be safe to call repeatedly and to return +// identical results, and a map-iteration-ordered issue list would break +// that promise on every other call. +// +// A nil return means valid. Callers that only need the boolean should +// keep using ValidateFields. +func ValidateFieldsDetailed(fields map[string]any, schema models.CollectionSchema) []FieldIssue { + var issues []FieldIssue for _, def := range schema.Fields { val, exists := fields[def.Key] @@ -51,7 +114,11 @@ func ValidateFields(fields map[string]any, schema models.CollectionSchema) error fields[def.Key] = def.Default continue } - errs = append(errs, fmt.Sprintf("field %q is required", def.Key)) + issues = append(issues, FieldIssue{ + Key: def.Key, + Kind: IssueRequired, + Message: fmt.Sprintf("field %q is required", def.Key), + }) continue } if def.Default != nil { @@ -62,14 +129,15 @@ func ValidateFields(fields map[string]any, schema models.CollectionSchema) error // Validate by type if err := validateFieldType(def, val); err != nil { - errs = append(errs, err.Error()) + issues = append(issues, FieldIssue{ + Key: def.Key, + Kind: IssueInvalid, + Message: err.Error(), + }) } } - if len(errs) > 0 { - return fmt.Errorf("field validation failed: %s", strings.Join(errs, "; ")) - } - return nil + return issues } // ValidatePartialFields validates ONLY the keys present in `patch` against diff --git a/internal/items/validate_test.go b/internal/items/validate_test.go index 909c5de0..025f5b1f 100644 --- a/internal/items/validate_test.go +++ b/internal/items/validate_test.go @@ -1,6 +1,7 @@ package items import ( + "strings" "testing" "github.com/PerpetualSoftware/pad/internal/models" @@ -412,3 +413,87 @@ func TestValidatePartialFields_AllowsDeletingOptionalField(t *testing.T) { t.Fatalf("deleting an optional field via null should be allowed, got: %v", err) } } + +// --- ValidateFieldsDetailed (PLAN-2357 / TASK-2364) -------------------- + +// TestValidateFieldsDetailed_AttributesAndOrders pins the two properties +// the copy preflight depends on: every failure is attributable to a KEY +// with a kind, and the order is schema order (not map order), so a caller +// that is specified to return identical results on repeated calls can. +func TestValidateFieldsDetailed_AttributesAndOrders(t *testing.T) { + schema := models.CollectionSchema{Fields: []models.FieldDef{ + {Key: "alpha", Type: "text", Required: true}, + {Key: "beta", Type: "select", Options: []string{"x", "y"}}, + {Key: "gamma", Type: "text", Required: true}, + {Key: "delta", Type: "text", Default: "d"}, + }} + + var last []FieldIssue + for i := 0; i < 20; i++ { + fields := map[string]any{"beta": "nope"} + issues := ValidateFieldsDetailed(fields, schema) + if len(issues) != 3 { + t.Fatalf("got %d issues, want 3: %+v", len(issues), issues) + } + want := []struct { + key string + kind FieldIssueKind + }{ + {"alpha", IssueRequired}, + {"beta", IssueInvalid}, + {"gamma", IssueRequired}, + } + for j, w := range want { + if issues[j].Key != w.key || issues[j].Kind != w.kind { + t.Fatalf("issue %d = %+v, want key=%s kind=%s", j, issues[j], w.key, w.kind) + } + if issues[j].Message == "" { + t.Errorf("issue %d carries no message", j) + } + } + // Defaults are still applied in place, exactly as ValidateFields does. + if fields["delta"] != "d" { + t.Errorf("default not applied: %+v", fields) + } + if last != nil && !sameIssues(last, issues) { + t.Fatalf("run %d differed from the previous run: %+v vs %+v", i, last, issues) + } + last = issues + } +} + +// TestValidateFields_DelegatesToDetailed — the joined-error surface must +// stay in step with the structured one; they share a traversal so they can +// never disagree about validity. +func TestValidateFields_DelegatesToDetailed(t *testing.T) { + schema := models.CollectionSchema{Fields: []models.FieldDef{ + {Key: "alpha", Type: "text", Required: true}, + }} + + if err := ValidateFields(map[string]any{"alpha": "ok"}, schema); err != nil { + t.Fatalf("valid map reported an error: %v", err) + } + err := ValidateFields(map[string]any{}, schema) + if err == nil { + t.Fatal("missing required field did not error") + } + issues := ValidateFieldsDetailed(map[string]any{}, schema) + if len(issues) != 1 || issues[0].Kind != IssueRequired { + t.Fatalf("detailed issues = %+v", issues) + } + if !strings.Contains(err.Error(), issues[0].Message) { + t.Errorf("joined error %q does not contain the detailed message %q", err, issues[0].Message) + } +} + +func sameIssues(a, b []FieldIssue) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} 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/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/server/authz_cross_workspace.go b/internal/server/authz_cross_workspace.go new file mode 100644 index 00000000..ab84b476 --- /dev/null +++ b/internal/server/authz_cross_workspace.go @@ -0,0 +1,844 @@ +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:"-"` + + // collectionScoped records that the verdict was computed against a + // CrossWorkspaceCollectionScope. It exists so WriteCollectionNotFound + // can REFUSE to collapse a denial it was not built for, rather than + // trusting its doc comment — see that method. Unexported and never + // serialized; nothing outside this file sets it. + collectionScoped bool +} + +// 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") + } +} + +// crossWorkspaceCollectionNotFoundCode / Message are the ONE answer given +// for a destination collection that is absent, soft-deleted, in another +// workspace, or simply hidden from the caller. Exported as constants so a +// handler's own "GetCollectionBySlug returned nil" branch and this writer +// cannot drift apart — if the two ever produce different bytes, the +// difference is the disclosure. +const ( + crossWorkspaceCollectionNotFoundCode = "collection_not_found" + crossWorkspaceCollectionNotFoundMessage = "Destination collection not found" +) + +// WriteCollectionNotFound is the denial writer for a +// CrossWorkspaceCollectionScope check on a workspace the caller has +// ALREADY been authorized into (i.e. a WriteDenied on a +// CrossWorkspaceWorkspaceOnlyScope has already passed). +// +// It exists because PLAN-2357's DR-15 requires the copy preflight to +// distinguish "destination workspace not accessible" (403) from +// "collection not found" (404) — and the naive way to do that leaks. If +// an absent collection 404s while a collection that exists but is hidden +// 403s, a restricted member of workspace B can enumerate the collections +// they were deliberately excluded from, one slug at a time. So: +// +// - collection absent, soft-deleted, foreign to the workspace, or +// hidden from the caller → an identical 404 collection_not_found, +// byte-for-byte the same response the handler emits when +// GetCollectionBySlug returns nil; +// - anything else (no workspace access, token not allow-listed, lookup +// failure) → delegated to WriteDenied unchanged. +// +// PRECONDITION, same as WriteDenied's: only call this where the caller +// supplied the destination themselves. It acknowledges the workspace, and +// that acknowledgement is only safe because the workspace-level check ran +// first and already decided the caller belongs there. +// +// The other half of the precondition IS enforced: the collapse only fires +// on a verdict produced by a CrossWorkspaceCollectionScope. Handed a +// verdict from an item or workspace-only scope it falls through to +// WriteDenied, so a future caller cannot use it to turn a normally hidden +// denial into the distinctive collection_not_found response for a +// collection the caller never named (Codex round 7). +// +// Note the asymmetry with the SOURCE side, which uses WriteHidden: the +// source item is addressed through the request's own workspace URL, so a +// 404 is the natural shape there and there is no destination the caller +// named to acknowledge. +func (a CrossWorkspaceAccess) WriteCollectionNotFound(w http.ResponseWriter) { + if !a.collectionScoped { + a.WriteDenied(w) + return + } + switch a.Reason { + case CrossWorkspaceCollectionNotVisible, CrossWorkspaceScopeMismatch: + writeError(w, http.StatusNotFound, + crossWorkspaceCollectionNotFoundCode, crossWorkspaceCollectionNotFoundMessage) + default: + a.WriteDenied(w) + } +} + +// 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 { + // Stamped onto every verdict below, allow or deny, so + // WriteCollectionNotFound can tell a collection-scoped denial from any + // other kind. Done in one place, on the way out, rather than at each + // return — a missed stamp would silently re-open the hole the flag + // exists to close. + verdict := func(a CrossWorkspaceAccess) CrossWorkspaceAccess { + a.collectionScoped = scope.collectionID != "" + return a + } + return verdict(s.authorizeCrossWorkspaceInner(r, workspaceSlugOrID, scope, needEdit)) +} + +func (s *Server) authorizeCrossWorkspaceInner(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..2d20713b --- /dev/null +++ b/internal/server/authz_cross_workspace_test.go @@ -0,0 +1,1139 @@ +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) + } + } +} + +// TestCrossWorkspace_WriteCollectionNotFoundRefusesForeignVerdicts pins the +// enforced half of WriteCollectionNotFound's precondition (PLAN-2357 / +// TASK-2364). The collapse to a 404 collection_not_found is only safe for +// a denial the caller provoked by NAMING a collection; applied to an +// item-scoped or workspace-only verdict it would turn a deliberately +// indistinguishable 403 into a distinctive response about a collection the +// caller never mentioned. The flag makes that misuse impossible rather +// than merely discouraged. +func TestCrossWorkspace_WriteCollectionNotFoundRefusesForeignVerdicts(t *testing.T) { + f := newCrossWSFixture(t) + u := f.member("wcnf@example.com", "wcnfuser", "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}) + + // Collection-scoped denial on the hidden collection: collapses to 404. + collVerdict := f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceCollectionScope(f.hiddenB.ID)) + assertDenied(t, collVerdict, CrossWorkspaceCollectionNotVisible, "hidden collection") + rec := httptest.NewRecorder() + collVerdict.WriteCollectionNotFound(rec) + if rec.Code != http.StatusNotFound { + t.Fatalf("collection-scoped denial: got %d, want 404: %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "collection_not_found") { + t.Errorf("collection-scoped denial body = %s, want collection_not_found", rec.Body.String()) + } + + // An ITEM scope can reach the very reasons the collapse switches on — + // CrossWorkspaceScopeMismatch here (an item from workspace A handed in + // alongside workspace B), and CrossWorkspaceCollectionNotVisible when + // the item's own collection is archived. Neither may produce the + // collection-shaped 404: the caller named an ITEM. This is the leg + // that actually falsifies the guard — without it these become + // "collection_not_found" for a collection nobody mentioned. + itemVerdict := f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.itemA)) + assertDenied(t, itemVerdict, CrossWorkspaceScopeMismatch, "foreign item scope") + rec = httptest.NewRecorder() + itemVerdict.WriteCollectionNotFound(rec) + if rec.Code != http.StatusForbidden { + t.Fatalf("item-scoped scope_mismatch: got %d, want WriteDenied's 403: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "collection_not_found") { + t.Errorf("item-scoped denial leaked the collection-shaped response: %s", rec.Body.String()) + } + + // And the plain hidden-item denial, for completeness. + hiddenItemVerdict := f.srv.AuthorizeCrossWorkspaceEdit(r, f.wsB.Slug, CrossWorkspaceItemScope(f.hiddenIB)) + assertDenied(t, hiddenItemVerdict, CrossWorkspaceItemNotVisible, "hidden item scope") + rec = httptest.NewRecorder() + hiddenItemVerdict.WriteCollectionNotFound(rec) + if rec.Code != http.StatusForbidden { + t.Fatalf("item-scoped denial: got %d, want 403: %s", rec.Code, rec.Body.String()) + } + + // Workspace-only verdicts likewise fall through to WriteDenied. + stranger := mustUser(t, f.srv, "wcnf-stranger@example.com", "wcnfstranger", "") + sr := f.request(stranger, reqOpts{wsRoleCtx: "editor", wsIDCtx: f.wsA.ID}) + wsVerdict := f.srv.AuthorizeCrossWorkspaceEdit(sr, f.wsB.ID, CrossWorkspaceWorkspaceOnlyScope()) + if wsVerdict.Allowed { + t.Fatalf("fixture: expected the stranger to be denied") + } + rec = httptest.NewRecorder() + wsVerdict.WriteCollectionNotFound(rec) + if rec.Code != http.StatusForbidden { + t.Fatalf("workspace-only denial: got %d, want 403: %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 6db39a45..ed4ddbd2 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) } @@ -1773,10 +1781,42 @@ func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) { result.Fields[k] = v } - // Check for required field errors (after overrides) - if len(result.Errors) > 0 { - writeError(w, http.StatusBadRequest, "missing_required_fields", - fmt.Sprintf("Required fields missing: %s", strings.Join(result.Errors, ", "))) + // Validate AFTER the overrides are applied — PLAN-2357 DR-12. + // + // The comment above the old check claimed "after overrides" but the + // value it tested, result.Errors, is computed by MigrateFields BEFORE + // any override exists (migrate.go:62). So an override that SATISFIED a + // required destination field still 400'd, and an override with an + // invalid value was never type-checked at all — it went straight into + // the item. Both halves are fixed by running the validator over the + // MERGED map instead; ValidateFieldsDetailed also injects destination + // defaults, which is why result.Fields is what gets serialized below. + // + // Dormant until now — handleMove passes field_overrides: undefined + // (ItemDetail.svelte), so no override ever reached the stale check. + // PLAN-2357's copy dialog is the first caller to send them, which is + // what makes this the PR that has to fix it rather than file it. + // + // The required-field branch keeps the historical + // `missing_required_fields` code and message shape, because CLI and + // web callers key off it; genuinely invalid VALUES get their own + // `invalid_fields` code rather than being mislabelled as missing. + if issues := items.ValidateFieldsDetailed(result.Fields, targetSchema); len(issues) > 0 { + var missing, invalid []string + for _, iss := range issues { + if iss.Kind == items.IssueRequired { + missing = append(missing, fmt.Sprintf("required field %q has no value", iss.Key)) + } else { + invalid = append(invalid, iss.Message) + } + } + if len(missing) > 0 { + writeError(w, http.StatusBadRequest, "missing_required_fields", + fmt.Sprintf("Required fields missing: %s", strings.Join(missing, ", "))) + return + } + writeError(w, http.StatusBadRequest, "invalid_fields", + fmt.Sprintf("Invalid field value(s): %s", strings.Join(invalid, "; "))) return } diff --git a/internal/server/handlers_items_copy.go b/internal/server/handlers_items_copy.go new file mode 100644 index 00000000..2f0837bd --- /dev/null +++ b/internal/server/handlers_items_copy.go @@ -0,0 +1,877 @@ +package server + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "net/http" + "runtime/debug" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/events" + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" +) + +// Cross-workspace item COPY — PLAN-2357 / TASK-2365, implementing DR-9's +// re-check obligation, DR-13's no-retry rule and DR-14's fanout contract. +// +// ROUTE: +// +// POST /api/v1/workspaces/{slug}/items/{itemSlug}/copy +// +// The URL's workspace is the SOURCE. The destination is named in the body. +// The dry-run sibling is POST …/items/{itemSlug}/copy/preflight +// (TASK-2364) and takes the IDENTICAL request shape — deliberately, so a +// dialog can send one body to both and a client can preview then commit +// without rebuilding it. +// +// REQUEST — itemCopyPreflightRequest, reused verbatim: +// +// { +// "target_workspace": "pad-web", // required; slug or UUID +// "target_collection": "tasks", // required; collection slug in the destination +// "field_overrides": {"priority": "high"}, // optional; key MUST be declared by the destination schema +// "archive_source": false // optional; the MOVE path (copy + archive source) +// } +// +// Override semantics are the preflight's, exactly: an undeclared key is a +// 400, and a null value UNSETS the key (letting the destination schema's +// default re-apply) rather than persisting a literal JSON null. Both of +// those were live disagreements when TASK-2364 shipped; TASK-2365 moved the +// gate and the delete into Store.migrateCopyFields so the preview and the +// copy cannot answer differently. See TestCopyEndpoint_PreflightAndCopyAgree*. +// +// RESPONSE — 201 Created, ItemCopyResult. It carries everything a client +// needs to navigate to the copy (destination workspace slug + ref + slug) +// and, on a move, to navigate AWAY from the source (source.archived, plus +// its ref and workspace slug). `item` is the full destination item, so a +// dialog can render it without a follow-up GET into a workspace it may have +// no route mounted for. +// +// ERROR STATUSES — the preflight's set, plus what only a mutation can hit. +// +// SCOPED TO WHAT handleCopyItem ITSELF EMITS. The route sits inside the full +// middleware stack, so a client must also handle what every other +// /api/v1/workspaces/… route can return before the handler runs: 401 +// unauthorized, 403 csrf_error, 403 for an unverified email, 404 for an +// unresolvable workspace slug, 429 rate_limited, and 500 internal_error +// (which this handler can also emit for a failed lookup). Those are not +// restated per-endpoint anywhere in Pad and are not restated here; the list +// below is the business vocabulary a copy client has to understand +// specifically (Codex round 14). +// +// 400 invalid_body — the JSON body did not decode +// 400 missing_field — target_workspace / target_collection absent +// 400 malformed_override — an override names a field the destination +// schema does not declare (store-side gate; +// byte-identical intent to the preflight's) +// 400 validation_error — the FINAL destination fields (after +// migration, after overrides) fail the +// destination schema. Covers both halves of +// the preflight's split: a needs_value the +// caller never resolved, and an override +// whose VALUE is invalid. The preflight is +// where those are told apart per field; a +// mutation just refuses. +// 403 forbidden — destination workspace not accessible +// 403 permission_denied — destination workspace outside the bearer +// token's consent allow-list +// 403 plan_limit_exceeded — the destination workspace is at its +// items_per_workspace cap (DR-16). Cloud +// mode only; see the EnforceItemLimit note +// below. Carries writePlanLimitError's usual +// payload (plan, limit, current). That is +// MORE than the preflight reveals — the +// preflight documents quota as one of the +// things `valid` deliberately does not +// evaluate — and it is intended: it is +// byte-identical to what handleCreateItem +// already returns to any caller who may +// create in that collection, which is exactly +// the right this endpoint has already +// established by check 4. Withholding it here +// would hide an actionable answer behind a +// distinction a POST to the same collection +// erases anyway (Codex round 8, declined). +// 404 not_found — source item absent or not visible, or the +// caller may not edit it (WriteHidden; the +// source side never distinguishes the two) +// 409 archived — source exists, caller can see it, it is +// archived +// 404 collection_not_found — destination collection absent, archived, +// foreign, or hidden from the caller. Also +// covers a destination collection that was +// still there when the caller was authorized +// and gone by the time the copy locked it. +// 403 forbidden — destination collection visible but the +// caller may not create into it +// 403 actor_required — nobody to attribute the copy to (no +// authenticated user AND a source item with +// no creator). Shared verbatim with the +// preflight. +// 409 conflict — a unique constraint in the destination +// (slug, title, playbook invocation_slug) +// 409 cross_backend_attachments — the copy would have to move attachment +// BYTES between storage backends, which v1 +// refuses (store.ErrCopyCrossBackendAttachments). +// NOT REACHABLE TODAY: the handler leaves +// TargetBackend empty, which disables the +// store's cross-backend detection outright (see +// the field's note below), and Pad registers +// exactly one backend. The mapping is here so +// that wiring a second backend is a one-line +// change rather than a new 500; a client need +// not handle it until then (Codex round 14). +// 500 copy_failed — an AMBIGUOUS failure. See DR-13 below; the +// message tells the user to check the +// destination rather than retry. +// +// DR-13 — NO TRANSPARENT RETRY. There is no idempotency key in v1, so a +// retried copy is a duplicate, and a client that timed out after the commit +// cannot tell. This handler therefore calls the store op EXACTLY ONCE per +// request, whatever comes back: there is no retry loop, no backoff, and no +// "transient error" classification that could grow into one. +// TestCopyEndpoint_DoesNotRetryOnAmbiguousError pins the invocation count at +// one, so adding a retry breaks a test rather than shipping duplicates. The +// 500's message is part of the same contract — it tells the user to CHECK +// THE DESTINATION, because the copy may well have landed. +// +// The clients are clean today: internal/cli/client.go issues one +// httpClient.Do per request, and web/src/lib/api/client.ts retries GET and +// HEAD only, explicitly excluding POST. +// +// ⚠ READ THIS BEFORE PUTTING COPY ON THE MCP SURFACE. PLAN-2357 defers +// `pad_item.action: copy`, and whoever picks it up inherits a live conflict: +// internal/mcp/errors.go annotates EVERY 5xx with "Usually transient — retry +// once", which is the exact advice this endpoint's 500 exists to contradict. +// An agent that follows that hint duplicates the item. Routing copy through +// the MCP dispatcher therefore requires special-casing the `copy_failed` code +// in that classifier first — it is not a wiring-only change (Codex round 8). +// +// DR-9 — THE VERDICT IS RE-CHECKED IN-TRANSACTION. The four-check ladder runs +// at the top of the handler; inside the write transaction, against the +// snapshots re-read under the copy's locks, the copy asserts it is operating +// on the SAME resources that verdict was about +// (CrossWorkspaceCopyRequest.PreCheck / copyResourceInvariantPreCheck). +// AuthorizeCrossWorkspaceEdit says on both its entry points that its verdict +// is not atomic, and the window is real: between the handler's check and the +// lock, the source item can be moved into a collection hidden from the +// caller. See copyResourceInvariantPreCheck for exactly what that does and +// does not guarantee — it is deliberately a pure, I/O-free comparison. +// +// DR-14 — FANOUT IS POST-COMMIT, ASYMMETRIC, AND SEQ-ATTRIBUTED. See +// emitCopyFanout. +// +// COST AND RATE LIMITING. A copy holds BOTH workspaces' advisory locks for +// the length of its transaction, and that length scales with the number of +// attachment references in the item — each cloned row is an insert inside the +// lock. The endpoint inherits only the general authenticated limiter +// (600/min), which PLAN-2357 DR-16 explicitly adjudicates: "Rate limiting is +// a P2 and deliberately deferred… a dedicated lower limit on copy and its +// preflight is worth adding, but it is a hardening follow-on, not a +// correctness gate." Nothing here is unbounded — variants are server-derived, +// exactly two per original and idempotent (thumbnailSpecs), so an attacker +// cannot inflate the per-attachment cost — but the deferral is a decision on +// record rather than an oversight (Codex round 22, declined). +// +// CALLER OBLIGATIONS from CrossWorkspaceCopyResult, both honored here: +// storageInfoCache invalidation for the DESTINATION when attachments were +// copied, and EnforceItemLimit passed s.cloudMode rather than an +// unconditional true (passing true would apply free-tier item caps to +// self-hosted users whose plan row happens to say "free" — a limit that path +// has never had). + +// ItemCopyResult is the 201 response. +// +// Shape note: the three blocks mirror the preflight's Source / Destination / +// ArchiveSource so a dialog that rendered the preview can render the outcome +// with the same accessors. +type ItemCopyResult struct { + Source ItemCopyResultSource `json:"source"` + Destination ItemCopyResultDestination `json:"destination"` + + // ArchiveSource echoes the request. It is what the request ASKED for; + // Source.Archived is what actually happened. They agree on every + // successful response — the field is here so a client holding only this + // object can tell a copy from a move without inferring it. + ArchiveSource bool `json:"archive_source"` + + // Item is the destination item, exactly as the copy committed it: its + // Seq is workspace B's committed seq for this create, and its Ref / + // Slug / CollectionSlug are the destination's. + // + // NOT enriched with relations (children, links, parent). A fresh copy + // has none by construction — DR-17 scrubs the parent and copies no + // links — so enrichment would be a round-trip that can only ever + // return empty. + Item *models.Item `json:"item"` + + Warnings ItemCopyResultWarnings `json:"warnings"` +} + +// ItemCopyResultSource identifies what was copied, and whether it is still +// there. On a move a client uses this to navigate away from a source that +// no longer exists as a live item. +type ItemCopyResultSource struct { + WorkspaceSlug string `json:"workspace_slug"` + CollectionSlug string `json:"collection_slug"` + Ref string `json:"ref,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` + + // Archived is true when this copy archived the source (the move path). + // A plain copy leaves the source completely untouched. + Archived bool `json:"archived"` + + // Seq is workspace A's committed seq for the ARCHIVE, and is present + // only on a move — a plain copy does not write in A at all and must not + // advance its cursor (DR-14). A client holding an A-side delta cursor + // can use it to reconcile without a poll. + Seq int64 `json:"seq,omitempty"` +} + +// ItemCopyResultDestination is where the copy landed. Ref + WorkspaceSlug +// are the navigation pair. +type ItemCopyResultDestination struct { + WorkspaceSlug string `json:"workspace_slug"` + WorkspaceName string `json:"workspace_name"` + CollectionSlug string `json:"collection_slug"` + CollectionName string `json:"collection_name"` + Ref string `json:"ref,omitempty"` + Slug string `json:"slug"` + + // Seq is workspace B's committed seq for the create. Its own + // workspace's seq, never A's (DR-14). + Seq int64 `json:"seq,omitempty"` +} + +// ItemCopyResultWarnings is the after-the-fact counterpart to the +// preflight's warning block: what the copy actually dropped and actually +// cloned, as opposed to what a preview predicted. +// +// It is deliberately NARROWER than ItemCopyPreflightWarnings. The +// relationship counters (child_count, dropped_parent, the link maps) are +// preview-only: they describe the SOURCE's relatives, which the copy did not +// touch and which the caller already saw before agreeing. Recomputing them +// post-commit would mean a second ACL-filtered pass over workspace A to +// restate a number the preflight already gave. +type ItemCopyResultWarnings struct { + // DroppedFields are the destination-schema keys migration could not + // carry. Always non-nil. + DroppedFields []string `json:"dropped_fields"` + + // DroppedAssignee / DroppedAgentRole record the DR-8 scrubs. + DroppedAssignee bool `json:"dropped_assignee"` + DroppedAgentRole bool `json:"dropped_agent_role"` + + // AttachmentCount / AttachmentBytes are what actually landed in the + // destination workspace's storage. + AttachmentCount int `json:"attachment_count"` + AttachmentBytes int64 `json:"attachment_bytes"` + + // UnresolvableRefCount is how many pad-attachment references resolved + // to nothing under the source workspace's scope (DR-11a). Not cloned, + // never fatal; the copy renders exactly as broken as the source did. + UnresolvableRefCount int `json:"unresolvable_ref_count"` +} + +// copyPreCheckDenial is an authorization refusal from the IN-TRANSACTION +// re-check. It records WHICH side refused so the handler can write the same +// response that side would have written at the top of the request — the +// disclosure posture must not change just because the denial arrived later. +type copyPreCheckDenial struct { + // side is "source" or "destination". + side string + access CrossWorkspaceAccess +} + +func (e *copyPreCheckDenial) Error() string { + return fmt.Sprintf("cross-workspace copy pre-check denied on the %s side: %s", e.side, e.access.Reason) +} + +// handleCopyItem copies one item into another workspace, optionally +// archiving the source. See the file header for the full contract. +func (s *Server) handleCopyItem(w http.ResponseWriter, r *http.Request) { + sourceWorkspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + itemSlug := chi.URLParam(r, "itemSlug") + item, err := s.store.ResolveItem(sourceWorkspaceID, itemSlug) + if err != nil { + writeInternalError(w, err) + return + } + if item == nil { + // ResolveItem filters soft-deleted rows, so this covers "absent" AND + // "archived"; writeItemResolveError separates them exactly as the + // preflight, move and update paths do. + s.writeItemResolveError(w, r, sourceWorkspaceID, itemSlug) + return + } + + // ---- Authorization, DR-10a/DR-10b, four checks in order -------------- + // + // Identical to the preflight's, statement for statement, including the + // early return between the source and destination halves: a destination + // verdict built for a caller who could not read the source is itself a + // disclosure. Diverging here would let a caller preview a copy they + // cannot perform, or worse, perform one they cannot preview. + src := s.AuthorizeCrossWorkspaceEdit(r, sourceWorkspaceID, CrossWorkspaceItemScope(item)) + if !src.Allowed { + src.WriteHidden(w, "Item") + return + } + + var input itemCopyPreflightRequest + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + if input.TargetWorkspace == "" { + writeError(w, http.StatusBadRequest, "missing_field", "target_workspace is required") + return + } + if input.TargetCollection == "" { + writeError(w, http.StatusBadRequest, "missing_field", "target_collection is required") + return + } + + dstWS := s.AuthorizeCrossWorkspaceEdit(r, input.TargetWorkspace, CrossWorkspaceWorkspaceOnlyScope()) + if !dstWS.Allowed { + dstWS.WriteDenied(w) + return + } + + targetColl, err := s.store.GetCollectionBySlug(dstWS.WorkspaceID(), input.TargetCollection) + if err != nil { + writeInternalError(w, err) + return + } + if targetColl == nil { + writeError(w, http.StatusNotFound, + crossWorkspaceCollectionNotFoundCode, crossWorkspaceCollectionNotFoundMessage) + return + } + + dst := s.AuthorizeCrossWorkspaceEdit(r, input.TargetWorkspace, CrossWorkspaceCollectionScope(targetColl.ID)) + if !dst.Allowed { + dst.WriteCollectionNotFound(w) + return + } + + // Refused HERE rather than left to the store, which answers a bare + // "actor is required" that would be mapped to the ambiguous 500 and send + // the user hunting for an item nothing ever tried to create. The + // preflight applies the identical guard, so the preview cannot promise a + // copy this would refuse (Codex round 4). + actorID := copyActorID(r, item) + if actorID == "" { + writeCopyActorRequired(w) + return + } + + actor, actorSource := actorFromRequest(r) + + // ---- The copy. ONE call, no retry (DR-13). --------------------------- + res, err := s.copyItemAcrossWorkspaces(store.CrossWorkspaceCopyRequest{ + SourceItemID: item.ID, + TargetWorkspaceID: dst.WorkspaceID(), + TargetCollectionID: targetColl.ID, + FieldOverrides: input.FieldOverrides, + Actor: actorID, + CreatedBy: actor, + Source: actorSource, + ArchiveSource: input.ArchiveSource, + + // TargetBackend is deliberately LEFT EMPTY, which disables + // cross-backend detection. Pad registers exactly one attachment + // backend today (attachments.FSPrefix, wired in cmd/pad), and — the + // binding reason — the preflight leaves it empty too. Setting it on + // only one of the two would let the copy refuse a request its own + // preview accepted, which is the DR-6 disagreement this pair of + // endpoints exists to prevent. When a second backend lands, both + // call sites get it together. + TargetBackend: "", + + // s.cloudMode, NEVER an unconditional true: EnforceItemLimit mirrors + // enforcePlanLimit's `if !s.cloudMode { return true }` gate, and + // forcing it on would apply free-tier item caps to self-hosted users + // whose plan row says "free". + EnforceItemLimit: s.cloudMode, + + // DR-9's in-tx re-check, against the under-lock snapshots. + PreCheck: copyResourceInvariantPreCheck(copyAuthorizedResources{ + sourceItemID: item.ID, + sourceWorkspaceID: sourceWorkspaceID, + sourceCollectionID: item.CollectionID, + targetCollectionID: targetColl.ID, + targetWorkspaceID: dst.WorkspaceID(), + source: src, + destination: dst, + }), + }) + if err != nil { + s.writeCopyError(w, err) + return + } + + // ---- Post-commit, in this order. ------------------------------------- + // + // The copy is committed and IRREVERSIBLE from here. Nothing below may + // turn into a non-2xx response: a client that sees a failure after a + // successful copy is exactly the ambiguity DR-13 forbids resolving by + // retrying, and there is nothing here worth telling the user the copy + // failed over. + + // EVERY collection fact below comes from res, not from the pre-transaction + // reads above. The store re-reads both collection rows under a FOR UPDATE + // pin and hands them back for exactly this reason: between the + // authorization lookup and the lock, the source item can be moved into a + // different collection and either collection can be renamed. Reporting the + // pre-transaction slug would attribute the source's archive event to a + // collection the copy did not touch and hand the client a stale slug + // (Codex round 1 P2). + s.afterCopyCommit(r, res) + + resp := ItemCopyResult{ + Source: ItemCopyResultSource{ + // The CANONICAL slug, never the URL parameter — /workspaces/{slug} + // also accepts a UUID, and a client building a "go back to the + // source" link out of that gets one that does not resolve (Codex + // round 14). + WorkspaceSlug: src.WorkspaceSlug(), + CollectionSlug: res.SourceCollection.Slug, + Ref: res.Source.Ref, + Slug: res.Source.Slug, + Title: res.Source.Title, + Archived: res.SourceSeq != nil, + }, + Destination: ItemCopyResultDestination{ + WorkspaceSlug: dst.WorkspaceSlug(), + WorkspaceName: dst.Workspace.Name, + CollectionSlug: res.TargetCollection.Slug, + CollectionName: res.TargetCollection.Name, + Ref: res.Item.Ref, + Slug: res.Item.Slug, + Seq: res.Item.Seq, + }, + ArchiveSource: input.ArchiveSource, + Item: res.Item, + Warnings: ItemCopyResultWarnings{ + DroppedFields: nonNilStrings(res.DroppedFields), + DroppedAssignee: res.DroppedAssignee, + DroppedAgentRole: res.DroppedAgentRole, + AttachmentCount: res.AttachmentsCopied, + AttachmentBytes: res.BytesCopied, + UnresolvableRefCount: len(res.UnresolvableRefs), + }, + } + if res.SourceSeq != nil { + resp.Source.Seq = *res.SourceSeq + } + + writeJSON(w, http.StatusCreated, resp) +} + +// copyItemAcrossWorkspaces calls the store op exactly once. +// +// The indirection exists ONLY so a test can count invocations and prove +// DR-13's no-retry guarantee — see copyItemFn. Production always takes the +// nil branch. It is deliberately a plain call with no error classification: +// the moment a "should we retry this?" branch appears here, the guarantee is +// gone and no test outside this package would notice. +func (s *Server) copyItemAcrossWorkspaces(req store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + if s.copyItemFn != nil { + return s.copyItemFn(req) + } + return s.store.CopyItemAcrossWorkspaces(req) +} + +// copyResourceInvariantPreCheck builds the DR-9 in-transaction re-check. +// +// WHAT IT CHECKS. Identity, and nothing else: that the resources the store +// re-read UNDER ITS LOCKS are the same resources the handler's four-check +// ladder was run against. The source item is still the one that was +// authorized, still in that workspace and — critically — still in the +// COLLECTION it was authorized in; the destination collection is still the +// one that was authorized, still in the destination workspace. +// +// WHAT IT CLOSES. The window TASK-2358 documents on both +// AuthorizeCrossWorkspaceEdit entry points ("NOT ATOMIC … a MUTATING caller +// must re-read both sides and re-apply the check"). Concretely: between the +// handler's verdict and the copy's locks, the source item can be MOVED into a +// different collection. Without this the copy reads that item under the lock +// and copies it out on the strength of a verdict about a collection it is no +// longer in — an item the caller may have no right to see at all, landing in +// a workspace they control. That is the exfiltration DR-10b is about, and it +// is the one failure mode in this family that is both genuinely reachable and +// genuinely damaging. +// +// It refuses on ANY collection change, not only a move into a hidden one. +// Deliberately conservative, and it pays a second dividend: the migration +// would otherwise run against a source schema the preflight never showed the +// user, which is a DR-6 disagreement of its own. +// +// WHAT IT DOES NOT CHECK, and why — an honest boundary is worth more than a +// broad-sounding one. It does NOT re-read membership or grants. An earlier +// version re-ran the whole ladder here, and that was wrong on two counts +// (Codex round 6): +// +// - It OVERCLAIMED. The authorize helpers read through s.store, not this +// transaction, and Postgres READ COMMITTED takes a fresh snapshot per +// statement — so the "re-check" judged locked resources against +// authorization state read at several unsynchronised moments. And +// membership can be revoked and committed after those reads and before +// this transaction commits regardless, because membership writers do not +// participate in the copy's advisory locks. Narrowing that window from +// "since the handler ran" to "since the lock was taken" is arbitrary; it +// is not a boundary. +// - It was a LIVENESS HAZARD. Those reads need a pool connection while this +// transaction already holds one. SQLite's pool is capped at 16 +// (sqliteMaxOpenConns): sixteen concurrent copies means one transaction +// holding the BEGIN IMMEDIATE write lock and fifteen parked inside +// db.Begin(), so the active transaction's re-check finds no free +// connection and waits — for transactions that cannot proceed until it +// commits. An application-level deadlock that unwinds only when a waiter +// hits the 30-second busy timeout, on the security-critical path of the +// one operation that holds TWO workspaces' locks. The pure comparison +// below performs NO I/O at all and cannot do that. +// +// THE CONSEQUENCE, so this is not read as an oversight: a membership, grant +// or token-consent revocation that commits while a copy is in flight does NOT +// stop that copy. Codex round 22 raised this as an attack; it is declined on +// three grounds. Re-reading here would only NARROW the window from "since the +// handler ran" to "since the lock was taken", never close it — closing it +// needs every membership writer to take the copy's workspace advisory locks, +// a workspace-wide change this task has no mandate for. No other write path +// in Pad closes it either: handleCreateItem, handleUpdateItem, +// handleDeleteItem and handleMoveItem all authorize before their store call +// and none re-checks inside, so a copy that tried would be inconsistent with +// the product rather than safer than it. And the attempt costs a real +// liveness hazard, described below. The honest statement is that +// authorization is evaluated once, at the start of a request measured in +// milliseconds. +// +// The comparisons cannot fail spuriously: every value is an immutable +// identifier the handler read moments earlier. +func copyResourceInvariantPreCheck(authorized copyAuthorizedResources) func(*sql.Tx, *models.Item, *models.Collection) error { + return func(_ *sql.Tx, source *models.Item, targetColl *models.Collection) error { + if source == nil || source.ID != authorized.sourceItemID || + source.WorkspaceID != authorized.sourceWorkspaceID || + source.CollectionID != authorized.sourceCollectionID { + // Returned bare: CopyItemAcrossWorkspaces wraps it in + // store.CopyPreCheckError so the rollback is classified as a + // caller-facing rejection, and writeCopyError recovers this type + // through that wrapper with errors.As. + return ©PreCheckDenial{side: "source", access: authorized.source} + } + if targetColl == nil || targetColl.ID != authorized.targetCollectionID || + targetColl.WorkspaceID != authorized.targetWorkspaceID { + return ©PreCheckDenial{side: "destination", access: authorized.destination} + } + return nil + } +} + +// copyAuthorizedResources records exactly what the handler's four-check +// ladder was run against, so the in-transaction re-check can assert the copy +// is operating on the same things. +// +// The two CrossWorkspaceAccess values are the ALLOWED verdicts, carried only +// so a refusal can be written through the same disclosure-preserving writer +// the handler itself would have used. Their Allowed flag is never inspected; +// by construction it is true. +type copyAuthorizedResources struct { + sourceItemID string + sourceWorkspaceID string + sourceCollectionID string + targetCollectionID string + targetWorkspaceID string + + source CrossWorkspaceAccess + destination CrossWorkspaceAccess +} + +// afterCopyCommit performs every post-commit side effect the copy owes, under +// ONE panic boundary. +// +// The boundary is the point. At the moment this runs the transaction has +// committed and the copy is irreversible, but the 201 has not been written +// yet — so a panic escaping into chi's recoverer would turn a SUCCEEDED copy +// into a 500. That is precisely the ambiguous outcome DR-13 forbids a client +// from resolving by retrying, and the retry would duplicate the item. None of +// the work below is worth telling the user their copy failed over: a missed +// event is reconciled by the next delta poll, and a stale storage figure +// expires in thirty seconds. +// +// The cache invalidation is INSIDE the boundary rather than beside it (Codex +// round 3): storageInfoCache is a mutex-guarded map, so a panic there is not +// something to reason about case by case — it is simply another way to lose a +// committed copy's response, and it belongs under the same guard as the +// fanout. +// +// The recover handler itself reads only pre-captured strings. Dereferencing +// res inside a deferred recover would let a malformed result panic AGAIN +// while recovering, which is unrecoverable and takes the process with it. +func (s *Server) afterCopyCommit(r *http.Request, res *store.CrossWorkspaceCopyResult) { + // Captured eagerly so the deferred handler can never dereference res. + var sourceWorkspaceID, targetWorkspaceID, targetItemID string + if res != nil { + sourceWorkspaceID = res.SourceWorkspaceID + if res.Item != nil { + targetWorkspaceID = res.Item.WorkspaceID + targetItemID = res.Item.ID + } + } + defer func() { + if rec := recover(); rec != nil { + slog.Error("cross-workspace copy post-commit work panicked; the copy IS committed and the response stands", + "source_workspace_id", sourceWorkspaceID, + "target_workspace_id", targetWorkspaceID, + "target_item_id", targetItemID, + "panic", rec, + "stack", string(debug.Stack())) + } + }() + + // CALLER OBLIGATION from CrossWorkspaceCopyResult. Workspace B's storage + // usage is memoized for 30 seconds and the store has no handle on the + // cache, so without this the destination's storage page reports stale + // usage for the rest of the window — right after the user watched the + // bytes land. Only the DESTINATION: the copy adds rows in B and changes + // nothing in A, even on a move (soft-delete never removes bytes). + if res.AttachmentsCopied > 0 { + s.storageInfoCache.invalidate(targetWorkspaceID) + } + + s.emitCopyFanout(r, res) +} + +// emitCopyFanout is DR-14's emission contract, in full. It is the whole of +// what a copy publishes, and the asymmetry is the specification: +// +// - ALWAYS, in the DESTINATION: activity `created`, SSE ItemCreated, +// webhook `item.created`. +// - ONLY on archive_source, in the SOURCE: activity `archived`, SSE +// ItemArchived, webhook `item.deleted`. +// - A PLAIN COPY EMITS NOTHING AT ALL IN THE SOURCE. Not a "copied" +// activity row, not an ItemUpdated. The source row is untouched by a +// plain copy — its seq does not move — so any event there would tell A's +// watchers something changed when nothing did, and would desynchronise +// a delta cursor that is legitimately still valid. +// - Every event carries ITS OWN workspace's committed seq. B's create +// event carries res.Item.Seq; A's archive event carries *res.SourceSeq, +// the value the archive assigned under A's seq lock. Crossing them would +// hand each workspace's clients a cursor from the other's sequence +// space, which is not merely wrong but silently wrong: the numbers are +// plausible. +// +// POST-COMMIT, and only post-commit. Emitting inside the transaction leaks +// an event for a copy that then rolls back — the existing move path already +// treats activity logging as best-effort post-commit for the same reason. +// The corollary is that a rolled-back copy emits nothing anywhere, which +// falls out of this being called only on the success path. +// +// THE ONE GAP, stated because it is inherent rather than overlooked (Codex +// round 20): if tx.Commit() itself errors, the store returns no result, so +// nothing here runs — and on Postgres a commit whose acknowledgement is lost +// may nonetheless have committed. That is DR-13's ambiguity in its purest +// form: a copy that landed with no events. It cannot be closed from here — +// database/sql reports commit failure as an ordinary error with no +// "maybe-committed" signal, and the seq needed to emit anything is only +// readable inside the transaction that failed. It is bounded rather than +// silent: delta sync is a cursor walk over items.seq, not an event log, so +// the destination's next /items-changes poll surfaces the item regardless, +// and the endpoint's 500 tells the user to go and look. +// +// SYNCHRONOUS, deliberately. logActivity writes a row, the SSE publish can +// reach Redis, and dispatchWebhook lists and marshals before it spawns its +// delivery goroutines — so this runs before the 201 and can add latency to +// it. That is exactly what handleCreateItem, handleDeleteItem and +// handleMoveItem already do (Codex round 3), and diverging would buy nothing: +// moving it to a goroutine would make the emission ordering — which DR-14 +// specifies and these tests assert — unobservable, and would decouple the +// "copy succeeded" response from the events that describe it for no +// correctness gain. If fanout latency ever becomes a problem it is a +// workspace-wide change to the three primitives, not a special case here. +// +// COLLECTION SLUGS COME FROM res, the under-lock snapshots — never from the +// handler's pre-transaction reads. An event carrying the collection the item +// used to be in is a quiet lie to every SSE consumer that routes on it. +// +// It is called only from afterCopyCommit, which owns the panic containment +// for this and for the cache invalidation alike. +func (s *Server) emitCopyFanout(r *http.Request, res *store.CrossWorkspaceCopyResult) { + actor, actorSource := actorFromRequest(r) + actorName := actorNameFromRequest(r) + + // --- Destination: always all three. --- + targetWorkspaceID := res.Item.WorkspaceID + s.logActivity(targetWorkspaceID, res.Item.ID, "created", r) + s.publishItemEventWithName(events.ItemCreated, targetWorkspaceID, res.Item.ID, res.Item.Title, + res.TargetCollection.Slug, actor, actorName, actorSource, res.Item.Seq) + s.dispatchWebhook(targetWorkspaceID, "item.created", res.Item) + + // --- Source: only on a move, and SourceSeq is the discriminator. It is + // non-nil if and only if the archive ran, so this cannot emit an archive + // event for a copy that did not archive, even if a future caller passes + // ArchiveSource inconsistently. --- + if res.SourceSeq == nil { + return + } + s.logActivity(res.SourceWorkspaceID, res.Source.ID, "archived", r) + s.publishItemEventWithName(events.ItemArchived, res.SourceWorkspaceID, res.Source.ID, res.Source.Title, + res.SourceCollection.Slug, actor, actorName, actorSource, *res.SourceSeq) + s.dispatchWebhook(res.SourceWorkspaceID, "item.deleted", res.Source) +} + +// writeCopyError maps the store's typed errors onto the statuses the +// contract promises. Everything the store classifies as a caller-facing +// rejection has an entry; the default is the DR-13 ambiguity message. +func (s *Server) writeCopyError(w http.ResponseWriter, err error) { + // The in-tx authorization re-check, first: it must produce the SAME + // response the same denial would have produced at the top of the + // request. A caller whose grant was revoked mid-copy learns exactly what + // they would have learned a millisecond earlier, and no more. + var denial *copyPreCheckDenial + if errors.As(err, &denial) { + if denial.side == "source" { + denial.access.WriteHidden(w, "Item") + } else { + denial.access.WriteCollectionNotFound(w) + } + return + } + + var limit *store.ItemLimitError + if errors.As(err, &limit) { + writePlanLimitError(w, limit.Result) + return + } + + var undeclared *store.UndeclaredOverrideError + if errors.As(err, &undeclared) { + // Same code the preflight emits, and bounded the same way: the keys + // are caller-supplied and validateFieldType-style verbatim echoing + // turns a 400 into an amplifier. + writeError(w, http.StatusBadRequest, "malformed_override", + "Destination collection has no field(s): "+summarizeKeys(undeclared.Keys)) + return + } + + var validation *store.FieldValidationError + if errors.As(err, &validation) { + writeError(w, http.StatusBadRequest, "validation_error", + summarizeMessages([]string{validation.Err.Error()})) + return + } + + if errors.Is(err, store.ErrCopyCrossBackendAttachments) { + writeError(w, http.StatusConflict, "cross_backend_attachments", + "This item's attachments are stored in a different storage backend than the destination workspace. Cross-backend copy is not supported.") + return + } + + // A collection that vanished between the authorization lookup and the + // copy's lock. Both are PRE-WRITE rejections — nothing was inserted and + // nothing can have committed — so neither may fall through to the + // ambiguous 500, which would send the user hunting for an item that + // provably does not exist (Codex round 1 P2). Each side keeps its own + // disclosure posture: the source collapses to the bare "Item not found" + // every other source-side refusal gives, and the destination reuses the + // one collection_not_found answer shared with "absent" and "hidden". + if errors.Is(err, store.ErrCopySourceCollectionMissing) { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + if errors.Is(err, store.ErrCopyTargetCollectionMissing) { + writeError(w, http.StatusNotFound, + crossWorkspaceCollectionNotFoundCode, crossWorkspaceCollectionNotFoundMessage) + return + } + + if errors.Is(err, sql.ErrNoRows) { + // The source vanished or was archived between the authorization + // check and the copy's lock. Same 404 the source side gives for + // everything else — the source never distinguishes absence from + // forbidden-ness. + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + if store.IsUniqueViolation(err) { + // The destination's slug-per-workspace constraint or the playbook + // invocation_slug partial unique index. Kept generic for the same + // reason createItemChecked keeps it generic — it covers both, and + // naming which one would report on rows the caller may not see. + // + // store.IsUniqueViolation rather than a local string match, so this + // and CreateItem's 409 cannot drift apart (Codex round 8). The + // heuristic is a text match and CAN in principle misfire on an + // unrelated error whose message happens to contain the phrase — which + // is harmless here, and worth saying why: EVERY error out of + // CopyItemAcrossWorkspaces means the transaction did not commit (a + // failed Commit rolls back, and nothing after a successful Commit can + // fail). So a misclassified error is 409 instead of 500 on a request + // that provably wrote nothing either way. The one genuinely ambiguous + // case — a commit whose outcome the client never learns because the + // connection died — does not carry this text. + writeError(w, http.StatusConflict, "conflict", + "The copy conflicts with an existing record in the destination workspace (duplicate slug, title, or invocation slug)") + return + } + + // DR-13's ambiguous case. The copy MAY have committed — a failure can + // land on the commit itself — so the message says "check", never "retry". + // Deliberately not writeInternalError: its generic text would invite the + // exact retry that duplicates the item. + slog.Error("cross-workspace item copy failed", "error", err) + writeError(w, http.StatusInternalServerError, "copy_failed", + "The copy did not complete. It may or may not have landed — check the destination workspace before trying again.") +} + +// copyActorID resolves the identity a copy is attributed to: every cloned +// attachment's uploaded_by and the provenance row's created_by. Returns "" +// when there is nobody to attribute it to. +// +// SHARED WITH THE PREFLIGHT, and that is the point. The store REQUIRES a +// non-empty actor, and on a fresh install (no users yet, everything open +// until `pad auth setup`) there is no current user at all — so the fallback +// is the source item's creator, which CreateItem defaults to "user" on every +// write path. The preflight originally had its own version of this with an +// extra `"preflight"` literal at the end, which was harmless there (it hands +// the value to a planner that writes nothing) but made the preview accept a +// request the copy would refuse — a THIRD divergence of exactly the kind +// DR-6 exists to prevent, found in Codex round 4. One function, one answer; +// both endpoints refuse identically via writeCopyActorRequired. +func copyActorID(r *http.Request, item *models.Item) string { + if id := currentUserID(r); id != "" { + return id + } + return item.CreatedBy +} + +// writeCopyActorRequired is the shared refusal for an unattributable copy. +// +// Both endpoints emit it, byte for byte, so the preview and the mutation +// agree. It is deliberately NOT routed through the copy's ambiguous-outcome +// 500: this is decided before anything is attempted, so telling the user to +// go check the destination would be false. +func writeCopyActorRequired(w http.ResponseWriter) { + writeError(w, http.StatusForbidden, "actor_required", + "A copy must be attributed to a user. Sign in, or run `pad auth setup` to create the first account.") +} + +// nonNilStrings guarantees `[]` rather than `null` on the wire, matching the +// preflight's promise for every list it returns. +func nonNilStrings(in []string) []string { + if in == nil { + return []string{} + } + return in +} diff --git a/internal/server/handlers_items_copy_preflight.go b/internal/server/handlers_items_copy_preflight.go new file mode 100644 index 00000000..3c574756 --- /dev/null +++ b/internal/server/handlers_items_copy_preflight.go @@ -0,0 +1,1030 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/items" + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" +) + +// Cross-workspace copy PREFLIGHT — PLAN-2357 / TASK-2364, implementing +// DR-6, DR-12 and DR-15. +// +// ROUTE (the contract; Phase 3's dialog and the CLI both build against it): +// +// POST /api/v1/workspaces/{slug}/items/{itemSlug}/copy/preflight +// +// The URL's workspace is the SOURCE. The destination is named in the body. +// The mutating sibling (TASK-2365) is POST …/items/{itemSlug}/copy and +// takes the identical request shape. +// +// REQUEST +// +// { +// "target_workspace": "pad-web", // required; slug or UUID +// "target_collection": "tasks", // required; collection slug in the destination +// "field_overrides": {"priority": "high"}, // optional; key MUST be declared by the destination schema +// "archive_source": false // optional; the MOVE path (copy + archive source) +// } +// +// `field_overrides` mirrors the existing move endpoint's field of the same +// name: a flat map of destination-schema field key → value. Keys the +// destination schema does not declare are REJECTED rather than passed +// through — see the malformed_override status below. +// +// A null value means "unset this key", and it is REMOVED from the map +// rather than set to nil. That distinction is normative, not incidental: +// ValidateFields treats a nil value as absent for the required check but +// LEAVES IT IN THE MAP, so assigning nil instead of deleting persists a +// literal `"key": null` into items.fields — a value this preview reports +// as unset. Delete, then validate. +// +// RECONCILED in TASK-2365. Store.migrateCopyFields +// (internal/store/items_cross_workspace_copy.go) used to do +// `migrated.Fields[k] = v` for every override including a nil one, so a +// null override this preflight showed as unset was written as JSON null +// by the copy. It now deletes the key, matching the loop below. +// TestCopyEndpoint_PreflightAndCopyAgreeOnNullOverride runs both paths +// over one input and fails if they drift apart again. +// +// What a nulled key becomes therefore depends on the destination schema, +// not on the null: validation re-applies any DEFAULT the key has, so a +// nulled field with a default comes back in `carried` with from="default" +// (required or not). Only a REQUIRED field with NO default lands in +// `needs_value`; an optional one with no default is simply absent. +// +// RESPONSE — 200, and see the doc comment on each type below for the +// per-field contract. The three bucket names `carried` / `dropped` / +// `needs_value` are the contract (DR-15); renaming one is a breaking +// change. All three arrays and both link maps are ALWAYS present, never +// null. +// +// ERROR STATUSES (DR-15 requires these three to be distinguishable): +// +// 400 invalid_body — the JSON body did not decode +// 400 missing_field — target_workspace / target_collection absent +// 400 malformed_override — an override names a field the destination +// schema does not declare +// 400 invalid_override — an override's VALUE fails the destination +// schema's type / options / pattern rules +// (DR-12's second half) +// 403 forbidden — destination workspace not accessible +// 403 permission_denied — destination workspace outside the bearer +// token's consent allow-list +// 404 not_found — source item absent or not visible, or the +// caller may not edit it (WriteHidden; the +// source side never distinguishes the two) +// 409 archived — the source item exists and the caller can +// see it, but it is archived. Shared with the +// move and update paths via +// writeItemResolveError; the copy path has no +// reason to be the one endpoint that reports +// an archived item as simply absent, and a +// client can say something useful about it. +// 404 collection_not_found — destination collection absent, archived, +// foreign, or hidden from the caller +// 403 forbidden — destination collection visible but the +// caller may not create into it +// 403 actor_required — there is nobody to attribute the copy to (no +// authenticated user AND a source item with no +// creator). Shared verbatim with the mutating +// copy so the preview cannot promise something +// the copy would refuse. +// +// NON-MUTATION is part of the contract (DR-15), scoped to the COPY's own +// domain: the handler creates no item, no attachment rows and no +// provenance row, advances NEITHER workspace's seq, and emits no +// activity, SSE or webhook. It is safe to call repeatedly from a live UI +// as the user changes the destination, and repeated identical calls +// return identical bytes — which is why every list below is sorted +// deterministically rather than left in Go map-iteration order. +// +// It is NOT a claim that the REQUEST is side-effect-free. Every +// authenticated request in Pad touches the session/activity machinery +// before a handler runs (RequireAuth's asynchronous users.last_active_at +// write, session renewal and IP-rotation audit rows) and mutates process +// state on the way through (rate-limiter buckets, request metrics, the +// compiled-pattern cache). Those are properties of the middleware stack, +// identical for a GET, and DR-15 is not about them. Read the guarantee as +// "a preflight leaves no trace a copy would have left". +// +// RESIDUAL, recorded rather than fixed (Codex round 3). Because DR-11 +// requires attachment references to be enumerated from the FINAL +// destination fields, a caller who may edit the source item can put a +// `pad-attachment:` literal into a text override and read back +// whether that UUID resolves inside the SOURCE workspace, and its exact +// byte size, via attachment_count / attachment_bytes / +// unresolvable_ref_count. Not fixed here for three reasons: attachment +// ids are unguessable v4 UUIDs, so this enumerates nothing an attacker +// does not already hold; the MUTATING copy has the identical property by +// design (the same override would clone the blob outright), so narrowing +// only the preflight would buy nothing; and enumerating from the +// pre-override fields instead would violate DR-11's stated ordering and +// make the dry-run's byte total disagree with the copy's — which is the +// entire reason the planner is shared between them. If this is judged +// worth closing it belongs at the planner's scope, as a DR-11a +// amendment covering both callers, not as a divergence here. + +// itemCopyPreflightRequest is the wire shape. TASK-2365's mutating copy +// takes the same one. +type itemCopyPreflightRequest struct { + TargetWorkspace string `json:"target_workspace"` + TargetCollection string `json:"target_collection"` + FieldOverrides map[string]any `json:"field_overrides"` + ArchiveSource bool `json:"archive_source"` +} + +// ItemCopyPreflight is the 200 response. +type ItemCopyPreflight struct { + Source ItemCopyPreflightSource `json:"source"` + Destination ItemCopyPreflightDestination `json:"destination"` + + // ArchiveSource echoes the request so a client rendering a cached + // response cannot mistake a copy preview for a move preview. It also + // selects the weighting of Warnings.ChildrenOrphaned (DR-4). + ArchiveSource bool `json:"archive_source"` + + // Valid means EXACTLY ONE THING: the field mapping is complete — + // NeedsValue is empty, so the caller has nothing left to resolve. It + // is the boolean a dialog's primary button should gate on. + // + // It is NOT a prediction that the copy will succeed, and a client that + // treats it as one will mishandle the mutating call's error path. + // Conditions it deliberately does not evaluate, all of which live + // inside TASK-2363's write transaction because that is the only place + // they can be decided correctly (PLAN-2357: "a dry-run's schema + // snapshot is advisory; the in-tx read is authoritative"): + // + // - unique_scope collisions — a carried invocation_slug can collide + // with an existing destination item and hit the partial unique + // index on insert; + // - the destination workspace's items_per_workspace quota (DR-16), + // which is enforced in-tx and is advisory anywhere else; + // - cross-backend attachment transfer, which the copy resolves with + // a target backend the preflight does not supply; + // - anything that changes between this call and the copy — the + // destination collection being archived or reshaped, the source + // being moved or archived, the caller's grants being revoked. + // + // It is not a permission statement either; an unauthorized caller + // never reaches a 200. + Valid bool `json:"valid"` + + Fields ItemCopyPreflightFields `json:"fields"` + Warnings ItemCopyPreflightWarnings `json:"warnings"` +} + +// ItemCopyPreflightSource identifies the item being copied, in displayable +// terms only — the caller has already been authorized to see it. +type ItemCopyPreflightSource struct { + WorkspaceSlug string `json:"workspace_slug"` + CollectionSlug string `json:"collection_slug"` + Ref string `json:"ref,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` +} + +// ItemCopyPreflightDestination identifies where the copy would land. +// Populated only after all four authorization checks have passed, so it +// never discloses a workspace or collection the caller cannot reach. +type ItemCopyPreflightDestination struct { + WorkspaceSlug string `json:"workspace_slug"` + WorkspaceName string `json:"workspace_name"` + CollectionSlug string `json:"collection_slug"` + CollectionName string `json:"collection_name"` +} + +// ItemCopyPreflightFields is the bucketing (DR-6/DR-15). The three names +// are the contract. Every slice is non-nil. +type ItemCopyPreflightFields struct { + // Carried are the fields the copy WOULD have, in destination-schema + // order. + Carried []ItemCopyPreflightCarried `json:"carried"` + + // Dropped are values that will not survive the copy: schema fields + // with no usable counterpart in the destination, plus the assignment + // pair (DR-8), which is not a schema field but is reported here + // because DR-8 requires it to appear in this bucket. + Dropped []ItemCopyPreflightDropped `json:"dropped"` + + // NeedsValue are destination fields the copy cannot satisfy on its + // own: required-and-empty, or carrying a value the destination schema + // rejects. Supply an override for each and the entry clears. + NeedsValue []ItemCopyPreflightNeedsValue `json:"needs_value"` +} + +// ItemCopyPreflightCarried is one field that survives to the destination. +type ItemCopyPreflightCarried struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + // Value is the FINAL value — after migration, after overrides, after + // schema defaults. + Value any `json:"value"` + // From explains where Value came from: + // "migrated" — carried across from the source item + // "override" — supplied by the caller's field_overrides + // "default" — filled in from the destination schema's default + From string `json:"from"` +} + +// ItemCopyPreflightDropped is one value that will not be copied. +type ItemCopyPreflightDropped struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + // Kind is "field" for a collection-schema field, or "assignment" for + // the assignee / agent-role pair (DR-8), which live on the item row + // rather than in its fields. + Kind string `json:"kind"` + // Reason is one of: + // "no_target_field" — destination schema has no such key + // "incompatible_type" — the key exists in BOTH schemas but + // the value cannot be converted + // (includes a select value absent from + // the destination's options) + // "undeclared_source_field" — the item carries a key its OWN + // collection schema does not declare + // (an orphan left by a schema edit). + // MigrateFields has no source type to + // convert from, so it drops the value + // even when the destination declares + // the key — which is a different fact + // from a type mismatch and reads very + // differently in a dialog + // "assignee_not_a_member" — assignee is not a member of the + // destination workspace (DR-8) + // "agent_role_not_portable" — role slugs are workspace-local and + // never carry (DR-8) + Reason string `json:"reason"` +} + +// ItemCopyPreflightNeedsValue is one destination field the caller must +// resolve with an override before the copy can proceed. +type ItemCopyPreflightNeedsValue struct { + Key string `json:"key"` + Label string `json:"label,omitempty"` + Type string `json:"type,omitempty"` + Options []string `json:"options,omitempty"` + Required bool `json:"required"` + // Reason is "missing_required" (no value and no default) or + // "invalid_value" (a value carried across that the destination schema + // rejects — only reachable for non-override values; an INVALID + // OVERRIDE is a 400, not a bucket entry). + Reason string `json:"reason"` + // Message is the validator's explanation, for display. + Message string `json:"message,omitempty"` +} + +// ItemCopyPreflightWarnings is DR-15's full warning set. Nothing here +// blocks the copy; all of it is information the user is entitled to +// before agreeing (DR-17: "none of this may be silent"). +type ItemCopyPreflightWarnings struct { + // ChildCount is how many live child items the source has (DR-4). + // They are never copied. + ChildCount int `json:"child_count"` + + // ChildrenOrphaned is ChildCount > 0 AND archive_source — the move + // path, where archiving the source leaves the children parentless in + // the source workspace. This is DR-4's "weighted more heavily on the + // move path" made explicit rather than left to the client. + ChildrenOrphaned bool `json:"children_orphaned"` + + // DroppedParent is true when the source has a parent item; the copy + // is unparented (DR-17). The parent is deliberately NOT named — it is + // an item in the source workspace whose own visibility this endpoint + // has not checked. + // + // Counts children reached by EITHER mechanism (see DroppedParent), + // deduplicated. A child that carries both a link row and the column + // counts once. + // + // TWO parent mechanisms are consulted, because the copy scrubs both: + // the `parent` item_links edge every Pad surface resolves and displays + // (readParentLinkTarget, enrichItemForResponse, GetChildItems), AND + // the items.parent_id COLUMN, which models.ItemCreate accepts on the + // create route and CreateItem inserts verbatim. Either one alone would + // under-report — an item created through the API with a raw parent_id + // has no item_links row at all, and CopyItemAcrossWorkspaces sets + // ParentID: nil regardless. + DroppedParent bool `json:"dropped_parent"` + + // OutgoingLinks / IncomingLinks count the source's dependency edges + // by link_type — the "blocked task that silently becomes actionable" + // case DR-17 calls out. None of them carry. + // + // Hierarchy link types ("parent", "implements" and the legacy "plan" + // alias) are EXCLUDED from both maps: they are reported by ChildCount + // and DroppedParent, and counting them twice would let a client that + // sums the maps overstate the loss. Always non-nil; empty means no + // dependency edges. + OutgoingLinks map[string]int `json:"outgoing_links"` + IncomingLinks map[string]int `json:"incoming_links"` + + // DroppedAssignee / DroppedAgentRole mirror the corresponding + // `dropped` bucket entries (DR-8). An assignee who IS a member of the + // destination workspace carries, so DroppedAssignee is false in the + // common same-owner case. + DroppedAssignee bool `json:"dropped_assignee"` + DroppedAgentRole bool `json:"dropped_agent_role"` + + // AttachmentCount / AttachmentBytes are what the copy would add to + // the destination workspace's storage. REPORTED, NOT ENFORCED + // (DR-16). Bytes are the sum of size_bytes over every row that would + // be created, including thumbnail variants, matching what the + // destination's storage page will show afterwards. + AttachmentCount int `json:"attachment_count"` + AttachmentBytes int64 `json:"attachment_bytes"` + + // UnresolvableRefCount is how many pad-attachment: references in the + // copied payload resolve to nothing under the source workspace's + // scope (DR-11a). They are not cloned and do not block the copy; the + // copy renders exactly as broken as the source does. + UnresolvableRefCount int `json:"unresolvable_ref_count"` +} + +// legacyHierarchyLinkTypes are hierarchy link types store.ChildLinkTypes +// does NOT walk. Today that is just "plan", the pre-rename alias that can +// still sit alongside a "parent" row on the same edge (see +// GetChildItemsTx's DISTINCT, which exists because of exactly that +// duplication). +// +// A LONE "plan" edge — one with no accompanying "parent" row — is +// therefore counted as neither a child nor a dependency. That is +// deliberate: GetChildItems does not consider it a child, so calling it +// one here would make child_count disagree with every other surface, and +// calling it a dependency would report a blocker that does not exist. +// Under-reporting a legacy duplicate is the safe direction, and the +// condition is not reachable through any current write path. +var legacyHierarchyLinkTypes = []string{"plan"} + +// hierarchyLinkTypes are the link types that express parent/child rather +// than dependency, and so are reported by child_count / dropped_parent +// instead of by the link maps. +// +// DERIVED from store.ChildLinkTypes rather than restated, so a new child +// link type added in the store cannot silently start being counted here +// as a dependency edge — which would misreport a parent/child +// relationship as a blocker. TestPreflightHierarchyTypesCoverStoreChildren +// pins the containment. +var hierarchyLinkTypes = buildHierarchyLinkTypes() + +func buildHierarchyLinkTypes() map[string]bool { + out := map[string]bool{} + for _, t := range store.ChildLinkTypes() { + out[t] = true + } + for _, t := range legacyHierarchyLinkTypes { + out[t] = true + } + return out +} + +// handleCopyItemPreflight answers "what would this cross-workspace copy +// do?" without doing any of it. See the file header for the full contract. +func (s *Server) handleCopyItemPreflight(w http.ResponseWriter, r *http.Request) { + sourceWorkspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + itemSlug := chi.URLParam(r, "itemSlug") + item, err := s.store.ResolveItem(sourceWorkspaceID, itemSlug) + if err != nil { + writeInternalError(w, err) + return + } + if item == nil { + // ResolveItem filters soft-deleted rows, so this covers "absent" + // AND "archived". writeItemResolveError separates them the same + // way the move and update paths do — 409 archived when the caller + // can independently SEE the archived row, 404 otherwise — so the + // distinction is never made for someone who could not see it. + s.writeItemResolveError(w, r, sourceWorkspaceID, itemSlug) + return + } + + // ---- Authorization, DR-10a/DR-10b, four checks in order ----------- + // + // Checks 1 and 2 (source item visibility, then source edit) compose + // out of one AuthorizeCrossWorkspaceEdit call with an item scope; 3 + // and 4 (destination collection visibility, then destination edit) + // out of one with a collection scope. The early return between them + // is part of the ordering, not style: a destination verdict built for + // a caller who could not read the source is itself a disclosure. + // + // The helper is used for the SOURCE too, even though it is the + // request's own workspace, because it derives the role from + // membership rather than from workspaceRole(r) — the same answer the + // front door gives, computed without the context value the + // destination half must not touch. One code path, one ordering. + src := s.AuthorizeCrossWorkspaceEdit(r, sourceWorkspaceID, CrossWorkspaceItemScope(item)) + if !src.Allowed { + // WriteHidden: absence and forbidden-ness are one 404. A preflight + // that confirmed a hidden item exists would be the leak DR-10b is + // about. + src.WriteHidden(w, "Item") + return + } + + // decodeJSON, not json.NewDecoder: it wraps the body in a + // MaxBytesReader so field_overrides cannot be used to make the server + // allocate a multi-GB map on an endpoint that is designed to be called + // repeatedly from a live UI (Codex round 7). + var input itemCopyPreflightRequest + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + if input.TargetWorkspace == "" { + writeError(w, http.StatusBadRequest, "missing_field", "target_workspace is required") + return + } + if input.TargetCollection == "" { + writeError(w, http.StatusBadRequest, "missing_field", "target_collection is required") + return + } + + // Check 3, part one: workspace-level access to the destination. This + // is the narrow legitimate use of the workspace-only scope — an early + // reject BEFORE the collection is known, because resolving a + // collection slug requires the destination workspace's ID and doing + // that lookup first would answer a question about a workspace the + // caller may have no business addressing. + dstWS := s.AuthorizeCrossWorkspaceEdit(r, input.TargetWorkspace, CrossWorkspaceWorkspaceOnlyScope()) + if !dstWS.Allowed { + // WriteDenied, not WriteHidden: the caller named this workspace + // themselves, so acknowledging the refusal tells them nothing they + // did not already assert. It still refuses to separate "absent" + // from "forbidden". + dstWS.WriteDenied(w) + return + } + + targetColl, err := s.store.GetCollectionBySlug(dstWS.WorkspaceID(), input.TargetCollection) + if err != nil { + writeInternalError(w, err) + return + } + if targetColl == nil { + writeError(w, http.StatusNotFound, + crossWorkspaceCollectionNotFoundCode, crossWorkspaceCollectionNotFoundMessage) + return + } + + // Checks 3 (collection visibility) and 4 (collection edit). The + // collection-scoped denial is written by WriteCollectionNotFound, + // which collapses "hidden" onto the same 404 the nil branch above + // emits — otherwise a restricted member of the destination could + // enumerate the collections they were excluded from. + dst := s.AuthorizeCrossWorkspaceEdit(r, input.TargetWorkspace, CrossWorkspaceCollectionScope(targetColl.ID)) + if !dst.Allowed { + dst.WriteCollectionNotFound(w) + return + } + + // Who the copy would be attributed to, resolved through the SAME function + // the mutating copy uses and refused here on the same terms. Nothing is + // written on this path, so a placeholder would work — and that is exactly + // the trap: an earlier version supplied a `"preflight"` literal as a last + // resort, so this endpoint happily previewed a copy the mutation would + // refuse outright for want of an actor (Codex round 4). + // + // Placed HERE, immediately after the fourth authorization check and + // BEFORE any field or override handling, because the mutating copy checks + // it in the same position. Leaving it down by the attachment planner made + // the two disagree about a request that fails BOTH ways — no actor and a + // malformed override — with the preview reporting the override and the + // copy the actor (Codex round 5). Agreement is about the ORDER of + // refusals, not just the set. + actorID := copyActorID(r, item) + if actorID == "" { + writeCopyActorRequired(w) + return + } + + // ---- Everything below this line is authorized and read-only ------- + // + // SNAPSHOT CAVEAT. What follows is a sequence of independent, unlocked + // reads, so a concurrent write can leave the buckets and the warnings + // describing slightly different moments — and the authorization + // verdict above is explicitly not atomic either (see + // AuthorizeCrossWorkspaceEdit). That is by design and is why the + // mutating copy re-reads both sides and re-applies every check inside + // its transaction (DR-9): a dry-run's snapshot is advisory, the in-tx + // read is authoritative. Taking locks here would put a read-only + // preview that a live UI calls on every keystroke in contention with + // real writes, to buy a consistency the copy cannot rely on anyway. + + sourceColl, err := s.store.GetCollection(item.CollectionID) + if err != nil { + writeInternalError(w, err) + return + } + if sourceColl == nil { + writeInternalError(w, fmt.Errorf("copy preflight: source collection %s missing", item.CollectionID)) + return + } + + var sourceSchema, targetSchema models.CollectionSchema + if err := json.Unmarshal([]byte(sourceColl.Schema), &sourceSchema); err != nil { + writeInternalError(w, fmt.Errorf("copy preflight: parse source schema: %w", err)) + return + } + if err := json.Unmarshal([]byte(targetColl.Schema), &targetSchema); err != nil { + writeInternalError(w, fmt.Errorf("copy preflight: parse target schema: %w", err)) + return + } + + targetDefs := make(map[string]models.FieldDef, len(targetSchema.Fields)) + for _, f := range targetSchema.Fields { + targetDefs[f.Key] = f + } + + // An override naming a field the destination does not declare is a + // client bug, and silently ignoring it would let a dialog show a + // value that never lands. Rejected before anything else is computed + // so the 400 cannot depend on the source item's contents. + // + // RECONCILED in TASK-2365. Store.migrateCopyFields used to merge every + // override unconditionally, and ValidateFields ignores keys the schema + // does not declare, so the copy PERSISTED an undeclared override as an + // orphan key on the new item while this preflight refused the same + // request. The gate now exists on both sides + // (store.UndeclaredOverrideError, surfaced by the copy endpoint as the + // same 400 malformed_override this emits); + // TestCopyEndpoint_PreflightAndCopyAgreeOnUndeclaredOverride pins it. + // items.UndeclaredOverrideKeys, the SAME function the store's copy path + // calls. It moved into internal/items in TASK-2365 because two + // implementations of "which keys are undeclared" is precisely the DR-6 + // divergence this pair of endpoints exists to prevent (Codex round 17). + if bad := items.UndeclaredOverrideKeys(input.FieldOverrides, targetSchema.Fields); len(bad) > 0 { + writeError(w, http.StatusBadRequest, "malformed_override", + "Destination collection has no field(s): "+summarizeKeys(bad)) + return + } + + // A fields blob that will not decode into an object is treated as + // empty, matching handleMoveItem and the bulk move path. Diverging — + // 500ing on corrupt data the move path happily migrates — would make + // the preview refuse a copy the copy itself would accept, which is the + // DR-6 disagreement this endpoint exists to prevent. It also covers the + // benign empty-string case on old rows. + var currentFields map[string]any + if err := json.Unmarshal([]byte(item.Fields), ¤tFields); err != nil { + currentFields = map[string]any{} + } + + // ---- DR-12: migrate → apply overrides → THEN validate ------------- + // + // LIMITATION, inherited from MigrateFields and shared with the + // existing intra-workspace move path (Codex round 5). Migration + // matches on key and type only — plus the option list for a select. + // It does not consider a relation field's `collection`, `computed`, + // `terminal_options` or `unique_scope`. So a same-named `relation` + // field carries a SOURCE-workspace item id into the destination and + // is reported as a clean carry, when across workspaces it is a + // dangling reference. Reporting that here would require the preflight + // to model semantics MigrateFields does not, and the copy would then + // disagree with its own preview — the divergence DR-6 exists to + // prevent. It belongs in MigrateFields, for both callers at once. + // + // MigrateFields computes result.Errors before any override exists, so + // those errors are stale the instant an override merges in. They are + // deliberately never read here; the authoritative answer comes from + // items.ValidateFieldsDetailed run over the MERGED map, which also + // applies destination defaults and type/option/pattern checks. + migrated := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields) + + final := make(map[string]any, len(migrated.Fields)+len(input.FieldOverrides)) + origin := make(map[string]string, len(final)) + for k, v := range migrated.Fields { + final[k] = v + // MigrateFields' output is two things at once: values carried + // across from the source item, and destination-schema defaults it + // filled in for keys the source had nothing for. Presence in the + // SOURCE's field map is what separates them. + if _, fromSource := currentFields[k]; fromSource { + origin[k] = "migrated" + } else { + origin[k] = "default" + } + } + overridden := make(map[string]bool, len(input.FieldOverrides)) + for k, v := range input.FieldOverrides { + overridden[k] = true + if v == nil { + // An explicit null means "leave this unset" — drop it so the + // validator sees a genuinely absent key (and re-reports it as + // needs_value if the destination requires it). + delete(final, k) + delete(origin, k) + continue + } + final[k] = v + origin[k] = "override" + } + // ValidateFieldsDetailed injects any remaining schema defaults into + // `final` in place, so a key that appears only afterwards has no + // origin entry and is reported as "default". + issues := items.ValidateFieldsDetailed(final, targetSchema) + + // DR-12's other half: an override whose VALUE is invalid is rejected, + // not bucketed. It is the caller's own input and there is nothing for + // them to resolve in a needs_value row that they did not just type. + var badOverrides []string + issueByKey := make(map[string]items.FieldIssue, len(issues)) + for _, iss := range issues { + issueByKey[iss.Key] = iss + if iss.Kind == items.IssueInvalid && overridden[iss.Key] { + badOverrides = append(badOverrides, iss.Message) + } + } + if len(badOverrides) > 0 { + sort.Strings(badOverrides) + // Bounded for the same reason the malformed_override message is: + // validateFieldType quotes the offending VALUE verbatim, so a + // single ~2 MiB override string would otherwise be reflected back + // in full. The cap keeps the 400 useful without making the + // endpoint an amplifier. + writeError(w, http.StatusBadRequest, "invalid_override", + "Invalid override value(s): "+summarizeMessages(badOverrides)) + return + } + + resp := ItemCopyPreflight{ + Source: ItemCopyPreflightSource{ + // The CANONICAL slug from the resolved workspace, never the URL + // parameter: /workspaces/{slug} also accepts a UUID, and echoing + // that back in a field a client uses to build a web route hands + // it a link that does not resolve (Codex round 14). Same reason + // the destination reports dst.WorkspaceSlug(). + WorkspaceSlug: src.WorkspaceSlug(), + CollectionSlug: sourceColl.Slug, + Ref: item.Ref, + Slug: item.Slug, + Title: item.Title, + }, + Destination: ItemCopyPreflightDestination{ + WorkspaceSlug: dst.WorkspaceSlug(), + WorkspaceName: dst.Workspace.Name, + CollectionSlug: targetColl.Slug, + CollectionName: targetColl.Name, + }, + ArchiveSource: input.ArchiveSource, + Fields: ItemCopyPreflightFields{ + Carried: []ItemCopyPreflightCarried{}, + Dropped: []ItemCopyPreflightDropped{}, + NeedsValue: []ItemCopyPreflightNeedsValue{}, + }, + Warnings: ItemCopyPreflightWarnings{ + OutgoingLinks: map[string]int{}, + IncomingLinks: map[string]int{}, + }, + } + + // carried / needs_value, walked in destination-schema order so the + // response is stable across identical calls. + for _, def := range targetSchema.Fields { + if iss, bad := issueByKey[def.Key]; bad { + reason := "invalid_value" + if iss.Kind == items.IssueRequired { + reason = "missing_required" + } + resp.Fields.NeedsValue = append(resp.Fields.NeedsValue, ItemCopyPreflightNeedsValue{ + Key: def.Key, + Label: def.Label, + Type: def.Type, + Options: def.Options, + Required: def.Required, + Reason: reason, + Message: iss.Message, + }) + continue + } + val, present := final[def.Key] + if !present { + continue + } + from := origin[def.Key] + if from == "" { + from = "default" + } + resp.Fields.Carried = append(resp.Fields.Carried, ItemCopyPreflightCarried{ + Key: def.Key, + Label: def.Label, + Type: def.Type, + Value: val, + From: from, + }) + } + + // dropped — schema fields first, in SOURCE-schema order (MigrateFields + // ranges a map, so its Dropped slice arrives in nondeterministic + // order and must be re-sorted or repeated calls would differ). + for _, key := range sortedDroppedKeys(migrated.Dropped, sourceSchema.Fields) { + reason := "no_target_field" + label := key + srcDef, declaredBySource := fieldDefByKey(sourceSchema.Fields, key) + if def, exists := targetDefs[key]; exists { + // The key exists downstream, so migration rejected the VALUE. + // Which of the two ways matters to whoever reads this: a + // genuine type mismatch, or a key the SOURCE schema never + // declared — MigrateFields then has no source type to convert + // from and drops the value unconditionally, even when the two + // keys would otherwise have been compatible. + reason = "incompatible_type" + if !declaredBySource { + reason = "undeclared_source_field" + } + if def.Label != "" { + label = def.Label + } + } else if declaredBySource && srcDef.Label != "" { + label = srcDef.Label + } + resp.Fields.Dropped = append(resp.Fields.Dropped, ItemCopyPreflightDropped{ + Key: key, Label: label, Kind: "field", Reason: reason, + }) + } + + // dropped — the assignment pair (DR-8). Not schema fields, but DR-8 + // requires them in this bucket, and they carry a parallel boolean in + // warnings so a client can render either surface. + if item.AssignedUserID != nil && *item.AssignedUserID != "" { + member, mErr := s.store.GetWorkspaceMember(dst.WorkspaceID(), *item.AssignedUserID) + if mErr != nil { + writeInternalError(w, mErr) + return + } + if member == nil { + resp.Warnings.DroppedAssignee = true + resp.Fields.Dropped = append(resp.Fields.Dropped, ItemCopyPreflightDropped{ + Key: "assigned_user", Label: "Assignee", Kind: "assignment", + Reason: "assignee_not_a_member", + }) + } + } + if item.AgentRoleID != nil && *item.AgentRoleID != "" { + resp.Warnings.DroppedAgentRole = true + resp.Fields.Dropped = append(resp.Fields.Dropped, ItemCopyPreflightDropped{ + Key: "agent_role", Label: "Agent role", Kind: "assignment", + Reason: "agent_role_not_portable", + }) + } + + // ---- Relationship warnings (DR-4 / DR-17) ------------------------- + // + // The counters are CALLER-RELATIVE, deliberately. Store.GetChildItems + // and Store.GetItemLinks apply no ACL of their own; the /children and + // /links endpoints filter their results by collection visibility and + // item grants afterwards, and this must do the same or it becomes a + // wider window onto the source workspace than the endpoints that + // already answer these questions. A caller holding nothing but an edit + // grant on the source item would otherwise learn the exact number and + // type of children and dependency edges attached to it, every one of + // which may live in a collection hidden from them (Codex round 3). + // + // The trade is that a hidden child still gets left behind without being + // counted. That is the correct side to err on: the alternative reports + // a loss the caller was never entitled to know about, and the same + // asymmetry already exists everywhere else these relationships surface. + relVisibleIDs, err := s.visibleCollectionIDs(r, sourceWorkspaceID) + if err != nil { + writeInternalError(w, err) + return + } + relFullCollIDs, relGrantedItemIDs, err := s.guestResourceFilter(r, sourceWorkspaceID) + if err != nil { + writeInternalError(w, err) + return + } + // relVisibleIDs == nil means "unrestricted caller"; both helper sets are + // then irrelevant and every relationship is legitimately visible. + relVisible := func(other *models.Item) bool { + if relVisibleIDs == nil { + return true + } + if other == nil { + return false + } + if !isCollectionVisible(other.CollectionID, relVisibleIDs) { + return false + } + return s.isItemVisibleToGuest(r, sourceWorkspaceID, other, relFullCollIDs, relGrantedItemIDs) + } + + // Children come from BOTH mechanisms, for the same reason + // DroppedParent consults both: GetChildItems joins item_links and + // never looks at items.parent_id, while an item created through the + // API with a raw parent_id has no link row at all. Missing those + // would leave children_orphaned false on a move that strands them + // (Codex round 12). Deduplicated by id — a child can legitimately + // carry both. + children, err := s.store.GetChildItems(item.ID) + if err != nil { + writeInternalError(w, err) + return + } + // ListItems is workspace-scoped and filters soft-deleted rows, so the + // column-side query needs no extra guard of its own. + columnChildren, err := s.store.ListItems(sourceWorkspaceID, models.ItemListParams{ + ParentID: item.ID, + NoContent: true, + }) + if err != nil { + writeInternalError(w, err) + return + } + countedChildren := make(map[string]bool, len(children)+len(columnChildren)) + for _, set := range [][]models.Item{children, columnChildren} { + for i := range set { + if countedChildren[set[i].ID] || !relVisible(&set[i]) { + continue + } + countedChildren[set[i].ID] = true + } + } + resp.Warnings.ChildCount = len(countedChildren) + resp.Warnings.ChildrenOrphaned = len(countedChildren) > 0 && input.ArchiveSource + + links, err := s.store.GetItemLinks(item.ID) + if err != nil { + writeInternalError(w, err) + return + } + for _, link := range links { + if relVisibleIDs != nil { + // Judge the OTHER end of the edge, the same way + // handleGetItemLinks does. + otherID := link.TargetID + if otherID == item.ID { + otherID = link.SourceID + } + other, oErr := s.store.GetItem(otherID) + if oErr != nil { + // DELIBERATE divergence from handleGetItemLinks, which + // swallows this error and omits the link. Omitting a row + // from a LIST is a partial answer; omitting it from a + // COUNT is a wrong one, and the user is being asked to + // agree to a loss on the strength of that number. Fail + // loudly rather than under-report. + writeInternalError(w, oErr) + return + } + // A nil `other` (the row vanished between the two reads) is + // skipped by relVisible, matching /links. + if !relVisible(other) { + continue + } + } + if hierarchyLinkTypes[link.LinkType] { + if link.SourceID == item.ID { + // The source's own parent edge — the copy is unparented. + resp.Warnings.DroppedParent = true + } + continue + } + if link.SourceID == item.ID { + resp.Warnings.OutgoingLinks[link.LinkType]++ + } + if link.TargetID == item.ID { + resp.Warnings.IncomingLinks[link.LinkType]++ + } + } + + // The legacy items.parent_id column, which the item_links scan above + // cannot see. models.ItemCreate accepts `parent_id` on the create + // route and CreateItem inserts it verbatim, so this is API-reachable + // state, and CopyItemAcrossWorkspaces scrubs it along with the link + // (Codex round 11). Same visibility filter as every other + // relationship, plus an explicit workspace guard: the column's foreign + // key constrains it to items(id) but says nothing about WHICH + // workspace, and GetItem is id-global with no workspace predicate, so + // a value pointing outside the source must not be honoured. + if !resp.Warnings.DroppedParent && item.ParentID != nil && *item.ParentID != "" { + legacyParent, pErr := s.store.GetItem(*item.ParentID) + if pErr != nil { + writeInternalError(w, pErr) + return + } + if legacyParent != nil && legacyParent.WorkspaceID == sourceWorkspaceID && relVisible(legacyParent) { + resp.Warnings.DroppedParent = true + } + } + + // ---- Attachment warnings (DR-11 / DR-11a / DR-16) ----------------- + // + // The planner is shared with the mutating copy precisely so these + // numbers ARE the numbers rather than a second implementation that + // drifts. DryRun is what makes a plan legal without a destination + // item id, and nothing here inserts the rows it returns. + // + // DR-11's ordering: refs are enumerated from the source CONTENT plus + // the FINAL destination fields — never the source's raw fields, which + // would count blobs referenced only by keys migration dropped and + // overstate AttachmentBytes. + plan, err := s.store.PlanAttachmentCopy(store.AttachmentCopyRequest{ + SourceWorkspaceID: item.WorkspaceID, + TargetWorkspaceID: dst.WorkspaceID(), + DryRun: true, + UploadedBy: actorID, + Content: item.Content, + Fields: final, + }) + if err != nil { + writeInternalError(w, err) + return + } + resp.Warnings.AttachmentCount = len(plan.Rows) + resp.Warnings.AttachmentBytes = plan.TotalBytes + resp.Warnings.UnresolvableRefCount = len(plan.UnresolvableRefs) + + resp.Valid = len(resp.Fields.NeedsValue) == 0 + + writeJSON(w, http.StatusOK, resp) +} + +// sortedDroppedKeys orders MigrateFields' Dropped slice deterministically: +// source-schema order first (that is the order the user sees the fields in +// on the source item), then any orphan keys the source schema no longer +// declares, alphabetically. Duplicates are collapsed. +func sortedDroppedKeys(dropped []string, sourceFields []models.FieldDef) []string { + rank := make(map[string]int, len(sourceFields)) + for i, f := range sourceFields { + rank[f.Key] = i + } + seen := make(map[string]bool, len(dropped)) + out := make([]string, 0, len(dropped)) + for _, k := range dropped { + if seen[k] { + continue + } + seen[k] = true + out = append(out, k) + } + sort.SliceStable(out, func(i, j int) bool { + ri, iOK := rank[out[i]] + rj, jOK := rank[out[j]] + switch { + case iOK && jOK: + return ri < rj + case iOK != jOK: + return iOK + default: + return out[i] < out[j] + } + }) + return out +} + +// summarizeKeys / summarizeMessages render at most a handful of +// caller-supplied strings, each truncated, for an error message. The +// request body is capped at 2 MiB, which still leaves room for thousands +// of long keys or one enormous value — and validateFieldType quotes the +// offending value verbatim. Echoing either in full would turn a 400 into +// an amplifier. +func summarizeKeys(keys []string) string { return summarize(keys, 5, 64, ", ") } + +func summarizeMessages(msgs []string) string { return summarize(msgs, 5, 200, "; ") } + +func summarize(vals []string, maxVals, maxLen int, sep string) string { + shown := vals + if len(shown) > maxVals { + shown = shown[:maxVals] + } + parts := make([]string, 0, len(shown)) + for _, v := range shown { + // Truncate on a RUNE boundary: these are user-supplied strings and + // a byte slice through a multi-byte character would emit invalid + // UTF-8 into the JSON body. + if len(v) > maxLen { + runes := []rune(v) + if len(runes) > maxLen { + runes = runes[:maxLen] + } + v = string(runes) + "…" + } + parts = append(parts, v) + } + out := strings.Join(parts, sep) + if len(vals) > len(shown) { + out += fmt.Sprintf(" (and %d more)", len(vals)-len(shown)) + } + return out +} + +func fieldDefByKey(defs []models.FieldDef, key string) (models.FieldDef, bool) { + for _, d := range defs { + if d.Key == key { + return d, true + } + } + return models.FieldDef{}, false +} diff --git a/internal/server/handlers_items_copy_preflight_test.go b/internal/server/handlers_items_copy_preflight_test.go new file mode 100644 index 00000000..aeaefcdc --- /dev/null +++ b/internal/server/handlers_items_copy_preflight_test.go @@ -0,0 +1,1487 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/events" + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" + "github.com/PerpetualSoftware/pad/internal/webhooks" +) + +// Tests for the cross-workspace copy preflight (PLAN-2357 / TASK-2364). +// +// Three properties carry the weight here and each has its own section: +// +// 1. DR-12 ordering — overrides are applied BEFORE validation, so an +// override that satisfies a required field clears it and an override +// with an invalid value is type-checked and refused. +// 2. DR-10a/DR-10b — all four authorization checks, in order, on the +// DRY-RUN. A preflight that confirms a hidden source item exists, or +// echoes a hidden destination's schema, is itself the leak. +// 3. DR-15 non-mutation — nothing is written, no seq moves, nothing is +// emitted, and repeated calls are byte-identical. + +// --- fixture ----------------------------------------------------------- + +type copyPreflightFixture struct { + t *testing.T + srv *Server + bus *events.MemoryBus + + owner *models.User + + wsA, wsB *models.Workspace + collA *models.Collection + collB *models.Collection + // hiddenB is a collection in B that restricted members cannot see. + hiddenB *models.Collection + + source *models.Item +} + +// srcSchema / dstSchema are built to exercise every bucket at once: +// +// status — carries (select value present in both option sets) +// priority— carries; also the field an INVALID override targets +// impact — dropped, no_target_field (absent from the destination) +// count — dropped, incompatible_type (number → select) +// code — needs_value/invalid_value: text→text carries the source's +// value, which the destination's pattern then rejects +// ticket — needs_value/missing_required (required, no default) +// tier — carries with from="default" (destination-only default) +const srcSchemaJSON = `{"fields":[ + {"key":"status","label":"Status","type":"select","options":["open","done"]}, + {"key":"priority","label":"Priority","type":"select","options":["low","high"]}, + {"key":"impact","label":"Impact","type":"text"}, + {"key":"count","label":"Count","type":"number"}, + {"key":"code","label":"Code","type":"text"} +]}` + +const dstSchemaJSON = `{"fields":[ + {"key":"status","label":"Status","type":"select","options":["open","done"],"required":true}, + {"key":"priority","label":"Priority","type":"select","options":["low","high"]}, + {"key":"count","label":"Count","type":"select","options":["x","y"]}, + {"key":"code","label":"Code","type":"text","pattern":"^[0-9]+$"}, + {"key":"ticket","label":"Ticket","type":"text","required":true}, + {"key":"tier","label":"Tier","type":"select","options":["a","b"],"default":"a"} +]}` + +func newCopyPreflightFixture(t *testing.T) *copyPreflightFixture { + t.Helper() + srv := testServer(t) + bus := events.New() + srv.SetEventBus(bus) + t.Cleanup(bus.Close) + + owner := mustUser(t, srv, "copy-owner@example.com", "copyowner", "") + wsA := mustWorkspace(t, srv, "Copy Source WS", owner.ID) + wsB := mustWorkspace(t, srv, "Copy Dest WS", owner.ID) + + collA := mustSchemaCollection(t, srv, wsA.ID, "Tasks A", srcSchemaJSON) + collB := mustSchemaCollection(t, srv, wsB.ID, "Tasks B", dstSchemaJSON) + hiddenB := mustSchemaCollection(t, srv, wsB.ID, "Secrets B", dstSchemaJSON) + + source, err := srv.store.CreateItem(wsA.ID, collA.ID, models.ItemCreate{ + Title: "The Source", + Content: "body", + Fields: `{"status":"open","priority":"low","impact":"large","count":7,"code":"abc"}`, + CreatedBy: owner.ID, + }) + if err != nil { + t.Fatalf("CreateItem(source): %v", err) + } + + return ©PreflightFixture{ + t: t, srv: srv, bus: bus, owner: owner, + wsA: wsA, wsB: wsB, collA: collA, collB: collB, hiddenB: hiddenB, + source: source, + } +} + +func mustSchemaCollection(t *testing.T, srv *Server, wsID, name, schema string) *models.Collection { + t.Helper() + c, err := srv.store.CreateCollection(wsID, models.CollectionCreate{Name: name, Schema: schema}) + if err != nil { + t.Fatalf("CreateCollection(%s): %v", name, err) + } + return c +} + +// call issues the preflight exactly as the router would, with the caller's +// auth surface described by o. The workspace-A role and resolved ID are +// stashed the way RequireWorkspaceAccess stashes them — the destination +// half of the handler must ignore both. +func (f *copyPreflightFixture) call(user *models.User, o reqOpts, body map[string]any) *httptest.ResponseRecorder { + f.t.Helper() + raw, err := json.Marshal(body) + if err != nil { + f.t.Fatalf("marshal body: %v", err) + } + r := httptest.NewRequest("POST", + "/api/v1/workspaces/"+f.wsA.Slug+"/items/"+f.source.Slug+"/copy/preflight", + bytes.NewReader(raw)) + r.Header.Set("Content-Type", "application/json") + + 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") + } + + rr := httptest.NewRecorder() + f.srv.handleCopyItemPreflight(rr, r) + return rr +} + +// callRaw is call() with a body the caller controls byte for byte, for the +// undecodable-JSON case that a map[string]any cannot express. +func (f *copyPreflightFixture) callRaw(user *models.User, raw []byte) *httptest.ResponseRecorder { + f.t.Helper() + r := httptest.NewRequest("POST", + "/api/v1/workspaces/"+f.wsA.Slug+"/items/"+f.source.Slug+"/copy/preflight", + bytes.NewReader(raw)) + r.Header.Set("Content-Type", "application/json") + + ctx := r.Context() + if user != nil { + ctx = WithCurrentUser(ctx, 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() + f.srv.handleCopyItemPreflight(rr, r.WithContext(ctx)) + return rr +} + +// ok issues the preflight as the owner and requires a 200. +func (f *copyPreflightFixture) ok(body map[string]any) ItemCopyPreflight { + f.t.Helper() + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusOK { + f.t.Fatalf("preflight: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var out ItemCopyPreflight + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + f.t.Fatalf("parse preflight: %v\nbody: %s", err, rr.Body.String()) + } + return out +} + +func (f *copyPreflightFixture) baseBody() map[string]any { + return map[string]any{ + "target_workspace": f.wsB.Slug, + "target_collection": f.collB.Slug, + } +} + +func errCode(t *testing.T, rr *httptest.ResponseRecorder) string { + t.Helper() + var env struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &env); err != nil { + t.Fatalf("parse error envelope: %v\nbody: %s", err, rr.Body.String()) + } + return env.Error.Code +} + +func carriedByKey(t *testing.T, resp ItemCopyPreflight, key string) ItemCopyPreflightCarried { + t.Helper() + for _, c := range resp.Fields.Carried { + if c.Key == key { + return c + } + } + t.Fatalf("expected %q in carried, got %+v", key, resp.Fields.Carried) + return ItemCopyPreflightCarried{} +} + +func droppedByKey(resp ItemCopyPreflight, key string) (ItemCopyPreflightDropped, bool) { + for _, d := range resp.Fields.Dropped { + if d.Key == key { + return d, true + } + } + return ItemCopyPreflightDropped{}, false +} + +func needsValueByKey(resp ItemCopyPreflight, key string) (ItemCopyPreflightNeedsValue, bool) { + for _, n := range resp.Fields.NeedsValue { + if n.Key == key { + return n, true + } + } + return ItemCopyPreflightNeedsValue{}, false +} + +// --- bucketing --------------------------------------------------------- + +// TestCopyPreflight_Buckets is the DR-15 bucket-correctness case: a field +// that carries, one with no counterpart, one whose value the destination +// cannot take, and one required-and-unsatisfiable. +func TestCopyPreflight_Buckets(t *testing.T) { + f := newCopyPreflightFixture(t) + resp := f.ok(f.baseBody()) + + if resp.Valid { + t.Errorf("expected valid=false while needs_value is non-empty: %+v", resp.Fields.NeedsValue) + } + + // carries, straight from the source + if got := carriedByKey(t, resp, "status"); got.Value != "open" || got.From != "migrated" { + t.Errorf("status: got %+v, want value=open from=migrated", got) + } + if got := carriedByKey(t, resp, "priority"); got.Value != "low" || got.From != "migrated" { + t.Errorf("priority: got %+v, want value=low from=migrated", got) + } + // carries, filled in from the DESTINATION schema's default + if got := carriedByKey(t, resp, "tier"); got.Value != "a" || got.From != "default" { + t.Errorf("tier: got %+v, want value=a from=default", got) + } + + // dropped — no counterpart in the destination schema at all + d, ok := droppedByKey(resp, "impact") + if !ok || d.Reason != "no_target_field" || d.Kind != "field" { + t.Errorf("impact: got %+v (present=%v), want kind=field reason=no_target_field", d, ok) + } + if d.Label != "Impact" { + t.Errorf("impact: label = %q, want the source schema's label", d.Label) + } + // dropped — the key exists downstream but the value cannot convert + d, ok = droppedByKey(resp, "count") + if !ok || d.Reason != "incompatible_type" { + t.Errorf("count: got %+v (present=%v), want reason=incompatible_type", d, ok) + } + + // needs_value — required in the destination, unsatisfiable from the source + n, ok := needsValueByKey(resp, "ticket") + if !ok || n.Reason != "missing_required" || !n.Required { + t.Errorf("ticket: got %+v (present=%v), want reason=missing_required required=true", n, ok) + } + // needs_value — a value DID carry, and the destination schema rejects it + n, ok = needsValueByKey(resp, "code") + if !ok || n.Reason != "invalid_value" { + t.Errorf("code: got %+v (present=%v), want reason=invalid_value", n, ok) + } + if n.Message == "" { + t.Error("code: needs_value entry carries no message to display") + } + + // A field in needs_value must NOT also appear in carried — the buckets + // are a partition, and a client rendering both would show a value it is + // simultaneously being asked to supply. + for _, c := range resp.Fields.Carried { + if c.Key == "ticket" || c.Key == "code" { + t.Errorf("%s appears in BOTH carried and needs_value", c.Key) + } + } + + // Identity blocks, so a dialog can render a header without a second call. + if resp.Source.Ref == "" || resp.Source.Title != "The Source" { + t.Errorf("source block = %+v", resp.Source) + } + if resp.Destination.WorkspaceSlug != f.wsB.Slug || resp.Destination.CollectionSlug != f.collB.Slug { + t.Errorf("destination block = %+v", resp.Destination) + } +} + +// TestCopyPreflight_OverrideClearsNeedsValue is the DR-12 bug in one test: +// MigrateFields computes its Errors BEFORE any override exists, so an +// implementation that reads them still reports `ticket` as missing after +// the caller has supplied it. +func TestCopyPreflight_OverrideClearsNeedsValue(t *testing.T) { + f := newCopyPreflightFixture(t) + + body := f.baseBody() + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123"} + resp := f.ok(body) + + if _, ok := needsValueByKey(resp, "ticket"); ok { + t.Error("override supplied a value for `ticket` but it is still in needs_value (DR-12: overrides are applied BEFORE validation)") + } + if _, ok := needsValueByKey(resp, "code"); ok { + t.Error("override replaced the invalid `code` value but it is still in needs_value") + } + if len(resp.Fields.NeedsValue) != 0 { + t.Errorf("needs_value should be empty, got %+v", resp.Fields.NeedsValue) + } + if !resp.Valid { + t.Error("valid should be true once needs_value is empty") + } + if got := carriedByKey(t, resp, "ticket"); got.Value != "T-1" || got.From != "override" { + t.Errorf("ticket: got %+v, want value=T-1 from=override", got) + } + if got := carriedByKey(t, resp, "code"); got.Value != "123" || got.From != "override" { + t.Errorf("code: got %+v, want value=123 from=override", got) + } +} + +// TestCopyPreflight_InvalidOverrideRejected is DR-12's other half: the +// override is type-checked, which the pre-fix move path never did. +func TestCopyPreflight_InvalidOverrideRejected(t *testing.T) { + f := newCopyPreflightFixture(t) + + body := f.baseBody() + // "urgent" is not one of the destination's priority options. + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123", "priority": "urgent"} + + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an invalid override value, got %d: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "invalid_override" { + t.Errorf("error code = %q, want invalid_override", code) + } +} + +// TestCopyPreflight_MalformedOverrideRejected — an override naming a field +// the destination schema does not declare is refused rather than silently +// dropped, so a dialog cannot show a value that will never land. +func TestCopyPreflight_MalformedOverrideRejected(t *testing.T) { + f := newCopyPreflightFixture(t) + + body := f.baseBody() + body["field_overrides"] = map[string]any{"not_a_field": "x"} + + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "malformed_override" { + t.Errorf("error code = %q, want malformed_override", code) + } +} + +// TestCopyPreflight_NullOverrideUnsetsAndReappears pins the null semantics: +// an explicit null removes the key, which for a REQUIRED destination field +// means it comes back as needs_value rather than being written as nil. +func TestCopyPreflight_NullOverrideUnsetsAndReappears(t *testing.T) { + f := newCopyPreflightFixture(t) + + body := f.baseBody() + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123", "status": nil} + resp := f.ok(body) + + n, ok := needsValueByKey(resp, "status") + if !ok || n.Reason != "missing_required" { + t.Errorf("status: got %+v (present=%v), want reason=missing_required after a null override", n, ok) + } + if resp.Valid { + t.Error("valid should be false while status is unsatisfied") + } +} + +// --- warnings ---------------------------------------------------------- + +// TestCopyPreflight_WarningsAllReported asserts every DR-15 warning with a +// non-zero value in one scenario, because the point of the set is that +// none of it may be silent (DR-17). +func TestCopyPreflight_WarningsAllReported(t *testing.T) { + f := newCopyPreflightFixture(t) + s := f.srv.store + + // A parent in A — the copy is unparented (DR-17). + parent := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "The Parent") + if _, err := s.SetParentLink(f.wsA.ID, f.source.ID, parent.ID, f.owner.ID); err != nil { + t.Fatalf("SetParentLink: %v", err) + } + // A child in A — never copied, and orphaned in place on the move path. + child := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "The Child") + if _, err := s.SetParentLink(f.wsA.ID, child.ID, f.source.ID, f.owner.ID); err != nil { + t.Fatalf("SetParentLink(child): %v", err) + } + // Dependency edges in both directions. + blockee := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "Blockee") + blocker := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "Blocker") + if _, err := s.CreateItemLink(f.wsA.ID, + models.ItemLinkCreate{TargetID: blockee.ID, LinkType: "blocks", CreatedBy: f.owner.ID}, f.source.ID); err != nil { + t.Fatalf("CreateItemLink(outgoing): %v", err) + } + if _, err := s.CreateItemLink(f.wsA.ID, + models.ItemLinkCreate{TargetID: f.source.ID, LinkType: "blocks", CreatedBy: f.owner.ID}, blocker.ID); err != nil { + t.Fatalf("CreateItemLink(incoming): %v", err) + } + + // An assignee who is a member of A but a stranger to B (DR-8), and an + // agent role, which is workspace-local and never carries. + stranger := mustUser(t, f.srv, "stranger@example.com", "strangeruser", "") + if err := s.AddWorkspaceMember(f.wsA.ID, stranger.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + role, err := s.CreateAgentRole(f.wsA.ID, models.AgentRoleCreate{Name: "Reviewer"}) + if err != nil { + t.Fatalf("CreateAgentRole: %v", err) + } + if _, err := s.UpdateItem(f.source.ID, models.ItemUpdate{ + AssignedUserID: &stranger.ID, AgentRoleID: &role.ID, + }); err != nil { + t.Fatalf("UpdateItem(assignment): %v", err) + } + + // One live attachment referenced from the body, plus a reference that + // resolves to nothing under the DR-11a scope. + att := &models.Attachment{ + WorkspaceID: f.wsA.ID, UploadedBy: f.owner.ID, + StorageKey: "fs:deadbeef", ContentHash: "deadbeef", + MimeType: "image/png", SizeBytes: 4096, Filename: "shot.png", + } + if err := s.CreateAttachment(att); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + newContent := "see ![](pad-attachment:" + att.ID + ") and ![](pad-attachment:00000000-0000-4000-8000-000000000000)" + if _, err := s.UpdateItem(f.source.ID, models.ItemUpdate{Content: &newContent}); err != nil { + t.Fatalf("UpdateItem(content): %v", err) + } + + body := f.baseBody() + body["archive_source"] = true + w := f.ok(body).Warnings + + if w.ChildCount != 1 { + t.Errorf("child_count = %d, want 1", w.ChildCount) + } + if !w.ChildrenOrphaned { + t.Error("children_orphaned should be true on the archive_source (move) path") + } + if !w.DroppedParent { + t.Error("dropped_parent should be true — the source has a parent and the copy is unparented") + } + if w.OutgoingLinks["blocks"] != 1 { + t.Errorf("outgoing_links = %v, want blocks:1", w.OutgoingLinks) + } + if w.IncomingLinks["blocks"] != 1 { + t.Errorf("incoming_links = %v, want blocks:1", w.IncomingLinks) + } + // The hierarchy edges are reported by child_count / dropped_parent and + // must not be double-counted into the dependency maps. + for _, hk := range []string{"parent", "implements", "plan"} { + if _, ok := w.OutgoingLinks[hk]; ok { + t.Errorf("outgoing_links leaked hierarchy type %q: %v", hk, w.OutgoingLinks) + } + if _, ok := w.IncomingLinks[hk]; ok { + t.Errorf("incoming_links leaked hierarchy type %q: %v", hk, w.IncomingLinks) + } + } + if !w.DroppedAssignee { + t.Error("dropped_assignee should be true — the assignee is not a member of the destination") + } + if !w.DroppedAgentRole { + t.Error("dropped_agent_role should be true — role slugs are workspace-local") + } + if w.AttachmentCount != 1 || w.AttachmentBytes != 4096 { + t.Errorf("attachment_count/bytes = %d/%d, want 1/4096", w.AttachmentCount, w.AttachmentBytes) + } + if w.UnresolvableRefCount != 1 { + t.Errorf("unresolvable_ref_count = %d, want 1", w.UnresolvableRefCount) + } + + // The assignment pair must ALSO appear in the `dropped` bucket (DR-8 + // says so explicitly), not only as warning booleans. + if d, ok := droppedByKey(f.ok(body), "assigned_user"); !ok || d.Kind != "assignment" || d.Reason != "assignee_not_a_member" { + t.Errorf("assigned_user dropped entry = %+v (present=%v)", d, ok) + } + if d, ok := droppedByKey(f.ok(body), "agent_role"); !ok || d.Kind != "assignment" || d.Reason != "agent_role_not_portable" { + t.Errorf("agent_role dropped entry = %+v (present=%v)", d, ok) + } +} + +// TestCopyPreflight_AssigneeCarriesWhenMemberOfDestination is DR-8's +// affirmative half: the common same-owner case loses nothing. +func TestCopyPreflight_AssigneeCarriesWhenMemberOfDestination(t *testing.T) { + f := newCopyPreflightFixture(t) + // The fixture owner is a member of BOTH workspaces. + if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{AssignedUserID: &f.owner.ID}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + + resp := f.ok(f.baseBody()) + if resp.Warnings.DroppedAssignee { + t.Error("dropped_assignee should be false when the assignee is a member of the destination") + } + if d, ok := droppedByKey(resp, "assigned_user"); ok { + t.Errorf("assignee should not be in the dropped bucket: %+v", d) + } +} + +// TestCopyPreflight_ChildrenNotOrphanedOnPlainCopy — the weighting is the +// archive_source flag's job, not the client's. +func TestCopyPreflight_ChildrenNotOrphanedOnPlainCopy(t *testing.T) { + f := newCopyPreflightFixture(t) + child := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "Kid") + if _, err := f.srv.store.SetParentLink(f.wsA.ID, child.ID, f.source.ID, f.owner.ID); err != nil { + t.Fatalf("SetParentLink: %v", err) + } + + resp := f.ok(f.baseBody()) + if resp.Warnings.ChildCount != 1 { + t.Fatalf("child_count = %d, want 1", resp.Warnings.ChildCount) + } + if resp.Warnings.ChildrenOrphaned { + t.Error("children_orphaned must be false on a plain copy — the source keeps its children") + } + if resp.ArchiveSource { + t.Error("archive_source should echo false") + } +} + +// --- error statuses ---------------------------------------------------- + +// TestCopyPreflight_ErrorStatusesAreDistinguishable covers DR-15's explicit +// requirement that "destination workspace not accessible", "collection not +// found" and "malformed override" are told apart. +func TestCopyPreflight_ErrorStatusesAreDistinguishable(t *testing.T) { + f := newCopyPreflightFixture(t) + + // A workspace the caller is a stranger to. + outsider := mustUser(t, f.srv, "outsider@example.com", "outsideruser", "") + wsC := mustWorkspace(t, f.srv, "Unreachable WS", outsider.ID) + + body := f.baseBody() + body["target_workspace"] = wsC.Slug + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusForbidden { + t.Errorf("inaccessible destination workspace: got %d, want 403: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "forbidden" { + t.Errorf("inaccessible destination workspace: error code = %q, want forbidden", code) + } + + // A collection that does not exist in an accessible workspace. + body = f.baseBody() + body["target_collection"] = "no-such-collection" + rr = f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusNotFound { + t.Errorf("absent destination collection: got %d, want 404: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "collection_not_found" { + t.Errorf("error code = %q, want collection_not_found", code) + } + + // Missing required request fields. + rr = f.call(f.owner, reqOpts{}, map[string]any{"target_collection": f.collB.Slug}) + if rr.Code != http.StatusBadRequest || errCode(t, rr) != "missing_field" { + t.Errorf("absent target_workspace: got %d/%s", rr.Code, rr.Body.String()) + } + rr = f.call(f.owner, reqOpts{}, map[string]any{"target_workspace": f.wsB.Slug}) + if rr.Code != http.StatusBadRequest || errCode(t, rr) != "missing_field" { + t.Errorf("absent target_collection: got %d/%s", rr.Code, rr.Body.String()) + } + + // A body that is not decodable at all is its own code. + rr = f.callRaw(f.owner, []byte("{not json")) + if rr.Code != http.StatusBadRequest || errCode(t, rr) != "invalid_body" { + t.Errorf("undecodable body: got %d/%s, want 400 invalid_body", rr.Code, rr.Body.String()) + } +} + +// TestCopyPreflight_ArchivedSourceReports409 — an archived source is +// neither "absent" nor copyable. It reports 409 archived, the same status +// the move and update paths report, and only to a caller who can already +// see the archived row. +func TestCopyPreflight_ArchivedSourceReports409(t *testing.T) { + f := newCopyPreflightFixture(t) + if err := f.srv.store.DeleteItem(f.source.ID); err != nil { + t.Fatalf("DeleteItem: %v", err) + } + + rr := f.call(f.owner, reqOpts{}, f.baseBody()) + if rr.Code != http.StatusConflict { + t.Fatalf("archived source: got %d, want 409: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "archived" { + t.Errorf("archived source: error code = %q, want archived", code) + } + + // A caller who cannot see the archived item must NOT learn it is + // archived — that would be an existence oracle for a hidden item. + otherA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Other A", srcSchemaJSON) + u := f.restrictedEditor("archived-hidden@example.com", "archivedhidden", + []string{otherA.ID}, []string{f.collB.ID}) + rr = f.call(u, reqOpts{wsRoleCtx: "editor"}, f.baseBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("archived source, hidden from the caller: got %d, want 404: %s", + rr.Code, rr.Body.String()) + } +} + +// --- authorization: the four checks, in order -------------------------- + +// restrictedEditor returns an editor in both workspaces whose membership in +// each is collection_access="specific", limited to the named collections. +func (f *copyPreflightFixture) restrictedEditor(email, username string, aColls, bColls []string) *models.User { + f.t.Helper() + u := mustUser(f.t, f.srv, email, username, "") + for _, m := range []struct { + ws *models.Workspace + colls []string + wsRole string + }{{f.wsA, aColls, "editor"}, {f.wsB, bColls, "editor"}} { + if err := f.srv.store.AddWorkspaceMember(m.ws.ID, u.ID, m.wsRole); err != nil { + f.t.Fatalf("AddWorkspaceMember: %v", err) + } + if err := f.srv.store.SetMemberCollectionAccess(m.ws.ID, u.ID, "specific", m.colls); err != nil { + f.t.Fatalf("SetMemberCollectionAccess: %v", err) + } + } + return u +} + +// TestCopyPreflight_SourceItemNotVisible is DR-10b's headline regression: +// a restricted editor who cannot see the SOURCE collection gets a bare 404 +// and learns nothing about either side. This is the exfiltration direction +// — the more damaging of the two. +func TestCopyPreflight_SourceItemNotVisible(t *testing.T) { + f := newCopyPreflightFixture(t) + // Restricted in A to a collection the source is NOT in; unrestricted + // enough in B to write there, so only the source check can refuse. + otherA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Other A", srcSchemaJSON) + u := f.restrictedEditor("hidden-src@example.com", "hiddensrc", + []string{otherA.ID}, []string{f.collB.ID}) + + rr := f.call(u, reqOpts{wsRoleCtx: "editor"}, f.baseBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 for a hidden source item, got %d: %s", rr.Code, rr.Body.String()) + } + // Nothing about the source item OR the destination schema may appear. + assertDisclosesNothing(t, rr, f) +} + +// TestCopyPreflight_SourceEditDenied — visible but read-only in A. The +// copy is a read out of A, but DR-10b requires EDIT on the source, and the +// refusal is the same non-disclosing 404 as invisibility. +func TestCopyPreflight_SourceEditDenied(t *testing.T) { + f := newCopyPreflightFixture(t) + viewer := mustUser(t, f.srv, "viewer-a@example.com", "vieweruser", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, viewer.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, viewer.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + + rr := f.call(viewer, reqOpts{wsRoleCtx: "viewer"}, f.baseBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404 when the caller may not edit the source, got %d: %s", rr.Code, rr.Body.String()) + } + assertDisclosesNothing(t, rr, f) +} + +// TestCopyPreflight_DestinationCollectionNotVisible is DR-10a: a restricted +// editor in B may not preflight into a collection hidden from them, and the +// refusal is byte-identical to the one for a collection that does not exist +// — otherwise the endpoint enumerates hidden collections one slug at a time. +func TestCopyPreflight_DestinationCollectionNotVisible(t *testing.T) { + f := newCopyPreflightFixture(t) + u := f.restrictedEditor("hidden-dst@example.com", "hiddendst", + []string{f.collA.ID}, []string{f.collB.ID}) + + body := f.baseBody() + body["target_collection"] = f.hiddenB.Slug + hiddenRR := f.call(u, reqOpts{wsRoleCtx: "editor"}, body) + if hiddenRR.Code != http.StatusNotFound { + t.Fatalf("expected 404 for a hidden destination collection, got %d: %s", + hiddenRR.Code, hiddenRR.Body.String()) + } + + body = f.baseBody() + body["target_collection"] = "definitely-not-a-collection" + absentRR := f.call(u, reqOpts{wsRoleCtx: "editor"}, body) + + if hiddenRR.Code != absentRR.Code || hiddenRR.Body.String() != absentRR.Body.String() { + t.Fatalf("hidden and absent destination collections are distinguishable:\n hidden: %d %s\n absent: %d %s", + hiddenRR.Code, hiddenRR.Body.String(), absentRR.Code, absentRR.Body.String()) + } + // The hidden collection's schema must not appear anywhere. + if bytes.Contains(hiddenRR.Body.Bytes(), []byte("ticket")) { + t.Errorf("refusal disclosed the hidden collection's schema: %s", hiddenRR.Body.String()) + } +} + +// TestCopyPreflight_DestinationEditDenied — the collection is visible but +// the caller is a viewer in B, so check 4 refuses. +func TestCopyPreflight_DestinationEditDenied(t *testing.T) { + f := newCopyPreflightFixture(t) + u := mustUser(t, f.srv, "viewer-b@example.com", "viewerbuser", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + + rr := f.call(u, reqOpts{wsRoleCtx: "editor"}, f.baseBody()) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 when the caller may not create into the destination, got %d: %s", + rr.Code, rr.Body.String()) + } + if bytes.Contains(rr.Body.Bytes(), []byte("ticket")) { + t.Errorf("refusal disclosed the destination schema: %s", rr.Body.String()) + } +} + +// TestCopyPreflight_SourceCheckRunsBeforeDestination pins the ORDERING, not +// just the set. A caller who fails BOTH checks must get the SOURCE's +// verdict — a destination verdict built for someone who could not read the +// source is a disclosure the source check was supposed to prevent. +func TestCopyPreflight_SourceCheckRunsBeforeDestination(t *testing.T) { + f := newCopyPreflightFixture(t) + otherA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Other A", srcSchemaJSON) + // Hidden source AND a destination workspace they are a stranger to. + u := mustUser(t, f.srv, "both-fail@example.com", "bothfail", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if err := f.srv.store.SetMemberCollectionAccess(f.wsA.ID, u.ID, "specific", []string{otherA.ID}); err != nil { + t.Fatalf("SetMemberCollectionAccess: %v", err) + } + + rr := f.call(u, reqOpts{wsRoleCtx: "editor"}, f.baseBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected the SOURCE's 404 to win, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// TestCopyPreflight_TokenAllowListEnforcedOnDestination — the consent +// allow-list is applied automatically only for the workspace in the URL, so +// the destination must be checked by hand. This is the denial a naive +// implementation misses. +func TestCopyPreflight_TokenAllowListEnforcedOnDestination(t *testing.T) { + f := newCopyPreflightFixture(t) + + rr := f.call(f.owner, reqOpts{ + bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug}, + }, f.baseBody()) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 when the destination is outside the token's consent scope, got %d: %s", + rr.Code, rr.Body.String()) + } + // The allow-list reports itself — it is the caller's OWN consent scope, + // not information about the target — and that distinct code is what a + // client needs in order to tell the user to re-consent rather than to + // ask for permissions they may already have. + if code := errCode(t, rr); code != "permission_denied" { + t.Errorf("token allow-list denial: error code = %q, want permission_denied", code) + } + + // Sanity: consenting to both workspaces lets the same call through, so + // the denial above is the allow-list and not a broken fixture. + rr = f.call(f.owner, reqOpts{ + bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug, f.wsB.Slug}, + }, f.baseBody()) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 with both workspaces consented, got %d: %s", rr.Code, rr.Body.String()) + } +} + +// assertDisclosesNothing checks a refusal body for anything that would +// confirm the source item exists or reveal the destination schema. +func assertDisclosesNothing(t *testing.T, rr *httptest.ResponseRecorder, f *copyPreflightFixture) { + t.Helper() + body := rr.Body.Bytes() + for _, secret := range []string{ + f.source.Title, f.source.Slug, f.source.ID, // source identity + "ticket", "tier", "impact", // destination + source schema keys + f.collB.Slug, f.wsB.Slug, f.wsB.Name, // destination identity + } { + if secret == "" { + continue + } + if bytes.Contains(body, []byte(secret)) { + t.Errorf("refusal disclosed %q: %s", secret, rr.Body.String()) + } + } +} + +// --- non-mutation (DR-15) ---------------------------------------------- + +type worldSnapshot struct { + items int + attachments int + moves int + activities int + seqA, seqB int64 +} + +func (f *copyPreflightFixture) snapshot() worldSnapshot { + f.t.Helper() + return worldSnapshot{ + items: f.countRows(`SELECT COUNT(*) FROM items`), + attachments: f.countRows(`SELECT COUNT(*) FROM attachments`), + moves: f.countRows(`SELECT COUNT(*) FROM item_workspace_moves`), + activities: f.countRows(`SELECT COUNT(*) FROM activities`), + seqA: f.maxSeq(f.wsA.ID), + seqB: f.maxSeq(f.wsB.ID), + } +} + +func (f *copyPreflightFixture) countRows(query string) int { + f.t.Helper() + var n int + if err := f.srv.store.DB().QueryRow(f.srv.store.D().Rebind(query)).Scan(&n); err != nil { + f.t.Fatalf("count (%s): %v", query, err) + } + return n +} + +func (f *copyPreflightFixture) maxSeq(workspaceID string) int64 { + f.t.Helper() + var seq int64 + if err := f.srv.store.DB().QueryRow( + f.srv.store.D().Rebind(`SELECT COALESCE(MAX(seq), 0) FROM items WHERE workspace_id = ?`), + workspaceID).Scan(&seq); err != nil { + f.t.Fatalf("max seq: %v", err) + } + return seq +} + +// TestCopyPreflight_WritesNothing is DR-15's non-mutation clause, asserted +// term by term: no item, no attachment row, no provenance row, no activity, +// no SSE event, and NEITHER workspace's seq advances. It must be safe to +// call repeatedly from a live UI as the user changes the destination, so it +// is called several times with several bodies. +func TestCopyPreflight_WritesNothing(t *testing.T) { + f := newCopyPreflightFixture(t) + + // Give the source an attachment reference and an assignment so the + // preflight exercises every read path it has. + att := &models.Attachment{ + WorkspaceID: f.wsA.ID, UploadedBy: f.owner.ID, + StorageKey: "fs:cafebabe", ContentHash: "cafebabe", + MimeType: "image/png", SizeBytes: 512, Filename: "a.png", + } + if err := f.srv.store.CreateAttachment(att); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + content := "![](pad-attachment:" + att.ID + ")" + if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{Content: &content}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + + // Subscribe to BOTH workspaces before the snapshot so any publish + // lands in a buffer we can inspect afterwards. + chA := f.bus.Subscribe(f.wsA.ID) + defer f.bus.Unsubscribe(chA) + chB := f.bus.Subscribe(f.wsB.ID) + defer f.bus.Unsubscribe(chB) + + before := f.snapshot() + + bodies := []map[string]any{ + f.baseBody(), + func() map[string]any { + b := f.baseBody() + b["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123"} + return b + }(), + func() map[string]any { + b := f.baseBody() + b["archive_source"] = true + return b + }(), + // Including refused calls: a 4xx must not write either. + func() map[string]any { + b := f.baseBody() + b["field_overrides"] = map[string]any{"priority": "urgent"} + return b + }(), + } + for i, b := range bodies { + rr := f.call(f.owner, reqOpts{}, b) + if rr.Code != http.StatusOK && rr.Code != http.StatusBadRequest { + t.Fatalf("call %d: unexpected status %d: %s", i, rr.Code, rr.Body.String()) + } + } + + after := f.snapshot() + if before != after { + t.Fatalf("the preflight mutated state:\n before: %+v\n after: %+v", before, after) + } + + // No SSE. Draining the subscription channels is the direct assertion; + // EventsSince(0) covers a publish that happened before we subscribed. + for label, ch := range map[string]chan events.Event{"A": chA, "B": chB} { + select { + case ev := <-ch: + t.Errorf("workspace %s received an SSE event from a dry run: %+v", label, ev) + default: + } + } + if got := f.bus.EventsSince(f.wsA.ID, 0); len(got) != 0 { + t.Errorf("workspace A event buffer = %+v, want empty", got) + } + if got := f.bus.EventsSince(f.wsB.ID, 0); len(got) != 0 { + t.Errorf("workspace B event buffer = %+v, want empty", got) + } +} + +// TestCopyPreflight_NoWebhookDispatch — the fanout DR-14 specifies for the +// mutating copy must not fire on the preflight. Asserted with a positive +// control: a REAL item creation in the destination afterwards does deliver, +// so "nothing arrived" is a fact about the preflight and not about a +// dispatcher that was never wired up. +func TestCopyPreflight_NoWebhookDispatch(t *testing.T) { + f := newCopyPreflightFixture(t) + + received := make(chan string, 8) + recv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + Event string `json:"event"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + received <- payload.Event + w.WriteHeader(http.StatusOK) + })) + defer recv.Close() + + d := webhooks.NewDispatcher(f.srv.store) + d.SkipSSRF = true // the receiver is a loopback httptest server + f.srv.SetWebhookDispatcher(d) + + if _, err := f.srv.store.CreateWebhook(f.wsB.ID, models.WebhookCreate{ + URL: recv.URL, Events: `["*"]`, + }); err != nil { + t.Fatalf("CreateWebhook: %v", err) + } + + for i := 0; i < 3; i++ { + if rr := f.call(f.owner, reqOpts{}, f.baseBody()); rr.Code != http.StatusOK { + t.Fatalf("preflight: %d %s", rr.Code, rr.Body.String()) + } + } + + // The NEGATIVE assertion comes first, over an explicit window, and no + // legitimate traffic exists yet — so anything that arrives here came + // from a preflight. Deliberately not an ordering argument: Dispatch + // starts each delivery in its own goroutine, so "the control arrived + // first" would prove nothing about a straggler behind it. + select { + case ev := <-received: + t.Fatalf("a preflight dispatched webhook %q — the dry run must emit nothing", ev) + case <-time.After(webhookQuietWindow): + } + + // Positive control. Without it the silence above would also be + // consistent with a receiver that never works. + f.srv.dispatchWebhook(f.wsB.ID, "item.created", map[string]string{"probe": "1"}) + select { + case ev := <-received: + if ev != "item.created" { + t.Fatalf("positive control delivered %q, want item.created", ev) + } + case <-time.After(5 * time.Second): + t.Fatal("positive control never delivered; the webhook harness is broken, so the negative assertion above proves nothing") + } +} + +// webhookQuietWindow is how long TestCopyPreflight_NoWebhookDispatch waits +// for a delivery that must never come. Deliveries are goroutine-dispatched +// to a loopback httptest server in the same process, so a real one lands in +// microseconds; the margin is for a loaded CI box. It only costs wall-clock +// time in the passing case, so it is set generously rather than tightly. +const webhookQuietWindow = 750 * time.Millisecond + +// TestCopyPreflight_RepeatedCallsAreByteIdentical — DR-15 requires the +// preflight to be safe to call repeatedly, which is only true if the answer +// is stable. Go map iteration order makes this a real risk: the bucket +// lists and MigrateFields' Dropped slice all originate from maps. +func TestCopyPreflight_RepeatedCallsAreByteIdentical(t *testing.T) { + f := newCopyPreflightFixture(t) + body := f.baseBody() + body["field_overrides"] = map[string]any{"ticket": "T-1"} + + first := f.call(f.owner, reqOpts{}, body).Body.String() + for i := 0; i < 20; i++ { + if got := f.call(f.owner, reqOpts{}, body).Body.String(); got != first { + t.Fatalf("call %d differed:\n first: %s\n got: %s", i+2, first, got) + } + } +} + +// TestCopyPreflight_EmptyBucketsAreArraysNotNull pins the wire shape for +// the TypeScript consumer: `[]`, never `null`, and the same for the two +// link maps. +func TestCopyPreflight_EmptyBucketsAreArraysNotNull(t *testing.T) { + f := newCopyPreflightFixture(t) + // A destination whose schema is identical to the source's: nothing + // drops, nothing is missing. + twinB := mustSchemaCollection(t, f.srv, f.wsB.ID, "Twin B", srcSchemaJSON) + + body := f.baseBody() + body["target_collection"] = twinB.Slug + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusOK { + t.Fatalf("preflight: %d %s", rr.Code, rr.Body.String()) + } + + var raw struct { + Fields struct { + Carried json.RawMessage `json:"carried"` + Dropped json.RawMessage `json:"dropped"` + NeedsValue json.RawMessage `json:"needs_value"` + } `json:"fields"` + Warnings struct { + Outgoing json.RawMessage `json:"outgoing_links"` + Incoming json.RawMessage `json:"incoming_links"` + } `json:"warnings"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &raw); err != nil { + t.Fatalf("parse: %v", err) + } + for name, blob := range map[string]json.RawMessage{ + "carried": raw.Fields.Carried, "dropped": raw.Fields.Dropped, + "needs_value": raw.Fields.NeedsValue, + } { + if string(blob) == "null" || len(blob) == 0 { + t.Errorf("fields.%s serialized as %q, want an array", name, string(blob)) + } + } + if string(raw.Warnings.Outgoing) != "{}" || string(raw.Warnings.Incoming) != "{}" { + t.Errorf("link maps = %s / %s, want {} / {}", + raw.Warnings.Outgoing, raw.Warnings.Incoming) + } + if string(raw.Fields.Dropped) != "[]" { + t.Errorf("dropped = %s, want [] for an identical schema", raw.Fields.Dropped) + } +} + +// TestCopyPreflight_SourceIsItemScopedNotCollectionScoped pins WHICH scope +// the source check uses. An item scope and a collection scope both look +// plausible there and both refuse the restricted-editor cases above, so the +// tests that assert denials cannot tell them apart. +// +// This is the case that can: a GUEST in the source workspace whose sole +// claim is an edit grant on the source ITEM. That is exactly what an item +// grant means, so they may copy it — but a collection-scoped check applies +// full-collection-access semantics and would refuse them. Swapping the +// scope silently narrows a legitimate caller out. +func TestCopyPreflight_SourceIsItemScopedNotCollectionScoped(t *testing.T) { + f := newCopyPreflightFixture(t) + + guest := mustUser(t, f.srv, "grantguest@example.com", "grantguest", "") + // No membership in A at all — only the item grant. + if _, err := f.srv.store.CreateItemGrant(f.wsA.ID, f.source.ID, guest.ID, "edit", f.owner.ID); err != nil { + t.Fatalf("CreateItemGrant: %v", err) + } + // A genuine editor in the destination, so only the source scope is + // under test. + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, guest.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + + rr := f.call(guest, reqOpts{wsRoleCtx: "guest"}, f.baseBody()) + if rr.Code != http.StatusOK { + t.Fatalf("a guest holding an edit grant on the source must be able to preflight it; got %d: %s", + rr.Code, rr.Body.String()) + } + + // The negative half of the same grant: a SIBLING item in the same + // collection, which the guest has no grant on, stays unreachable. + sibling := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "Ungranted Sibling") + prev := f.source + f.source = sibling + rr = f.call(guest, reqOpts{wsRoleCtx: "guest"}, f.baseBody()) + f.source = prev + if rr.Code != http.StatusNotFound { + t.Fatalf("an ungranted sibling item must stay hidden; got %d: %s", rr.Code, rr.Body.String()) + } +} + +// TestCopyPreflight_RelationshipCountersAreACLFiltered — the warning +// counters must not become a wider window onto the source workspace than +// /children and /links already are. Store.GetChildItems and +// Store.GetItemLinks apply no ACL of their own, so an unfiltered preflight +// tells a caller holding nothing but an edit grant on the source item the +// exact number and type of relationships attached to it, every one of +// which may live in a collection hidden from them (Codex round 3). +func TestCopyPreflight_RelationshipCountersAreACLFiltered(t *testing.T) { + f := newCopyPreflightFixture(t) + s := f.srv.store + + // Two collections in A: one the restricted caller can see, one not. + openA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Open A", srcSchemaJSON) + secretA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Secret A", srcSchemaJSON) + + // One visible child + one hidden child. + for _, coll := range []*models.Collection{openA, secretA} { + child := mustItem(t, f.srv, f.wsA.ID, coll.ID, "Child in "+coll.Slug) + if _, err := s.SetParentLink(f.wsA.ID, child.ID, f.source.ID, f.owner.ID); err != nil { + t.Fatalf("SetParentLink: %v", err) + } + } + // One visible dependency edge + one hidden one. + for _, coll := range []*models.Collection{openA, secretA} { + blockee := mustItem(t, f.srv, f.wsA.ID, coll.ID, "Blockee in "+coll.Slug) + if _, err := s.CreateItemLink(f.wsA.ID, + models.ItemLinkCreate{TargetID: blockee.ID, LinkType: "blocks", CreatedBy: f.owner.ID}, + f.source.ID); err != nil { + t.Fatalf("CreateItemLink: %v", err) + } + } + + // Control: an unrestricted owner sees both of each. + if w := f.ok(f.baseBody()).Warnings; w.ChildCount != 2 || w.OutgoingLinks["blocks"] != 2 { + t.Fatalf("owner: child_count=%d outgoing=%v, want 2 and blocks:2", w.ChildCount, w.OutgoingLinks) + } + + // The restricted caller can see the source's collection and the open + // one, but not the secret one. + u := f.restrictedEditor("rel-restricted@example.com", "relrestricted", + []string{f.collA.ID, openA.ID}, []string{f.collB.ID}) + + rr := f.call(u, reqOpts{wsRoleCtx: "editor"}, f.baseBody()) + if rr.Code != http.StatusOK { + t.Fatalf("restricted caller preflight: %d %s", rr.Code, rr.Body.String()) + } + var resp ItemCopyPreflight + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + if resp.Warnings.ChildCount != 1 { + t.Errorf("child_count = %d, want 1 — the child in the hidden collection must not be counted", + resp.Warnings.ChildCount) + } + if resp.Warnings.OutgoingLinks["blocks"] != 1 { + t.Errorf("outgoing_links = %v, want blocks:1 — the edge into the hidden collection must not be counted", + resp.Warnings.OutgoingLinks) + } + + // And the same for the source's own parent, which is the dropped_parent + // warning rather than a count. + hiddenParent := mustItem(t, f.srv, f.wsA.ID, secretA.ID, "Hidden Parent") + if _, err := s.SetParentLink(f.wsA.ID, f.source.ID, hiddenParent.ID, f.owner.ID); err != nil { + t.Fatalf("SetParentLink(parent): %v", err) + } + if !f.ok(f.baseBody()).Warnings.DroppedParent { + t.Fatal("owner should see dropped_parent") + } + rr = f.call(u, reqOpts{wsRoleCtx: "editor"}, f.baseBody()) + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("parse: %v", err) + } + if resp.Warnings.DroppedParent { + t.Error("dropped_parent must be false for a caller who cannot see the parent's collection") + } +} + +// TestCopyPreflight_OrphanSourceKeyHasItsOwnReason — a key the item carries +// that its OWN schema no longer declares drops even when the destination +// declares it, because MigrateFields has no source type to convert from. +// That is a different fact from a type mismatch, and labelling it +// "incompatible_type" tells a dialog to explain the wrong thing. +func TestCopyPreflight_OrphanSourceKeyHasItsOwnReason(t *testing.T) { + f := newCopyPreflightFixture(t) + + // `ticket` is declared by the DESTINATION but not by the source + // schema; write it onto the item anyway, the way a schema edit leaves + // an orphan key behind. + fields := `{"status":"open","priority":"low","code":"123","ticket":"T-7"}` + if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{Fields: &fields}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + + resp := f.ok(f.baseBody()) + + d, ok := droppedByKey(resp, "ticket") + if !ok { + t.Fatalf("orphan key `ticket` is not in the dropped bucket: %+v", resp.Fields.Dropped) + } + if d.Reason != "undeclared_source_field" { + t.Errorf("ticket dropped reason = %q, want undeclared_source_field", d.Reason) + } + // And it is genuinely dropped, not carried — the orphan value must not + // silently satisfy the destination's required field. + n, ok := needsValueByKey(resp, "ticket") + if !ok || n.Reason != "missing_required" { + t.Errorf("ticket should still be needs_value/missing_required, got %+v (present=%v)", n, ok) + } + + // A key declared by BOTH schemas whose value cannot convert keeps the + // type-mismatch reason, so the two are actually distinguished. + if d, ok := droppedByKey(f.ok(f.baseBody()), "count"); ok && d.Reason != "incompatible_type" { + t.Errorf("count dropped reason = %q, want incompatible_type", d.Reason) + } +} + +// TestCopyPreflight_NullOverrideOnDefaultedFieldRedefaults — the other half +// of the null semantics. Deleting the key hands it back to validation, +// which re-applies the destination schema's default, so a nulled field +// that HAS a default is carried rather than reported as missing. Only a +// required field with no default becomes needs_value (asserted separately +// in TestCopyPreflight_NullOverrideUnsetsAndReappears). +func TestCopyPreflight_NullOverrideOnDefaultedFieldRedefaults(t *testing.T) { + f := newCopyPreflightFixture(t) + // A destination whose `status` is required AND defaulted. + defaulted := mustSchemaCollection(t, f.srv, f.wsB.ID, "Defaulted B", `{"fields":[ + {"key":"status","label":"Status","type":"select","options":["open","done"],"required":true,"default":"done"} + ]}`) + + body := f.baseBody() + body["target_collection"] = defaulted.Slug + body["field_overrides"] = map[string]any{"status": nil} + resp := f.ok(body) + + if _, ok := needsValueByKey(resp, "status"); ok { + t.Error("a nulled REQUIRED field that has a default must not be needs_value — validation re-applies the default") + } + if got := carriedByKey(t, resp, "status"); got.Value != "done" || got.From != "default" { + t.Errorf("status: got %+v, want value=done from=default", got) + } + if !resp.Valid { + t.Error("valid should be true — nothing is left for the caller to resolve") + } +} + +// TestCopyPreflight_OversizedBodyIsRejected — the endpoint is designed to +// be called on every keystroke of a live dialog, so the body must go +// through the project's MaxBytesReader-wrapped decoder rather than +// json.NewDecoder. Without the cap a single request allocates the whole +// blob (Codex round 7). +func TestCopyPreflight_OversizedBodyIsRejected(t *testing.T) { + f := newCopyPreflightFixture(t) + + // Comfortably past defaultJSONBodyLimit (2 MiB). + var b bytes.Buffer + b.WriteString(`{"target_workspace":"` + f.wsB.Slug + `","target_collection":"` + f.collB.Slug + `","field_overrides":{`) + for i := 0; i < 60000; i++ { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `"k%d":"%s"`, i, strings.Repeat("x", 64)) + } + b.WriteString("}}") + if b.Len() <= defaultJSONBodyLimit { + t.Fatalf("test body is only %d bytes, under the %d limit", b.Len(), defaultJSONBodyLimit) + } + + rr := f.callRaw(f.owner, b.Bytes()) + if rr.Code != http.StatusBadRequest { + t.Fatalf("oversized body: got %d, want 400: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "invalid_body" { + t.Errorf("oversized body: error code = %q, want invalid_body", code) + } +} + +// TestCopyPreflight_MalformedOverrideMessageIsBounded — the 400 must not +// echo an unbounded slice of caller-supplied keys back at them. +func TestCopyPreflight_MalformedOverrideMessageIsBounded(t *testing.T) { + f := newCopyPreflightFixture(t) + + overrides := map[string]any{} + for i := 0; i < 200; i++ { + overrides[fmt.Sprintf("ghost%03d%s", i, strings.Repeat("y", 200))] = "v" + } + body := f.baseBody() + body["field_overrides"] = overrides + + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusBadRequest || errCode(t, rr) != "malformed_override" { + t.Fatalf("got %d %s, want 400 malformed_override", rr.Code, rr.Body.String()) + } + if n := rr.Body.Len(); n > 1024 { + t.Errorf("malformed_override body is %d bytes — the key list is not bounded: %s", n, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "more") { + t.Errorf("expected the message to say how many keys were elided: %s", rr.Body.String()) + } +} + +// TestCopyPreflight_InvalidOverrideMessageIsBounded — validateFieldType +// quotes the offending value verbatim, so an override carrying a ~2 MiB +// string would be reflected back in full without a cap (Codex round 8). +func TestCopyPreflight_InvalidOverrideMessageIsBounded(t *testing.T) { + f := newCopyPreflightFixture(t) + + // `code` has pattern ^[0-9]+$, so any long non-numeric string fails + // and lands in the message. + huge := strings.Repeat("z", 400000) + body := f.baseBody() + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": huge} + + rr := f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusBadRequest || errCode(t, rr) != "invalid_override" { + t.Fatalf("got %d %s, want 400 invalid_override", rr.Code, rr.Body.String()) + } + if n := rr.Body.Len(); n > 2048 { + t.Errorf("invalid_override body is %d bytes — the value is not truncated", n) + } + + // Truncation must not emit invalid UTF-8 through a multi-byte rune. + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": strings.Repeat("é", 400)} + rr = f.call(f.owner, reqOpts{}, body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("got %d %s, want 400", rr.Code, rr.Body.String()) + } + if !utf8.Valid(rr.Body.Bytes()) { + t.Error("truncated message is not valid UTF-8") + } +} + +// TestPreflightHierarchyTypesCoverStoreChildren is the drift alarm for the +// one invariant the preflight's link partition rests on: every link type +// store.GetChildItems treats as a child must be classified as hierarchy +// here. Add a third child link type to the store without this and the +// preflight starts reporting parent/child edges as dependency blockers. +func TestPreflightHierarchyTypesCoverStoreChildren(t *testing.T) { + for _, lt := range store.ChildLinkTypes() { + if !hierarchyLinkTypes[lt] { + t.Errorf("store.ChildLinkTypes contains %q but the preflight does not classify it as hierarchy — "+ + "it would be counted as a dependency edge", lt) + } + } + // And the legacy alias is still covered, so a "plan" row alongside a + // "parent" row cannot double-count. + for _, lt := range legacyHierarchyLinkTypes { + if !hierarchyLinkTypes[lt] { + t.Errorf("legacy hierarchy type %q is not classified as hierarchy", lt) + } + } +} + +// TestCopyPreflight_RouteIsWiredAndEchoesArchiveSource goes through the +// REAL router rather than calling the handler directly, so the route +// registration and the middleware chain are covered too — every other +// test in this file hand-builds the chi context. +func TestCopyPreflight_RouteIsWiredAndEchoesArchiveSource(t *testing.T) { + srv := testServer(t) + wsA := createWSForTest(t, srv) + wsARow, err := srv.store.GetWorkspaceBySlug(wsA) + if err != nil || wsARow == nil { + t.Fatalf("GetWorkspaceBySlug: %v", err) + } + collA := mustSchemaCollection(t, srv, wsARow.ID, "Route Src", srcSchemaJSON) + collB := mustSchemaCollection(t, srv, wsARow.ID, "Route Dst", srcSchemaJSON) + item := createItem(t, srv, wsA, collA.Slug, map[string]interface{}{ + "title": "Routed", "fields": `{"status":"open"}`, + }) + + rr := doRequest(srv, "POST", + "/api/v1/workspaces/"+wsA+"/items/"+item.Slug+"/copy/preflight", + map[string]interface{}{ + "target_workspace": wsA, + "target_collection": collB.Slug, + "archive_source": true, + }) + if rr.Code != http.StatusOK { + t.Fatalf("routed preflight: got %d, want 200: %s", rr.Code, rr.Body.String()) + } + var resp ItemCopyPreflight + parseJSON(t, rr, &resp) + if !resp.ArchiveSource { + t.Error("archive_source must echo back as true on the move path") + } + if !resp.Valid { + t.Errorf("expected a same-schema copy to be valid: %+v", resp.Fields.NeedsValue) + } + if resp.Destination.CollectionSlug != collB.Slug { + t.Errorf("destination = %+v", resp.Destination) + } +} + +// TestCopyPreflight_LegacyParentIDColumnIsReported — an item created +// through the API with a raw `parent_id` has NO parent item_links row, so +// the link scan alone misses it. CopyItemAcrossWorkspaces scrubs the +// column anyway, which would drop a parent the preview never announced +// (Codex round 11). +func TestCopyPreflight_LegacyParentIDColumnIsReported(t *testing.T) { + f := newCopyPreflightFixture(t) + + parent := mustItem(t, f.srv, f.wsA.ID, f.collA.ID, "Column Parent") + withColumn, err := f.srv.store.CreateItem(f.wsA.ID, f.collA.ID, models.ItemCreate{ + Title: "Has parent_id", Fields: `{"status":"open"}`, + ParentID: &parent.ID, CreatedBy: f.owner.ID, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + // Sanity: no item_links row exists, so only the column can carry it. + if links, lErr := f.srv.store.GetItemLinks(withColumn.ID); lErr != nil || len(links) != 0 { + t.Fatalf("fixture: expected no item_links, got %d (err=%v)", len(links), lErr) + } + + prev := f.source + f.source = withColumn + defer func() { f.source = prev }() + + if !f.ok(f.baseBody()).Warnings.DroppedParent { + t.Error("dropped_parent must be true for an item whose parent lives in the legacy parent_id column") + } + + // The mirror-image gap: from the PARENT's side, that same row is a + // child GetChildItems cannot see, so child_count (and, on the move + // path, children_orphaned) would miss it. + f.source = parent + body := f.baseBody() + body["archive_source"] = true + w := f.ok(body).Warnings + if w.ChildCount != 1 { + t.Errorf("child_count = %d, want 1 — a child linked only by the legacy parent_id column", w.ChildCount) + } + if !w.ChildrenOrphaned { + t.Error("children_orphaned must be true on the move path for a column-linked child") + } + + // A child carrying BOTH a link row and the column counts once. + if _, err := f.srv.store.SetParentLink(f.wsA.ID, withColumn.ID, parent.ID, f.owner.ID); err != nil { + t.Fatalf("SetParentLink: %v", err) + } + if got := f.ok(body).Warnings.ChildCount; got != 1 { + t.Errorf("child_count = %d, want 1 — a child with both a link and the column must not double-count", got) + } + + // A column value pointing at another workspace is NOT honoured — the + // column carries no foreign key, so it is attacker-influenced state. + foreign := mustItem(t, f.srv, f.wsB.ID, f.collB.ID, "Foreign Parent") + crossWS, err := f.srv.store.CreateItem(f.wsA.ID, f.collA.ID, models.ItemCreate{ + Title: "Foreign parent_id", Fields: `{"status":"open"}`, + ParentID: &foreign.ID, CreatedBy: f.owner.ID, + }) + if err != nil { + t.Fatalf("CreateItem(cross-ws): %v", err) + } + f.source = crossWS + if f.ok(f.baseBody()).Warnings.DroppedParent { + t.Error("a parent_id pointing outside the source workspace must not be reported as a dropped parent") + } +} diff --git a/internal/server/handlers_items_copy_test.go b/internal/server/handlers_items_copy_test.go new file mode 100644 index 00000000..02a46676 --- /dev/null +++ b/internal/server/handlers_items_copy_test.go @@ -0,0 +1,1698 @@ +package server + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/PerpetualSoftware/pad/internal/events" + "github.com/PerpetualSoftware/pad/internal/models" + "github.com/PerpetualSoftware/pad/internal/store" + "github.com/PerpetualSoftware/pad/internal/webhooks" +) + +// Tests for the mutating cross-workspace copy (PLAN-2357 / TASK-2365). +// +// The fixture is the PREFLIGHT's, deliberately and not for convenience: the +// headline deliverable of this task is that the two endpoints agree about +// what a copy persists, and an agreement test run against two different +// fixtures proves nothing. Everything below therefore drives one server, one +// source item and one destination schema through both routes. +// +// Sections: +// +// 1. DR-6 agreement — the preflight's `carried` bucket IS the copy's +// persisted field map, for the two reconciled divergences and in +// general. +// 2. DR-14 fanout — the asymmetric matrix, with seq attribution. +// 3. DR-9 / DR-10a / DR-10b — the four denials, and the in-transaction +// re-check. +// 4. DR-13 — exactly one store invocation, never a retry. +// 5. Caller obligations — storageInfoCache, EnforceItemLimit. + +// --- driving the copy endpoint ----------------------------------------- + +// callCopy issues the MUTATION exactly as the router would, with the same +// context stashing f.call uses for the preflight. Sharing the shape matters: +// a difference in how the two are invoked would weaken every agreement +// assertion below. +func (f *copyPreflightFixture) callCopy(user *models.User, o reqOpts, body map[string]any) *httptest.ResponseRecorder { + f.t.Helper() + raw, err := json.Marshal(body) + if err != nil { + f.t.Fatalf("marshal body: %v", err) + } + r := httptest.NewRequest("POST", + "/api/v1/workspaces/"+f.wsA.Slug+"/items/"+f.source.Slug+"/copy", + bytes.NewReader(raw)) + r.Header.Set("Content-Type", "application/json") + + 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") + } + + rr := httptest.NewRecorder() + f.srv.handleCopyItem(rr, r) + return rr +} + +// copyOK issues the copy as the owner and requires a 201. +func (f *copyPreflightFixture) copyOK(body map[string]any) ItemCopyResult { + f.t.Helper() + rr := f.callCopy(f.owner, reqOpts{}, body) + if rr.Code != http.StatusCreated { + f.t.Fatalf("copy: expected 201, got %d: %s", rr.Code, rr.Body.String()) + } + var out ItemCopyResult + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + f.t.Fatalf("parse copy result: %v\nbody: %s", err, rr.Body.String()) + } + return out +} + +// resolvableBody is a request the copy can actually satisfy: the two +// needs_value entries the base fixture leaves open are supplied, so the +// preflight comes back valid=true and the copy comes back 201. Anything a +// test wants to vary it layers on top. +func (f *copyPreflightFixture) resolvableBody() map[string]any { + b := f.baseBody() + b["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123"} + return b +} + +// persistedFields reads the destination item's fields blob back out of the +// database. Deliberately NOT the response body: the contract being asserted +// is what the copy STORED, and a handler that echoed the right thing while +// writing the wrong thing would pass a response-only check. +func (f *copyPreflightFixture) persistedFields(itemID string) map[string]any { + f.t.Helper() + item, err := f.srv.store.GetItem(itemID) + if err != nil || item == nil { + f.t.Fatalf("GetItem(%s): %v (nil=%v)", itemID, err, item == nil) + } + var out map[string]any + if err := json.Unmarshal([]byte(item.Fields), &out); err != nil { + f.t.Fatalf("parse persisted fields %q: %v", item.Fields, err) + } + return out +} + +// --- 1. DR-6: the preview IS the copy ---------------------------------- + +// assertPreflightMatchesCopy is the general form of this task's headline +// deliverable, and every specific divergence case below is a call to it. +// +// It sends ONE body to BOTH endpoints and asserts that the preflight's +// `carried` bucket — key set AND values — is exactly the field map the copy +// persisted. That is the whole of DR-6 stated as an executable claim: "one +// answer, two consumers". A divergence in either direction fails it, so it +// catches a THIRD disagreement nobody has named yet as readily as the two +// that were. +// +// Values are compared after a JSON round-trip on both sides (the preflight's +// come through the response encoder; the copy's through items.fields), so +// numbers are float64 on both and the comparison is apples to apples. +func assertPreflightMatchesCopy(t *testing.T, f *copyPreflightFixture, label string, body map[string]any) ItemCopyResult { + t.Helper() + + pre := f.ok(body) + if !pre.Valid { + t.Fatalf("%s: preflight is not valid, so the copy cannot be expected to succeed: %+v", + label, pre.Fields.NeedsValue) + } + want := make(map[string]any, len(pre.Fields.Carried)) + for _, c := range pre.Fields.Carried { + want[c.Key] = c.Value + } + + res := f.copyOK(body) + got := f.persistedFields(res.Item.ID) + + if !reflect.DeepEqual(want, got) { + t.Fatalf("%s: the preflight and the copy disagree about what is persisted.\n"+ + " preflight carried: %#v\n copy persisted: %#v", label, want, got) + } + return res +} + +// TestCopyEndpoint_PreflightAndCopyAgreeOnNullOverride is divergence #1, +// closed. +// +// Before TASK-2365, Store.migrateCopyFields did `migrated.Fields[k] = v` for +// EVERY override including a nil one. items.ValidateFields treats a nil value +// as absent for the required check but LEAVES IT IN THE MAP, so the copy +// persisted a literal `"key": null` — a value the preflight had just reported +// as unset, and which also suppressed the destination schema's default. The +// store now deletes the key, exactly as the preflight's merge loop does. +// +// Two shapes, because they fail differently: +// +// - `tier` HAS a destination default, so nulling it must re-apply the +// default ("a"), not persist null and not leave the key absent; +// - `priority` is optional with NO default, so nulling it must leave the +// key genuinely ABSENT — not present-and-null. +func TestCopyEndpoint_PreflightAndCopyAgreeOnNullOverride(t *testing.T) { + t.Run("defaulted field re-defaults", func(t *testing.T) { + f := newCopyPreflightFixture(t) + body := f.resolvableBody() + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123", "tier": nil} + + res := assertPreflightMatchesCopy(t, f, "null override on a defaulted field", body) + + got := f.persistedFields(res.Item.ID) + if v, ok := got["tier"]; !ok || v != "a" { + t.Fatalf("tier = %v (present=%v), want the destination default %q", v, ok, "a") + } + }) + + t.Run("undefaulted optional field is absent, not null", func(t *testing.T) { + f := newCopyPreflightFixture(t) + body := f.resolvableBody() + body["field_overrides"] = map[string]any{"ticket": "T-1", "code": "123", "priority": nil} + + res := assertPreflightMatchesCopy(t, f, "null override on an undefaulted field", body) + + got := f.persistedFields(res.Item.ID) + v, present := got["priority"] + if present { + t.Fatalf("priority is present as %#v; a null override must DELETE the key, "+ + "not persist a literal null the preview reported as unset", v) + } + + // The raw blob is checked too. reflect.DeepEqual over the decoded map + // would also catch `"priority":null` (it decodes to a present nil), but + // the byte-level assertion is the one that reads as the regression. + item, err := f.srv.store.GetItem(res.Item.ID) + if err != nil || item == nil { + t.Fatalf("GetItem: %v", err) + } + if bytes.Contains([]byte(item.Fields), []byte(`"priority":null`)) { + t.Fatalf("persisted fields carry a literal JSON null: %s", item.Fields) + } + }) +} + +// TestCopyEndpoint_PreflightAndCopyAgreeOnUndeclaredOverride is divergence +// #2, closed. +// +// The preflight always refused an override naming a field the destination +// schema does not declare (400 malformed_override). The copy merged it, and +// items.ValidateFields ignores keys the schema does not declare, so the copy +// PERSISTED an orphan key on the new item. The gate now exists on the store +// side too, and the two refusals must be indistinguishable to a client: same +// status, same code. +// +// The negative half matters as much as the positive: nothing may be created. +// "Refused" and "created it anyway without the key" would both produce a +// non-201 if only the status were checked. +func TestCopyEndpoint_PreflightAndCopyAgreeOnUndeclaredOverride(t *testing.T) { + f := newCopyPreflightFixture(t) + + body := f.resolvableBody() + body["field_overrides"] = map[string]any{ + "ticket": "T-1", "code": "123", "not_a_field": "x", "also_not": 7, + } + + before := f.snapshot() + + preRR := f.call(f.owner, reqOpts{}, body) + copyRR := f.callCopy(f.owner, reqOpts{}, body) + + if preRR.Code != http.StatusBadRequest { + t.Fatalf("preflight: expected 400, got %d: %s", preRR.Code, preRR.Body.String()) + } + if copyRR.Code != preRR.Code { + t.Fatalf("the copy answered %d where the preflight answered %d — the preview lies about "+ + "whether the request is acceptable:\n preflight: %s\n copy: %s", + copyRR.Code, preRR.Code, preRR.Body.String(), copyRR.Body.String()) + } + if pc, cc := errCode(t, preRR), errCode(t, copyRR); pc != cc { + t.Fatalf("error codes differ: preflight %q, copy %q", pc, cc) + } + if got := errCode(t, copyRR); got != "malformed_override" { + t.Fatalf("copy error code = %q, want malformed_override", got) + } + + // Both offending keys are named, so a client can fix its request. + for _, key := range []string{"also_not", "not_a_field"} { + if !bytes.Contains(copyRR.Body.Bytes(), []byte(key)) { + t.Errorf("the refusal does not name the offending key %q: %s", key, copyRR.Body.String()) + } + } + + // And nothing was written — the store must refuse before the insert, not + // create the item and drop the key. + if after := f.snapshot(); after != before { + t.Fatalf("a refused copy mutated state:\n before: %+v\n after: %+v", before, after) + } +} + +// TestCopyEndpoint_PreflightAndCopyAgreeGenerally sweeps the agreement +// assertion across the request shapes a dialog can produce, so a THIRD +// divergence has to survive more than the two cases that were named. +// +// Each body is run against a FRESH fixture: the copy mutates, and a shared +// one would let an earlier case's write (a destination item that changes the +// unique-slug space, say) decide a later one. +func TestCopyEndpoint_PreflightAndCopyAgreeGenerally(t *testing.T) { + cases := []struct { + name string + overrides map[string]any + archive bool + }{ + { + name: "minimum resolvable set", + overrides: map[string]any{"ticket": "T-1", "code": "123"}, + }, + { + name: "override replaces a value that would have migrated", + // priority carries "low" from the source; the override must win, + // and both endpoints must agree it did. + overrides: map[string]any{"ticket": "T-1", "code": "123", "priority": "high"}, + }, + { + name: "override supplies a key the destination defaults", + // tier has a default; an explicit value must beat it. + overrides: map[string]any{"ticket": "T-1", "code": "123", "tier": "b"}, + }, + { + name: "override targets a key migration DROPPED", + // count is number→select and drops. Overriding it with a legal + // destination value must produce a carried field, identically on + // both paths. + overrides: map[string]any{"ticket": "T-1", "code": "123", "count": "y"}, + }, + { + name: "the move path", + overrides: map[string]any{"ticket": "T-1", "code": "123"}, + archive: true, + }, + { + name: "every override at once, including a null", + overrides: map[string]any{ + "ticket": "T-1", "code": "456", "priority": "high", + "count": "x", "tier": nil, "status": "done", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newCopyPreflightFixture(t) + body := f.baseBody() + body["field_overrides"] = tc.overrides + body["archive_source"] = tc.archive + assertPreflightMatchesCopy(t, f, tc.name, body) + }) + } +} + +// TestCopyEndpoint_PreflightAndCopyAgreeWithNoAttributableActor is the THIRD +// divergence, found by hunting for one (Codex round 4) rather than because it +// was already documented. +// +// The preflight used to fall back to a literal `"preflight"` actor when there +// was no authenticated user and the source item had no creator. That is +// harmless on a path that writes nothing — and that is exactly why it was +// wrong: the preview happily described a copy the store would refuse outright +// for want of an actor, which the handler would then have reported as the +// ambiguous "it may or may not have landed" 500 for an operation that never +// began. Both endpoints now resolve the actor through one function and refuse +// identically. +// +// Reachable only on a fresh install (no users at all, everything open until +// `pad auth setup`) holding an item whose created_by is empty — which no +// current write path produces, since CreateItem defaults it to "user". Narrow, +// but the failure mode it produced was a lie about a mutation. +// +// This is also the coverage for the PREFLIGHT's actor_required branch: it +// drives handleCopyItemPreflight directly and requires its answer to be +// byte-identical to the copy's, which is a stronger assertion than a separate +// preflight-only test would make (Codex round 10). +func TestCopyEndpoint_PreflightAndCopyAgreeWithNoAttributableActor(t *testing.T) { + // A FRESH INSTALL: zero users, so crossWorkspaceRole synthesises "owner" + // for a nil-user caller and the four-check ladder passes. That is the only + // state in which the actor can be unresolvable — with any user row + // present, a nil-user caller is denied long before this matters. + srv := testServer(t) + mustFreshWS := func(name string) *models.Workspace { + // CreateWorkspace WITHOUT the membership row mustWorkspace adds: with + // no users there is nobody to be a member, and workspace_members has a + // foreign key onto users. + ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: name}) + if err != nil { + t.Fatalf("CreateWorkspace(%s): %v", name, err) + } + return ws + } + wsA := mustFreshWS("Fresh Source WS") + wsB := mustFreshWS("Fresh Dest WS") + collA := mustSchemaCollection(t, srv, wsA.ID, "Fresh A", srcSchemaJSON) + collB := mustSchemaCollection(t, srv, wsB.ID, "Fresh B", srcSchemaJSON) + + source, err := srv.store.CreateItem(wsA.ID, collA.ID, models.ItemCreate{ + Title: "Unattributable", Fields: `{"status":"open"}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + if n, err := srv.store.UserCount(); err != nil || n != 0 { + t.Fatalf("fixture precondition: want a fresh install with 0 users, got %d (%v)", n, err) + } + // CreateItem defaults created_by to "user", so the unattributable state + // has to be forced. That is the point of the case: it is legacy/corrupt + // data, not something a write path produces. + if _, err := srv.store.DB().Exec( + srv.store.D().Rebind(`UPDATE items SET created_by = '' WHERE id = ?`), source.ID, + ); err != nil { + t.Fatalf("blank created_by: %v", err) + } + + callWith := func(path string, h http.HandlerFunc, overrides map[string]any) *httptest.ResponseRecorder { + payload := map[string]any{ + "target_workspace": wsB.Slug, "target_collection": collB.Slug, + } + if overrides != nil { + payload["field_overrides"] = overrides + } + body, mErr := json.Marshal(payload) + if mErr != nil { + t.Fatalf("marshal: %v", mErr) + } + r := httptest.NewRequest("POST", path, bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + ctx := contextWithWorkspaceRoleForTest(r.Context(), "owner") + ctx = contextWithResolvedWorkspaceIDForTest(ctx, wsA.ID) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", wsA.Slug) + rctx.URLParams.Add("itemSlug", source.Slug) + ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) + rr := httptest.NewRecorder() + h(rr, r.WithContext(ctx)) + return rr + } + call := func(path string, h http.HandlerFunc) *httptest.ResponseRecorder { + return callWith(path, h, nil) + } + + var itemsBefore int + if err := srv.store.DB().QueryRow( + srv.store.D().Rebind(`SELECT COUNT(*) FROM items`)).Scan(&itemsBefore); err != nil { + t.Fatalf("count: %v", err) + } + + base := "/api/v1/workspaces/" + wsA.Slug + "/items/" + source.Slug + preRR := call(base+"/copy/preflight", srv.handleCopyItemPreflight) + copyRR := call(base+"/copy", srv.handleCopyItem) + + if preRR.Code != copyRR.Code || preRR.Body.String() != copyRR.Body.String() { + t.Fatalf("the preview and the copy answer differently when there is nobody to attribute to:\n"+ + " preflight: %d %s\n copy: %d %s", + preRR.Code, preRR.Body.String(), copyRR.Code, copyRR.Body.String()) + } + if copyRR.Code != http.StatusForbidden || errCode(t, copyRR) != "actor_required" { + t.Fatalf("got %d %s, want 403 actor_required", copyRR.Code, copyRR.Body.String()) + } + // Never the ambiguous 500: nothing was attempted, so telling the user to + // go check the destination would be false. + if bytes.Contains(copyRR.Body.Bytes(), []byte("check the destination")) { + t.Errorf("an unattempted copy produced the ambiguous-outcome message: %s", copyRR.Body.String()) + } + + var itemsAfter int + if err := srv.store.DB().QueryRow( + srv.store.D().Rebind(`SELECT COUNT(*) FROM items`)).Scan(&itemsAfter); err != nil { + t.Fatalf("count: %v", err) + } + if itemsAfter != itemsBefore { + t.Errorf("a refused copy created %d item(s)", itemsAfter-itemsBefore) + } + + // The two must also agree on the ORDER of refusals, not just the set. A + // request that fails BOTH ways — no actor AND an override naming a field + // the destination does not declare — must get the same answer from both, + // or a dialog fixes the override and is then told about the actor by only + // one of them (Codex round 5). + bad := map[string]any{"not_a_field": "x"} + preBoth := callWith(base+"/copy/preflight", srv.handleCopyItemPreflight, bad) + copyBoth := callWith(base+"/copy", srv.handleCopyItem, bad) + if preBoth.Code != copyBoth.Code || preBoth.Body.String() != copyBoth.Body.String() { + t.Fatalf("the preview and the copy refuse a doubly-invalid request differently:\n"+ + " preflight: %d %s\n copy: %d %s", + preBoth.Code, preBoth.Body.String(), copyBoth.Code, copyBoth.Body.String()) + } + + // CONTROL: with a real creator on the row, the same fresh-install caller + // copies successfully — so the 403 above is about the missing actor and + // not about the fresh-install state itself. + if _, err := srv.store.DB().Exec( + srv.store.D().Rebind(`UPDATE items SET created_by = 'user' WHERE id = ?`), source.ID, + ); err != nil { + t.Fatalf("restore created_by: %v", err) + } + if rr := call(base+"/copy", srv.handleCopyItem); rr.Code != http.StatusCreated { + t.Fatalf("control copy: got %d, want 201: %s", rr.Code, rr.Body.String()) + } +} + +// TestCopyEndpoint_PreflightWarningsMatchTheCopy checks the OTHER half of +// the shared answer: the attachment numbers. The planner is shared precisely +// so the preview's byte total is the copy's byte total, and a client shows +// the user that number before they agree. +func TestCopyEndpoint_PreflightWarningsMatchTheCopy(t *testing.T) { + f := newCopyPreflightFixture(t) + + att := &models.Attachment{ + WorkspaceID: f.wsA.ID, UploadedBy: f.owner.ID, + StorageKey: "fs:deadbeef", ContentHash: "deadbeef", + MimeType: "image/png", SizeBytes: 4096, Filename: "shared.png", + } + if err := f.srv.store.CreateAttachment(att); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + // One resolvable ref and one that resolves to nothing, so both counters + // are exercised. + content := "![](pad-attachment:" + att.ID + ") and ![](pad-attachment:00000000-0000-4000-8000-000000000000)" + if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{Content: &content}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + + body := f.resolvableBody() + pre := f.ok(body) + res := f.copyOK(body) + + if pre.Warnings.AttachmentCount != res.Warnings.AttachmentCount { + t.Errorf("attachment_count: preflight %d, copy %d", + pre.Warnings.AttachmentCount, res.Warnings.AttachmentCount) + } + if pre.Warnings.AttachmentBytes != res.Warnings.AttachmentBytes { + t.Errorf("attachment_bytes: preflight %d, copy %d", + pre.Warnings.AttachmentBytes, res.Warnings.AttachmentBytes) + } + if pre.Warnings.UnresolvableRefCount != res.Warnings.UnresolvableRefCount { + t.Errorf("unresolvable_ref_count: preflight %d, copy %d", + pre.Warnings.UnresolvableRefCount, res.Warnings.UnresolvableRefCount) + } + if res.Warnings.AttachmentCount != 1 || res.Warnings.UnresolvableRefCount != 1 { + t.Fatalf("fixture did not exercise both counters: %+v", res.Warnings) + } +} + +// --- 2. DR-14: the fanout matrix --------------------------------------- + +// copyFanoutObserver captures all three emission channels for BOTH +// workspaces at once. The whole point of DR-14 is an ASYMMETRY, and an +// assertion that only watches the destination cannot see it. +type copyFanoutObserver struct { + t *testing.T + f *copyPreflightFixture + chA chan events.Event + chB chan events.Event + + hooks chan webhookDelivery + recvA *httptest.Server + recvB *httptest.Server + baseline map[string]int +} + +type webhookDelivery struct { + workspace string // "A" or "B" + event string +} + +func newCopyFanoutObserver(t *testing.T, f *copyPreflightFixture) *copyFanoutObserver { + t.Helper() + o := ©FanoutObserver{ + t: t, f: f, + chA: f.bus.Subscribe(f.wsA.ID), + chB: f.bus.Subscribe(f.wsB.ID), + hooks: make(chan webhookDelivery, 32), + } + t.Cleanup(func() { + f.bus.Unsubscribe(o.chA) + f.bus.Unsubscribe(o.chB) + }) + + d := webhooks.NewDispatcher(f.srv.store) + d.SkipSSRF = true // loopback httptest receivers + f.srv.SetWebhookDispatcher(d) + + for _, side := range []struct { + label string + ws *models.Workspace + dst **httptest.Server + }{{"A", f.wsA, &o.recvA}, {"B", f.wsB, &o.recvB}} { + label := side.label + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload struct { + Event string `json:"event"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + o.hooks <- webhookDelivery{workspace: label, event: payload.Event} + w.WriteHeader(http.StatusOK) + })) + *side.dst = srv + t.Cleanup(srv.Close) + if _, err := f.srv.store.CreateWebhook(side.ws.ID, models.WebhookCreate{ + URL: srv.URL, Events: `["*"]`, + }); err != nil { + t.Fatalf("CreateWebhook(%s): %v", label, err) + } + } + + o.baseline = o.activityCounts() + return o +} + +// activityCounts returns per-workspace ":" counts, so an +// assertion can name exactly which row it expects where. +func (o *copyFanoutObserver) activityCounts() map[string]int { + o.t.Helper() + out := map[string]int{} + for label, ws := range map[string]*models.Workspace{"A": o.f.wsA, "B": o.f.wsB} { + rows, err := o.f.srv.store.ListWorkspaceActivity(ws.ID, models.ActivityListParams{Limit: 500}) + if err != nil { + o.t.Fatalf("ListWorkspaceActivity(%s): %v", label, err) + } + for _, a := range rows { + out[label+":"+a.Action]++ + } + } + return out +} + +// newActivity is the activity delta since the observer was created. +func (o *copyFanoutObserver) newActivity() map[string]int { + o.t.Helper() + now := o.activityCounts() + delta := map[string]int{} + for k, v := range now { + if d := v - o.baseline[k]; d > 0 { + delta[k] = d + } + } + return delta +} + +// drainSSE collects everything published to a workspace's channel, giving +// stragglers a short window to arrive. +func drainSSE(ch chan events.Event) []events.Event { + var out []events.Event + deadline := time.After(fanoutQuietWindow) + for { + select { + case ev := <-ch: + out = append(out, ev) + case <-deadline: + return out + } + } +} + +// drainWebhooks collects deliveries over one quiet window. The window is a +// wait for something that must NOT arrive in half these assertions, so it is +// generous rather than tight; it only costs wall-clock time when passing. +func (o *copyFanoutObserver) drainWebhooks() []webhookDelivery { + var out []webhookDelivery + deadline := time.After(fanoutQuietWindow) + for { + select { + case d := <-o.hooks: + out = append(out, d) + case <-deadline: + return out + } + } +} + +const fanoutQuietWindow = 750 * time.Millisecond + +func eventTypes(evs []events.Event) []string { + out := make([]string, 0, len(evs)) + for _, e := range evs { + out = append(out, string(e.Type)) + } + sort.Strings(out) + return out +} + +func webhookEventsFor(ds []webhookDelivery, workspace string) []string { + var out []string + for _, d := range ds { + if d.workspace == workspace { + out = append(out, d.event) + } + } + sort.Strings(out) + return out +} + +// TestCopyEndpoint_PlainCopyEmitsNothingInTheSource is the strictest clause +// of DR-14, and the one an implementation is most likely to get wrong by +// being helpful: a plain copy touches NOTHING in workspace A, so it must +// emit nothing there. Not a "copied" activity row, not an ItemUpdated. A +// spurious event would tell A's watchers the source changed when it did not, +// and would push a delta cursor that is legitimately still valid. +func TestCopyEndpoint_PlainCopyEmitsNothingInTheSource(t *testing.T) { + f := newCopyPreflightFixture(t) + + // Push B's counter ahead of A's so the seq assertion below is capable of + // failing: with both workspaces on their first item the two seqs are + // equal and a crossed pair would be invisible (Codex round 24, the same + // trap round 20 found in the move test). + for i := 0; i < 3; i++ { + if _, err := f.srv.store.CreateItem(f.wsB.ID, f.collB.ID, models.ItemCreate{ + Title: fmt.Sprintf("Filler %d", i), + Fields: `{"status":"open","ticket":"T-0"}`, + }); err != nil { + t.Fatalf("CreateItem(filler %d): %v", i, err) + } + } + + o := newCopyFanoutObserver(t, f) + + seqABefore := f.maxSeq(f.wsA.ID) + res := f.copyOK(f.resolvableBody()) + + // --- destination: all three, and only those three --- + if got := o.newActivity(); !reflect.DeepEqual(got, map[string]int{"B:created": 1}) { + t.Errorf("activity delta = %v, want exactly {B:created:1}", got) + } + evB := drainSSE(o.chB) + if got := eventTypes(evB); !reflect.DeepEqual(got, []string{string(events.ItemCreated)}) { + t.Fatalf("workspace B SSE = %v, want exactly [%s]", got, events.ItemCreated) + } + // ...carrying B's OWN committed seq. The matrix is only half-asserted + // without this: DR-14 specifies the emission set AND the attribution, + // and a plain copy is the case where the source has no seq to cross with. + if wantB := f.maxSeq(f.wsB.ID); evB[0].Seq != wantB { + t.Errorf("workspace B's create event carries seq %d, want B's committed seq %d", + evB[0].Seq, wantB) + } + if evB[0].Seq == seqABefore { + t.Errorf("B's create event carries seq %d, which is also A's — fixture went vacuous", evB[0].Seq) + } + if res.Destination.Seq != evB[0].Seq { + t.Errorf("destination.seq %d disagrees with the event's %d", res.Destination.Seq, evB[0].Seq) + } + hooks := o.drainWebhooks() + if got := webhookEventsFor(hooks, "B"); !reflect.DeepEqual(got, []string{"item.created"}) { + t.Errorf("workspace B webhooks = %v, want exactly [item.created]", got) + } + + // --- source: nothing at all, on any channel --- + if got := drainSSE(o.chA); len(got) != 0 { + t.Errorf("a PLAIN copy published %d SSE event(s) in the source workspace: %+v", len(got), got) + } + if got := webhookEventsFor(hooks, "A"); len(got) != 0 { + t.Errorf("a PLAIN copy dispatched webhooks in the source workspace: %v", got) + } + + // ...and A's delta cursor must not have moved either (DR-14 seq clause). + if got := f.maxSeq(f.wsA.ID); got != seqABefore { + t.Errorf("a plain copy advanced workspace A's seq from %d to %d", seqABefore, got) + } + if res.Source.Archived { + t.Error("a plain copy reported source.archived = true") + } + if res.Source.Seq != 0 { + t.Errorf("a plain copy reported a source seq (%d); A never wrote", res.Source.Seq) + } +} + +// TestCopyEndpoint_MoveEmitsInBothWorkspaces is the other half of the +// matrix, plus the seq-ATTRIBUTION clause: each event carries its OWN +// workspace's committed seq, never the other's. +func TestCopyEndpoint_MoveEmitsInBothWorkspaces(t *testing.T) { + f := newCopyPreflightFixture(t) + + // FORCE THE TWO SEQ SPACES APART before anything is observed. Without + // this the assertion below is VACUOUS: the source is workspace A's first + // item and the copy is workspace B's first item, so both seqs are 1 and + // crossing them changes nothing (Codex round 20). Filling B's counter + // makes the destination's seq unmistakably B's. + for i := 0; i < 3; i++ { + if _, err := f.srv.store.CreateItem(f.wsB.ID, f.collB.ID, models.ItemCreate{ + Title: fmt.Sprintf("Filler %d", i), + Fields: `{"status":"open","ticket":"T-0"}`, + }); err != nil { + t.Fatalf("CreateItem(filler %d): %v", i, err) + } + } + + o := newCopyFanoutObserver(t, f) + + body := f.resolvableBody() + body["archive_source"] = true + res := f.copyOK(body) + + if got := o.newActivity(); !reflect.DeepEqual(got, map[string]int{"B:created": 1, "A:archived": 1}) { + t.Errorf("activity delta = %v, want exactly {B:created:1, A:archived:1}", got) + } + + evB := drainSSE(o.chB) + if got := eventTypes(evB); !reflect.DeepEqual(got, []string{string(events.ItemCreated)}) { + t.Fatalf("workspace B SSE = %v, want exactly [%s]", got, events.ItemCreated) + } + evA := drainSSE(o.chA) + if got := eventTypes(evA); !reflect.DeepEqual(got, []string{string(events.ItemArchived)}) { + t.Fatalf("workspace A SSE = %v, want exactly [%s]", got, events.ItemArchived) + } + + hooks := o.drainWebhooks() + if got := webhookEventsFor(hooks, "B"); !reflect.DeepEqual(got, []string{"item.created"}) { + t.Errorf("workspace B webhooks = %v, want [item.created]", got) + } + if got := webhookEventsFor(hooks, "A"); !reflect.DeepEqual(got, []string{"item.deleted"}) { + t.Errorf("workspace A webhooks = %v, want [item.deleted]", got) + } + + // --- seq attribution --- + // + // The two seq spaces are independent counters, so a crossed pair is not + // merely wrong, it is PLAUSIBLE — both numbers are small integers that + // look like they belong. Each is checked against the row it actually + // describes, read back from the database, and the filler items above + // guarantee the two values DIFFER so that crossing them fails. + wantB := f.maxSeq(f.wsB.ID) + var srcSeq int64 + if err := f.srv.store.DB().QueryRow( + f.srv.store.D().Rebind(`SELECT seq FROM items WHERE id = ?`), f.source.ID, + ).Scan(&srcSeq); err != nil { + t.Fatalf("read archived source seq: %v", err) + } + if wantB == srcSeq { + t.Fatalf("fixture precondition: both workspaces' seqs are %d, so a crossed pair would "+ + "pass every assertion below", wantB) + } + if evB[0].Seq != wantB { + t.Errorf("workspace B's create event carries seq %d, want B's committed seq %d "+ + "(A's is %d — check for a crossed pair)", evB[0].Seq, wantB, srcSeq) + } + if evA[0].Seq != srcSeq { + t.Errorf("workspace A's archive event carries seq %d, want A's committed archive seq %d "+ + "(B's is %d — check for a crossed pair)", evA[0].Seq, srcSeq, wantB) + } + // The RESPONSE carries the same two, and must not cross them either. + if res.Destination.Seq != wantB { + t.Errorf("destination.seq = %d, want B's %d", res.Destination.Seq, wantB) + } + if evA[0].ItemID != f.source.ID { + t.Errorf("A's event names item %s, want the SOURCE %s", evA[0].ItemID, f.source.ID) + } + if evB[0].ItemID != res.Item.ID { + t.Errorf("B's event names item %s, want the COPY %s", evB[0].ItemID, res.Item.ID) + } + + // The response carries what a client needs to navigate away from the + // source it just archived. + if !res.Source.Archived || res.Source.Seq != srcSeq { + t.Errorf("source block = %+v, want archived=true seq=%d", res.Source, srcSeq) + } + if res.Destination.Ref == "" || res.Destination.WorkspaceSlug != f.wsB.Slug { + t.Errorf("destination block = %+v, want a ref and workspace %q", res.Destination, f.wsB.Slug) + } +} + +// TestCopyEndpoint_RolledBackCopyEmitsNothing — the reason all fanout is +// post-commit. A refused copy must leave both workspaces exactly as they +// were and publish nothing anywhere; an implementation that emitted from +// inside the transaction would announce an item that does not exist. +// +// Two rollback shapes are exercised: one the store refuses before it writes +// (validation), and one the handler sees as an opaque store failure after +// the call (the DR-13 ambiguity path), because a fanout misplaced into a +// `defer` would survive the first and not the second. +func TestCopyEndpoint_RolledBackCopyEmitsNothing(t *testing.T) { + t.Run("store refuses the request", func(t *testing.T) { + f := newCopyPreflightFixture(t) + o := newCopyFanoutObserver(t, f) + before := f.snapshot() + + // No overrides: `ticket` is required in the destination with no + // default, so the in-tx validation refuses. + body := f.baseBody() + body["archive_source"] = true + rr := f.callCopy(f.owner, reqOpts{}, body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an unsatisfiable field map, got %d: %s", rr.Code, rr.Body.String()) + } + + assertNoFanout(t, f, o, before) + }) + + t.Run("store fails opaquely", func(t *testing.T) { + f := newCopyPreflightFixture(t) + o := newCopyFanoutObserver(t, f) + before := f.snapshot() + + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + return nil, errors.New("connection reset mid-commit") + } + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) + } + + assertNoFanout(t, f, o, before) + }) +} + +func assertNoFanout(t *testing.T, f *copyPreflightFixture, o *copyFanoutObserver, before worldSnapshot) { + t.Helper() + if after := f.snapshot(); after != before { + t.Errorf("a rolled-back copy mutated state:\n before: %+v\n after: %+v", before, after) + } + if got := o.newActivity(); len(got) != 0 { + t.Errorf("a rolled-back copy logged activity: %v", got) + } + if got := drainSSE(o.chA); len(got) != 0 { + t.Errorf("a rolled-back copy published in workspace A: %+v", got) + } + if got := drainSSE(o.chB); len(got) != 0 { + t.Errorf("a rolled-back copy published in workspace B: %+v", got) + } + if got := o.drainWebhooks(); len(got) != 0 { + t.Errorf("a rolled-back copy dispatched webhooks: %+v", got) + } +} + +// TestCopyEndpoint_FanoutHarnessIsRealPositiveControl — the negative +// assertions above are only worth anything if the harness CAN observe an +// emission. Without this, a receiver that never worked would make every +// "emitted nothing" check pass vacuously. +func TestCopyEndpoint_FanoutHarnessIsRealPositiveControl(t *testing.T) { + f := newCopyPreflightFixture(t) + o := newCopyFanoutObserver(t, f) + + f.srv.dispatchWebhook(f.wsA.ID, "item.created", map[string]string{"probe": "A"}) + f.srv.dispatchWebhook(f.wsB.ID, "item.created", map[string]string{"probe": "B"}) + + got := o.drainWebhooks() + if len(webhookEventsFor(got, "A")) == 0 || len(webhookEventsFor(got, "B")) == 0 { + t.Fatalf("the webhook harness cannot observe deliveries on both sides (%+v); "+ + "every negative fanout assertion in this file would pass vacuously", got) + } +} + +// --- 3. authorization --------------------------------------------------- + +// TestCopyEndpoint_AuthorizationDenials runs the four-check ladder against +// the MUTATION. It is the preflight's set, re-asserted here rather than +// assumed: the two handlers share no code for this, only a shape, and the +// mutation is the one where getting it wrong writes data. +// +// EVERY subtest snapshots the world and asserts it is unchanged, not just +// that the status is right. A denial that returns 403 after having written +// the item is the failure that matters, and a status-only assertion cannot +// see it (Codex round 6). +func TestCopyEndpoint_AuthorizationDenials(t *testing.T) { + t.Run("source item not visible", func(t *testing.T) { + f := newCopyPreflightFixture(t) + otherA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Other A", srcSchemaJSON) + u := f.restrictedEditor("copy-hidden-src@example.com", "copyhiddensrc", + []string{otherA.ID}, []string{f.collB.ID}) + + before := f.snapshot() + rr := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, f.resolvableBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) + } + assertDisclosesNothing(t, rr, f) + if after := f.snapshot(); after != before { + t.Fatal("a refused copy wrote something") + } + }) + + t.Run("source edit denied", func(t *testing.T) { + f := newCopyPreflightFixture(t) + viewer := mustUser(t, f.srv, "copy-viewer-a@example.com", "copyviewera", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, viewer.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, viewer.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + before := f.snapshot() + rr := f.callCopy(viewer, reqOpts{wsRoleCtx: "viewer"}, f.resolvableBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) + } + assertDisclosesNothing(t, rr, f) + if after := f.snapshot(); after != before { + t.Fatal("a refused copy wrote something") + } + }) + + t.Run("destination collection not visible is indistinguishable from absent", func(t *testing.T) { + f := newCopyPreflightFixture(t) + u := f.restrictedEditor("copy-hidden-dst@example.com", "copyhiddendst", + []string{f.collA.ID}, []string{f.collB.ID}) + + before := f.snapshot() + body := f.resolvableBody() + body["target_collection"] = f.hiddenB.Slug + hiddenRR := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, body) + + body = f.resolvableBody() + body["target_collection"] = "definitely-not-a-collection" + absentRR := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, body) + + if after := f.snapshot(); after != before { + t.Fatal("a refused copy wrote something") + } + if hiddenRR.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", hiddenRR.Code, hiddenRR.Body.String()) + } + if hiddenRR.Code != absentRR.Code || hiddenRR.Body.String() != absentRR.Body.String() { + t.Fatalf("hidden and absent destination collections are distinguishable:\n hidden: %d %s\n absent: %d %s", + hiddenRR.Code, hiddenRR.Body.String(), absentRR.Code, absentRR.Body.String()) + } + }) + + t.Run("destination edit denied", func(t *testing.T) { + f := newCopyPreflightFixture(t) + u := mustUser(t, f.srv, "copy-viewer-b@example.com", "copyviewerb", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if err := f.srv.store.AddWorkspaceMember(f.wsB.ID, u.ID, "viewer"); err != nil { + t.Fatalf("AddWorkspaceMember B: %v", err) + } + before := f.snapshot() + rr := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, f.resolvableBody()) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) + } + if after := f.snapshot(); after != before { + t.Fatal("a refused copy wrote something") + } + }) + + t.Run("source check runs before destination", func(t *testing.T) { + f := newCopyPreflightFixture(t) + otherA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Other A", srcSchemaJSON) + u := mustUser(t, f.srv, "copy-both-fail@example.com", "copybothfail", "") + if err := f.srv.store.AddWorkspaceMember(f.wsA.ID, u.ID, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember A: %v", err) + } + if err := f.srv.store.SetMemberCollectionAccess(f.wsA.ID, u.ID, "specific", []string{otherA.ID}); err != nil { + t.Fatalf("SetMemberCollectionAccess: %v", err) + } + before := f.snapshot() + rr := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, f.resolvableBody()) + // The caller is a stranger to workspace B, so the DESTINATION check + // would refuse too — with a 403 and a different body. Getting the + // source's 404 is therefore evidence about ORDER, not merely about + // the set of checks: the two verdicts are distinguishable. + if rr.Code != http.StatusNotFound { + t.Fatalf("expected the SOURCE's 404 to win, got %d: %s", rr.Code, rr.Body.String()) + } + assertDisclosesNothing(t, rr, f) + if after := f.snapshot(); after != before { + t.Fatal("a refused copy wrote something") + } + }) + + t.Run("token allow-list is enforced on the destination", func(t *testing.T) { + f := newCopyPreflightFixture(t) + before := f.snapshot() + rr := f.callCopy(f.owner, reqOpts{ + bearer: true, setAllowed: true, allowed: []string{f.wsA.Slug}, + }, f.resolvableBody()) + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", rr.Code, rr.Body.String()) + } + if got := errCode(t, rr); got != "permission_denied" { + t.Errorf("error code = %q, want permission_denied", got) + } + if after := f.snapshot(); after != before { + t.Fatal("a consent-denied copy wrote something") + } + }) + + t.Run("archived source reports 409", func(t *testing.T) { + f := newCopyPreflightFixture(t) + if err := f.srv.store.DeleteItem(f.source.ID); err != nil { + t.Fatalf("DeleteItem: %v", err) + } + before := f.snapshot() + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusConflict || errCode(t, rr) != "archived" { + t.Fatalf("expected 409 archived, got %d %s", rr.Code, rr.Body.String()) + } + if after := f.snapshot(); after != before { + t.Fatal("a refused copy wrote something") + } + }) +} + +// TestCopyEndpoint_InTransactionReCheckRefuses is DR-9's re-check, driven +// END TO END rather than by calling the closure directly. +// +// The race is made deterministic with the copyItemFn seam: the source item +// is moved into a DIFFERENT collection after the handler's four-check ladder +// has already passed and before the store is entered, which is precisely the +// interleaving the re-check exists for. Without the store invoking PreCheck +// — or invoking it before it re-reads the source under the locks, or after +// the first write — the copy would go through and this test fails on both +// the status and the destination's item count (Codex round 6: the previous +// version called the closure directly and proved nothing about the store's +// integration). +// +// The item is moved into a collection the caller CANNOT see, so the refusal +// is also the real DR-10b escalation and not merely a bookkeeping mismatch. +func TestCopyEndpoint_InTransactionReCheckRefuses(t *testing.T) { + f := newCopyPreflightFixture(t) + + hiddenA := mustSchemaCollection(t, f.srv, f.wsA.ID, "Hidden A", srcSchemaJSON) + u := f.restrictedEditor("copy-recheck@example.com", "copyrecheck", + []string{f.collA.ID}, []string{f.collB.ID}) + + // Baseline: this caller CAN copy, so the refusal below is about the + // re-check and not about the caller. + if rr := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, f.resolvableBody()); rr.Code != http.StatusCreated { + t.Fatalf("baseline copy should succeed, got %d: %s", rr.Code, rr.Body.String()) + } + + // Counted rather than snapshotted: the interleaved MoveItem below is a + // deliberate write of its own (it bumps workspace A's seq), so a + // whole-world snapshot would flag the setup instead of the copy. What + // must not change is what the COPY would have produced. + destItems := func() int { + return f.countRows(`SELECT COUNT(*) FROM items WHERE workspace_id = '` + f.wsB.ID + `'`) + } + beforeDest := destItems() + beforeMoves := f.countRows(`SELECT COUNT(*) FROM item_workspace_moves`) + + f.srv.copyItemFn = func(req store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + // Lands AFTER the handler authorized the item in collA and BEFORE the + // store takes its locks and re-reads it. + if _, err := f.srv.store.MoveItem(f.source.ID, hiddenA.ID, f.source.Fields); err != nil { + return nil, err + } + return f.srv.store.CopyItemAcrossWorkspaces(req) + } + + rr := f.callCopy(u, reqOpts{wsRoleCtx: "editor"}, f.resolvableBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("a source that moved into a hidden collection under the lock was copied anyway "+ + "(status %d): %s", rr.Code, rr.Body.String()) + } + // The SOURCE side's non-disclosing refusal, not a 500 and not a message + // that names the collection it moved into. + if errCode(t, rr) != "not_found" { + t.Errorf("error code = %q, want the source side's not_found", errCode(t, rr)) + } + assertDisclosesNothing(t, rr, f) + + // Nothing landed. This is the assertion that fails if the store stopped + // calling PreCheck, called it before re-reading the source under the + // lock, or called it after the first write. + if got := destItems(); got != beforeDest { + t.Fatalf("the refused copy created %d item(s) in the destination", got-beforeDest) + } + if got := f.countRows(`SELECT COUNT(*) FROM item_workspace_moves`); got != beforeMoves { + t.Fatalf("the refused copy wrote %d provenance row(s)", got-beforeMoves) + } +} + +// TestCopyEndpoint_ReCheckRefusesAForeignDestinationCollection is the other +// half of the invariant: the destination collection the copy locks must be +// the one that was authorized, in the workspace that was authorized. +func TestCopyEndpoint_ReCheckRefusesAForeignDestinationCollection(t *testing.T) { + f := newCopyPreflightFixture(t) + + precheck := copyResourceInvariantPreCheck(copyAuthorizedResources{ + sourceItemID: f.source.ID, + sourceWorkspaceID: f.wsA.ID, + sourceCollectionID: f.collA.ID, + targetCollectionID: f.collB.ID, + targetWorkspaceID: f.wsB.ID, + }) + + if err := precheck(nil, f.source, f.collB); err != nil { + t.Fatalf("the unchanged case must pass: %v", err) + } + for _, tc := range []struct { + name string + coll *models.Collection + side string + }{ + {"a different collection entirely", f.hiddenB, "destination"}, + {"the right id in the wrong workspace", &models.Collection{ID: f.collB.ID, WorkspaceID: f.wsA.ID}, "destination"}, + {"nil", nil, "destination"}, + } { + err := precheck(nil, f.source, tc.coll) + if err == nil { + t.Fatalf("%s: the re-check accepted it", tc.name) + } + // Returned bare — the STORE wraps it in CopyPreCheckError, which is + // what makes it a caller-facing rejection rather than a logged + // incident. That wrapping is pinned on the store side by + // TestCopyAcrossWorkspaces_PreCheckRefusalIsWrapped. + var denial *copyPreCheckDenial + if !errors.As(err, &denial) || denial.side != tc.side { + t.Fatalf("%s: denial = %+v, want side %q", tc.name, denial, tc.side) + } + } +} + +// TestCopyEndpoint_PreCheckIsActuallyWired guards the wiring rather than the +// logic: a PreCheck that is built and never passed to the store would leave +// every assertion above true and the guarantee absent. +func TestCopyEndpoint_PreCheckIsActuallyWired(t *testing.T) { + f := newCopyPreflightFixture(t) + + var got store.CrossWorkspaceCopyRequest + f.srv.copyItemFn = func(req store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + got = req + return f.srv.store.CopyItemAcrossWorkspaces(req) + } + f.copyOK(f.resolvableBody()) + + if got.PreCheck == nil { + t.Fatal("the copy request carries no PreCheck; DR-9's in-transaction re-check is not wired") + } + // ...and the one that was wired is bound to THIS request's resources, not + // a permissive stub: hand it a foreign item and it must refuse. + if err := got.PreCheck(nil, &models.Item{ + ID: "someone-elses-item", WorkspaceID: f.wsA.ID, CollectionID: f.collA.ID, + }, f.collB); err == nil { + t.Error("the wired PreCheck accepts an item other than the one that was authorized") + } + // Caller obligation from CrossWorkspaceCopyResult: s.cloudMode, never an + // unconditional true. testServer is self-hosted, so this must be false — + // forcing it on would apply free-tier item caps to self-hosted users. + if f.srv.cloudMode { + t.Fatal("fixture precondition: testServer should not be in cloud mode") + } + if got.EnforceItemLimit { + t.Error("EnforceItemLimit is true on a SELF-HOSTED server; it must track s.cloudMode") + } + // Left empty so the copy cannot refuse something its own preview + // accepted — the preflight does not set it either. + if got.TargetBackend != "" { + t.Errorf("TargetBackend = %q; the preflight leaves it empty and the two must agree", got.TargetBackend) + } +} + +// TestCopyEndpoint_EnforceItemLimitFollowsCloudMode is the other half of the +// same obligation. +func TestCopyEndpoint_EnforceItemLimitFollowsCloudMode(t *testing.T) { + f := newCopyPreflightFixture(t) + f.srv.cloudMode = true + + var got store.CrossWorkspaceCopyRequest + f.srv.copyItemFn = func(req store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + got = req + return f.srv.store.CopyItemAcrossWorkspaces(req) + } + f.copyOK(f.resolvableBody()) + + if !got.EnforceItemLimit { + t.Error("EnforceItemLimit is false in CLOUD mode; the destination quota would not be enforced") + } +} + +// --- 4. DR-13: never retry ---------------------------------------------- + +// TestCopyEndpoint_DoesNotRetryOnAmbiguousError is the executable form of +// DR-13. There is no idempotency key in v1, so a retry after a failure that +// may have committed produces a DUPLICATE item — and a client that timed out +// cannot tell the two apart. The store op must therefore be invoked exactly +// once per request, whatever comes back. +// +// Error shapes are varied on purpose: a plausible "transient" error is the +// one a future maintainer is most likely to add a retry for, so it is named +// here. +func TestCopyEndpoint_DoesNotRetryOnAmbiguousError(t *testing.T) { + for _, tc := range []struct { + name string + err error + }{ + {"looks transient", errors.New("driver: bad connection")}, + {"looks like a deadlock", errors.New("ERROR: deadlock detected (SQLSTATE 40P01)")}, + {"commit failed ambiguously", fmt.Errorf("copy item across workspaces: commit: %w", errors.New("i/o timeout"))}, + } { + t.Run(tc.name, func(t *testing.T) { + f := newCopyPreflightFixture(t) + calls := 0 + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + calls++ + return nil, tc.err + } + + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if calls != 1 { + t.Fatalf("the store op was invoked %d times; a mutating copy must never be retried "+ + "(there is no idempotency key, so a retry duplicates the item)", calls) + } + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String()) + } + if got := errCode(t, rr); got != "copy_failed" { + t.Errorf("error code = %q, want copy_failed", got) + } + // The message must send the user to LOOK, not to retry: the copy + // may well have landed. + if !bytes.Contains(rr.Body.Bytes(), []byte("check the destination")) { + t.Errorf("the ambiguous-failure message does not tell the user to check the destination: %s", + rr.Body.String()) + } + // And it must not leak the underlying error to the client. + if bytes.Contains(rr.Body.Bytes(), []byte("SQLSTATE")) || + bytes.Contains(rr.Body.Bytes(), []byte("bad connection")) { + t.Errorf("the 500 echoed the driver error: %s", rr.Body.String()) + } + }) + } +} + +// TestCopyEndpoint_TypedStoreErrorsMapToStatuses covers the mapping +// obligations CrossWorkspaceCopyResult documents, for the two that cannot be +// produced from a real fixture on a self-hosted SQLite server. +func TestCopyEndpoint_TypedStoreErrorsMapToStatuses(t *testing.T) { + f := newCopyPreflightFixture(t) + + t.Run("item limit renders the plan-limit payload", func(t *testing.T) { + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + return nil, &store.ItemLimitError{Result: &store.LimitResult{ + Feature: "items_per_workspace", Limit: 100, Current: 100, Plan: "free", + }} + } + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusForbidden || errCode(t, rr) != "plan_limit_exceeded" { + t.Fatalf("got %d %s, want 403 plan_limit_exceeded", rr.Code, rr.Body.String()) + } + }) + + t.Run("cross-backend attachments is a clear 4xx", func(t *testing.T) { + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + return nil, store.ErrCopyCrossBackendAttachments + } + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code < 400 || rr.Code >= 500 { + t.Fatalf("got %d, want a 4xx: %s", rr.Code, rr.Body.String()) + } + if errCode(t, rr) != "cross_backend_attachments" { + t.Fatalf("error code = %q, want cross_backend_attachments", errCode(t, rr)) + } + }) + + // MAPPING ONLY, and the subtest names now say so. These INJECT the store's + // sentinels rather than reproducing a real lock-time disappearance, which + // no test can schedule deterministically; DETECTION is covered on the + // store side by TestCopyAcrossWorkspaces_CollectionMissingSentinels, which + // soft-deletes each collection for real and asserts the sentinel with + // errors.Is (Codex round 7 — the claim used to be unsupported). + // What is asserted here is the half the handler owns: a + // pre-write rejection with a precise answer must never fall through to + // the ambiguous 500 and send the user looking for an item that provably + // does not exist (Codex rounds 1 and 6). + t.Run("ErrCopyTargetCollectionMissing maps to collection_not_found", func(t *testing.T) { + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + return nil, store.ErrCopyTargetCollectionMissing + } + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusNotFound || errCode(t, rr) != crossWorkspaceCollectionNotFoundCode { + t.Fatalf("got %d %s, want 404 %s", rr.Code, rr.Body.String(), crossWorkspaceCollectionNotFoundCode) + } + // Byte-identical to the answer for a collection that never existed — + // otherwise the timing distinguishes hidden from absent. + body := f.resolvableBody() + body["target_collection"] = "definitely-not-a-collection" + f.srv.copyItemFn = nil + absent := f.callCopy(f.owner, reqOpts{}, body) + if absent.Body.String() != rr.Body.String() { + t.Errorf("vanished and absent destination collections are distinguishable:\n vanished: %s\n absent: %s", + rr.Body.String(), absent.Body.String()) + } + }) + + t.Run("ErrCopySourceCollectionMissing maps to the source's bare 404", func(t *testing.T) { + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + return nil, store.ErrCopySourceCollectionMissing + } + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusNotFound || errCode(t, rr) != "not_found" { + t.Fatalf("got %d %s, want 404 not_found", rr.Code, rr.Body.String()) + } + if bytes.Contains(rr.Body.Bytes(), []byte("collection")) { + t.Errorf("the source-side refusal names a collection: %s", rr.Body.String()) + } + }) + + t.Run("sql.ErrNoRows maps to a 404", func(t *testing.T) { + f.srv.copyItemFn = func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + return nil, sql.ErrNoRows + } + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusNotFound { + t.Fatalf("got %d, want 404: %s", rr.Code, rr.Body.String()) + } + }) +} + +// TestCopyEndpoint_UsesLockedCollectionSnapshots — the store re-reads both +// collection rows under a FOR UPDATE pin and returns them precisely so the +// caller does not build its answer from a pre-transaction read that can be +// stale (Codex round 1 P2). +// +// Staleness is forced rather than raced: the copyItemFn seam RENAMES the +// source's collection between the handler's read and the store call, which is +// what a concurrent collection edit looks like from the handler's point of +// view. The rename is chosen over a move deliberately — a move is refused +// outright by the in-tx invariant re-check, so the only observable staleness +// left is a slug that changed under a collection whose identity did not. +func TestCopyEndpoint_UsesLockedCollectionSnapshots(t *testing.T) { + f := newCopyPreflightFixture(t) + o := newCopyFanoutObserver(t, f) + + const renamed = "renamed-under-the-lock" + f.srv.copyItemFn = func(req store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) { + if _, err := f.srv.store.DB().Exec( + f.srv.store.D().Rebind(`UPDATE collections SET slug = ? WHERE id = ?`), renamed, f.collA.ID, + ); err != nil { + return nil, err + } + return f.srv.store.CopyItemAcrossWorkspaces(req) + } + + body := f.resolvableBody() + body["archive_source"] = true + res := f.copyOK(body) + + if res.Source.CollectionSlug != renamed { + t.Errorf("source.collection_slug = %q, want the under-lock %q — the response was built "+ + "from a pre-transaction read", res.Source.CollectionSlug, renamed) + } + evA := drainSSE(o.chA) + if len(evA) != 1 { + t.Fatalf("workspace A SSE = %+v, want exactly one archive event", evA) + } + if evA[0].Collection != renamed { + t.Errorf("A's archive event is attributed to collection %q, want the under-lock %q — "+ + "an SSE consumer routing on the collection would file the tombstone in the wrong place", + evA[0].Collection, renamed) + } +} + +// --- 5. caller obligations ---------------------------------------------- + +// TestCopyEndpoint_InvalidatesDestinationStorageCache — CrossWorkspaceCopy +// Result states that storageInfoCache invalidation belongs to the caller +// because the store has no handle on it. Without it the destination's +// storage page reports stale usage for the cache's 30-second window, right +// after the user watched the bytes land. +// +// The SOURCE's entry must survive: the copy adds rows in B and removes +// nothing from A, so invalidating A would be a needless recompute +// masquerading as correctness. +func TestCopyEndpoint_InvalidatesDestinationStorageCache(t *testing.T) { + f := newCopyPreflightFixture(t) + + att := &models.Attachment{ + WorkspaceID: f.wsA.ID, UploadedBy: f.owner.ID, + StorageKey: "fs:feedface", ContentHash: "feedface", + MimeType: "image/png", SizeBytes: 2048, Filename: "s.png", + } + if err := f.srv.store.CreateAttachment(att); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + content := "![](pad-attachment:" + att.ID + ")" + if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{Content: &content}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + + // Prime both entries with sentinel values a recompute could never + // produce, so "still cached" and "recomputed to the same number" are + // distinguishable. + f.srv.storageInfoCache.set(f.wsA.ID, &store.WorkspaceStorageInfo{UsedBytes: 111111}) + f.srv.storageInfoCache.set(f.wsB.ID, &store.WorkspaceStorageInfo{UsedBytes: 222222}) + + res := f.copyOK(f.resolvableBody()) + if res.Warnings.AttachmentCount == 0 { + t.Fatal("fixture precondition: the copy cloned no attachments, so nothing would be invalidated") + } + + if f.srv.storageInfoCache.get(f.wsB.ID) != nil { + t.Error("the destination's storage cache entry survived a copy that added attachment rows") + } + if f.srv.storageInfoCache.get(f.wsA.ID) == nil { + t.Error("the SOURCE's storage cache entry was invalidated; the copy changed nothing in A") + } +} + +// TestCopyEndpoint_PostCommitPanicStillReturns201 — by the time the +// post-commit work runs, the copy is committed and IRREVERSIBLE, but the +// response has not been written. A panic escaping into chi's recoverer would +// turn a succeeded copy into a 500, and DR-13 forbids the client from +// resolving that ambiguity by retrying — so the retry would duplicate the +// item, or the user would be told a copy failed that plainly did not. +// +// The panic is induced in the CACHE INVALIDATION rather than the fanout, +// because that is the step Codex round 3 found sitting outside the guard. +func TestCopyEndpoint_PostCommitPanicStillReturns201(t *testing.T) { + f := newCopyPreflightFixture(t) + + att := &models.Attachment{ + WorkspaceID: f.wsA.ID, UploadedBy: f.owner.ID, + StorageKey: "fs:c0ffee", ContentHash: "c0ffee", + MimeType: "image/png", SizeBytes: 64, Filename: "p.png", + } + if err := f.srv.store.CreateAttachment(att); err != nil { + t.Fatalf("CreateAttachment: %v", err) + } + content := "![](pad-attachment:" + att.ID + ")" + if _, err := f.srv.store.UpdateItem(f.source.ID, models.ItemUpdate{Content: &content}); err != nil { + t.Fatalf("UpdateItem: %v", err) + } + + // A nil cache makes invalidate() nil-deref. Crude on purpose: the point + // is that ANY panic in post-commit work is contained, not that this + // particular one is expected. + f.srv.storageInfoCache = nil + + rr := f.callCopy(f.owner, reqOpts{}, f.resolvableBody()) + if rr.Code != http.StatusCreated { + t.Fatalf("a panic in post-commit work changed the response to %d: %s", rr.Code, rr.Body.String()) + } + + // And the copy really is there — "201 with nothing behind it" would be a + // worse outcome than the 500. + var out ItemCopyResult + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("parse: %v", err) + } + if item, err := f.srv.store.GetItem(out.Item.ID); err != nil || item == nil { + t.Fatalf("the committed copy is not readable: %v", err) + } +} + +// TestCopyEndpoint_NoAttachmentsLeavesTheCacheAlone — the invalidation is +// conditional on attachments actually being copied, so the common case does +// not pay for a recompute. +func TestCopyEndpoint_NoAttachmentsLeavesTheCacheAlone(t *testing.T) { + f := newCopyPreflightFixture(t) + f.srv.storageInfoCache.set(f.wsB.ID, &store.WorkspaceStorageInfo{UsedBytes: 222222}) + + res := f.copyOK(f.resolvableBody()) + if res.Warnings.AttachmentCount != 0 { + t.Fatalf("fixture precondition: expected no attachments, got %d", res.Warnings.AttachmentCount) + } + if f.srv.storageInfoCache.get(f.wsB.ID) == nil { + t.Error("an attachment-free copy invalidated the destination's storage cache") + } +} + +// --- response contract -------------------------------------------------- + +// TestCopyEndpoint_ResponseIsNavigable pins the acceptance criterion that +// TASK-2366 (the CLI) and Phase 3 (the dialog) build against: enough to +// reach the copy, and on a move enough to leave the source. +func TestCopyEndpoint_ResponseIsNavigable(t *testing.T) { + f := newCopyPreflightFixture(t) + res := f.copyOK(f.resolvableBody()) + + if res.Destination.WorkspaceSlug != f.wsB.Slug { + t.Errorf("destination.workspace_slug = %q, want %q", res.Destination.WorkspaceSlug, f.wsB.Slug) + } + if res.Destination.CollectionSlug != f.collB.Slug { + t.Errorf("destination.collection_slug = %q, want %q", res.Destination.CollectionSlug, f.collB.Slug) + } + if res.Destination.Ref == "" || res.Destination.Slug == "" { + t.Errorf("destination block is not navigable: %+v", res.Destination) + } + if res.Item == nil || res.Item.ID == "" || res.Item.WorkspaceID != f.wsB.ID { + t.Fatalf("item block = %+v, want the destination item", res.Item) + } + if res.Item.Ref != res.Destination.Ref { + t.Errorf("item.ref %q disagrees with destination.ref %q", res.Item.Ref, res.Destination.Ref) + } + if res.Source.WorkspaceSlug != f.wsA.Slug || res.Source.Slug != f.source.Slug { + t.Errorf("source block = %+v, want workspace %q slug %q", res.Source, f.wsA.Slug, f.source.Slug) + } + if res.ArchiveSource { + t.Error("archive_source echoed true for a plain copy") + } + if res.Warnings.DroppedFields == nil { + t.Error("dropped_fields is null on the wire; it must be [] like every other list in this pair") + } + // The source is still live after a plain copy. + if live, err := f.srv.store.GetItem(f.source.ID); err != nil || live == nil { + t.Errorf("a plain copy removed the source: %v", err) + } +} + +// TestCopyEndpoint_ReportsCanonicalWorkspaceSlugs — /workspaces/{slug} also +// accepts a workspace UUID, so a response that echoes the URL parameter hands +// a client a "workspace_slug" that is not a slug and a link that does not +// resolve (Codex round 14). Both endpoints report the RESOLVED canonical slug, +// and both are checked here because they must agree. +func TestCopyEndpoint_ReportsCanonicalWorkspaceSlugs(t *testing.T) { + f := newCopyPreflightFixture(t) + + // Address the source workspace by UUID, exactly as the router permits. + body, err := json.Marshal(f.resolvableBody()) + if err != nil { + t.Fatalf("marshal: %v", err) + } + newReq := func() *http.Request { + r := httptest.NewRequest("POST", + "/api/v1/workspaces/"+f.wsA.ID+"/items/"+f.source.Slug+"/copy", + bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + ctx := WithCurrentUser(r.Context(), f.owner) + ctx = contextWithWorkspaceRoleForTest(ctx, "owner") + ctx = contextWithResolvedWorkspaceIDForTest(ctx, f.wsA.ID) + rctx := chi.NewRouteContext() + rctx.URLParams.Add("slug", f.wsA.ID) // the UUID form + rctx.URLParams.Add("itemSlug", f.source.Slug) + return r.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) + } + + preRR := httptest.NewRecorder() + f.srv.handleCopyItemPreflight(preRR, newReq()) + if preRR.Code != http.StatusOK { + t.Fatalf("preflight: %d %s", preRR.Code, preRR.Body.String()) + } + var pre ItemCopyPreflight + if err := json.Unmarshal(preRR.Body.Bytes(), &pre); err != nil { + t.Fatalf("parse preflight: %v", err) + } + if pre.Source.WorkspaceSlug != f.wsA.Slug { + t.Errorf("preflight source.workspace_slug = %q, want the canonical slug %q", + pre.Source.WorkspaceSlug, f.wsA.Slug) + } + + copyRR := httptest.NewRecorder() + f.srv.handleCopyItem(copyRR, newReq()) + if copyRR.Code != http.StatusCreated { + t.Fatalf("copy: %d %s", copyRR.Code, copyRR.Body.String()) + } + var res ItemCopyResult + if err := json.Unmarshal(copyRR.Body.Bytes(), &res); err != nil { + t.Fatalf("parse copy: %v", err) + } + if res.Source.WorkspaceSlug != f.wsA.Slug { + t.Errorf("copy source.workspace_slug = %q, want the canonical slug %q", + res.Source.WorkspaceSlug, f.wsA.Slug) + } + if res.Source.WorkspaceSlug != pre.Source.WorkspaceSlug { + t.Errorf("the preview and the copy report different source workspaces: %q vs %q", + pre.Source.WorkspaceSlug, res.Source.WorkspaceSlug) + } +} + +// TestCopyEndpoint_MoveArchivesTheSource — the other half, at the data +// layer rather than the response: the source is gone from live reads and the +// provenance row records where it went. +func TestCopyEndpoint_MoveArchivesTheSource(t *testing.T) { + f := newCopyPreflightFixture(t) + body := f.resolvableBody() + body["archive_source"] = true + res := f.copyOK(body) + + if live, err := f.srv.store.GetItem(f.source.ID); err != nil || live != nil { + t.Errorf("the source is still live after a move (err=%v)", err) + } + moves, err := f.srv.store.ListItemWorkspaceMovesBySource(f.source.ID) + if err != nil { + t.Fatalf("ListItemWorkspaceMovesBySource: %v", err) + } + if len(moves) != 1 || moves[0].TargetItemID != res.Item.ID || !moves[0].ArchivedSource { + t.Fatalf("provenance rows = %+v, want one archived pointer at %s", moves, res.Item.ID) + } +} + +// TestCopyEndpoint_RouteIsWired drives the real router, so a handler that +// works in isolation but was never mounted fails here. +func TestCopyEndpoint_RouteIsWired(t *testing.T) { + srv := testServer(t) + wsA := createWSForTest(t, srv) + wsARow, err := srv.store.GetWorkspaceBySlug(wsA) + if err != nil || wsARow == nil { + t.Fatalf("GetWorkspaceBySlug: %v", err) + } + collA := mustSchemaCollection(t, srv, wsARow.ID, "Route Src", srcSchemaJSON) + collB := mustSchemaCollection(t, srv, wsARow.ID, "Route Dst", srcSchemaJSON) + item := createItem(t, srv, wsA, collA.Slug, map[string]interface{}{ + "title": "Routed", "fields": `{"status":"open"}`, + }) + + rr := doRequest(srv, "POST", + "/api/v1/workspaces/"+wsA+"/items/"+item.Slug+"/copy", + map[string]interface{}{ + "target_workspace": wsA, + "target_collection": collB.Slug, + }) + if rr.Code != http.StatusCreated { + t.Fatalf("routed copy: got %d, want 201: %s", rr.Code, rr.Body.String()) + } + var resp ItemCopyResult + parseJSON(t, rr, &resp) + if resp.Destination.CollectionSlug != collB.Slug || resp.Item == nil { + t.Fatalf("routed copy response = %+v", resp) + } + // Same-workspace copy is a legal degenerate case and lands a second, + // independent item — the endpoint is not a move-within-a-workspace + // shortcut and must not be mistaken for one. + if resp.Item.ID == item.ID { + t.Error("the copy returned the SOURCE item; nothing was created") + } +} diff --git a/internal/server/handlers_items_move_overrides_test.go b/internal/server/handlers_items_move_overrides_test.go new file mode 100644 index 00000000..d91343d0 --- /dev/null +++ b/internal/server/handlers_items_move_overrides_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "net/http" + "strings" + "testing" + + "github.com/PerpetualSoftware/pad/internal/models" +) + +// The DR-12 fix on the EXISTING intra-workspace move path (PLAN-2357). +// +// handleMoveItem merged field_overrides into result.Fields and then tested +// result.Errors — a value MigrateFields computes BEFORE any override +// exists. So an override that satisfied a required destination field still +// 400'd, and an override with an invalid value was never type-checked and +// went straight into the item. +// +// The bug was dormant because the web UI's handleMove passes +// field_overrides: undefined. PLAN-2357's copy dialog is the first caller +// to send them, which is what puts the fix in this PR rather than a +// follow-up. + +func moveTestCollections(t *testing.T, srv *Server) (string, *models.Collection, *models.Collection) { + t.Helper() + slug := createWSForTest(t, srv) + ws, err := srv.store.GetWorkspaceBySlug(slug) + if err != nil || ws == nil { + t.Fatalf("GetWorkspaceBySlug(%s): %v", slug, err) + } + src := mustSchemaCollection(t, srv, ws.ID, "Move Src", `{"fields":[ + {"key":"note","label":"Note","type":"text"} + ]}`) + dst := mustSchemaCollection(t, srv, ws.ID, "Move Dst", `{"fields":[ + {"key":"note","label":"Note","type":"text"}, + {"key":"ticket","label":"Ticket","type":"text","required":true}, + {"key":"priority","label":"Priority","type":"select","options":["low","high"]} + ]}`) + return slug, src, dst +} + +// TestMoveItem_OverrideSatisfiesRequiredField — the first half of DR-12. +// Pre-fix this returned 400 missing_required_fields even though `ticket` +// was supplied. +func TestMoveItem_OverrideSatisfiesRequiredField(t *testing.T) { + srv := testServer(t) + slug, src, dst := moveTestCollections(t, srv) + + item := createItem(t, srv, slug, src.Slug, map[string]interface{}{ + "title": "Movable", "fields": `{"note":"hi"}`, + }) + + // Control: without the override the destination's required field is + // genuinely unsatisfiable, so the move must still be refused. + rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/move", + map[string]interface{}{"target_collection": dst.Slug}) + if rr.Code != http.StatusBadRequest { + t.Fatalf("move without the override: expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "missing_required_fields" { + t.Errorf("error code = %q, want missing_required_fields", code) + } + + // With the override the required field IS satisfied, so the move lands. + rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/move", + map[string]interface{}{ + "target_collection": dst.Slug, + "field_overrides": map[string]interface{}{"ticket": "T-9"}, + }) + if rr.Code != http.StatusOK { + t.Fatalf("move with a satisfying override: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var moved models.Item + parseJSON(t, rr, &moved) + if moved.CollectionSlug != dst.Slug { + t.Errorf("item landed in %q, want %q", moved.CollectionSlug, dst.Slug) + } + if want := `"ticket":"T-9"`; !strings.Contains(moved.Fields, want) { + t.Errorf("moved item fields = %s, want it to contain %s", moved.Fields, want) + } +} + +// TestMoveItem_InvalidOverrideRejected — the second half of DR-12. Pre-fix +// "urgent" was written into the item unchecked, because result.Errors was +// empty and nothing else validated the merged map. +func TestMoveItem_InvalidOverrideRejected(t *testing.T) { + srv := testServer(t) + slug, src, dst := moveTestCollections(t, srv) + + item := createItem(t, srv, slug, src.Slug, map[string]interface{}{ + "title": "Movable", "fields": `{"note":"hi"}`, + }) + + rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/move", + map[string]interface{}{ + "target_collection": dst.Slug, + "field_overrides": map[string]interface{}{"ticket": "T-9", "priority": "urgent"}, + }) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for an out-of-options override, got %d: %s", rr.Code, rr.Body.String()) + } + if code := errCode(t, rr); code != "invalid_fields" { + t.Errorf("error code = %q, want invalid_fields", code) + } + + // And the item must not have moved. + fresh, err := srv.store.GetItem(item.ID) + if err != nil { + t.Fatalf("GetItem: %v", err) + } + if fresh.CollectionID != src.ID { + t.Errorf("item moved despite the 400 — collection is now %q", fresh.CollectionID) + } +} 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/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/server/server.go b/internal/server/server.go index 9b9f9688..755bfed9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -150,6 +150,24 @@ type Server struct { // guarding. storageInfoCache *storageInfoCache + // copyItemFn indirects Store.CopyItemAcrossWorkspaces for the + // cross-workspace copy endpoint (PLAN-2357 / TASK-2365). + // + // It exists because DR-13 forbids the endpoint from ever transparently + // retrying a mutating copy — there is no idempotency key in v1, so a + // retry after a post-commit failure duplicates the item — and the only + // falsifiable way to assert "called exactly once" is to count the calls. + // Several tests also use it to inject the store's typed errors and to + // land a concurrent mutation deterministically between the handler's + // authorization and the store call. See handleCopyItem and + // TestCopyEndpoint_DoesNotRetryOnAmbiguousError. + // + // It is nil on every production path — nothing outside package server can + // set it, no constructor or setter assigns it, and only _test.go files do + // (Codex round 6: it is compiled into the binary, so "test-only" describes + // the convention, not a compiler-enforced guarantee). + copyItemFn func(store.CrossWorkspaceCopyRequest) (*store.CrossWorkspaceCopyResult, error) + // importBundleMaxBytes caps a single workspace import bundle. // 0 → defaultImportBundleMaxBytes (2 GiB). Set via // SetImportBundleMaxBytes from cmd/pad/main.go using the @@ -1450,6 +1468,24 @@ func (s *Server) setupRouter() { r.Delete("/", s.handleDeleteItem) r.Post("/restore", s.handleRestoreItem) r.Post("/move", s.handleMoveItem) + // Cross-workspace copy PREFLIGHT (PLAN-2357 / + // TASK-2364). Reports what a copy into another + // workspace would carry, drop and need, and + // leaves no trace a copy would have left — see + // handlers_items_copy_preflight.go for the exact + // scope of that guarantee. POST because the + // request carries a body (destination + override + // map), not because it mutates. The mutating + // sibling lands at /copy in TASK-2365. + r.Post("/copy/preflight", s.handleCopyItemPreflight) + // Cross-workspace copy, the MUTATION (PLAN-2357 / + // TASK-2365). Same request shape as the preflight + // above; with archive_source it is the move. Post- + // commit fanout is asymmetric — see + // handlers_items_copy.go. Registered after the more + // specific /copy/preflight, though chi's trie makes + // the order immaterial. + r.Post("/copy", s.handleCopyItem) // Export a single playbook/convention item as a // portable artifact (Markdown + YAML frontmatter). // Gated by per-item visibility, not the workspace- @@ -1953,26 +1989,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 +2370,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) diff --git a/internal/store/attachments.go b/internal/store/attachments.go index 889d0bf7..9e38a8e9 100644 --- a/internal/store/attachments.go +++ b/internal/store/attachments.go @@ -65,7 +65,43 @@ 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 { + 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)") + } if a.ID == "" { a.ID = newID() } @@ -76,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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), @@ -780,10 +816,11 @@ func (s *Store) WorkspaceItemSlugMap(workspaceID string) (map[string]string, err // workspace's ids. // // Implementation: a single transaction that loads every item's -// content+fields, runs strings.ReplaceAll for each pair, and -// writes back only when something changed. ReplaceAll is safe -// because attachment UUIDs don't appear as substrings of other -// UUIDs (RFC4122 hex, length 36, all unique by construction). +// content+fields, runs remapAttachmentRefs over each, and writes +// back only when something changed. That helper tokenizes with +// attachmentRefRE and matches whole ids — NOT strings.ReplaceAll, +// which used to rewrite a mapped id sitting as the PREFIX of a +// longer one (Codex round 26). See its doc. // // FTS reindex via the existing rebuild helper happens AFTER the // transaction commits — direct UPDATE bypasses the SQLite FTS @@ -847,18 +884,34 @@ func (s *Store) RemapAttachmentReferencesInWorkspace(workspaceID string, oldToNe // RemapAttachmentReferencesInWorkspace which also handles the FTS // reindex. // -// The "pad-attachment:" prefix is part of the search key so we don't -// accidentally rewrite a UUID that happens to appear in unrelated -// content (e.g. an item title that mentions an attachment id by -// accident — unlikely but free to guard against). +// IT TOKENIZES WITH attachmentRefRE, the SAME pattern the copy +// planner enumerates references with, and rewrites only when the +// captured id matches a map key EXACTLY. +// +// A plain strings.ReplaceAll over "pad-attachment:"+old was the +// obvious implementation and was subtly wrong (Codex round 26). The +// regex is greedy — `pad-attachment:x` captures `x`, one +// id, which resolves to nothing and is deliberately left alone — but a +// substring replace happily rewrote the `` PREFIX inside it, +// producing text that matched neither what the planner enumerated nor +// what the user wrote. PLAN-2357 DR-11a is explicit that an +// unresolvable reference keeps its literal text "so the copy renders +// exactly as broken as the source did", and +// items_cross_workspace_copy.go claims the rewrite "covers precisely +// the reference set the plan cloned". Sharing the tokenizer is what +// makes both true rather than nearly true. func remapAttachmentRefs(s string, oldToNew map[string]string) string { - for old, fresh := range oldToNew { - if old == "" || fresh == "" || old == fresh { - continue + if len(oldToNew) == 0 || !strings.Contains(s, attachmentRefPrefix) { + return s + } + return attachmentRefRE.ReplaceAllStringFunc(s, func(match string) string { + id := strings.TrimPrefix(match, attachmentRefPrefix) + fresh, ok := oldToNew[id] + if !ok || fresh == "" || fresh == id { + return match } - s = strings.ReplaceAll(s, "pad-attachment:"+old, "pad-attachment:"+fresh) - } - return s + return attachmentRefPrefix + fresh + }) } // WorkspaceAttachmentsForExport returns every original (non-derived, diff --git a/internal/store/attachments_copy_plan.go b/internal/store/attachments_copy_plan.go new file mode 100644 index 00000000..94e40181 --- /dev/null +++ b/internal/store/attachments_copy_plan.go @@ -0,0 +1,621 @@ +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) tokenizes with THIS REGEX and +// rewrites a match only when its captured id is a map key exactly. The +// two therefore agree on where a reference starts and ends by +// construction rather than by coincidence — which is the property that +// matters, and which a plain strings.ReplaceAll did not have (Codex +// round 26: it rewrote a mapped id sitting as the PREFIX of a longer, +// unresolvable one). 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). 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 (remapAttachmentRefs only + // rewrites ids it actually encounters as whole tokens) 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. + // + // ORDER-INDEPENDENT, and no longer for a reason that could stop being + // true. remapAttachmentRefs walks the text once with attachmentRefRE + // and looks each captured id up in this map, so a pair is applied only + // where a whole reference matches it exactly — never inside another + // id, and never to text a previous pair produced. + // + // It used to depend on "no attachment id is a prefix of another", + // which held only because every id comes from newID() and is a + // fixed-length UUID. That assumption was doing real work and it was + // already violated by user-authored text (`pad-attachment:x` is + // one longer, unresolvable id whose prefix a substring replace + // happily rewrote — Codex round 26). The dependency is gone, not + // merely restated. + 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)) + } +} diff --git a/internal/store/item_workspace_moves.go b/internal/store/item_workspace_moves.go new file mode 100644 index 00000000..b5d0341f --- /dev/null +++ b/internal/store/item_workspace_moves.go @@ -0,0 +1,264 @@ +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. +// +// 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+` + 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 +} + +// 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. +// +// 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..22771dbb --- /dev/null +++ b/internal/store/item_workspace_moves_test.go @@ -0,0 +1,760 @@ +package store + +import ( + "fmt" + "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 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 { + 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) + } +} + +// 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/internal/store/items.go b/internal/store/items.go index da753246..bbaabeb1 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,147 @@ 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) { + 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. + // + // 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 + } + + if id == "" { + 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 @@ -336,6 +513,18 @@ func (s *Store) getCollectionSlugTx(tx *sql.Tx, collectionID string) (string, er return slug, nil } +// IsUniqueViolation is the exported form of isUniqueViolation, for HTTP +// handlers that have to turn a store error into a 409 without re-implementing +// the heuristic. +// +// Exported in TASK-2365 rather than duplicated at the call site: the +// cross-workspace copy endpoint needs exactly this test, and a second copy of +// the same two magic strings is a place for the two to drift (Codex round 8). +// It is a string match rather than a SQLSTATE/driver-type check because +// internal/store is driver-agnostic and both drivers sit behind database/sql; +// the strings are stable parts of each engine's user-facing error text. +func IsUniqueViolation(err error) bool { return isUniqueViolation(err) } + // isUniqueViolation checks whether an error is a unique constraint violation. // Works for both SQLite (UNIQUE constraint failed) and PostgreSQL (duplicate key). func isUniqueViolation(err error) bool { 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/items_cross_workspace_copy.go b/internal/store/items_cross_workspace_copy.go new file mode 100644 index 00000000..ada3e601 --- /dev/null +++ b/internal/store/items_cross_workspace_copy.go @@ -0,0 +1,1156 @@ +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. The one concession is +// CrossWorkspaceCopyRequest.PreCheck: a hook that lets the HTTP layer re-check +// its verdict against the UNDER-LOCK snapshots (DR-9), still without this +// package knowing what a permission is. Read that field's doc before assuming +// what it guarantees — the production hook compares resource IDENTITY and does +// not re-evaluate grants. +// +// FIELD SEMANTICS ARE SHARED WITH THE PREFLIGHT (DR-6). migrateCopyFields and +// handleCopyItemPreflight must agree on what a copy would persist, key for key +// — the preview lying about the copy is the failure the shared-endpoint design +// exists to prevent. TASK-2364 shipped with two known disagreements in the +// override merge (null overrides, undeclared overrides); TASK-2365 closed both +// on the preflight's side. See migrateCopyFields. +// +// 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") + +// ErrCopySourceCollectionMissing / ErrCopyTargetCollectionMissing are returned +// when a collection that existed when the caller was authorized is gone by the +// time this transaction re-reads it under the locks — soft-deleted, or (for the +// destination) never in the workspace the caller named. +// +// SENTINELS RATHER THAN fmt.Errorf, because the distinction is caller-facing. +// Both are pre-write rejections: nothing has been inserted and nothing can +// have committed. Reporting them as an anonymous failure would make the HTTP +// layer answer with its AMBIGUOUS-outcome 500 ("the copy may or may not have +// landed — check the destination"), which is both wrong and actively unhelpful +// — it sends the user hunting for an item that provably does not exist, over a +// condition that has a precise answer. The window is the same one +// AuthorizeCrossWorkspaceEdit warns about on both entry points; it is narrow, +// not impossible. +// +// They are deliberately NOT collapsed into one: the two sides get different +// responses under the disclosure posture — the source is a bare "Item not +// found", the destination the shared collection_not_found — and a single +// sentinel would force the handler to guess. +var ( + ErrCopySourceCollectionMissing = errors.New("copy item across workspaces: source collection not found") + ErrCopyTargetCollectionMissing = errors.New("copy item across workspaces: target collection not found") +) + +// 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 } + +// UndeclaredOverrideError reports field overrides naming keys the DESTINATION +// collection's schema does not declare (TASK-2365, reconciling the second of +// TASK-2364's two KNOWN DIVERGENCES). +// +// Before this existed, migrateCopyFields merged every override +// unconditionally and ValidateFields ignores keys the schema does not +// declare — so the copy PERSISTED an undeclared override as an orphan key on +// the new item while the preflight refused the identical request with a 400 +// `malformed_override`. That is exactly the DR-6 disagreement the shared +// preflight exists to prevent: the preview said "no", the copy said "yes, and +// here is a field your schema has never heard of". +// +// The preflight is the stricter and safer side, so the gate moved here rather +// than the rejection being removed there. Silently dropping the keys instead +// would be worse than either: a client that asked for a value would get an +// item without it and no way to tell. +// +// Keys is sorted, so the message is stable for the same input. +type UndeclaredOverrideError struct { + Keys []string +} + +func (e *UndeclaredOverrideError) Error() string { + return fmt.Sprintf("copy item across workspaces: destination collection has no field(s): %s", + strings.Join(e.Keys, ", ")) +} + +// CopyPreCheckError wraps a refusal from CrossWorkspaceCopyRequest.PreCheck. +// +// It exists purely so the rollback is classified as a caller-facing rejection +// rather than an incident: an authorization re-check that fires is a 403 or a +// 404 the HTTP layer renders, not something an operator should be paged about. +// The wrapped error is the caller's own, and Unwrap lets them recover it with +// errors.As to choose the status. +type CopyPreCheckError struct { + Err error +} + +func (e *CopyPreCheckError) Error() string { + return fmt.Sprintf("copy item across workspaces: pre-check refused: %v", e.Err) +} + +func (e *CopyPreCheckError) 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 + + // PreCheck, when non-nil, runs INSIDE the transaction once every input has + // been re-read under the locks and before anything is written. Returning + // an error rolls the whole copy back. + // + // It exists for one caller and one reason: TASK-2358's authorization + // verdict is explicitly NOT ATOMIC, and PLAN-2357 DR-9 requires the + // mutating copy to re-read both sides and re-check inside its + // transaction. The four-check ladder itself stays at the HTTP layer — + // this store op still enforces data invariants only (see the file header) + // — but the SNAPSHOTS it judges have to be the locked ones, because the + // item can be moved into a different (possibly hidden) collection between + // the handler's check and the lock. + // + // WHAT THE ONE PRODUCTION HOOK ACTUALLY DOES, stated here because a future + // caller must not assume more: it compares IDENTITY ONLY — that the locked + // source item and destination collection are the same ones the handler's + // ladder was run against. It does NOT re-read membership or grants, and + // nothing in this transaction makes authorization state atomic with the + // copy. See server.copyResourceInvariantPreCheck for why that is the + // honest boundary (an I/O-performing hook here also risks pool starvation + // while this transaction holds both workspaces' locks). + // + // `source` is the under-lock re-read of the source item; `targetColl` is + // the under-lock, workspace-scoped destination collection. Both are + // DETACHED DEEP COPIES of what the rest of the pipeline consumes (see + // detachedSnapshot) — read them; retaining or mutating them, including + // through their pointer and slice fields, changes nothing. + // + // Return any error to refuse. It is wrapped in CopyPreCheckError for you, + // so a refusal is classified as a caller-facing rejection rather than an + // operator-visible incident (see isExpectedCopyRejection); recover your + // own error type with errors.As. The established shape is + // MoveItemWithPreCheck / UpdateItemWithPreCheck. + PreCheck func(tx *sql.Tx, source *models.Item, targetColl *models.Collection) error + + // 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 + + // SourceCollection / TargetCollection are the two collection rows as read + // under the FOR UPDATE pin — the schemas the migration actually consumed. + // + // Returned rather than left to the caller to re-read, for the same reason + // Source is: the caller's pre-transaction copies are advisory and both can + // be stale by the time this commits. The item can have been MOVED into + // another collection in A, and either collection can have been renamed. + // A caller that builds its response or its fanout from the pre-transaction + // rows attributes the events to a collection the copy did not use, and + // hands the client a slug that no longer resolves (Codex round 1 P2). + SourceCollection *models.Collection + TargetCollection *models.Collection + + // 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, ErrCopySourceCollectionMissing + } + targetColl, err := s.getCollectionInWorkspaceTx(tx, req.TargetCollectionID, req.TargetWorkspaceID) + if err != nil { + return nil, err + } + if targetColl == nil { + return nil, ErrCopyTargetCollectionMissing + } + + // --- 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 + } + + // --- Caller's in-tx re-check (DR-9), against the LOCKED snapshots and + // before anything is written or any quota is consumed. --- + // + // DETACHED SNAPSHOTS, not the canonical pointers. The very objects handed + // over here go on to drive migration, attachment planning and the insert, + // and are returned in the result for the post-commit fanout — so a hook + // that mutated one, or retained it and mutated it later, would silently + // rewrite what this transaction copies or what its events say (Codex + // round 10). A hook has no business needing more than a read, and + // detachedSnapshot makes that structural rather than a convention — + // including for every pointer- and slice-backed field, which a plain + // struct copy would have left aliased (Codex rounds 11 and 12). + // + // The refusal is WRAPPED here rather than left to the hook. The doc on + // PreCheck used to ask callers to wrap it themselves, which meant a + // caller who forgot turned an intended 403 into an operator-visible + // rollback incident. Guaranteeing it in one place removes the trap; + // errors.As still recovers the caller's own error type through Unwrap. + if req.PreCheck != nil { + sourceSnapshot, err := detachedSnapshot(source) + if err != nil { + return nil, err + } + targetCollSnapshot, err := detachedSnapshot(targetColl) + if err != nil { + return nil, err + } + if err := req.PreCheck(tx, sourceSnapshot, targetCollSnapshot); err != nil { + return nil, &CopyPreCheckError{Err: err} + } + } + + // --- Fields: migrate -> override -> validate (DR-12). --- + // + // BEFORE THE QUOTA, and the order is a contract rather than a preference + // (Codex round 18). This is pure computation over rows already read, so + // running it first costs nothing and changes no invariant — the quota + // still runs inside the transaction and still runs before any insert, + // which is all DR-16 requires. What it buys is agreement with the + // preflight: the preflight has no quota check at all, so with the checks + // the other way round a malformed request into a quota-full cloud + // workspace got 403 plan_limit_exceeded from the copy and 400 + // malformed_override from its own preview. A bad request is a bad request + // whether or not the destination happens to be full, and a client told + // "you are out of room" cannot fix an override it was never told about. + finalFields, dropped, err := migrateCopyFields(source.Fields, sourceColl.Schema, targetColl.Schema, req.FieldOverrides) + if err != nil { + return nil, err + } + + // --- 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} + } + } + + // --- 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, + SourceCollection: sourceColl, + TargetCollection: targetColl, + SourceWorkspaceID: sourceWorkspaceID, + Move: move, + SourceSeq: sourceSeq, + AttachmentsCopied: len(plan.Rows), + BytesCopied: plan.TotalBytes, + UnresolvableRefs: plan.UnresolvableRefs, + DroppedFields: dropped, + DroppedAssignee: droppedAssignee, + DroppedAgentRole: droppedAgentRole, + }, nil +} + +// detachedSnapshot returns a deep copy of v that a PreCheck hook can neither +// mutate nor retain into anything the copy depends on. +// +// WHY NOT `out := *src`. Struct assignment copies the POINTER and SLICE +// fields, so a hook doing `*source.AssignedUserID = "someone-else"` reaches +// straight through into carryAssigneeTx and puts a different user on the +// copied item (Codex round 11). +// +// WHY NOT FIELD-BY-FIELD CLONING. That was the first fix, and it was wrong in +// the way hand-maintained lists are always wrong: models.Item carries a dozen +// reference-backed fields — ParentID, AssignedUserID, AgentRoleID, ItemNumber, +// DeletedAt, IsUnparented, DerivedClosure, CodeContext, Convention, MovedTo, +// ImplementationNotes, DecisionLog — several of which getItemTx hydrates, and +// the enumeration missed four of them (Codex round 12). Worse, it would go +// stale silently the next time a field is added to the model, in a way no test +// would notice. +// +// A JSON round-trip is total by construction and stays correct as the models +// grow. It is legitimate here specifically because models.Item and +// models.Collection are API DTOs: every field carries a JSON tag and none is +// `json:"-"`, so nothing is lost. The cost — one marshal plus one unmarshal — +// is paid once per copy, only when a hook is installed, against a transaction +// that already holds two workspaces' advisory locks. +func detachedSnapshot[T any](src *T) (*T, error) { + raw, err := json.Marshal(src) + if err != nil { + return nil, fmt.Errorf("copy item across workspaces: snapshot for pre-check: %w", err) + } + var out T + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("copy item across workspaces: snapshot for pre-check: %w", err) + } + return &out, 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. +// +// THE OVERRIDE MERGE IS THE PREFLIGHT'S, EXACTLY (TASK-2365). Both of +// TASK-2364's KNOWN DIVERGENCES lived in this loop and both are closed here, +// on the side the preflight already took: +// +// - an UNDECLARED key is refused (UndeclaredOverrideError), not merged into +// items.fields as an orphan the destination schema never mentions; +// - a NULL value DELETES the key rather than assigning nil. ValidateFields +// treats a nil value as absent for the required check but LEAVES IT IN THE +// MAP, so assigning nil persisted a literal `"key": null` that the preview +// reported as unset. Deleting also lets the destination schema's DEFAULT +// re-apply, which is what the preflight shows. +// +// Anything added to this loop has a counterpart in +// handleCopyItemPreflight's, and TestCopyEndpoint_PreflightAndCopyAgree* +// fails when the two drift. +// +// NOT BYTE-FAITHFUL, and deliberately not fixed here (Codex round 26). +// Decoding items.fields into map[string]any and re-encoding it turns every +// JSON number into a float64, so an integer past 2^53 is rounded, and key +// order, escaping and number formatting are normalised rather than preserved. +// That is how EVERY field write in Pad works — handleMoveItem, handleUpdateItem +// and items.ValidateFields all operate on map[string]any — and, decisively, +// the preflight does the identical round-trip, so the preview and the copy +// AGREE. Making the copy alone byte-faithful would break that agreement, which +// is the one thing this pipeline exists to preserve. It belongs with BUG-2367, +// as a change to the field model for all callers at once. +// +// 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) + } + + // Refused BEFORE the source item's fields are even parsed, so the + // rejection cannot depend on the source's contents — same ordering the + // preflight uses for the same reason. + if bad := items.UndeclaredOverrideKeys(overrides, targetSchema.Fields); len(bad) > 0 { + return nil, nil, &UndeclaredOverrideError{Keys: bad} + } + + 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 { + if v == nil { + // An explicit null means "leave this unset". DELETE rather than + // assign nil: ValidateFields leaves a nil value in the map, so + // assignment persists a literal `"key": null` the preflight + // reported as unset — and suppresses the schema default that the + // preflight shows re-applying. + delete(migrated.Fields, k) + continue + } + 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), an undeclared field override, the item quota (DR-16, +// which logs its own bounded line), a source that is missing or already +// archived, a caller-supplied PreCheck refusal (the HTTP layer's in-tx +// authorization re-check, which is a 403/404 the caller renders), 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 undeclared *UndeclaredOverrideError + var limit *ItemLimitError + var precheck *CopyPreCheckError + return errors.As(err, &validation) || + errors.As(err, &undeclared) || + errors.As(err, &limit) || + errors.As(err, &precheck) || + errors.Is(err, sql.ErrNoRows) || + errors.Is(err, ErrCopySourceCollectionMissing) || + errors.Is(err, ErrCopyTargetCollectionMissing) || + 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..00a31ac3 --- /dev/null +++ b/internal/store/items_cross_workspace_copy_test.go @@ -0,0 +1,1337 @@ +package store + +import ( + "database/sql" + "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) + } + } + }) + } +} + +// --- Collections that vanish under the lock (TASK-2365) --------------------- + +// TestCopyAcrossWorkspaces_CollectionMissingSentinels pins the DETECTION half +// of the pre-write rejection the HTTP layer maps to a 404. +// +// Both are reachable through the same narrow window: the caller is authorized +// against a live collection, and it is soft-deleted before this transaction +// re-reads it under the locks. They must come back as the exported sentinels +// rather than anonymous errors, because the HTTP layer's fallback is DR-13's +// "the copy may or may not have landed" 500 — a message that would send the +// user hunting for an item nothing ever tried to create. +// +// A foreign target collection takes the SAME sentinel as an absent one: +// getCollectionInWorkspaceTx is workspace-scoped, and that scope is the +// security boundary which makes "a collection in someone else's workspace" a +// not-found rather than a cross-workspace write. +func TestCopyAcrossWorkspaces_CollectionMissingSentinels(t *testing.T) { + t.Run("source collection soft-deleted under the lock", func(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Orphaned", "body") + if err := f.s.DeleteCollection(f.colA.ID, ""); err != nil { + t.Fatalf("DeleteCollection: %v", err) + } + req := f.req() + req.SourceItemID = src.ID + _, err := f.s.CopyItemAcrossWorkspaces(req) + if !errors.Is(err, ErrCopySourceCollectionMissing) { + t.Fatalf("err = %v, want ErrCopySourceCollectionMissing", err) + } + }) + + t.Run("target collection soft-deleted under the lock", func(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Homeless", "body") + if err := f.s.DeleteCollection(f.colB.ID, ""); err != nil { + t.Fatalf("DeleteCollection: %v", err) + } + req := f.req() + req.SourceItemID = src.ID + _, err := f.s.CopyItemAcrossWorkspaces(req) + if !errors.Is(err, ErrCopyTargetCollectionMissing) { + t.Fatalf("err = %v, want ErrCopyTargetCollectionMissing", err) + } + }) + + t.Run("target collection in another workspace", func(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Confused Deputy", "body") + req := f.req() + req.SourceItemID = src.ID + req.TargetCollectionID = f.colC.ID // live, but in workspace C + _, err := f.s.CopyItemAcrossWorkspaces(req) + if !errors.Is(err, ErrCopyTargetCollectionMissing) { + t.Fatalf("err = %v, want ErrCopyTargetCollectionMissing", err) + } + }) + + // All of them are caller-facing rejections, so the copy must NOT log them + // as incidents alongside genuine deadlocks and DB failures. + t.Run("classified as expected rejections", func(t *testing.T) { + for _, err := range []error{ErrCopySourceCollectionMissing, ErrCopyTargetCollectionMissing} { + if !isExpectedCopyRejection(err) { + t.Errorf("%v is logged as an incident; it is a 404 the caller renders", err) + } + } + }) +} + +// TestCopyAcrossWorkspaces_BadRequestBeatsQuota pins the ORDER of the two +// refusals, which is a DR-6 agreement question rather than a preference. +// +// The preflight has no quota check at all — it documents the destination +// workspace's item quota as one of the things `valid` deliberately does not +// evaluate — so if the copy tested the quota first, a malformed request into a +// full cloud workspace would come back 403 plan_limit_exceeded from the copy +// and 400 malformed_override from its own preview (Codex round 18). A client +// told "you are out of room" cannot fix an override it was never told about. +// +// Moving field validation ahead of the quota costs nothing and weakens +// nothing: it is pure computation over rows already read, and the quota still +// runs inside the transaction before any insert, which is all DR-16 asks. +func TestCopyAcrossWorkspaces_BadRequestBeatsQuota(t *testing.T) { + // A cap of 1, already consumed, so the destination is genuinely full. + f := newQuotaFixture(t, 1) + if _, err := f.s.CreateItem(f.wsB.ID, f.colB.ID, models.ItemCreate{Title: "Occupant"}); err != nil { + t.Fatalf("CreateItem(occupant): %v", err) + } + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Doomed", "body") + + // Control: with a well-formed request the quota IS what refuses. + quotaReq := f.req() + quotaReq.SourceItemID = src.ID + quotaReq.EnforceItemLimit = true + if _, err := f.s.CopyItemAcrossWorkspaces(quotaReq); err == nil { + t.Fatal("fixture precondition: the destination is not actually full") + } else { + var limitErr *ItemLimitError + if !errors.As(err, &limitErr) { + t.Fatalf("control: err = %v, want *ItemLimitError", err) + } + } + + // The real assertion: the same full destination, plus an undeclared + // override, must report the OVERRIDE. + badReq := f.req() + badReq.SourceItemID = src.ID + badReq.EnforceItemLimit = true + badReq.FieldOverrides = map[string]any{"not_a_field": "x"} + + _, err := f.s.CopyItemAcrossWorkspaces(badReq) + var undeclared *UndeclaredOverrideError + if !errors.As(err, &undeclared) { + t.Fatalf("err = %v (%T), want *UndeclaredOverrideError — a bad request must be reported as a "+ + "bad request whether or not the destination happens to be full", err, err) + } + var limitErr *ItemLimitError + if errors.As(err, &limitErr) { + t.Error("the quota refusal shadowed the malformed override") + } +} + +// --- The attachment-ref rewrite (TASK-2365) --------------------------------- + +// TestRemapAttachmentRefsTokenizesLikeThePlanner pins the property that makes +// items_cross_workspace_copy.go's claim true — that the rewrite "covers +// precisely the reference set the plan cloned". +// +// The rewrite and the planner must AGREE ON WHERE A REFERENCE ENDS. The +// planner's regex is greedy, so `pad-attachment:x` is ONE id, `x`, +// which resolves to nothing and is deliberately left alone (DR-11a: an +// unresolvable ref keeps its literal text "so the copy renders exactly as +// broken as the source did"). A naive strings.ReplaceAll over +// "pad-attachment:"+old rewrote the `` prefix inside it anyway (Codex +// round 26), producing text matching neither the plan nor the user's input. +func TestRemapAttachmentRefsTokenizesLikeThePlanner(t *testing.T) { + const ( + oldID = "11111111-2222-4333-8444-555555555555" + newID = "99999999-8888-4777-8666-555555555555" + ) + idMap := map[string]string{oldID: newID} + + cases := []struct { + name string + in string + want string + }{ + { + "a plain reference is rewritten", + "![a](pad-attachment:" + oldID + ")", + "![a](pad-attachment:" + newID + ")", + }, + { + "a JSON-encoded reference is rewritten", + `{"cover":"pad-attachment:` + oldID + `"}`, + `{"cover":"pad-attachment:` + newID + `"}`, + }, + { + // The one this test exists for. + "a LONGER id that merely starts with a mapped one is left alone", + "pad-attachment:" + oldID + "x", + "pad-attachment:" + oldID + "x", + }, + { + "both forms in one body are handled independently", + "ok pad-attachment:" + oldID + " broken pad-attachment:" + oldID + "x", + "ok pad-attachment:" + newID + " broken pad-attachment:" + oldID + "x", + }, + { + "a bare id outside the prefix is never touched", + "the id is " + oldID + " on its own", + "the id is " + oldID + " on its own", + }, + { + "an unmapped reference survives verbatim", + "pad-attachment:00000000-0000-4000-8000-000000000000", + "pad-attachment:00000000-0000-4000-8000-000000000000", + }, + {"no references at all", "just prose", "just prose"}, + {"empty", "", ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := remapAttachmentRefs(tc.in, idMap); got != tc.want { + t.Fatalf("got %q\nwant %q", got, tc.want) + } + }) + } + + // An empty map is a no-op even over text full of references. + busy := "pad-attachment:" + oldID + if got := remapAttachmentRefs(busy, nil); got != busy { + t.Fatalf("nil map rewrote %q to %q", busy, got) + } +} + +// --- The PreCheck hook (TASK-2365) ------------------------------------------ + +// TestCopyAcrossWorkspaces_PreCheckRefusalIsWrapped pins two guarantees the +// hook's callers depend on and cannot verify themselves. +// +// The WRAPPING is the one that matters operationally: a hook refusal is a +// 403/404 the HTTP layer renders, not an incident, and asking every caller to +// remember to wrap it made "forgot to wrap" a silent way to page an operator +// over a routine permission denial (Codex round 10). The caller's own error +// type still comes back out through errors.As. +func TestCopyAcrossWorkspaces_PreCheckRefusalIsWrapped(t *testing.T) { + f := newCopyFixture(t) + src := createTestItem(t, f.s, f.wsA.ID, f.colA.ID, "Refused", "body") + + sentinel := errors.New("caller's own refusal") + req := f.req() + req.SourceItemID = src.ID + req.PreCheck = func(_ *sql.Tx, _ *models.Item, _ *models.Collection) error { return sentinel } + + _, err := f.s.CopyItemAcrossWorkspaces(req) + var wrapped *CopyPreCheckError + if !errors.As(err, &wrapped) { + t.Fatalf("err = %v (%T), want it wrapped in *CopyPreCheckError", err, err) + } + if !errors.Is(err, sentinel) { + t.Errorf("the caller's own error is not recoverable through the wrapper: %v", err) + } + if !isExpectedCopyRejection(err) { + t.Error("a hook refusal is logged as an incident; it is a status the caller renders") + } + // And nothing was written. + if items, lErr := f.s.ListItems(f.wsB.ID, models.ItemListParams{}); lErr != nil || len(items) != 0 { + t.Fatalf("the refused copy left %d item(s) in the destination (err=%v)", len(items), lErr) + } +} + +// TestCopyAcrossWorkspaces_PreCheckGetsCopies — the hook is handed DETACHED +// snapshots, so a hook that mutates (or retains and later mutates) what it was +// given cannot rewrite what the transaction actually copies, nor what the +// post-commit fanout says about it. +// +// Three shapes are exercised, because three different implementations fail at +// different ones: +// +// - VALUE fields (Title, Content, the collection's Slug) — a plain struct +// copy already handles these; +// - a POINTER field written THROUGH (`*source.AssignedUserID = x`) — a plain +// struct copy aliases it, and it reaches carryAssigneeTx (Codex round 11); +// - a SLICE field appended to and mutated in place (ImplementationNotes) — +// hand-enumerated pointer cloning misses these entirely (Codex round 12), +// and they ride out on the source's move webhook payload. +// +// The last two are the ones that make this test worth having; assert on all +// three so a regression in any implementation strategy is caught. +func TestCopyAcrossWorkspaces_PreCheckGetsCopies(t *testing.T) { + f := newCopyFixture(t) + + // The assignee must be a member of BOTH workspaces, or DR-8 drops it and + // the pointer half of this test proves nothing. + assignee := createTestUser(t, f.s, "assignee@example.com", "Assignee", "pw-assignee") + intruder := createTestUser(t, f.s, "intruder@example.com", "Intruder", "pw-intruder") + for _, ws := range []*models.Workspace{f.wsA, f.wsB} { + for _, u := range []string{assignee.ID, intruder.ID} { + if err := f.s.AddWorkspaceMember(ws.ID, u, "editor"); err != nil { + t.Fatalf("AddWorkspaceMember: %v", err) + } + } + } + + // implementation_notes hydrates Item.ImplementationNotes (a SLICE) via + // hydrateItemComputedMetadata, which getItemTx runs — so the hook is + // handed a slice header sharing the store's backing array unless the + // snapshot is a genuine deep copy. + src, err := f.s.CreateItem(f.wsA.ID, f.colA.ID, models.ItemCreate{ + Title: "Original Title", Content: "body", AssignedUserID: &assignee.ID, + Fields: `{"implementation_notes":[{"summary":"original note"}]}`, + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + + var heldItem *models.Item + var heldColl *models.Collection + req := f.req() + req.SourceItemID = src.ID + req.PreCheck = func(_ *sql.Tx, source *models.Item, targetColl *models.Collection) error { + heldItem, heldColl = source, targetColl + source.Title = "Hijacked" + source.Content = "hijacked body" + targetColl.Slug = "hijacked-slug" + if source.AssignedUserID == nil { + t.Fatal("fixture precondition: the hook was handed an unassigned item") + } + // THE POINTER CASE: writing THROUGH the pointer, not replacing it. + *source.AssignedUserID = intruder.ID + // THE SLICE CASE: mutating the shared backing array in place. + if len(source.ImplementationNotes) == 0 { + t.Fatal("fixture precondition: the hook was handed no implementation notes") + } + source.ImplementationNotes[0].Summary = "hijacked note" + return nil + } + + res := f.copy(t, req) + + if res.Item.Title != "Original Title" { + t.Errorf("the copy's title is %q; a PreCheck mutation reached the insert", res.Item.Title) + } + if res.Item.Content != "body" { + t.Errorf("the copy's content is %q; a PreCheck mutation reached the insert", res.Item.Content) + } + if res.TargetCollection.Slug == "hijacked-slug" { + t.Error("a PreCheck mutation reached the collection snapshot the caller fans out on") + } + if res.Item.AssignedUserID == nil || *res.Item.AssignedUserID != assignee.ID { + t.Errorf("the copy is assigned to %v, want %s — a PreCheck write THROUGH a pointer field "+ + "reached the insert, so the snapshot is only shallow", + res.Item.AssignedUserID, assignee.ID) + } + // res.Source is what the caller fans out on for the source's move webhook, + // so a slice mutation that reached it would be published. + if len(res.Source.ImplementationNotes) == 0 || + res.Source.ImplementationNotes[0].Summary != "original note" { + t.Errorf("the source snapshot's notes are %+v; a PreCheck mutation of a SLICE field "+ + "reached the result the caller fans out on", res.Source.ImplementationNotes) + } + if heldItem == res.Source || heldColl == res.TargetCollection { + t.Error("the hook was handed the canonical pointers, not copies") + } + if heldItem.AssignedUserID == res.Source.AssignedUserID { + t.Error("the hook's item aliases the canonical item's AssignedUserID pointer") + } + if len(heldItem.ImplementationNotes) > 0 && len(res.Source.ImplementationNotes) > 0 && + &heldItem.ImplementationNotes[0] == &res.Source.ImplementationNotes[0] { + t.Error("the hook's item shares the canonical item's ImplementationNotes backing array") + } +} 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) } diff --git a/internal/store/link_types.go b/internal/store/link_types.go new file mode 100644 index 00000000..c632541c --- /dev/null +++ b/internal/store/link_types.go @@ -0,0 +1,19 @@ +package store + +// ChildLinkTypes is the exported, read-only view of childLinkTypes +// (items.go) — the link types GetChildItems walks to find an item's +// children. +// +// It exists because callers OUTSIDE this package have to classify a +// link_type the same way GetChildItems does, and the only alternative is +// a second hardcoded list that drifts silently the day someone adds a +// third child link type. PLAN-2357's copy preflight is the first such +// caller: it partitions an item's links into hierarchy edges (reported as +// child_count / dropped_parent) and dependency edges (reported as +// outgoing/incoming link counts), and an edge that falls out of both +// partitions is a relationship the user is never told they are losing. +// +// Returns a copy; the underlying slice is package state. +func ChildLinkTypes() []string { + return append([]string(nil), childLinkTypes...) +} 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/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 } 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) --- diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index 818613e5..0f59370c 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,261 @@ 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; + +// ─── Cross-workspace copy preflight (PLAN-2357 / TASK-2364) ────────────────── +// +// POST /api/v1/workspaces/{ws}/items/{itemSlug}/copy/preflight +// +// The URL's workspace is the SOURCE; the destination is named in the body. +// The server owns the bucketing (DR-6) — do NOT reimplement any of this in +// TypeScript, or the dialog and the CLI will disagree the first time a +// migration rule changes. Call the endpoint again whenever the user changes +// the destination or an override: it leaves no trace a copy would have left +// (no item, no attachment rows, no provenance row, neither workspace's seq +// advances, no activity/SSE/webhook) and is safe to call repeatedly. It is +// still an ordinary authenticated request, so the usual middleware effects +// — rate-limit budget, request metrics, last-active timestamp — apply. +// +// Error statuses worth branching on (envelope: `{error:{code,message}}`): +// 400 `malformed_override` — an override names a field the destination +// schema does not declare +// 400 `invalid_override` — an override's VALUE is rejected by the +// destination schema +// 403 `forbidden` / `permission_denied` — destination workspace not +// accessible to the caller +// 404 `collection_not_found` — destination collection absent OR hidden +// (deliberately indistinguishable) +// 409 `archived` — source item exists and is visible, but is +// archived +// 404 `not_found` — source item absent or not visible +export interface ItemCopyPreflightRequest { + /** Destination workspace slug (a UUID is also accepted). */ + target_workspace: string; + /** Destination collection slug. */ + target_collection: string; + /** + * Destination-schema field key → value. A `null` value means "leave this + * key unset": the key is removed before validation, not written as null. + * What it becomes then depends on the destination schema — a field with + * a default comes back in `carried` with `from: "default"`, a REQUIRED + * field with no default lands in `needs_value`, and an optional one with + * no default is simply absent. Keys the destination schema does not + * declare are rejected with 400 `malformed_override`. + */ + field_overrides?: Record; + /** The MOVE path: copy, then archive the source. Default false. */ + archive_source?: boolean; +} + +/** Where the value in a `carried` row came from. */ +export type ItemCopyPreflightOrigin = 'migrated' | 'override' | 'default'; + +export interface ItemCopyPreflightCarried { + key: string; + label?: string; + type?: string; + /** Final value — after migration, overrides and destination defaults. */ + value: unknown; + from: ItemCopyPreflightOrigin; +} + +export interface ItemCopyPreflightDropped { + key: string; + label?: string; + /** `assignment` covers the assignee / agent-role pair, not a schema field. */ + kind: 'field' | 'assignment'; + reason: + | 'no_target_field' + | 'incompatible_type' + /** The item carries a key its own source schema no longer declares. */ + | 'undeclared_source_field' + | 'assignee_not_a_member' + | 'agent_role_not_portable'; +} + +export interface ItemCopyPreflightNeedsValue { + key: string; + label?: string; + type?: string; + options?: string[]; + required: boolean; + reason: 'missing_required' | 'invalid_value'; + message?: string; +} + +/** + * The three bucket names are the contract (DR-15) — renaming one is a + * breaking change. All three arrays are always present, never null. + */ +export interface ItemCopyPreflightFields { + carried: ItemCopyPreflightCarried[]; + dropped: ItemCopyPreflightDropped[]; + needs_value: ItemCopyPreflightNeedsValue[]; +} + +/** + * DR-15's full warning set. None of it blocks the copy — it is what the user + * is entitled to know before agreeing (DR-17: "none of this may be silent"). + */ +export interface ItemCopyPreflightWarnings { + /** Live children of the source. Never copied (DR-4). */ + child_count: number; + /** `child_count > 0 && archive_source` — the move path orphans them. */ + children_orphaned: boolean; + /** + * The source has a parent; the copy is unparented (DR-17). Covers both + * the `parent` item_links edge and the legacy `parent_id` column, since + * the copy scrubs both. + */ + dropped_parent: boolean; + /** + * Dependency edges by `link_type`, e.g. `{ blocks: 2 }`. Hierarchy types + * (`parent` / `implements` / legacy `plan`) are excluded — they are + * reported by `child_count` and `dropped_parent`. Always present; `{}` + * means no dependency edges. None of these carry. + */ + outgoing_links: Record; + incoming_links: Record; + /** False when the assignee IS a member of the destination (DR-8). */ + dropped_assignee: boolean; + /** Role slugs are workspace-local, so an agent role never carries (DR-8). */ + dropped_agent_role: boolean; + /** Reported, not enforced (DR-16). Includes thumbnail variants. */ + attachment_count: number; + /** + * Serialized from a Go int64 as a JSON number. Safe as a JS `number`: + * the value is a sum of per-attachment byte counts for ONE item, so + * exceeding Number.MAX_SAFE_INTEGER (~9 PB) is not reachable. + */ + attachment_bytes: number; + /** `pad-attachment:` refs that resolve to nothing (DR-11a). Not fatal. */ + unresolvable_ref_count: number; +} + +export interface ItemCopyPreflight { + source: { + workspace_slug: string; + collection_slug: string; + ref?: string; + slug: string; + title: string; + }; + destination: { + workspace_slug: string; + workspace_name: string; + collection_slug: string; + collection_name: string; + }; + /** Echoes the request, and selects `warnings.children_orphaned`. */ + archive_source: boolean; + /** + * True when `fields.needs_value` is empty — i.e. the field mapping is + * complete. Gate the confirm button on it. + * + * It is NOT a promise that the copy will succeed: unique-slug + * collisions, the destination's item quota, cross-backend attachment + * transfer and any concurrent change are all decided inside the + * mutating copy's transaction. Always handle that call's error path. + */ + valid: boolean; + fields: ItemCopyPreflightFields; + warnings: ItemCopyPreflightWarnings; +} + +// --- Cross-workspace copy, the MUTATION (PLAN-2357 / TASK-2365) ------------ +// +// POST /api/v1/workspaces/{ws}/items/{itemSlug}/copy +// +// Same request shape as the preflight above — send ItemCopyPreflightRequest +// to both, so a dialog can preview and then commit without rebuilding it. +// Override semantics are identical too: an undeclared key is a 400, and a +// `null` value unsets rather than writing null. +// +// ⚠ NEVER RETRY THIS CALL AUTOMATICALLY (PLAN-2357 DR-13). There is no +// idempotency key in v1, so a retry after a request that already committed +// creates a DUPLICATE item — and a client that timed out cannot tell which +// happened. On 500 `copy_failed` the server is explicitly telling you the +// outcome is unknown: show the message, and send the user to look at the +// destination. The shared api client only retries GET/HEAD; keep it that way. +// +// Error statuses beyond the preflight's set: +// 403 `plan_limit_exceeded` — destination workspace at its item cap; +// carries {feature, limit, current, plan} +// 403 `actor_required` — nothing to attribute the copy to +// 409 `conflict` — unique-constraint collision in the +// destination (slug / title / invocation slug) +// 500 `copy_failed` — AMBIGUOUS; see the warning above + +/** Identity of the item that was copied, and whether it survived. */ +export interface ItemCopyResultSource { + /** Canonical slug, never the UUID form the URL may have used. */ + workspace_slug: string; + collection_slug: string; + ref?: string; + slug: string; + title: string; + /** True only on the move path. A plain copy leaves the source untouched. */ + archived: boolean; + /** + * The source workspace's committed seq for the ARCHIVE. Present only on + * a move — a plain copy does not write in the source at all and must not + * advance its cursor. + */ + seq?: number; +} + +/** Where the copy landed. `workspace_slug` + `ref` is the navigation pair. */ +export interface ItemCopyResultDestination { + workspace_slug: string; + workspace_name: string; + collection_slug: string; + collection_name: string; + ref?: string; + slug: string; + /** The destination workspace's committed seq — never the source's. */ + seq?: number; +} + +/** + * What the copy actually dropped and cloned — the after-the-fact counterpart + * to the preflight's warnings. Deliberately narrower: the relationship + * counters are preview-only, because they describe the SOURCE's relatives, + * which the copy did not touch and which the user already saw before + * agreeing. + */ +export interface ItemCopyResultWarnings { + /** Destination-schema keys migration could not carry. Never null. */ + dropped_fields: string[]; + dropped_assignee: boolean; + dropped_agent_role: boolean; + attachment_count: number; + attachment_bytes: number; + unresolvable_ref_count: number; +} + +/** The 201 response. */ +export interface ItemCopyResult { + source: ItemCopyResultSource; + destination: ItemCopyResultDestination; + /** Echoes the request; `source.archived` is what actually happened. */ + archive_source: boolean; + /** + * The destination item as committed. Not enriched with relations — a + * fresh copy has none: the parent is scrubbed and no links carry. + */ + item: Item; + warnings: ItemCopyResultWarnings; +} export interface ItemIndexResponse { items: ItemIndexRow[];