Merge origin/main 8ea3c8d5e into rook/task-4104-d3 (new ci.yml, platform TASK-78)

Claude-Session: https://claude.ai/code/session_01Pa22hLq5uwA4j17kAyQgbJ
This commit is contained in:
xarmian
2026-11-02 14:00:25 +00:00
98 changed files with 4774 additions and 1087 deletions
+350 -135
View File
@@ -7,8 +7,9 @@ on:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
# Per-SHA on main: never evict a pending run or its vendorHash heal.
group: ci-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -28,7 +29,51 @@ env:
# execute attacker code in CI. Bump the SHA + comment together when updating.
jobs:
changes:
name: changes
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
go: ${{ steps.scope.outputs.go }}
web: ${{ steps.scope.outputs.web }}
nix: ${{ steps.scope.outputs.nix }}
parity: ${{ steps.scope.outputs.parity }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# Checkout's merge ref normally contains both parents. Fetch exact event
# commits too, so a moved base or fork branch cannot change classification.
- name: Ensure exact PR commits are available
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git cat-file -e "$BASE_SHA^{commit}" || git fetch --no-tags origin "$BASE_SHA"
git cat-file -e "$HEAD_SHA^{commit}" || git fetch --no-tags origin "$HEAD_SHA"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Test CI policy and trace scripts
run: python3 -m unittest scripts/ci-scope_test.py scripts/ci-result_test.py scripts/ci-pg-trace_test.py
- name: Test source census
run: go test ./scripts/ci-web-census -count=1
- name: Census all tracked Go sources for web and PostgreSQL references
run: go run ./scripts/ci-web-census
- name: Classify paths
id: scope
run: python3 scripts/ci-scope.py
go:
needs: changes
if: needs.changes.outputs.go == 'true'
# Backstop only. A hung TEST is caught by -timeout=45m on the steps
# below, which fails fast AND prints the goroutine dump that tells
# you which test hung; this cap exists because a job with no
@@ -181,14 +226,10 @@ jobs:
run: go test -timeout=45m ./...
- name: Run tests with race detector
# Runs on both push-to-main AND pull_request. Previously gated to
# main only because GitHub Actions minutes were billed on private
# repos; the repo is public now, so PR minutes are free and we'd
# rather catch race regressions on the contributing branch than
# after merge. See BUG-1371 (also dropped test-only bcrypt cost via
# TestMain, which brought this step back under the then-30m budget —
# it did NOT raise the budget; BUG-1913 later did, 30m→45m, when the
# suite outgrew it again).
id: race
if: github.event_name == 'push'
# Dave's ruling (TASK-4224): race runs after merge on main only.
# PRs keep the non-race suite; a main failure is stop-the-line.
#
# Default 10m is tight: the full server-package suite under -race
# measures ~13m locally on a developer laptop after BUG-851 (the
@@ -214,7 +255,7 @@ jobs:
# Measured: -25% on 267 serial server tests, -40% on a 40-test probe.
#
# internal/server is NOT in this step: it runs as the three
# go-server-race shards below (TASK-4141). Alone it took 2519.644s
# go-server-race shards in race.yml (TASK-4141). Alone it took 2519.644s
# of this step's 2700s budget on main. The exclusion is asserted,
# not trusted: the step fails unless exactly one package was
# removed and it is internal/server. That catches a grep that
@@ -231,6 +272,26 @@ jobs:
[ "$removed" = github.com/PerpetualSoftware/pad/internal/server ] || { printf '::error::the race step must exclude exactly internal/server; it excluded:\n%s\n' "${removed:-(nothing)}"; exit 1; }
go test $GO_RACE_FLAGS -timeout=45m $pkgs
# A race failure on main is stop-the-line (Dave's TASK-4224 ruling), the
# same treatment race.yml gives the internal/server shards. Keyed on the
# race step's own outcome, so an ordinary test failure earlier in the job
# (which skips this step) is not reported as a race failure.
- name: Stop the line on main race failure
if: >-
always() && github.event_name == 'push' &&
(steps.race.outcome == 'failure' || steps.race.outcome == 'cancelled')
env:
FAILED_STEP: "Go: Run tests with race detector"
OUTCOME: ${{ steps.race.outcome }}
run: |
echo "::error::$FAILED_STEP: $OUTCOME at $GITHUB_SHA"
{
echo '# STOP THE LINE: a main race run failed or did not finish'
echo "Step: $FAILED_STEP ($OUTCOME)"
echo "Commit: $GITHUB_SHA"
echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
} >> "$GITHUB_STEP_SUMMARY"
- name: Build binary
run: go build -o pad ./cmd/pad
@@ -453,6 +514,8 @@ jobs:
run: go test ./internal/materialize/ -run '^(TestSupervisor.*|TestParseMemLimit|TestConfigFromEnv|TestParsePSRSS|TestPSRSSLive|TestParseStatVsize|TestStderrOOMMarkers|TestDeepNestFrameShallow)$' -count=1 -timeout 20m -v
go-postgres:
needs: changes
if: needs.changes.outputs.go == 'true'
# Backstop only. A hung TEST is caught by -timeout=45m on the steps
# below, which fails fast AND prints the goroutine dump that tells
# you which test hung; this cap exists because a job with no
@@ -569,6 +632,7 @@ jobs:
- name: Run tests against PostgreSQL
env:
PAD_TEST_POSTGRES_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
PAD_TEST_POSTGRES_TOOLS_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
# Explicit -timeout: see the SQLite "Run tests" step. This is the
# binary that actually blew the 10m default — internal/store creates a
# fresh database AND replays the full migration chain per test
@@ -579,13 +643,25 @@ jobs:
# exceeded 10m, i.e. >2.14x this box on that package. (An earlier
# version of this comment said "~2x slower, which put store over
# 10m" — 280s x 2 is 9m20s, so that explained nothing.)
run: go test -timeout=45m ./... -count=1
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" = pull_request ]; then
mapfile -t packages < <(sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d' scripts/ci-pg-packages.txt)
test "${#packages[@]}" -gt 0
go test -timeout=45m -count=1 "${packages[@]}"
else
PAD_TEST_PG_TRACE=1 go test -json -timeout=45m -count=1 ./... \
| tee /tmp/pg-full.jsonl \
| python3 scripts/ci-pg-trace.py --expect scripts/ci-pg-packages.txt --show-output
fi
- name: Run tests with race detector against PostgreSQL
id: race
if: github.event_name == 'push'
env:
PAD_TEST_POSTGRES_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
# Runs on both push-to-main AND pull_request — see SQLite race-step
# comment for the public-repo / BUG-1371 reasoning.
PAD_TEST_POSTGRES_TOOLS_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
# Push-to-main only, per Dave's TASK-4224 ruling.
#
# Headroom over the default 10m. PostgreSQL adds latency on
# every CREATE/DROP, and the PLAN-866 attachment work pushed the
@@ -596,7 +672,7 @@ jobs:
# suite's aggregate runtime crossed the old budget (this job
# variant failed main at f235a04 with the same timeout panic).
# checkptr off for modernc.org/... only: see the SQLite race step (BUG-3483).
# internal/server runs as the go-server-race-postgres shards instead
# internal/server runs as the go-server-race-postgres shards in race.yml instead
# (TASK-4141; it took 2658.534s of 2700s here on main). The exclusion
# is asserted as in the SQLite race step: exactly one package
# removed, and it is internal/server.
@@ -608,114 +684,26 @@ jobs:
[ "$removed" = github.com/PerpetualSoftware/pad/internal/server ] || { printf '::error::the race step must exclude exactly internal/server; it excluded:\n%s\n' "${removed:-(nothing)}"; exit 1; }
go test $GO_RACE_FLAGS -timeout=45m -count=1 $pkgs
# internal/server under -race, as 3 shards per driver (TASK-4141). In the
# ./... race steps it took 2519.644s (SQLite) and 2658.534s (PostgreSQL) of
# the 2700s per-binary budget on main, sharing ~4 vCPU with every other
# package. No test or family is more than ~4% of its wall and ~88% of it is
# serial tests, so there is no slow test to fix; splitting it is the lever.
# scripts/test-shard.sh says how tests are assigned, and go-server-shards
# proves the shards cover the package exactly once.
go-server-race:
# Backstop only, as on the go job: -timeout=45m fires first.
timeout-minutes: 75
name: Go race internal/server (SQLite ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# See the identical step in the `go` job.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Create web build placeholder for embed
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# Built for real, as in the `go` job: internal/server's tests run it.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
- name: Run internal/server shard with race detector
# Same stop-the-line treatment as the SQLite race step in the go job.
- name: Stop the line on main race failure
if: >-
always() && github.event_name == 'push' &&
(steps.race.outcome == 'failure' || steps.race.outcome == 'cancelled')
env:
SHARD: ${{ matrix.shard }}
FAILED_STEP: "Go (PostgreSQL): Run tests with race detector against PostgreSQL"
OUTCOME: ${{ steps.race.outcome }}
run: |
run=$(scripts/test-shard.sh regex ./internal/server/ 3 "$SHARD" $GO_RACE_FLAGS)
go test $GO_RACE_FLAGS -timeout=45m -run "$run" ./internal/server/
go-server-race-postgres:
# Backstop only, as on the go-postgres job: -timeout=45m fires first.
timeout-minutes: 75
name: Go race internal/server (PostgreSQL ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
services:
postgres:
# Same image and digest as the go-postgres job; bump them together.
image: mirror.gcr.io/library/postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
env:
POSTGRES_USER: pad
POSTGRES_PASSWORD: pad
POSTGRES_DB: pad
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pad"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# See the identical step in the `go` job.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Create web build placeholder for embed
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# Built for real, as in the `go` job: internal/server's tests run it.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
# internal/server's tests need no PostgreSQL client tools (no pg_dump
# or psql), so the PGDG install the go-postgres job does is not here.
- name: Run internal/server shard with race detector against PostgreSQL
env:
PAD_TEST_POSTGRES_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
SHARD: ${{ matrix.shard }}
run: |
run=$(scripts/test-shard.sh regex ./internal/server/ 3 "$SHARD" $GO_RACE_FLAGS)
go test $GO_RACE_FLAGS -timeout=45m -count=1 -run "$run" ./internal/server/
echo "::error::$FAILED_STEP: $OUTCOME at $GITHUB_SHA"
{
echo '# STOP THE LINE: a main race run failed or did not finish'
echo "Step: $FAILED_STEP ($OUTCOME)"
echo "Commit: $GITHUB_SHA"
echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
} >> "$GITHUB_STEP_SUMMARY"
go-server-shards:
needs: changes
if: needs.changes.outputs.go == 'true'
# The shards are only a gate if together they run every test exactly
# once. A shard bug drops tests SILENTLY, which is worse than the timeout
# it replaces, so this job fails the workflow unless Go's own -run
@@ -822,8 +810,7 @@ jobs:
# ran mobile-chromium alone, 63 passed and 553 skipped, in 1.9m). And the
# projects are lopsided: desktop-chromium carried 1222s of the 1359s of
# test time (572 of 635 passing tests). Desktop is therefore sharded in
# two and mobile runs whole. The "E2E (Playwright)" job below is the one
# check that says whether E2E passed.
# two and mobile runs whole. The "CI result" job below checks the combined E2E matrix result.
strategy:
fail-fast: false
matrix:
@@ -968,22 +955,250 @@ jobs:
path: web/test-results/
retention-days: 7
# The single E2E verdict. Every reader of CI (merge rules, waiters, people)
# keys on the check named "E2E (Playwright)", and the repo has no branch
# protection to list the shard jobs by name, so this job keeps that name and
# is green only when EVERY matrix job succeeded. `if: always()` is load-bearing:
# without it a failed or cancelled matrix job SKIPS this job, and a skipped check
# reads as neither red nor missing.
e2e-result:
name: E2E (Playwright)
needs: e2e
go-web-parity:
name: Go web parity
needs: changes
if: needs.changes.outputs.parity == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: npm
cache-dependency-path: web/package-lock.json
- name: Build web and the real materializer bundle
working-directory: web
run: npm ci && npx vite build && node scripts/build-materializer.mjs
- name: Run SQLite web parity tests from the census manifest
run: |
set -euo pipefail
while read -r package filter; do
[[ -z "$package" || "$package" == \#* ]] && continue
if [ -n "$filter" ]; then
go test -timeout=45m -count=1 "$package" -run "$filter"
else
go test -timeout=45m -count=1 "$package"
fi
done < scripts/ci-web-parity.txt
nix:
needs: changes
if: needs.changes.outputs.nix == 'true'
name: Nix build & check
runs-on: ubuntu-latest
# NO write permission here, deliberately — see `push-vendor-hash` below.
# This job builds; it never pushes.
outputs:
vendor_hash_bumped: ${{ steps.vendorhash.outputs.bumped }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23
# THE RED-BASELINE FIX (TASK-2954). `vendorHash` pins the Go module set by
# content hash; Dependabot updates go.mod/go.sum and has no idea this file
# exists, so EVERY Go bump PR failed the steps below on a hash mismatch,
# structurally and forever. A check that is always red is not a check —
# a bump that genuinely breaks the build looked identical, at a glance, to
# one that only moved the hash.
#
# This recomputes the hash in the WORKING TREE, so the steps below judge
# the build with this ref's actual module set. Nothing is pushed from here
# and no token is used: a Dependabot PR run gets a read-only GITHUB_TOKEN
# (it runs as if from a fork), and the `permissions` key does NOT lift that
# — only a repository-wide setting does, which would hand fork PRs write
# tokens too. The push therefore happens on `push: main` instead, in the
# job below, where the run is not Dependabot's.
#
# It runs on every run, not just Dependabot's: a human's own go.mod change
# moves the hash the same way, and a check that is honest only for one
# author is the shape this item exists to remove.
#
# It runs BEFORE `nix flake check`, which builds the package too and would
# hit the same mismatch first. It is deliberately not a gate: if the build
# fails for any reason other than a go-modules hash mismatch, the script
# exits non-zero, this step still succeeds, and the real steps below report
# the real failure. The gate stays exactly where it was.
- name: Recompute vendorHash before judging the build
id: vendorhash
run: |
set -o pipefail
if nix build .#default --print-build-logs 2>&1 | tee /tmp/nix-build.log; then
echo "Build is green; vendorHash is current."
exit 0
fi
if ! new_hash="$(nix/bump-vendor-hash.sh /tmp/nix-build.log)"; then
echo "Not a go-modules hash mismatch — leaving it to the build steps below."
exit 0
fi
echo "vendorHash -> $new_hash"
echo "bumped=true" >> "$GITHUB_OUTPUT"
- name: Carry the recomputed package.nix to the push job
if: steps.vendorhash.outputs.bumped == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: recomputed-package-nix
path: nix/package.nix
retention-days: 1
- name: nix flake check
run: nix flake check --print-build-logs
- name: nix build
run: nix build .#default --print-build-logs
- name: Smoke test binary
run: |
./result/bin/pad --version
./result/bin/pad --help
# The Go toolchain here exists only to install govulncheck for the
# scan step below — the pad binary under test was built by Nix above.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Scan Nix-built binary with govulncheck
# CI's main govulncheck job scans a `go build` binary, which honours
# go.mod's `toolchain go1.26.10` line — but nixpkgs pins
# GOTOOLCHAIN=local, so the Nix artifact is built with whatever Go
# patch the nixos-26.05 channel ships and can carry stdlib advisories
# the main gate can no longer see (BUG-2567). This scans the artifact
# that actually ships via Nix, against nix/accepted-advisories.txt:
# known-accepted advisories stay green and visible, any NEW advisory
# fails the job, and a warning fires when an accepted advisory clears
# (the signal to prune the list and eventually close BUG-2567).
#
# govulncheck pinned to the same release as ci.yml — bump the two
# together. GOTOOLCHAIN=auto on the install mirrors ci.yml's
# rationale: setup-go exports GOTOOLCHAIN=local, and if govulncheck's
# own go.mod ever requires a patch newer than setup-go's resolved
# one, `auto` lets Go fetch it instead of failing the install.
run: |
GOTOOLCHAIN=auto go install golang.org/x/vuln/cmd/govulncheck@v1.2.0
GOVULNCHECK="$(go env GOPATH)/bin/govulncheck" nix/vulnscan.sh result/bin/pad
# THE ONLY JOB WITH A WRITE TOKEN, and it exists as a separate job precisely so
# that the token is never minted in a context an untrusted change could reach.
#
# An `if:` on a STEP is not a security boundary — the job would still hold a
# write-capable GITHUB_TOKEN, and `actions/checkout` persists it in the
# workspace for every later command, so any change to this file inside a PR
# could use it. An `if:` on a JOB is different in kind: the job does not start,
# and no write token is issued for that run at all.
#
# WHY ON `push: main` AND NOT ON THE PR (ruled on TASK-2954's trail). A
# Dependabot PR run gets a read-only token whatever `permissions` says. The
# merge, though, is authored by a human, so the `push` run that follows is an
# ordinary run with an ordinary token. The PR's own check was already honest,
# because the job above recomputes in the working tree; `main` is what needs
# the committed fix, and it gets it from the run of the merge itself — or,
# when that run loses the push race, from the run of the next push to main.
# The two rejected alternatives were a PAT in the Dependabot secret store (a
# standing credential for a once-a-week fix) and enabling write tokens for
# pull-request workflows (which would give them to FORK PRs on a public repo —
# the surface CONVE-2438 exists to keep closed).
#
# THE LOOP TERMINATES BECAUSE THIS PUSH TRIGGERS NOTHING. A push made with
# the default `GITHUB_TOKEN` creates no workflow runs, so the commit this job
# pushes gets no run at all. Observed on the first heal this fix produced
# (BUG-2974, day 62): `gh run list --branch main` spans cbfc073e ->
# 31d11e76 -> 0b16be49 and skips the heal commit 249a8f87 entirely. Read the
# BRANCH listing, not `gh run list --commit` — that query also returned
# nothing for 0b16be49, which demonstrably has runs, so it cannot tell
# "no runs" from "no answer". The loop is closed by construction rather than
# by being green.
#
# THE CORRECTED-TREE TERMINATOR IS THE BACKSTOP, AND IT HAS NEVER RUN. If
# this push did trigger a run, the build job above would find the hash
# correct, print "Build is green; vendorHash is current.", never set
# `bumped`, and this job would not start — the FIX's own effect on the build,
# holding regardless of what any push touched. That is an argument, not a
# measurement: nothing has ever exercised it. It becomes load-bearing the
# moment this push uses a PAT or a GitHub App token, which is the obvious
# edit for anyone who wants CI to validate the healed tree. Making that
# change inherits an untested terminator; exercise it before relying on it.
#
# A CONSEQUENCE OF THE SAME MECHANISM, not a defect: the healed tip carries
# no CI of its own. Its tree is the merge commit plus the one hash line that
# merge's own Nix run computed and built against, and the next push to main
# builds on it normally.
#
# THE RANGE-VS-STATE DISTINCTION IS LOAD-BEARING, AND IT WAS GOT WRONG ONCE
# (BUG-2974). This
# comment used to name the loop guard as a gate asking whether the push had
# touched `go.mod`/`go.sum`, and that gate existed as a step below. It is the
# right question for loop prevention and the WRONG one for recovery: after a
# lost push race the tree that needs healing was written by an EARLIER push,
# so every later merge that did not itself move the module set refused to fix
# it, exited GREEN, and left main carrying a hash a clean nix build rejects.
# The gate is gone. The state comparison below is the only gate, and being a
# question about state it is equally true for a hash this push broke and one
# an earlier push broke and could not land.
#
# If another merge lands while this job runs, the push is REJECTED as a
# non-fast-forward and this job goes RED rather than overwriting anything —
# and the heal is genuinely not lost now, because the next push to main meets
# the same stale hash, recomputes it, and heals it from its own run.
#
# `needs: nix` without `if: always()` is the second half: this runs only when
# the build job SUCCEEDED, so a module set whose corrected tree still fails
# `nix flake check`, the smoke test or the vulnerability scan is never
# committed to main.
push-vendor-hash:
name: Heal main's vendorHash
needs: nix
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
needs.nix.outputs.vendor_hash_bumped == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing here reads history any more — BUG-2974 removed the gate that
# did. The depth stays full rather than dropping to the default shallow
# clone: this is the clone the heal commit is pushed from, full depth is
# what every run of this job has ever used, and a shallow push is not a
# variable worth introducing on a job that runs only when a Go bump
# actually moves the module set.
fetch-depth: 0
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: recomputed-package-nix
path: nix/
# THE GATE, and the only one, lives in the script: does what main COMMITTED
# differ from what the build job RECOMPUTED? The artifact exists only
# because the build job found a mismatch, but comparing there keeps the job
# honest about the tree it is actually looking at rather than about what
# another job reported. It is a file rather than a `run:` block so that
# nix/heal-vendor-hash_test.sh can drive it against a real git remote —
# BUG-2974 shipped green for a day because nothing could exercise it.
- name: Commit and push the recomputed hash
run: nix/heal-vendor-hash.sh
env:
SHA: ${{ github.sha }}
ci-result:
name: CI result
if: always()
needs: [changes, go, go-postgres, go-server-shards, go-web-parity, nix, native-smoke, web, e2e]
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Every E2E job succeeded
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Require every applicable job to succeed
env:
SHARDS: ${{ needs.e2e.result }}
run: |
echo "e2e matrix result: $SHARDS"
test "$SHARDS" = "success"
CI_NEEDS: ${{ toJSON(needs) }}
CI_EVENT_NAME: ${{ github.event_name }}
run: python3 scripts/ci-result.py
-239
View File
@@ -1,239 +0,0 @@
name: Nix
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
# PR runs share a group per ref and supersede each other — a newer push to the
# same PR makes the older run's answer worthless.
#
# PUSH runs get a group of their OWN, one per commit, and this is deliberate
# (TASK-2954). A run on `main` may owe main a vendorHash heal, and an evicted
# run never performs it. `cancel-in-progress: false` is not enough on its own —
# GitHub holds only ONE pending run per group, so a third push evicts the
# queued second, and the eviction looks exactly like a run that decided there
# was nothing to do. A per-commit group cannot be evicted by anything.
#
# What an evicted heal costs is now DELAY, not the fix. Since BUG-2974 the
# heal is gated on the committed hash rather than on what the push touched, so
# the next push to main meets the same stale hash and heals it from its own
# run. The per-commit group is what makes the heal land on the merge that
# caused it instead of waiting for whatever lands next.
#
# What that trades for: two main runs can reach the push step at once, and the
# loser gets a non-fast-forward rejection and goes red. That is the right
# direction — a red job is read, and the next push to main heals anyway.
group: nix-${{ github.event_name == 'push' && github.sha || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
# All third-party Actions are pinned to a 40-char commit SHA with a trailing
# '# vX.Y.Z' comment so a compromised maintainer or moved tag cannot silently
# execute attacker code in CI. Bump the SHA + comment together when updating.
jobs:
nix:
name: Nix build & check
runs-on: ubuntu-latest
# NO write permission here, deliberately — see `push-vendor-hash` below.
# This job builds; it never pushes.
outputs:
vendor_hash_bumped: ${{ steps.vendorhash.outputs.bumped }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: DeterminateSystems/nix-installer-action@3138316df39ed29be04236d7ffc686fa525866aa # v23
# THE RED-BASELINE FIX (TASK-2954). `vendorHash` pins the Go module set by
# content hash; Dependabot updates go.mod/go.sum and has no idea this file
# exists, so EVERY Go bump PR failed the steps below on a hash mismatch,
# structurally and forever. A check that is always red is not a check —
# a bump that genuinely breaks the build looked identical, at a glance, to
# one that only moved the hash.
#
# This recomputes the hash in the WORKING TREE, so the steps below judge
# the build with this ref's actual module set. Nothing is pushed from here
# and no token is used: a Dependabot PR run gets a read-only GITHUB_TOKEN
# (it runs as if from a fork), and the `permissions` key does NOT lift that
# — only a repository-wide setting does, which would hand fork PRs write
# tokens too. The push therefore happens on `push: main` instead, in the
# job below, where the run is not Dependabot's.
#
# It runs on every run, not just Dependabot's: a human's own go.mod change
# moves the hash the same way, and a check that is honest only for one
# author is the shape this item exists to remove.
#
# It runs BEFORE `nix flake check`, which builds the package too and would
# hit the same mismatch first. It is deliberately not a gate: if the build
# fails for any reason other than a go-modules hash mismatch, the script
# exits non-zero, this step still succeeds, and the real steps below report
# the real failure. The gate stays exactly where it was.
- name: Recompute vendorHash before judging the build
id: vendorhash
run: |
set -o pipefail
if nix build .#default --print-build-logs 2>&1 | tee /tmp/nix-build.log; then
echo "Build is green; vendorHash is current."
exit 0
fi
if ! new_hash="$(nix/bump-vendor-hash.sh /tmp/nix-build.log)"; then
echo "Not a go-modules hash mismatch — leaving it to the build steps below."
exit 0
fi
echo "vendorHash -> $new_hash"
echo "bumped=true" >> "$GITHUB_OUTPUT"
- name: Carry the recomputed package.nix to the push job
if: steps.vendorhash.outputs.bumped == 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: recomputed-package-nix
path: nix/package.nix
retention-days: 1
- name: nix flake check
run: nix flake check --print-build-logs
- name: nix build
run: nix build .#default --print-build-logs
- name: Smoke test binary
run: |
./result/bin/pad --version
./result/bin/pad --help
# The Go toolchain here exists only to install govulncheck for the
# scan step below — the pad binary under test was built by Nix above.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Scan Nix-built binary with govulncheck
# CI's main govulncheck job scans a `go build` binary, which honours
# go.mod's `toolchain go1.26.10` line — but nixpkgs pins
# GOTOOLCHAIN=local, so the Nix artifact is built with whatever Go
# patch the nixos-26.05 channel ships and can carry stdlib advisories
# the main gate can no longer see (BUG-2567). This scans the artifact
# that actually ships via Nix, against nix/accepted-advisories.txt:
# known-accepted advisories stay green and visible, any NEW advisory
# fails the job, and a warning fires when an accepted advisory clears
# (the signal to prune the list and eventually close BUG-2567).
#
# govulncheck pinned to the same release as ci.yml — bump the two
# together. GOTOOLCHAIN=auto on the install mirrors ci.yml's
# rationale: setup-go exports GOTOOLCHAIN=local, and if govulncheck's
# own go.mod ever requires a patch newer than setup-go's resolved
# one, `auto` lets Go fetch it instead of failing the install.
run: |
GOTOOLCHAIN=auto go install golang.org/x/vuln/cmd/govulncheck@v1.2.0
GOVULNCHECK="$(go env GOPATH)/bin/govulncheck" nix/vulnscan.sh result/bin/pad
# THE ONLY JOB WITH A WRITE TOKEN, and it exists as a separate job precisely so
# that the token is never minted in a context an untrusted change could reach.
#
# An `if:` on a STEP is not a security boundary — the job would still hold a
# write-capable GITHUB_TOKEN, and `actions/checkout` persists it in the
# workspace for every later command, so any change to this file inside a PR
# could use it. An `if:` on a JOB is different in kind: the job does not start,
# and no write token is issued for that run at all.
#
# WHY ON `push: main` AND NOT ON THE PR (ruled on TASK-2954's trail). A
# Dependabot PR run gets a read-only token whatever `permissions` says. The
# merge, though, is authored by a human, so the `push` run that follows is an
# ordinary run with an ordinary token. The PR's own check was already honest,
# because the job above recomputes in the working tree; `main` is what needs
# the committed fix, and it gets it from the run of the merge itself — or,
# when that run loses the push race, from the run of the next push to main.
# The two rejected alternatives were a PAT in the Dependabot secret store (a
# standing credential for a once-a-week fix) and enabling write tokens for
# pull-request workflows (which would give them to FORK PRs on a public repo —
# the surface CONVE-2438 exists to keep closed).
#
# THE LOOP TERMINATES BECAUSE THIS PUSH TRIGGERS NOTHING. A push made with
# the default `GITHUB_TOKEN` creates no workflow runs, so the commit this job
# pushes gets no run at all. Observed on the first heal this fix produced
# (BUG-2974, day 62): `gh run list --branch main` spans cbfc073e ->
# 31d11e76 -> 0b16be49 and skips the heal commit 249a8f87 entirely. Read the
# BRANCH listing, not `gh run list --commit` — that query also returned
# nothing for 0b16be49, which demonstrably has runs, so it cannot tell
# "no runs" from "no answer". The loop is closed by construction rather than
# by being green.
#
# THE CORRECTED-TREE TERMINATOR IS THE BACKSTOP, AND IT HAS NEVER RUN. If
# this push did trigger a run, the build job above would find the hash
# correct, print "Build is green; vendorHash is current.", never set
# `bumped`, and this job would not start — the FIX's own effect on the build,
# holding regardless of what any push touched. That is an argument, not a
# measurement: nothing has ever exercised it. It becomes load-bearing the
# moment this push uses a PAT or a GitHub App token, which is the obvious
# edit for anyone who wants CI to validate the healed tree. Making that
# change inherits an untested terminator; exercise it before relying on it.
#
# A CONSEQUENCE OF THE SAME MECHANISM, not a defect: the healed tip carries
# no CI of its own. Its tree is the merge commit plus the one hash line that
# merge's own Nix run computed and built against, and the next push to main
# builds on it normally.
#
# THE RANGE-VS-STATE DISTINCTION IS LOAD-BEARING, AND IT WAS GOT WRONG ONCE
# (BUG-2974). This
# comment used to name the loop guard as a gate asking whether the push had
# touched `go.mod`/`go.sum`, and that gate existed as a step below. It is the
# right question for loop prevention and the WRONG one for recovery: after a
# lost push race the tree that needs healing was written by an EARLIER push,
# so every later merge that did not itself move the module set refused to fix
# it, exited GREEN, and left main carrying a hash a clean nix build rejects.
# The gate is gone. The state comparison below is the only gate, and being a
# question about state it is equally true for a hash this push broke and one
# an earlier push broke and could not land.
#
# If another merge lands while this job runs, the push is REJECTED as a
# non-fast-forward and this job goes RED rather than overwriting anything —
# and the heal is genuinely not lost now, because the next push to main meets
# the same stale hash, recomputes it, and heals it from its own run.
#
# `needs: nix` without `if: always()` is the second half: this runs only when
# the build job SUCCEEDED, so a module set whose corrected tree still fails
# `nix flake check`, the smoke test or the vulnerability scan is never
# committed to main.
push-vendor-hash:
name: Heal main's vendorHash
needs: nix
if: >-
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
needs.nix.outputs.vendor_hash_bumped == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Nothing here reads history any more — BUG-2974 removed the gate that
# did. The depth stays full rather than dropping to the default shallow
# clone: this is the clone the heal commit is pushed from, full depth is
# what every run of this job has ever used, and a shallow push is not a
# variable worth introducing on a job that runs only when a Go bump
# actually moves the module set.
fetch-depth: 0
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: recomputed-package-nix
path: nix/
# THE GATE, and the only one, lives in the script: does what main COMMITTED
# differ from what the build job RECOMPUTED? The artifact exists only
# because the build job found a mismatch, but comparing there keeps the job
# honest about the tree it is actually looking at rather than about what
# another job reported. It is a file rather than a `run:` block so that
# nix/heal-vendor-hash_test.sh can drive it against a real git remote —
# BUG-2974 shipped green for a day because nothing could exercise it.
- name: Commit and push the recomputed hash
run: nix/heal-vendor-hash.sh
env:
SHA: ${{ github.sha }}
+157
View File
@@ -0,0 +1,157 @@
name: Race
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: race-${{ github.sha }}
cancel-in-progress: false
permissions:
contents: read
env:
# Keep identical to CI's shard coverage and non-server race flags.
GO_RACE_FLAGS: "-race -gcflags=modernc.org/...=-d=checkptr=0"
jobs:
# internal/server under -race, as 3 shards per driver (TASK-4141). In the
# ./... race steps it took 2519.644s (SQLite) and 2658.534s (PostgreSQL) of
# the 2700s per-binary budget on main, sharing ~4 vCPU with every other
# package. No test or family is more than ~4% of its wall and ~88% of it is
# serial tests, so there is no slow test to fix; splitting it is the lever.
# scripts/test-shard.sh says how tests are assigned, and go-server-shards
# proves the shards cover the package exactly once.
go-server-race:
# Backstop only, as on the go job: -timeout=45m fires first.
timeout-minutes: 75
name: Go race internal/server (SQLite ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# See the identical step in the `go` job.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Create web build placeholder for embed
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# Built for real, as in the `go` job: internal/server's tests run it.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
- name: Run internal/server shard with race detector
env:
SHARD: ${{ matrix.shard }}
run: |
run=$(scripts/test-shard.sh regex ./internal/server/ 3 "$SHARD" $GO_RACE_FLAGS)
go test $GO_RACE_FLAGS -timeout=45m -run "$run" ./internal/server/
# failure() alone misses a cancelled run, which leaves main with no race
# result at this SHA: that is stop-the-line too (TASK-4224 r1 H3).
- name: Stop the line on main race failure
if: failure() || cancelled()
env:
FAILED_JOB: Go race internal/server (SQLite ${{ matrix.shard }}/3)
run: |
echo "::error::$FAILED_JOB failed at $GITHUB_SHA"
{
echo '# STOP THE LINE: a main race run failed or did not finish'
echo "Job: $FAILED_JOB"
echo "Commit: $GITHUB_SHA"
echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
} >> "$GITHUB_STEP_SUMMARY"
go-server-race-postgres:
# Backstop only, as on the go-postgres job: -timeout=45m fires first.
timeout-minutes: 75
name: Go race internal/server (PostgreSQL ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3]
services:
postgres:
# Same image and digest as the go-postgres job; bump them together.
image: mirror.gcr.io/library/postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
env:
POSTGRES_USER: pad
POSTGRES_PASSWORD: pad
POSTGRES_DB: pad
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U pad"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: "1.26"
- name: Allow Go to fetch the toolchain go.mod pins
# See the identical step in the `go` job.
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Create web build placeholder for embed
run: mkdir -p web/build && echo "placeholder" > web/build/.gitkeep
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: "24"
cache: "npm"
cache-dependency-path: web/package-lock.json
# Built for real, as in the `go` job: internal/server's tests run it.
- name: Build the materializer bundle for embed
working-directory: web
run: npm ci && node scripts/build-materializer.mjs
# internal/server's tests need no PostgreSQL client tools (no pg_dump
# or psql), so the PGDG install the go-postgres job does is not here.
- name: Run internal/server shard with race detector against PostgreSQL
env:
PAD_TEST_POSTGRES_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
PAD_TEST_POSTGRES_TOOLS_URL: "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
SHARD: ${{ matrix.shard }}
run: |
run=$(scripts/test-shard.sh regex ./internal/server/ 3 "$SHARD" $GO_RACE_FLAGS)
go test $GO_RACE_FLAGS -timeout=45m -count=1 -run "$run" ./internal/server/
# failure() alone misses a cancelled run, which leaves main with no race
# result at this SHA: that is stop-the-line too (TASK-4224 r1 H3).
- name: Stop the line on main race failure
if: failure() || cancelled()
env:
FAILED_JOB: Go race internal/server (PostgreSQL ${{ matrix.shard }}/3)
run: |
echo "::error::$FAILED_JOB failed at $GITHUB_SHA"
{
echo '# STOP THE LINE: a main race run failed or did not finish'
echo "Job: $FAILED_JOB"
echo "Commit: $GITHUB_SHA"
echo "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
} >> "$GITHUB_STEP_SUMMARY"
+12 -2
View File
@@ -159,6 +159,7 @@ PAD_EMAIL_FROM_NAME=Pad # Sender display name (default: Pad)
## CLI
Global chat targets are `C<n>`, `<n>`, or `@<name>` for your DM with an agent.
An agent (one that sends X-Pad-Agent) has one chat, with its owner: for it `@<name>` is its OWN name, and `pad chat send @x` to another agent is refused before any request (show, read and delete keep their not_found).
All chat commands support `--format json`.
```bash
@@ -212,8 +213,17 @@ pad item move <ref> <target-collection>
pad item copy <ref> --to-workspace <slug> --collection <slug> [--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).
# Refuses rather than guessing when a destination field needs a value.
# Every copy carries an idempotency key (op_key; --op-key to choose it, else a
# fresh one per invocation, TASK-3585). The mutating call is retried ONCE, with
# that key, after an unknown outcome and only against a server that advertises
# idempotency_keys, where the retry replays the first copy; nothing else retries
# it (PLAN-2357 DR-13). On a still-unknown outcome the CLI says check the
# destination first; on a keyed server it prints the key, and a re-run with that
# --op-key within 24h of the first attempt (IdempotencyTTL, from the key's
# created_at) cannot make a second copy (the preflight refuses or the POST
# replays); after that the key is forgotten and a re-run can make a second
# copy. Without keys it warns of a duplicate (TASK-4131).
# Content semantics: markdown is copied verbatim except `pad-attachment:`
# refs that resolve to a LIVE attachment in the SOURCE workspace — those are
# repointed at the clones (+ variants). Foreign / soft-deleted / dangling ids
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/mcp"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/server"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
"github.com/spf13/cobra"
)
func TestBUG4119_CLIWrongPrefix(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, exists := range []bool{false, true} {
for _, action := range []string{"comment", "update", "delete"} {
t.Run(fmt.Sprintf("pg=%t/ident=%t/%s", pg, exists, action), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, exists)
srv := server.New(f.Store)
t.Cleanup(srv.Stop)
setupFormatRoutingTest(t, srv)
workspaceFlag = f.Workspace.Slug
formatFlag = "table"
var cmd *cobra.Command
args := []string{f.WrongRef}
switch action {
case "comment":
cmd = commentCmd()
args = append(args, "Wrong comment")
case "update":
cmd = updateCmd()
args = append(args, "--title", "Wrong title")
case "delete":
cmd = deleteCmd()
}
cmd.SetArgs(args)
before := f.Snapshot(t)
var err error
out := captureStdout(t, func() { err = cmd.Execute() })
if err == nil {
t.Errorf("CLI %s wrong-prefix: expected error, got success", action)
}
if strings.Contains(out, "Comment added") {
t.Errorf("CLI falsely reported success: %s", out)
}
f.AssertUnchanged(t, before)
})
}
}
}
}
// The existing stdio fixture re-executes this test binary as the real CLI.
func TestBUG4119_StdioUpdate(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, exists := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t/ident=%t", pg, exists), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, exists)
srv := server.New(f.Store)
t.Cleanup(srv.Stop)
ts := setupFormatRoutingTest(t, srv)
t.Setenv(padHelperEnv, "1")
bin, err := os.Executable()
if err != nil {
t.Fatal(err)
}
d := mcp.ExecDispatcher{Binary: bin}
before := f.Snapshot(t)
res, err := d.Dispatch(context.Background(), []string{"item", "update"}, []string{"--url", ts.URL, "--workspace", f.Workspace.Slug, "--format", "json", "--title", "Wrong title", f.WrongRef})
if err != nil || res == nil || !res.IsError {
t.Errorf("stdio update: want tool error, got %v %v", res, err)
}
f.AssertUnchanged(t, before)
})
}
}
}
func TestBUG4119_CLICommentReturnedRef(t *testing.T) {
for _, returned := range []string{"TASK-42", ""} {
t.Run("returned="+returned, func(t *testing.T) {
setupFormatRoutingTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/comments") {
w.WriteHeader(201)
_ = json.NewEncoder(w).Encode(map[string]any{"id": "comment", "item_ref": returned})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{})
}))
formatFlag = "table"
cmd := commentCmd()
cmd.SetArgs([]string{"PLAN-42", "Hi"})
var err error
out := captureStdout(t, func() { err = cmd.Execute() })
if err != nil {
t.Fatal(err)
}
want := returned
if want == "" {
want = "PLAN-42"
}
if !strings.Contains(out, "Comment added to "+want+"\n") {
t.Errorf("comment success line = %q, want server-returned ref %s", out, want)
}
})
}
}
func TestBUG4119_CLICommentMovedRef(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, false)
plan, err := f.Store.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Plans", Prefix: "PLAN"})
if err != nil {
t.Fatal(err)
}
it, err := f.Store.CreateItem(f.Workspace.ID, plan.ID, models.ItemCreate{Title: "Move me"})
if err != nil {
t.Fatal(err)
}
moved, err := f.Store.MoveItem(it.ID, f.Item.CollectionID, `{}`)
if err != nil {
t.Fatal(err)
}
srv := server.New(f.Store)
t.Cleanup(srv.Stop)
setupFormatRoutingTest(t, srv)
workspaceFlag = f.Workspace.Slug
formatFlag = "table"
cmd := commentCmd()
cmd.SetArgs([]string{it.Ref, "Comment via old ref"})
out := captureStdout(t, func() { err = cmd.Execute() })
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "Comment added to "+moved.Ref+"\n") {
t.Errorf("comment success line = %q, want server-returned ref %s", out, moved.Ref)
}
comments, err := f.Store.ListComments(it.ID)
if err != nil || len(comments) != 1 {
t.Errorf("moved comment: %v %v", comments, err)
}
})
}
}
+1 -1
View File
@@ -110,7 +110,7 @@ func chatShowCmd() *cobra.Command {
func chatSendCmd() *cobra.Command {
var replyTo int64
var opKey, contextWorkspace string
cmd := &cobra.Command{Use: "send <target> <body>", Example: " pad chat send @rook \"Ready for review\"", Short: "Send a chat message (- reads stdin)", Args: cobra.ExactArgs(2),
cmd := &cobra.Command{Use: "send <target> <body>", Example: " pad chat send @rook \"Ready for review\"", Short: "Send a chat message (- reads stdin)", Long: "Send a chat message. Targets are C<n>, <n> or @<name>.\n\nAs an agent, @<name> is your own DM with your owner (your own name); sending to another @x is another agent's DM and is refused. Use C<n> from `pad chat list`.", Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
if _, _, err := cli.ParseChatTarget(args[0]); err != nil {
return err
+5
View File
@@ -66,6 +66,11 @@ func TestChatShowReadMarker(t *testing.T) {
}
func TestChatSendAndTargetResolution(t *testing.T) {
// A human caller: an ambient PAD_AGENT or CLAUDECODE would make @rook
// another agent's DM (TASK-4186 PR C). The send cases would then be refused
// by the agent guard before any request, and show would find nothing.
t.Setenv("PAD_AGENT", "")
t.Setenv("CLAUDECODE", "")
oldFormat, oldWorkspace, oldURL := formatFlag, workspaceFlag, urlFlag
t.Cleanup(func() { formatFlag, workspaceFlag, urlFlag = oldFormat, oldWorkspace, oldURL })
+68 -25
View File
@@ -2059,6 +2059,32 @@ type itemCopyDeps struct {
// 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)
// ServerHonorsKeys reports whether the server advertises idempotency_keys
// (TASK-4131). It decides the unknown-outcome advice: only a keyed server
// makes a same-key re-run safe. Nil, or a capability lookup that fails,
// reads as false — the advice that cannot cause a duplicate.
ServerHonorsKeys func() bool
}
// newItemCopyDeps binds itemCopyDeps to a real client. Its own function so a
// test can check the binding, not just runItemCopy (CONVE-19, TASK-4131).
func newItemCopyDeps(client *cli.Client, ws, ref string) itemCopyDeps {
return itemCopyDeps{
Schema: func(wsSlug, collSlug string) models.CollectionSchema {
var schema models.CollectionSchema
if coll, err := client.GetCollection(wsSlug, collSlug); err == nil {
_ = models.UnmarshalItemFieldSchema([]byte(coll.Schema), &schema)
}
return schema
},
Preflight: func(req cli.ItemCopyRequest) (*cli.ItemCopyPreflight, json.RawMessage, error) {
return client.CopyItemPreflight(ws, ref, req)
},
Copy: func(req cli.ItemCopyRequest) (*cli.ItemCopyResult, json.RawMessage, error) {
return client.CopyItem(ws, ref, req)
},
ServerHonorsKeys: client.ServerHonorsKeys,
}
}
func itemCopyCmd() *cobra.Command {
@@ -2096,10 +2122,16 @@ The content is copied as written, with two exceptions worth knowing:
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.
Every copy carries an idempotency key: --op-key, or a fresh one per
invocation. If the outcome is unknown (the connection dropped, say), the copy
is retried ONCE with that key, and only when the server honours idempotency
keys; such a server answers a same-key retry with the first copy instead of
making another. Nothing else retries it. If the outcome is still unknown,
check the destination workspace first. On a server that honours keys the
command prints the key, and a re-run with the same
--op-key within 24 hours of the first attempt cannot make a second copy;
after that the key is forgotten and a re-run can make a second copy. On a
server that does not honour keys, a re-run would create a duplicate item.
Items can be referenced by issue ID (e.g. TASK-5) or slug.`,
Example: ` pad item copy IDEA-12 --to-workspace pad-web --collection tasks --dry-run
@@ -2114,22 +2146,7 @@ Items can be referenced by issue ID (e.g. TASK-5) or slug.`,
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 {
_ = models.UnmarshalItemFieldSchema([]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)
return runItemCopy(opts, newItemCopyDeps(client, ws, opts.Ref), os.Stdout, os.Stderr)
},
}
cmd.Flags().StringVar(&opts.TargetWorkspace, "to-workspace", "", "destination workspace slug (required)")
@@ -2287,17 +2304,39 @@ func runItemCopy(opts itemCopyOptions, deps itemCopyDeps, stdout, stderr io.Writ
return nil
}
if cli.CopyOutcomeUnknown(err) {
// DR-13. Do NOT suggest re-running.
// DR-13: never suggest a KEYLESS re-run (TASK-4131). Only a server
// that honours idempotency keys applies a same-key request at most
// once, so only there is a re-run with the key advised. The advice
// promises no replay: a re-run goes through the preflight first,
// which refuses (e.g. 409 archived for a landed move) rather than
// replaying. The probe here is a fresh one and may differ from the
// retry gate's; both answers are safe.
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")
// A plain error either way: the key line below is the copy's own,
// complete advice, so main's generic BUG-3999 hint must not repeat
// it (r1-M2).
unknown := fmt.Errorf("copy outcome unknown: check workspace %q before re-running", targetWorkspace)
if deps.ServerHonorsKeys != nil && deps.ServerHonorsKeys() {
fmt.Fprintf(stderr, "Check the destination first:\n")
fmt.Fprintf(stderr, " pad item list %s --workspace %s\n\n", targetCollection, targetWorkspace)
fmt.Fprintf(stderr, "If the copy is not there, re-run the same command with --op-key %s\n", req.OpKey)
fmt.Fprintf(stderr, "within 24 hours of the first attempt. This server applies a request with\n")
fmt.Fprintf(stderr, "the same key at most once in that window, so a re-run after a copy that\n")
fmt.Fprintf(stderr, "did land cannot make a second one. After the window,\n")
fmt.Fprintf(stderr, "the key is forgotten and a re-run can make a second copy.\n")
fmt.Fprintf(stderr, "If the first attempt is still in progress, the re-run is refused;\n")
fmt.Fprintf(stderr, "wait and run it again with the same key.\n")
return unknown
}
fmt.Fprintf(stderr, "This server does not honour idempotency keys, so a re-run would create\n")
fmt.Fprintf(stderr, "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 unknown
}
return err
}
@@ -3224,7 +3263,11 @@ func commentCmd() *cobra.Command {
return cli.PrintJSON(comment)
}
fmt.Printf("Comment added to %s\n", args[0])
ref := comment.ItemRef
if ref == "" { // Older servers omit item_ref (BUG-4119).
ref = args[0]
}
fmt.Printf("Comment added to %s\n", ref)
return nil
},
}
+120 -7
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -105,6 +107,8 @@ type recordingDeps struct {
schema models.CollectionSchema
// forbidCopy makes any mutating call a test failure.
forbidCopy bool
// honorsKeys is what ServerHonorsKeys reports (TASK-4131).
honorsKeys bool
}
func (d *recordingDeps) deps() itemCopyDeps {
@@ -133,6 +137,7 @@ func (d *recordingDeps) deps() itemCopyDeps {
d.copyCalls = append(d.copyCalls, req)
return d.result, d.resultRaw, d.copyErr
},
ServerHonorsKeys: func() bool { return d.honorsKeys },
}
}
@@ -1203,6 +1208,10 @@ func TestRunItemCopy_RefusesOnInvalidWithoutNamedFields(t *testing.T) {
// ── DR-13 at the command layer ───────────────────────────────────────────
// On a server that does NOT honour idempotency keys, a re-run can duplicate,
// so the message says check first and carries no key hint: main's BUG-3999
// hint promises the key is applied at most once, which only a keyed server
// keeps (TASK-4131).
func TestRunItemCopy_AmbiguousFailureNeverSuggestsARetry(t *testing.T) {
d := &recordingDeps{
t: t,
@@ -1211,6 +1220,7 @@ func TestRunItemCopy_AmbiguousFailureNeverSuggestsARetry(t *testing.T) {
}
opts := baseOpts()
opts.ArchiveSource = true
opts.OpKey = "key-keyless"
var out, errOut bytes.Buffer
err := runItemCopy(opts, d.deps(), &out, &errOut)
@@ -1225,7 +1235,7 @@ func TestRunItemCopy_AmbiguousFailureNeverSuggestsARetry(t *testing.T) {
"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",
"does not honour idempotency keys",
"DUPLICATE item",
"pad item list tasks --workspace pad-web",
} {
@@ -1234,11 +1244,76 @@ func TestRunItemCopy_AmbiguousFailureNeverSuggestsARetry(t *testing.T) {
}
}
// The whole point: nothing here may read as "try again".
for _, forbidden := range []string{"retry the command", "run it again", "re-run the copy"} {
for _, forbidden := range []string{"retry the command", "run it again", "re-run the copy", "--op-key", "no idempotency key", "key-keyless"} {
if strings.Contains(strings.ToLower(stderr), forbidden) {
t.Errorf("ambiguous-failure message must not suggest a retry; found %q", forbidden)
t.Errorf("ambiguous-failure message on a keyless server must not say %q\n--- stderr ---\n%s", forbidden, stderr)
}
}
var oe *cli.OpKeyError
if errors.As(err, &oe) {
t.Errorf("a keyless server's failure must not carry the key hint; got key %q", oe.Key)
}
if !strings.Contains(err.Error(), "check workspace") {
t.Errorf("the returned error should point at the destination; got %v", err)
}
}
// TASK-4131: on a server that honours idempotency keys a same-key request is
// applied at most once, so the message names the key on exactly ONE line and
// promises no replay (a re-run meets the preflight first, r1-M1). The error
// is plain: main's BUG-3999 hint would print the key a second time with
// different advice (r1-M2).
func TestRunItemCopy_AmbiguousFailureOnAKeyedServerNamesTheKey(t *testing.T) {
d := &recordingDeps{
t: t,
preflight: emptyPreflight(),
copyErr: fmt.Errorf("%w: request failed: EOF", cli.ErrCopyOutcomeUnknown),
honorsKeys: true,
}
opts := baseOpts()
opts.ArchiveSource = true
opts.OpKey = "key-4131"
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 || d.copyCalls[0].OpKey != "key-4131" {
t.Fatalf("one keyed copy call expected; got %+v", d.copyCalls)
}
stderr := errOut.String()
var keyLines []string
for _, line := range strings.Split(stderr, "\n") {
if strings.Contains(line, d.copyCalls[0].OpKey) {
keyLines = append(keyLines, line)
}
}
if len(keyLines) != 1 || !strings.Contains(keyLines[0], "--op-key "+d.copyCalls[0].OpKey) {
t.Errorf("exactly one stderr line must carry the sent key as --op-key; got %q\n--- stderr ---\n%s", keyLines, stderr)
}
for _, w := range []string{
"UNKNOWN outcome",
"Check the destination first",
"pad item list tasks --workspace pad-web",
"within 24 hours of the first attempt",
"the key is forgotten and a re-run can make a second copy",
"cannot make a",
"still in progress",
} {
if !strings.Contains(stderr, w) {
t.Errorf("keyed ambiguous-failure message missing %q\n--- stderr ---\n%s", w, stderr)
}
}
for _, forbidden := range []string{"no idempotency key", "DUPLICATE", "returns the first copy"} {
if strings.Contains(stderr, forbidden) {
t.Errorf("keyed ambiguous-failure message must not say %q\n--- stderr ---\n%s", forbidden, stderr)
}
}
var oe *cli.OpKeyError
if errors.As(err, &oe) {
t.Errorf("the error must be plain, or main's hint prints the key again; got key %q", oe.Key)
}
if !strings.Contains(err.Error(), "check workspace") {
t.Errorf("the returned error should point at the destination; got %v", err)
}
@@ -1648,10 +1723,17 @@ func TestItemCopyCmd_IsRegisteredWithEveryFlag(t *testing.T) {
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")
// The retry contract has to be discoverable from --help; it is the
// single most surprising thing about this command. Since TASK-3585 it
// is one KEYED retry and nothing else (TASK-4131), and the old "no
// idempotency key" sentence must not come back.
for _, w := range []string{"retried ONCE with that key", "Nothing else retries it", "--op-key within 24 hours of the first attempt", "a re-run can make a second copy"} {
if !strings.Contains(sub.Long, w) {
t.Errorf("--help must document the retry contract; missing %q", w)
}
}
if strings.Contains(sub.Long, "no idempotency key") {
t.Error("--help still says there is no idempotency key")
}
}
if !found {
@@ -2161,3 +2243,34 @@ func TestItemMoveNeedsValueHint(t *testing.T) {
}
})
}
// TASK-4131 (CONVE-19): the real binding reports the server's
// idempotency_keys capability, so the unknown-outcome advice follows the
// server rather than a nil func that always reads as keyless.
func TestNewItemCopyDeps_ServerHonorsKeysFollowsTheCapability(t *testing.T) {
for _, tc := range []struct {
body string
want bool
}{
{`{"idempotency_keys":true}`, true},
{`{"idempotency_keys":false}`, false},
} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/server/capabilities" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tc.body))
return
}
http.NotFound(w, r)
}))
deps := newItemCopyDeps(cli.NewClientFromURL(srv.URL), "ws", "IDEA-1")
if deps.ServerHonorsKeys == nil {
srv.Close()
t.Fatal("newItemCopyDeps left ServerHonorsKeys unbound")
}
if got := deps.ServerHonorsKeys(); got != tc.want {
t.Errorf("capabilities %s: ServerHonorsKeys() = %v, want %v", tc.body, got, tc.want)
}
srv.Close()
}
}
+15 -2
View File
@@ -251,12 +251,25 @@ func newRootCmd() *cobra.Command {
// --- helpers ---
// getConfig is loadConfig for commands: a config.toml that fails to load
// ends the process with the error.
func getConfig() *config.Config {
cfg, err := config.Load()
cfg, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
return cfg
}
// loadConfig loads the config and applies --url, returning a load error
// instead of exiting, for callers that must not end the process, such as
// the display-zone resolver (TASK-4142).
func loadConfig() (*config.Config, error) {
cfg, err := config.Load()
if err != nil {
return nil, err
}
// --url flag takes highest precedence. An explicit --url is an
// unambiguous "talk to this server" signal from the user, so it
// promotes Mode to remote even if the existing config.toml has
@@ -270,7 +283,7 @@ func getConfig() *config.Config {
cfg.Mode = config.ModeRemote
}
}
return cfg
return cfg, nil
}
// applyPadTomlOverride layers the per-directory .pad.toml `url` field on top
+15
View File
@@ -2,6 +2,7 @@ package main
import (
"database/sql"
"errors"
"net/url"
"os"
"os/exec"
@@ -9,6 +10,7 @@ import (
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
"github.com/google/uuid"
)
@@ -111,6 +113,7 @@ func TestPostgresBackupRestoreConnection(t *testing.T) {
if _, err := admin.Exec("CREATE DATABASE " + name); err != nil {
t.Fatal(err)
}
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.admin")
t.Cleanup(func() {
if _, err := admin.Exec("DROP DATABASE " + name + " WITH (FORCE)"); err != nil {
t.Error(err)
@@ -134,6 +137,7 @@ func TestPostgresBackupRestoreConnection(t *testing.T) {
if _, err := source.Exec("CREATE TABLE recovery_probe (value text); INSERT INTO recovery_probe VALUES ('restored row')"); err != nil {
t.Fatal(err)
}
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.source")
// A wrong ambient database must not override the explicit URI.
t.Setenv("PGDATABASE", "not_the_requested_database")
t.Setenv("PGPASSWORD", "not_the_explicit_password")
@@ -177,16 +181,19 @@ func TestPostgresBackupRestoreConnection(t *testing.T) {
if err := backup.Execute(); err != nil {
t.Fatal(err)
}
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.pg_dump")
t.Setenv("PAD_DATABASE_URL", convert(targetURL))
restore := dbRestoreCmd()
restore.SetArgs([]string{"--force", archive})
if err := restore.Execute(); err != nil {
t.Fatal(err)
}
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.psql")
var value string
if err := target.QueryRow("SELECT value FROM recovery_probe").Scan(&value); err != nil || value != "restored row" {
t.Fatalf("round trip: value=%q err=%v", value, err)
}
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.target")
}
t.Run("SQL failure rolls back and overrides psqlrc", func(t *testing.T) {
psqlrc := filepath.Join(t.TempDir(), "psqlrc")
@@ -203,6 +210,13 @@ func TestPostgresBackupRestoreConnection(t *testing.T) {
restore.SetArgs([]string{"--force", failedArchive})
if err := restore.Execute(); err == nil {
t.Error("restore returned success after a SQL error rolled back its transaction")
} else {
// psql exit 3 means a script error with ON_ERROR_STOP, after
// connecting and beginning the --single-transaction transaction.
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 3 {
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.psql.sql_error")
}
}
var value string
if err := target.QueryRow("SELECT value FROM recovery_probe").Scan(&value); err != nil || value != "restored row" {
@@ -214,6 +228,7 @@ func TestPostgresBackupRestoreConnection(t *testing.T) {
if err := validRestore.Execute(); err != nil {
t.Fatal(err)
}
storetest.TracePGConnected(t, "TestPostgresBackupRestoreConnection.psql")
if err := target.QueryRow("SELECT value FROM recovery_probe").Scan(&value); err != nil || value != "restored row" {
t.Fatalf("valid restore after SQL error: value=%q err=%v", value, err)
}
+63
View File
@@ -8,6 +8,7 @@ import (
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
@@ -309,3 +310,65 @@ func TestTZ4101_ResolverUnconfiguredNeverExits(t *testing.T) {
t.Fatalf("resolver exited, prompted or left UTC: err=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String())
}
}
// TASK-4142: a config.toml that fails to load must not exit the process from
// inside the renderer either. getConfig os.Exit(1)s on a load error; the
// resolver uses the non-exiting loadConfig and renders in UTC instead.
func TestTZ4142_ResolverBrokenConfigNeverExits(t *testing.T) {
if os.Getenv("TZ4142_SUBPROCESS") == "1" {
setTempHomeMain(t)
for _, k := range []string{"PAD_URL", "PAD_TOKEN", "PAD_MODE", "PAD_HOST", "PAD_PORT", "PAD_CLOUD", "PAD_DB_PATH"} {
t.Setenv(k, "")
}
dir := t.TempDir()
t.Setenv("PAD_DATA_DIR", dir)
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("url = \"unterminated\n"), 0o600); err != nil {
t.Fatal(err)
}
time.Local = time.FixedZone("Machine", 9*3600)
newRootCmd()
old := time.Date(2026, 1, 15, 2, 30, 0, 0, time.UTC)
fmt.Printf("INSTANT=%s\n", cli.FormatInstant(old, "2006-01-02 15:04"))
return
}
cmd := exec.Command(os.Args[0], "-test.run=^TestTZ4142_ResolverBrokenConfigNeverExits$")
cmd.Env = append(os.Environ(), "TZ4142_SUBPROCESS=1")
var stdout, stderr strings.Builder
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if err != nil || !strings.Contains(stdout.String(), "INSTANT=2026-01-15 02:30") || strings.Contains(stderr.String(), "Error loading config") {
t.Fatalf("resolver exited on a broken config.toml or left UTC: err=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String())
}
}
// TASK-4142: the split must not change what commands see. loadConfig hands the
// load error back, and getConfig still ends the process with it.
func TestTZ4142_LoadConfigReturnsErrorGetConfigExits(t *testing.T) {
broken := func(t *testing.T) {
setTempHomeMain(t)
dir := t.TempDir()
t.Setenv("PAD_DATA_DIR", dir)
t.Setenv("PAD_DB_PATH", "")
if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("url = \"unterminated\n"), 0o600); err != nil {
t.Fatal(err)
}
}
if os.Getenv("TZ4142_GETCONFIG") == "1" {
broken(t)
getConfig()
fmt.Println("RETURNED")
return
}
broken(t)
if cfg, err := loadConfig(); err == nil || cfg != nil {
t.Fatalf("loadConfig on a broken config.toml: cfg=%v err=%v, want nil and an error", cfg, err)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestTZ4142_LoadConfigReturnsErrorGetConfigExits$")
cmd.Env = append(os.Environ(), "TZ4142_GETCONFIG=1")
var stdout, stderr strings.Builder
cmd.Stdout, cmd.Stderr = &stdout, &stderr
err := cmd.Run()
if err == nil || strings.Contains(stdout.String(), "RETURNED") || !strings.Contains(stderr.String(), "Error loading config: ") {
t.Fatalf("getConfig did not exit with the load error: err=%v stdout=%q stderr=%q", err, stdout.String(), stderr.String())
}
}
+6 -2
View File
@@ -27,8 +27,12 @@ func installViewerZone() {
viewerZone.loc, viewerZone.note = time.UTC, ""
cli.SetDisplayLocationResolver(func() *time.Location {
viewerZone.load.Do(func() {
// Never exit or prompt from a renderer: unconfigured means UTC, quietly.
cfg := getConfig()
// Never exit or prompt from a renderer: unconfigured, or a config.toml
// that fails to load (TASK-4142), means UTC, quietly.
cfg, err := loadConfig()
if err != nil {
return
}
applyPadTomlOverride(cfg)
if !cfg.IsConfigured() {
return
+2
View File
@@ -25,6 +25,7 @@ REST API at `/api/v1/`. Key endpoints:
- **One row per item** keeps the activity feed's per-row visibility filter correct. The web feed, the dashboard's recent activity (`reorder_count`, additive) and `pad project activity`'s text and markdown output collapse a batch into one line AFTER that filter, so the count is what the reader can see. JSON activity stays per row.
- **Answers** `{items:[{id, seq}]}`. Advertised as `item_sort_order_bulk` in `GET /server/capabilities`. The web does not probe it (its bundle is served by the same binary). Not on MCP.
- `GET/PATCH/DELETE /workspaces/{ws}/items/{slug}` — item by slug
- **Identifier precedence (BUG-4119 r1):** UUID, then current `PREFIX-NUMBER` ref, then exact, case-sensitive slug. A ref-shaped identifier containing any uppercase letter never falls through to slug; live resolution may accept an old ref proven by move history. An identifier with no uppercase letter tries only the current ref before its exact slug, never move history or a number-only fallback. Thus `task-5` still resolves current `TASK-5`, while `release-2` can name its own slug when no current `RELEASE-2` exists.
- **Write responses (create/update) may carry `warnings.undeclared_fields`** (BUG-2850) — field keys stored in the item's `fields` blob that the collection's schema does not declare. They are ACCEPTED, not refused: a census found 168 live values under 14 such keys, and refusing them would break read-modify-write on items nobody edited wrongly. The element is additive and `omitempty`, so a clean write is byte-identical to before; system-written metadata (`implementation_notes`, `decision_log`, `github_pr`, `convention`) is excluded. The CLI prints the same list to **stderr**, never stdout, so `--format json` stays parseable
- **Write responses may also carry `warnings.content_outcome`** (BUG-2995) — on a 200 it takes exactly one value, `applied_pending_flush`: the content went to the live collaborative document (a browser tab has the item open) rather than to `items.content`. It describes the WRITE, not the row's state at response time — a concurrent flush may already have updated the row, and the server does not re-read to find out. The response's `content` is then the markdown as **sent**, not as stored, and a read before a later `?source=collab-snapshot` flush lands answers with the PREVIOUS content — the lag, not a failed write. The stored form may also differ from the sent form: the markdown round-trips through the editor, so bullet markers, emphasis characters, list numbering and blank-line runs normalise, while already-canonical markdown survives unchanged (measured — `web/src/lib/collab/bug2995Roundtrip.svelte.test.ts`). No duration is claimed anywhere, and the flush is NOT guaranteed to happen at all (BUG-3000); since TASK-2198 U4 the server recovers the body itself after the tab's room closes, which bounds the window when recovery is on and the job succeeds, and nothing more. The CLI prints this to **stderr**, like the other write warnings, and since TASK-3132 the LAST line of `pad item update`'s stdout also names it (`— body sent to the open editor … a following "pad item show" will return the OLD body`), so it survives `| tail -1` and a discarded stderr; that last line reads `body replaced, A → B bytes` / `body cleared` on every other content write
- **READ responses may carry `content_state`** (BUG-3000 / BUG-3033) — the read-side counterpart to `warnings.content_outcome` above, and its pending value is the same, `applied_pending_flush`. It has a second value no write produces, `superseded_set_aside` (BUG-3244): edits a collab schema-version rebuild set aside, which no tab can restore; see the set-aside bullet below. It wins when both apply, and a door that only marks staleness treats the two alike (`models.IsContentStateStale`, web `isBodyStale`) while any copy that names a remedy branches on it. A write warning says what a REQUEST did; this says what the ROW is: the item's op-log holds CONTENT-BEARING updates above `items.content_flushed_op_log_id`, so the body being served is BEHIND the live collaborative document. Since BUG-3124 rows that provably cannot change the document — y-protocols SyncStep1 frames, empty updates, and byte-identical re-sends of an earlier row (a SyncStep2 and an update differing only in their subtype byte count as identical since BUG-3136, which is what an open tab's answer to a joining tab is) — are persisted but marked `content_bearing = false` and not counted. Since BUG-3523 so is every SyncStep2 appended after it: a step2 is an answer to a peer's step1 and repeats content other rows carry, because a tab repairs its own possibly-missing edits with a full catch-up (an edit made offline, a socket that died, or a `barrier_ack` reporting a failed append). Rows classified before it, and the startup backfill, keep the older rule; they used to keep an item "pending" forever after its tab closed (measured: 470 pending items → 189 on the live DB). The classifier is envelope-only (no Yjs parse) and strict: a frame that does not parse exactly stays content-bearing. Additive and `omitempty`, and emitted only where a real body is — a summary or metadata projection that serves no content never claims its absent body is stale. The window is **bounded by server-side recovery** (TASK-2198 U4), not by a tab: when the item's collab room closes (60s after its last tab leaves) with content-bearing rows above the watermark, or when a one-minute sweep finds such an item dormant for 2 minutes with no room, a background worker materializes the WHOLE content-bearing op-log in a capped child process (`pad __materialize-worker`, internal/materialize) and `store.MaterializeFlush` lands it: cursor == MAX(op-log id), rows pending and no set-aside rows, checked in the write's own transaction under the collab item lock with no room open; watermark to the cursor only, `seq` bumped, one version row attributed `system` / `recovery` ("recovered from an unsaved editor session"), op-log NOT pruned. It never runs on a request path, and it is NOT a guarantee: a job that fails 3 times in a row is not retried until the op-log changes, an item with set-aside rows or rows from another schema version is skipped, and with `PAD_MATERIALIZE=off` the window is unbounded as before (the op-log GC's flush-coverage guard still RETAINS unflushed rows), so the row catches up only when a browser tab next opens the item. Opening it is now enough even when nothing needs flushing (BUG-3124 unit B): a caught-up tab whose document still renders to the stored body (byte for byte, or, since BUG-3197, as the editor's own serialization of it) calls `POST /workspaces/{ws}/items/{slug}/collab-watermark {op_log_cursor, content_sha256}`, and the server advances the flush watermark iff `cursor == MAX(op-log id)` and the sha256 matches `items.content`, atomically, writing no content, version, `seq` or `updated_at` — before this, a view that changed nothing was deduped and left the item "pending" forever. Web-client only; no CLI/MCP surface. The content is not lost — the op-log is the durability layer — but the marker is a signal to RE-READ LATER, never to re-send. The predicate is broader than "an API write is pending flush" and deliberately so: a user typing in an open tab produces the same stale body, and the row does not record WHY the op-log is ahead. Carried by every door that serialises `models.Item`, and by the hand-built ones too — the playbook `run` response, bootstrap convention and bodies-mode include payloads, the exported artifact's `provenance` block, and a line above the body in the MCP item resource. The CLI prints it to **stderr** (`pad item show --format markdown`, `pad playbook show`, `pad playbook run`), like the write warnings — except `pad item show`'s default TABLE output, which since TASK-3132 opens with it on **stdout**; markdown stays stderr because its stdout is the body verbatim for `update --stdin`
@@ -82,6 +83,7 @@ REST API at `/api/v1/`. Key endpoints:
- `GET /workspaces/{ws}/report` — the windowed Insights report: `window` (day | week | 2wk | month), `offset` (periods back), `collections` (slugs), `include_items`, and since TASK-3524 `tz` (an IANA zone such as `America/Los_Angeles`). With `tz` the throughput buckets are the viewer's local days (local hours in the day window), keyed in that zone, and the response echoes `tz`; without it they are UTC, byte for byte as before, which is what the CLI and MCP get. An unknown zone, or `Local` (the server's zone), answers 400 `invalid_tz`. The binary embeds the zone database, so this works on hosts without one. Nothing caches the response; a cache added later must key on `tz` with the other parameters and the caller's visibility scope
- `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details). Filters `action`, `actor` (`user` | `agent`), `source`, `since` (YYYY-MM-DD, or since TASK-3615 an RFC3339 instant, normalised to UTC at second precision with the fraction dropped, so the floor rounds down; an instant past year 9999 in UTC is refused `400 invalid_since`) and, since TASK-2219, `collection` (a collection slug: activity on items currently in that collection). All are applied in the store query before `limit`, so every page is full and the `before`/`before_id` keyset cursor walks the filtered feed. A `collection` that names no live collection, or one the caller cannot see, answers `[]`, not 404, so it is not an existence oracle
- `GET/POST/DELETE /workspaces/{ws}/webhooks` — webhook management
- `POST /workspaces/{ws}/items/{ref}/comments` — item-scoped comment writes return an additive `item_ref` containing the resolved item's current ref, including idempotent replays (BUG-4119). A proven old ref after a collection move may differ from this value. The CLI prints `item_ref`, falling back to the input ref when an older server omits it. A wrong prefix without matching move history returns 404 without writing a comment.
- `PATCH/DELETE /workspaces/{ws}/items/{slug}/comments/{id}` — comment edit/delete addressed through its item (TASK-2695): 404 unless the comment is on that item, then the same handler, ACL and events as `/workspaces/{ws}/comments/{id}` (edit author-only, delete item-edit). A delete of a comment that still has replies leaves a TOMBSTONE on either route (BUG-3252, 204 like any delete): the body is emptied and the reactions removed, author and timestamps are kept, the comment serialises with `deleted: true`, and its replies keep their parent. An edit, reply or reaction addressed to a tombstone answers 409 `comment_deleted`, deleting it again answers 404, and deleting its last reply removes it in the same transaction. Tombstones are left out of the decision trail and the workspace export. A server before the tombstone answered 409 `comment_has_replies` instead, and the CLI and MCP still classify that code. The CLI and every MCP transport use this form; `GET /server/capabilities` advertises it as `item_scoped_comment_writes`. Every serialised comment carries a derived `edited` bool
- `GET /workspaces/{ws}/changes?since=<unix_ms>` — the web sync delta: items updated at or after `since` (archived ones included; their ids are also in `deleted`), with full bodies, and `server_time`, the next `since`. **Bounded and paged by a cursor** (BUG-3791). `limit` defaults to, and is capped at, 1000, as on the item lists. When more remain the body says `has_more: true` with `next_cursor`; ask again with `?cursor=<next_cursor>` alone (a `since` beside it must equal the walk's). The cursor is a position in the workspace's write order (`seq`, then id), so a change committed during the walk comes back in a later page, and a row can appear twice. Every page repeats the FIRST page's `server_time`, which is a safe next `since` only once a page says `has_more: false`. Visibility is applied before the window, so a page's count and `has_more` never reflect rows the caller cannot see. **Contract change:** a client that ignores `has_more` gets the first 1000 changes of a longer delta and loses the rest when it advances to `server_time`; the web client (`walkChanges`) walks every page, and fails to a full refresh past 50
- `GET /workspaces/{ws}/items/{slug}/children` — child items linked to a parent, in the manual order (sort_order, created_at, item_number, id; item_number is unique per workspace but nullable, so id ends it; BUG-4005: item_number breaks same-second ties in creation order, as the web board does). **Bounded and paged like the item lists** (BUG-3789): `limit` defaults to and is capped at 1000, `offset` skips rows, and `X-Pad-Has-More` / `X-Pad-Next-Offset` carry the paging state; the body stays a bare array. Visibility is applied before the window, so the page and has-more count only children the caller may see. The web client walks every page
+61
View File
@@ -0,0 +1,61 @@
// Package agentchatcases is the one name table behind the TASK-4186 PR C
// acceptance bar: the client guard (cli.CheckAgentChatTarget) and the server's
// ensure handler must refuse exactly the same rows. The cli and mcp tests and
// the server test all import it, so a row added here is judged by both sides.
// It holds test data only and is imported by tests alone.
package agentchatcases
import (
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Case is one caller (the X-Pad-Agent header) addressing one target.
type Case struct {
Name string
Caller string
Target string // the chat target as typed: "@x", "C3" or "3"
// Key is the body agent_key the ensure call would send (the name after
// "@"). Empty for a C<n> / <n> target, which never reaches ensure.
Key string
// Refuse is the shared expectation: the guard AND the server's ensure
// handler must both refuse this row.
Refuse bool
}
func dm(name, caller, key string, refuse bool) Case {
return Case{Name: name, Caller: caller, Target: "@" + key, Key: key, Refuse: refuse}
}
// Cases is the table. The cap rows are built from models.MaxAgentNameRunes.
func Cases() []Case {
max := models.MaxAgentNameRunes
a := strings.Repeat("a", max)
over := a + "bcdef"
mbFull := strings.Repeat("é", max)
mbEdge := strings.Repeat("a", max-1) + "é"
mbOver := mbEdge + "é"
return []Case{
dm("exact match", "director", "director", false),
dm("other agent", "director", "dave", true),
dm("case difference refuses", "Director", "director", true),
dm("case difference refuses, other way", "director", "Director", true),
dm("caller trailing space", "director ", "director", false),
dm("caller leading space", " director", "director", false),
dm("target trailing space is raw", "director", "director ", true),
dm("target leading space is raw", "director", " director", true),
dm("caller control char", "dir\tec\x00tor", "director", false),
dm("target control char is raw", "director", "dir\tector", true),
dm("caller over cap, truncated target", over, a, false),
dm("caller over cap, full target", over, over, true),
dm("multi-byte caller exactly at cap", mbFull, mbFull, false),
dm("multi-byte caller one rune over cap, truncated target", mbOver, mbEdge, false),
dm("multi-byte caller one rune over cap, full target", mbOver, mbOver, true),
dm("multi-byte rune on the boundary", mbEdge, mbEdge, false),
dm("empty caller is a human", "", "dave", false),
dm("whitespace-only caller is a human", " \t ", "dave", false),
{Name: "C<n> target", Caller: "director", Target: "C3"},
{Name: "<n> target", Caller: "director", Target: "3"},
}
}
+2 -1
View File
@@ -116,7 +116,8 @@ var allowedStoreMethodsInsideDoor = map[string]string{
// of the door, with what each writes. Same rules as the list above.
var reviewedStoreFuncs = map[string]string{
"writeOutboxTx": "INSERT INTO event_outbox ... RETURNING on the tx it is handed",
"resolveRefTx": "reads items",
"resolveRefTx": "reads items; no INSERT/UPDATE/DELETE",
"itemRefLookupSQL": "builds the items SELECT and arguments; no INSERT/UPDATE/DELETE or SQL execution",
"resolveTitleTx": "reads items",
"resolveWorkspaceSlugTx": "reads workspaces",
"itemUpdatedSliceChanged": "compares two item snapshots in memory; no SQL",
+24
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"strconv"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// ParseChatTarget accepts an owner-scoped conversation number (C12 or 12)
@@ -22,6 +24,28 @@ func ParseChatTarget(target string) (number int, agent string, err error) {
return 0, "", fmt.Errorf("invalid chat target %q: use C<n>, <n>, or @<name>", target)
}
// CheckAgentChatTarget refuses, before any request, an @<name> send target
// that names a DM other than the calling agent's own. An agent has one chat,
// with its owner, and the server enforces that on send
// (handleEnsureChatConversation: agent_key must match the normalised
// X-Pad-Agent); this only replaces that refusal with a message naming the
// right target. Callers run it for send only: show, read and delete keep their
// truthful "not found". It compares exactly as the server does: the caller is
// normalised, the typed name is not, and case is not folded. A human (an empty
// normalised caller) and C<n> / <n> targets pass. It echoes only the caller's
// own name and the target typed, never another agent's.
func CheckAgentChatTarget(callerAgent, target string) error {
caller := models.NormalizeAgentName(callerAgent)
if caller == "" {
return nil
}
_, agent, err := ParseChatTarget(target)
if err != nil || agent == "" || agent == caller {
return nil
}
return fmt.Errorf("as agent %q you have one chat, with your owner: use @%s or C<n> (pad chat list); @%s is another agent's DM", caller, caller, agent)
}
// ChatReadSeq returns the highest displayed sequence only when the page reaches
// the existing read marker. A high-water mark must not skip unseen messages.
// Conversation sequences are contiguous, including deleted-message tombstones.
+103
View File
@@ -0,0 +1,103 @@
package cli
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/PerpetualSoftware/pad/internal/agentchatcases"
)
func TestCheckAgentChatTargetSharedTable(t *testing.T) {
for _, tc := range agentchatcases.Cases() {
t.Run(tc.Name, func(t *testing.T) {
err := CheckAgentChatTarget(tc.Caller, tc.Target)
if (err != nil) != tc.Refuse {
t.Fatalf("CheckAgentChatTarget(%q, %q) = %v, want refuse=%v", tc.Caller, tc.Target, err, tc.Refuse)
}
})
}
}
// chatStub records every request and answers conversations with other agents'
// DMs, so a leak of their keys into an error is visible.
type chatStub struct {
mu sync.Mutex
reqs []string
}
func (s *chatStub) handler(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.reqs = append(s.reqs, r.Method+" "+r.URL.Path)
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodPost {
_, _ = w.Write([]byte(`{"conversation":{"number":7}}`))
return
}
_, _ = w.Write([]byte(`{"conversations":[{"number":1,"agent_key":"secret-sibling"},{"number":2,"agent_key":"hidden-bot"}]}`))
}
func TestResolveChatTargetGuardSendsNothingOnRefusal(t *testing.T) {
stub := &chatStub{}
ts := httptest.NewServer(http.HandlerFunc(stub.handler))
defer ts.Close()
c := NewClientFromURLWithAgentName(ts.URL, "director")
_, err := c.ResolveChatTarget("@dave", true)
if err == nil {
t.Fatal("send: want refusal")
}
if len(stub.reqs) != 0 {
t.Fatalf("refusal still sent %v", stub.reqs)
}
for _, other := range []string{"secret-sibling", "hidden-bot"} {
if strings.Contains(err.Error(), other) {
t.Fatalf("error names another agent %q: %v", other, err)
}
}
if !strings.Contains(err.Error(), `@director`) || !strings.Contains(err.Error(), "C<n>") {
t.Fatalf("error does not point at the caller's own target: %v", err)
}
}
// show, read and delete resolve with ensure=false: an agent's @other keeps the
// truthful not_found from the lookup, and the guard (whose error is not an
// APIError) is not invoked.
func TestResolveChatTargetNoEnsureKeepsNotFound(t *testing.T) {
stub := &chatStub{}
ts := httptest.NewServer(http.HandlerFunc(stub.handler))
defer ts.Close()
c := NewClientFromURLWithAgentName(ts.URL, "director")
_, err := c.ResolveChatTarget("@dave", false)
var apiErr *APIError
if !errors.As(err, &apiErr) || apiErr.Code != "not_found" {
t.Fatalf("want not_found APIError, got %v", err)
}
if strings.Contains(err.Error(), "one chat") {
t.Fatalf("guard ran on a no-ensure resolve: %v", err)
}
if len(stub.reqs) != 1 || stub.reqs[0] != "GET /api/v1/me/conversations" {
t.Fatalf("want exactly the lookup, got %v", stub.reqs)
}
}
func TestResolveChatTargetGuardPasses(t *testing.T) {
stub := &chatStub{}
ts := httptest.NewServer(http.HandlerFunc(stub.handler))
defer ts.Close()
c := NewClientFromURLWithAgentName(ts.URL, "director ")
n, err := c.ResolveChatTarget("@director", true)
if err != nil || n != 7 {
t.Fatalf("own DM: n=%d err=%v", n, err)
}
if len(stub.reqs) != 2 {
t.Fatalf("requests = %v, want the list then the ensure", stub.reqs)
}
human := NewClientFromURLWithAgentName(ts.URL, "")
if n, err := human.ResolveChatTarget("@rook", true); err != nil || n != 7 {
t.Fatalf("human: n=%d err=%v", n, err)
}
}
+14
View File
@@ -1117,6 +1117,8 @@ func (c *Client) ListComments(wsSlug, itemSlug string) ([]models.Comment, error)
}
func (c *Client) CreateComment(wsSlug, itemSlug string, input models.CommentCreate) (*models.Comment, error) {
// BUG-4119: the shared response type retains additive item_ref; an older
// server leaves it empty so callers can fall back to the input spelling.
var result models.Comment
path := "/workspaces/" + url.PathEscape(wsSlug) + "/items/" + url.PathEscape(itemSlug) + "/comments"
err := c.post(path, input, &result)
@@ -1254,6 +1256,11 @@ func (c *Client) serverHonorsKeys() bool {
return caps.IdempotencyKeys
}
// ServerHonorsKeys is serverHonorsKeys for the command layer: false when the
// capability lookup fails. `pad item copy` uses it to choose its
// unknown-outcome advice (TASK-4131).
func (c *Client) ServerHonorsKeys() bool { return c.serverHonorsKeys() }
// retryableWithKey: no API answer at all (a transport error) or a 5xx.
func retryableWithKey(err error) bool {
var apiErr *APIError
@@ -3601,6 +3608,13 @@ func (c *Client) ResolveChatTarget(target string, ensure bool) (int, error) {
if err != nil || agent == "" {
return n, err
}
// Send only (ensure): show, read and delete keep the truthful not_found
// for an agent's @other, since they never create or message a DM.
if ensure {
if err := CheckAgentChatTarget(c.agentName, target); err != nil {
return 0, err
}
}
raw, err := c.ListChatConversations()
if err != nil {
return 0, err
+62
View File
@@ -0,0 +1,62 @@
package mcp
import (
"context"
"fmt"
"net/http/httptest"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
padserver "github.com/PerpetualSoftware/pad/internal/server"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
func TestBUG4119_RemoteWrongPrefix(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, exists := range []bool{false, true} {
for _, action := range []string{"comment", "delete", "update"} {
t.Run(fmt.Sprintf("pg=%t/ident=%t/%s", pg, exists, action), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, exists)
s := f.Store
srv := padserver.New(s)
t.Cleanup(srv.Stop)
u, err := s.CreateUser(models.UserCreate{Email: "mcp-prefix@example.com", Name: "Owner", Password: "test-password-long-enough"})
if err != nil {
t.Fatal(err)
}
if err = s.AddWorkspaceMember(f.Workspace.ID, u.ID, "owner"); err != nil {
t.Fatal(err)
}
d := &HTTPHandlerDispatcher{Handler: srv, UserResolver: func(context.Context) *models.User { return u }}
ms := NewServer(Options{Version: "test"})
if _, err = RegisterCatalog(ms.MCP(), CatalogOptions{Doc: liveCmdhelpDoc(t), Workspace: NewWorkspaceState(f.Workspace.Slug), Dispatcher: d, PadVersion: "test"}); err != nil {
t.Fatal(err)
}
transport := NewRemoteTransport(ms.MCP(), bug2772Sessions{})
ts := httptest.NewServer(transport)
t.Cleanup(ts.Close)
w := bug2772World{t: t, url: ts.URL + "/mcp"}
session := w.initialize("prefix-test")
before := f.Snapshot(t)
extra := ""
if action == "comment" {
extra = `,"message":"Wrong comment"`
}
if action == "update" {
extra = `,"title":"Wrong title"`
}
args := fmt.Sprintf(`{"action":%q,"workspace":%q,"ref":%q%s}`, action, f.Workspace.Slug, f.WrongRef, extra)
raw := w.call("pad_item", args, session)
if !strings.Contains(raw, `"isError":true`) {
t.Errorf("remote MCP %s wrong-prefix: want tool error, got %s", action, raw)
}
if !strings.Contains(strings.ToLower(raw), "not_found") && !strings.Contains(strings.ToLower(raw), "not found") {
t.Errorf("remote MCP must refuse as not-found: %s", raw)
}
f.AssertUnchanged(t, before)
})
}
}
}
}
+7
View File
@@ -68,6 +68,13 @@ func (d *HTTPHandlerDispatcher) resolveChatTarget(ctx context.Context, key strin
if agent == "" {
return n, nil
}
// TASK-4186 PR C: send only (ensure); show, read and delete keep not_found.
// The name is the one the dispatcher sends as X-Pad-Agent.
if ensure {
if err := cli.CheckAgentChatTarget(d.Clients.resolveRemoteCaller(ctx).Name, target); err != nil {
return 0, validationFailedResult(key, err.Error(), "Use your own @<name> or C<n> from pad_chat list.")
}
}
res := d.chatRequest(ctx, key, user, http.MethodGet, "/api/v1/me/conversations", nil)
if res.IsError {
return 0, res
@@ -0,0 +1,117 @@
package mcp
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
padserver "github.com/PerpetualSoftware/pad/internal/server"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
type chatRecorder struct {
next http.Handler
mu sync.Mutex
reqs []string
}
func (c *chatRecorder) ServeHTTP(w http.ResponseWriter, r *http.Request) {
c.mu.Lock()
c.reqs = append(c.reqs, r.Method+" "+r.URL.Path)
c.mu.Unlock()
c.next.ServeHTTP(w, r)
}
func (c *chatRecorder) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.reqs)
}
func (c *chatRecorder) since(n int) []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.reqs[n:]...)
}
// TASK-4186 PR C: the remote resolveChatTarget refuses send to another agent's @name
// before any list or POST (show, read and delete keep not_found), and does not name any other agent.
func TestChatRemoteAgentTargetGuard(t *testing.T) {
s := storetest.NewSQLite(t)
srv := padserver.New(s)
t.Cleanup(srv.Stop)
owner, err := s.CreateUser(models.UserCreate{Email: "chat4186@example.com", Name: "Owner", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
if _, _, err := s.EnsureAgentDM(owner.ID, "secret-sibling"); err != nil {
t.Fatal(err)
}
rec := &chatRecorder{next: srv}
clients := NewClientRegistry()
d := &HTTPHandlerDispatcher{Handler: rec, Clients: clients, UserResolver: func(context.Context) *models.User { return owner }}
ms := NewServer(Options{Version: "test", Clients: clients})
if _, err := RegisterCatalog(ms.MCP(), CatalogOptions{Doc: chatTestDoc(), Workspace: NewWorkspaceState(""), Dispatcher: d}); err != nil {
t.Fatal(err)
}
transport := NewRemoteTransport(ms.MCP(), bug2772Sessions{})
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
transport.ServeHTTP(w, r.WithContext(padserver.WithCurrentUser(r.Context(), owner)))
}))
t.Cleanup(ts.Close)
world := &bug2772World{t: t, s: s, srv: srv, owner: owner, url: ts.URL + "/mcp"}
session := world.initialize("director")
// Send is guarded: a validation_failed refusal from the guard's own text,
// and no request to /me/conversations.
sendArgs := `{"action":"send","target":"@dave","body":"hi"}`
before := rec.count()
raw := world.call("pad_chat", sendArgs, session)
if !strings.Contains(raw, `"isError":true`) {
t.Fatalf("send: want an error result, got %s", raw)
}
if !strings.Contains(raw, "validation") {
t.Fatalf("send: want a validation code, got %s", raw)
}
if !strings.Contains(raw, "one chat") {
t.Fatalf("send: refusal is not the guard's text: %s", raw)
}
if strings.Contains(raw, "secret-sibling") {
t.Fatalf("send: refusal names another agent: %s", raw)
}
if !strings.Contains(raw, "@director") {
t.Fatalf("send: refusal does not name the caller's own DM: %s", raw)
}
if n := rec.count(); n != before {
t.Fatalf("send: refusal still sent %v", rec.since(before))
}
// show, read and delete keep the truthful not_found: the guard is not
// invoked, so the lookup runs and finds no such DM.
for _, args := range []string{
`{"action":"show","target":"@dave"}`,
`{"action":"read","target":"@dave","to":1}`,
`{"action":"delete","target":"@dave","seq":1}`,
} {
before := rec.count()
raw := world.call("pad_chat", args, session)
if !strings.Contains(raw, `"isError":true`) || !strings.Contains(raw, `"code":"not_found"`) {
t.Fatalf("%s: want not_found, got %s", args, raw)
}
if strings.Contains(raw, "one chat") || strings.Contains(raw, "secret-sibling") {
t.Fatalf("%s: guard text or another agent in result: %s", args, raw)
}
if got := rec.since(before); len(got) != 1 || got[0] != "GET /api/v1/me/conversations" {
t.Fatalf("%s: want exactly the lookup, got %v", args, got)
}
}
raw = world.call("pad_chat", `{"action":"send","target":"@director","body":"hello"}`, session)
if strings.Contains(raw, `"isError":true`) {
t.Fatalf("own DM send refused: %s", raw)
}
}
+7 -17
View File
@@ -1458,23 +1458,13 @@ const CmdhelpVersion = "0.1"
// item stored a different item's UUID, and the write answered 201
// with the substitution visible only in `relation_targets`.
//
// What it PRESERVES, which is why the predicate is about the prefix
// rather than the item: a collection RENAME changes its prefix, and
// a relation already written as the old ref has to keep resolving
// (BUG-2873). That ref's prefix names nothing afterwards, so the
// fallback still fires for it. A prefix that IS live and simply has
// no item at that number is the caller naming a real collection that
// does not contain what they said, and is refused.
//
// RESIDUAL, stated because it is not closed: a ref pasted from
// ANOTHER workspace whose prefix also names no collection here still
// resolves by number. Nothing in the value distinguishes it from a
// rename's leftover, so closing it would cost the case the fallback
// exists for. Pinned by a test rather than left implied.
//
// No escape hatch, for 0.29's reason. Out of scope and unchanged:
// `GetItemByRef`, the navigational read path, where landing on the
// moved item is the helpful answer.
// Superseded by BUG-4119: all ref resolution, including GetItemByRef,
// relation fields, wiki links and cross-workspace backlinks, now requires
// an exact prefix or a move-log row for that item whose former
// collection's CURRENT prefix matches. Deleted former collections count,
// and reuse of the prefix does not invalidate that provenance. An absent
// prefix alone, including after a prefix-only rename, proves nothing.
// Parsed ref misses are terminal rather than falling through to slugs.
//
// 0.35 — BUG-3079. An injected schema DEFAULT now takes the same
// `validateFieldType` check a caller-supplied value takes. A default
+30
View File
@@ -0,0 +1,30 @@
package models
import (
"strings"
"unicode"
)
// NormalizeAgentName is the one stored form of a self-declared agent name,
// shared by write attribution, presence (TASK-4040), chat key validation
// (TASK-4039 r3) and the client-side chat target guard (TASK-4186 PR C):
// trimmed, control characters removed, at most MaxAgentNameRunes runes. It
// does not fold case.
func NormalizeAgentName(raw string) string {
if raw == "" {
return ""
}
var b strings.Builder
n := 0
for _, c := range strings.TrimSpace(raw) {
if unicode.IsControl(c) {
continue
}
if n == MaxAgentNameRunes {
break
}
b.WriteRune(c)
n++
}
return strings.TrimSpace(b.String())
}
+5 -2
View File
@@ -8,8 +8,11 @@ import (
// Comment represents a comment on an item.
type Comment struct {
ID string `json:"id"`
ItemID string `json:"item_id"`
ID string `json:"id"`
ItemID string `json:"item_id"`
// ItemRef is the resolved item's current ref on create-comment responses
// (BUG-4119). It is additive and not persisted. The CLI shares this type.
ItemRef string `json:"item_ref,omitempty"`
WorkspaceID string `json:"workspace_id"`
Author string `json:"author"`
// UserID is the authenticated user who authored the comment. Empty for
+4 -22
View File
@@ -2,8 +2,6 @@ package server
import (
"net/http"
"strings"
"unicode"
"github.com/PerpetualSoftware/pad/internal/models"
)
@@ -14,25 +12,9 @@ func agentNameFromRequest(r *http.Request) string {
return normalizeAgentName(r.Header.Get("X-Pad-Agent"))
}
// normalizeAgentName is the one stored form of a self-declared agent name,
// shared by write attribution, presence (TASK-4040) and chat key validation
// (TASK-4039 r3): trimmed, control characters removed, at most
// models.MaxAgentNameRunes runes.
// normalizeAgentName delegates to models.NormalizeAgentName, the single source
// of the stored form of a self-declared agent name (TASK-4186 PR C moved it so
// the CLI and MCP chat guard compares exactly as the server does).
func normalizeAgentName(raw string) string {
if raw == "" {
return ""
}
var b strings.Builder
n := 0
for _, c := range strings.TrimSpace(raw) {
if unicode.IsControl(c) {
continue
}
if n == models.MaxAgentNameRunes {
break
}
b.WriteRune(c)
n++
}
return strings.TrimSpace(b.String())
return models.NormalizeAgentName(raw)
}
@@ -279,7 +279,7 @@ func TestBUG2367_PreflightAndCopyAgree(t *testing.T) {
DroppedFields []string `json:"dropped_fields"`
NotUnique []b2367NotUnique `json:"not_unique"`
} `json:"warnings"`
Destination struct{ Slug string } `json:"destination"`
Destination struct{ Ref string } `json:"destination"`
}
parseJSON(t, cp, &c)
if len(c.Warnings.NotUnique) != 1 || c.Warnings.NotUnique[0].Message != wantMsg {
@@ -292,7 +292,9 @@ func TestBUG2367_PreflightAndCopyAgree(t *testing.T) {
if !dropped["invocation_slug"] || !dropped["progress"] {
t.Errorf("copy dropped_fields = %v, the preflight dropped invocation_slug and progress", c.Warnings.DroppedFields)
}
got := f.storedFields(dstWS, c.Destination.Slug)
// BUG-4119: a duplicate title can produce a ref-shaped slug
// (source-1); address the copied item by its exact ref instead.
got := f.storedFields(dstWS, c.Destination.Ref)
if _, ok := got["invocation_slug"]; ok {
t.Errorf("the copy stored the colliding slug: %v", got)
}
@@ -36,7 +36,8 @@ func TestBUG3746_ContentReplacementMovesTheSeedBoundary(t *testing.T) {
parseJSON(t, rr, &created)
seqA := created.Seq
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+created.Slug,
// BUG-4119: the title's slug is ref-shaped; use the item's own identity.
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+created.ID,
map[string]interface{}{"content": "B"})
if rr.Code != http.StatusOK {
t.Fatalf("replace: %d %s", rr.Code, rr.Body.String())
+309
View File
@@ -0,0 +1,309 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
func TestBUG4119_HTTPWrongPrefix(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, exists := range []bool{false, true} {
for _, method := range []string{"POST", "PATCH", "DELETE"} {
t.Run(fmt.Sprintf("pg=%t/ident=%t/%s", pg, exists, method), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, exists)
srv := New(f.Store)
t.Cleanup(srv.Stop)
path := "/api/v1/workspaces/" + f.Workspace.Slug + "/items/" + f.WrongRef
body := map[string]any{"content": "Wrong write", "fields": map[string]any{}}
if method == "POST" {
path += "/comments"
body = map[string]any{"body": "Wrong comment"}
}
before := f.Snapshot(t)
rr := doRequest(srv, method, path, body)
if rr.Code != http.StatusNotFound {
t.Errorf("%s wrong-prefix: got %d, want 404", method, rr.Code)
}
f.AssertUnchanged(t, before)
})
}
}
}
}
func TestBUG4119_HTTPImportedUppercaseRefSlug(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, method := range []string{http.MethodGet, http.MethodPatch, http.MethodDelete} {
t.Run(fmt.Sprintf("pg=%t/%s", pg, method), func(t *testing.T) {
f, slugged := storetest.NewImportedUppercaseRefSlugFixture(t, pg)
srv := New(f.Store)
t.Cleanup(srv.Stop)
slugOwner := f
slugOwner.Item = slugged
beforeNumber, beforeSlug := f.Snapshot(t), slugOwner.Snapshot(t)
var body any
if method == http.MethodPatch {
body = map[string]string{"content": "Wrong write to uppercase slug owner"}
}
rr := doRequest(srv, method, "/api/v1/workspaces/"+f.Workspace.Slug+"/items/"+f.WrongRef, body)
if rr.Code != http.StatusNotFound {
t.Errorf("%s %s: got %d, want 404", method, f.WrongRef, rr.Code)
}
f.AssertUnchanged(t, beforeNumber)
slugOwner.AssertUnchanged(t, beforeSlug)
})
}
}
}
func TestBUG4119_HTTPMovedRefAndTerminalMiss(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, false)
srv := New(f.Store)
t.Cleanup(srv.Stop)
plan, err := f.Store.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Plans", Prefix: "PLAN"})
if err != nil {
t.Fatal(err)
}
it, err := f.Store.CreateItem(f.Workspace.ID, plan.ID, models.ItemCreate{Title: "Moved plan"})
if err != nil {
t.Fatal(err)
}
task, err := f.Store.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Tasks", Prefix: "TASK"})
if err != nil {
t.Fatal(err)
}
if _, err = f.Store.MoveItem(it.ID, task.ID, `{}`); err != nil {
t.Fatal(err)
}
base := "/api/v1/workspaces/" + f.Workspace.Slug + "/items/"
for _, ref := range []string{it.Ref, fmt.Sprintf("TASK-%d", *it.ItemNumber), it.ID} {
if rr := doRequest(srv, "GET", base+ref, nil); rr.Code != 200 {
t.Errorf("show %s: %d %s", ref, rr.Code, rr.Body.String())
}
}
rr := doRequest(srv, "POST", base+it.Ref+"/comments", map[string]string{"body": "Old ref comment"})
if rr.Code != 201 {
t.Fatalf("comment via moved ref: %d %s", rr.Code, rr.Body.String())
}
var c struct {
ItemID string `json:"item_id"`
ItemRef string `json:"item_ref"`
}
parseJSON(t, rr, &c)
if c.ItemID != it.ID || c.ItemRef != fmt.Sprintf("TASK-%d", *it.ItemNumber) {
t.Errorf("comment response item_id=%q item_ref=%q", c.ItemID, c.ItemRef)
}
slugged, err := f.Store.CreateItem(f.Workspace.ID, task.ID, models.ItemCreate{Title: "Ident 999"})
if err != nil {
t.Fatal(err)
}
if slugged.Slug != "ident-999" {
t.Fatal(slugged.Slug)
}
if rr := doRequest(srv, "GET", base+"IDENT-999", nil); rr.Code != 404 {
t.Errorf("ref-shaped miss: got %d, want 404", rr.Code)
}
})
}
}
func TestBUG4119_RelationWrongPrefix(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, exists := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t/ident=%t", pg, exists), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, exists)
srv := New(f.Store)
t.Cleanup(srv.Stop)
schema := fmt.Sprintf(`{"fields":[{"key":"target","type":"relation","collection":%q}]}`, f.Item.CollectionSlug)
coll, err := f.Store.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Sources", Schema: schema})
if err != nil {
t.Fatal(err)
}
source, err := f.Store.CreateItem(f.Workspace.ID, coll.ID, models.ItemCreate{Title: "Source"})
if err != nil {
t.Fatal(err)
}
before := f.Snapshot(t)
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+f.Workspace.Slug+"/items/"+source.ID, map[string]any{"fields": map[string]any{"target": f.WrongRef}})
if rr.Code != 400 {
t.Errorf("relation wrong-prefix: got %d, want 400: %s", rr.Code, rr.Body.String())
}
after, err := f.Store.GetItem(source.ID)
if err != nil {
t.Fatal(err)
}
if after.Fields != source.Fields || after.Seq != source.Seq {
t.Errorf("relation wrote source fields: %s", after.Fields)
}
f.AssertUnchanged(t, before)
})
}
}
}
func TestBUG4119_HTTPLowercaseRefSlug(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, route := range []struct {
name, method, suffix string
status int
}{
{"update", "PATCH", "", 200}, {"delete", "DELETE", "", 204},
{"show", "GET", "", 200}, {"progress", "GET", "/progress", 200},
{"links", "GET", "/links", 200}, {"preflight", "POST", "/copy/preflight", 200},
{"copy", "POST", "/copy", 201},
} {
t.Run(fmt.Sprintf("pg=%t/%s", pg, route.name), func(t *testing.T) {
f, slugged := storetest.NewRefSlugFixture(t, pg)
s := f.Store
srv := New(s)
t.Cleanup(srv.Stop)
children, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{
Name: "Work children", Prefix: "CHILD",
Schema: `{"fields":[{"key":"status","type":"select","options":["open","done"],"terminal_options":["done"],"default":"open"}]}`,
})
if err != nil {
t.Fatal(err)
}
child, err := s.CreateItem(f.Workspace.ID, children.ID, models.ItemCreate{Title: "Slug child"})
if err != nil {
t.Fatal(err)
}
link, err := s.SetParentLink(f.Workspace.ID, child.ID, slugged.ID, "user")
if err != nil {
t.Fatal(err)
}
dest, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Copy destination"})
if err != nil {
t.Fatal(err)
}
coll, err := s.CreateCollection(dest.ID, models.CollectionCreate{Name: "Docs", Schema: `{"fields":[]}`})
if err != nil {
t.Fatal(err)
}
// Scope the existing mutation snapshot to #2: legitimate writes to the
// slug owner and destination must not mask writes to the number holder.
snapshot := func() string {
t.Helper()
item, err := s.GetItemIncludeDeleted(f.Item.ID)
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(item)
if err != nil {
t.Fatal(err)
}
result := string(b)
for _, query := range []string{
`SELECT COUNT(*) FROM item_versions WHERE item_id = ?`,
`SELECT COUNT(*) FROM comments WHERE item_id = ?`,
`SELECT COUNT(*) FROM activities WHERE document_id = ?`,
`SELECT COUNT(*) FROM event_outbox WHERE subject_id = ?`,
} {
var count int
if err := s.DB().QueryRow(s.D().Rebind(query), f.Item.ID).Scan(&count); err != nil {
t.Fatal(err)
}
result += fmt.Sprintf("/%d", count)
}
return result
}
before := snapshot()
var body any
if route.method == "PATCH" {
body = map[string]string{"content": "Updated slug owner"}
}
if route.method == "POST" {
body = map[string]string{"target_workspace": dest.Slug, "target_collection": coll.Slug}
}
rr := doRequest(srv, route.method, "/api/v1/workspaces/"+f.Workspace.Slug+"/items/release-2"+route.suffix, body)
if after := snapshot(); after != before {
t.Errorf("number 2 changed: before=%s after=%s", before, after)
}
if rr.Code != route.status {
t.Fatalf("%s release-2: got %d want %d: %s", route.name, rr.Code, route.status, rr.Body.String())
}
switch route.name {
case "update", "delete":
got, err := s.GetItemIncludeDeleted(slugged.ID)
if err != nil {
t.Fatal(err)
}
if route.name == "update" && got.Content != "Updated slug owner" {
t.Errorf("slug owner not updated: %q", got.Content)
}
if route.name == "delete" && got.DeletedAt == nil {
t.Error("slug owner not deleted")
}
case "show":
var got models.Item
parseJSON(t, rr, &got)
if got.ID != slugged.ID {
t.Errorf("show id=%s want=%s", got.ID, slugged.ID)
}
case "progress":
var got struct{ Total int }
parseJSON(t, rr, &got)
if got.Total != 1 {
t.Errorf("slug owner progress total=%d want=1", got.Total)
}
case "links":
var got []models.ItemLink
parseJSON(t, rr, &got)
if len(got) != 1 || got[0].ID != link.ID {
t.Errorf("slug owner links=%v want=%s", got, link.ID)
}
case "preflight":
var got ItemCopyPreflight
parseJSON(t, rr, &got)
if got.Source.Ref != slugged.Ref || got.Source.Slug != slugged.Slug {
t.Errorf("preflight source=%+v", got.Source)
}
case "copy":
var got ItemCopyResult
parseJSON(t, rr, &got)
if got.Source.Ref != slugged.Ref || got.Item == nil || got.Item.Content != slugged.Content || got.Item.Title != slugged.Title {
t.Errorf("copied wrong item: %+v", got)
}
}
})
}
}
}
func TestBUG4119_HTTPLowercaseOldRef(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f, _ := storetest.NewRefSlugFixture(t, pg)
plan, err := f.Store.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Plans", Prefix: "PLAN"})
if err != nil {
t.Fatal(err)
}
it, err := f.Store.CreateItem(f.Workspace.ID, plan.ID, models.ItemCreate{Title: "Moved plan"})
if err != nil {
t.Fatal(err)
}
if _, err := f.Store.MoveItem(it.ID, f.Item.CollectionID, `{}`); err != nil {
t.Fatal(err)
}
srv := New(f.Store)
t.Cleanup(srv.Stop)
base := "/api/v1/workspaces/" + f.Workspace.Slug + "/items/"
for _, ref := range []string{strings.ToLower(it.Ref), "RELEASE-2", "Release-2", "IDENT-10", "ident-10"} {
if rr := doRequest(srv, "GET", base+ref, nil); rr.Code != 404 {
t.Errorf("show %s: got %d want 404", ref, rr.Code)
}
}
if rr := doRequest(srv, "GET", base+it.Ref, nil); rr.Code != 200 {
t.Errorf("uppercase old ref: got %d want 200", rr.Code)
}
})
}
}
+2
View File
@@ -122,6 +122,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
writeError2(w, http.StatusGone, "idempotency_target_gone", "the comment this key created no longer exists", map[string]interface{}{"comment_id": a.CommentID})
return
}
c.ItemRef = item.Ref
writeJSON(w, status, c)
})
if !ok {
@@ -178,6 +179,7 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
Summary: truncateForSummary(comment.Body, 120),
})
comment.ItemRef = item.Ref
writeJSON(w, http.StatusCreated, comment)
}
+5
View File
@@ -1262,6 +1262,11 @@ func (s *Server) handleGetItem(w http.ResponseWriter, r *http.Request) {
itemSlug := chi.URLParam(r, "itemSlug")
item, err := s.store.ResolveItemIncludeDeleted(workspaceID, itemSlug)
if err == nil && item == nil {
// BUG-4119: show also accepts proven moved refs for live items.
// Include-deleted resolution itself stays exact-only.
item, err = s.store.ResolveItem(workspaceID, itemSlug)
}
if err != nil {
writeInternalError(w, err)
return
+8 -6
View File
@@ -142,9 +142,10 @@ import (
// 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
// DR-13 — NO TRANSPARENT RETRY. A KEYLESS retry of a committed copy is a
// duplicate, and a client that timed out after the commit cannot tell. Since
// TASK-3585 a copy may carry an idempotency key (claimKey in the handler),
// and a same-key retry replays the first copy's answer instead. 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
@@ -152,9 +153,10 @@ import (
// 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.
// The clients: internal/cli's CopyItem (client_items_copy.go) makes exactly
// one retry, KEYED, only after an unknown outcome and only against a server
// that advertises idempotency_keys (TASK-3585); 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:
+3 -3
View File
@@ -1272,8 +1272,8 @@ func TestCopyEndpoint_EnforceItemLimitFollowsCloudMode(t *testing.T) {
// --- 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
// DR-13. A keyless retry after a failure that may have committed produces a
// DUPLICATE item (a keyed one replays, TASK-3585) — 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.
//
@@ -1300,7 +1300,7 @@ func TestCopyEndpoint_DoesNotRetryOnAmbiguousError(t *testing.T) {
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)
"(a keyless retry duplicates the item)", calls)
}
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", rr.Code, rr.Body.String())
+6 -15
View File
@@ -85,14 +85,9 @@ func (s *Server) handleResolveCrossWorkspaceRef(w http.ResponseWriter, r *http.R
return
}
// 3. Resolve the ref within the workspace. parseRefForRedirect
// canonicalizes the prefix to uppercase so it lines up with
// GetItemByRef's exact-prefix path; refs whose prefix this parser
// accepts but store.parseItemRef does not still resolve via the
// workspace-unique number fallback inside GetItemByRef. That gap
// narrowed in BUG-2943 — parseItemRef now accepts digits after the
// first character — but the fallback is still what covers a moved
// item, so it is load-bearing for its own reason.
// 3. Resolve the ref within the workspace. Both parsers canonicalize
// prefixes to uppercase. BUG-4119: a prefix miss resolves only through
// the item's durable collection-move history, never its number alone.
prefix, number, ok := parseRefForRedirect(ref)
if !ok {
s.refResolverNotFound(w, r)
@@ -296,13 +291,9 @@ func (s *Server) resolverOwnerUsername(ws *models.Workspace) string {
return user.Username
}
// parseRefForRedirect splits a validated ref (the regex caller already
// confirmed `[A-Za-z][A-Za-z0-9]*-\d+`) into its uppercase prefix and
// number. GetItemByRef's primary path matches on exact prefix; its
// fallback path (workspace-unique number alone) handles items that have
// been moved to a different collection. Since BUG-2943 store.parseItemRef
// accepts a digit-bearing prefix too, so the two agree about that shape; the
// number lookup remains the path for an item whose collection changed.
// parseRefForRedirect splits a validated ref into its uppercase prefix and
// number. GetItemByRef accepts exact refs and move-log-proven former refs
// (BUG-4119); redirect parsing does not provide a number-only escape hatch.
func parseRefForRedirect(s string) (string, int, bool) {
up := strings.ToUpper(s)
dash := strings.LastIndex(up, "-")
+2 -1
View File
@@ -21,7 +21,8 @@ const (
func armViaAPI(t *testing.T, srv *Server, wsSlug string, item models.Item, at string) models.Reminder {
t.Helper()
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/items/"+item.Slug+"/reminders",
// BUG-4119: numbered titles may have ref-shaped slugs; use the item's UUID.
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/items/"+item.ID+"/reminders",
map[string]string{"remind_at": at})
if rr.Code != http.StatusCreated {
t.Fatalf("arm reminder: expected 201, got %d: %s", rr.Code, rr.Body.String())
+44 -190
View File
@@ -9,205 +9,59 @@ import (
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-3082. A relation value that is REF-SHAPED resolves through
// `itemByRefQ`, which falls back to matching by item NUMBER with the prefix
// discarded. Unconditionally, that turned a ref naming a collection the caller
// never meant into a silent write against an unrelated item: item numbers are
// workspace-unique and sequential ACROSS collections, so `CONVE-1` and
// `SECRE-1` are never both real, and the fallback answered the wrong one of
// them without raising anything.
//
// The fallback exists for a real case — a collection RENAME changes its prefix,
// and BUG-2873 made that reachable, so a relation written as the old ref has to
// keep resolving. The fix is that the fallback now fires only when the written
// prefix names NO live collection in the workspace, which is what a rename
// leaves behind.
//
// EVERY LEG GOES THROUGH A REFUSING WRITE DOOR and asserts the STORED BLOB, not
// just the status code. A 400 with the right message and a 201 that quietly
// stored someone else's UUID are the two outcomes this is discriminating
// between, and only the blob tells them apart from a door that refused
// everything.
// BUG-4119: a prefix miss needs the item's move log, not merely an absent
// live collection. Prefix renames alone, deleted unrelated collections, and
// foreign prefixes do not establish that the item belonged to that prefix.
func TestRelationRefDoesNotResolveByNumberAcrossALivePrefix(t *testing.T) {
t.Run("a live prefix with no such item is refused, not retargeted", func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
// `CONVE-1` names a collection that EXISTS. Item number 1 is the
// Secrets item, which is what the field declares — so if the prefix is
// discarded the value resolves, passes the collection check, and lands.
code, blob := createConventionWithRole(t, srv, wsSlug, ws, "CONVE-1")
if code == http.StatusCreated {
if got, _ := blob["role"].(string); got == target.ID {
t.Fatalf("the write RETARGETED: the caller sent %q and the item now points at %s (%s, %q). "+
"Item numbers are workspace-wide, so CONVE-1 and SECRE-1 are different items; "+
"resolving by number with the prefix discarded is the silent corruption BUG-3082 is about",
"CONVE-1", target.ID, target.Ref, target.Title)
for _, kind := range []string{"live", "renamed", "deleted", "foreign", "unknown", "exact"} {
t.Run(kind, func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
ref := "CONVE-" + itemNumberOf(t, target)
switch kind {
case "renamed":
renamePrefix(t, srv, ws.ID, "secrets", "VAULT")
ref = target.Ref
case "deleted":
archive := mustSchemaCollection(t, srv, ws.ID, "Archive", `{"fields":[]}`)
if err := srv.store.DeleteCollection(archive.ID, ""); err != nil {
t.Fatal(err)
}
ref = archive.Prefix + "-" + itemNumberOf(t, target)
case "foreign":
other, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Other"})
if err != nil {
t.Fatal(err)
}
elsewhere := mustSchemaCollection(t, srv, other.ID, "Foreign", `{"fields":[]}`)
ref = elsewhere.Prefix + "-" + itemNumberOf(t, target)
case "unknown":
ref = "ZZZZ-" + itemNumberOf(t, target)
case "exact":
ref = target.Ref
}
t.Fatalf("expected a refusal for a ref naming a live collection with no such item, got 201 storing role=%#v", blob["role"])
}
if code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", code)
}
})
t.Run("the rename case still resolves by number", func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
// Rename the Secrets collection's PREFIX. The target keeps its item
// number, so the ref the caller has written down — SECRE-1 — now names
// a prefix no live collection carries. That is the whole reason the
// by-number fallback exists (BUG-2873) and it must survive the fix.
renamePrefix(t, srv, ws.ID, "secrets", "VAULT")
code, blob := createConventionWithRole(t, srv, wsSlug, ws, target.Ref)
if code != http.StatusCreated {
t.Fatalf("a ref left behind by a prefix rename must still resolve; got %d", code)
}
if got, _ := blob["role"].(string); got != target.ID {
t.Fatalf("stored role = %q, want the renamed target's id %q", got, target.ID)
}
})
t.Run("control: the correct ref resolves", func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
code, blob := createConventionWithRole(t, srv, wsSlug, ws, target.Ref)
if code != http.StatusCreated {
t.Fatalf("the target's own ref must resolve; got %d", code)
}
if got, _ := blob["role"].(string); got != target.ID {
t.Fatalf("stored role = %q, want %q", got, target.ID)
}
})
t.Run("a soft-deleted collection's prefix counts as absent", func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
// A prefix belonging to a collection that has been DELETED is not in
// use any more, and its items' refs are exactly the leftovers the
// by-number fallback exists to keep resolving — the same shape as a
// rename, reached a different way. The gate therefore asks for a LIVE
// collection, and this leg is what holds the `deleted_at IS NULL` in
// collectionPrefixExistsQ: without it the query counts the dead
// collection, the fallback is suppressed, and the value is refused.
archive := mustSchemaCollection(t, srv, ws.ID, "Archive", `{"fields":[]}`)
if err := srv.store.DeleteCollection(archive.ID, ""); err != nil {
t.Fatalf("DeleteCollection(archive): %v", err)
}
colls, err := srv.store.ListCollections(ws.ID)
if err != nil {
t.Fatalf("ListCollections: %v", err)
}
for i := range colls {
if colls[i].ID == archive.ID {
t.Fatalf("the archive collection is still live, so this leg would prove nothing")
code, blob := createConventionWithRole(t, srv, wsSlug, ws, ref)
if kind == "exact" {
if code != http.StatusCreated || blob["role"] != target.ID {
t.Fatalf("exact relation: %d %v", code, blob)
}
} else if code != http.StatusBadRequest {
t.Fatalf("unproven ref %s: got %d, want 400", ref, code)
}
}
code, blob := createConventionWithRole(t, srv, wsSlug, ws, archive.Prefix+"-"+itemNumberOf(t, target))
if code != http.StatusCreated {
t.Fatalf("a ref under a DELETED collection's prefix must still fall through to the by-number fallback; got %d", code)
}
if got, _ := blob["role"].(string); got != target.ID {
t.Fatalf("stored role = %q, want %q", got, target.ID)
}
})
t.Run("a prefix live in ANOTHER workspace does not count here", func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
// The gate asks whether THIS workspace has a live collection with the
// written prefix. Prefixes are not globally unique — `TASK` is live in
// most workspaces — so an unscoped lookup would suppress the fallback
// for a rename in every workspace whose old prefix another workspace
// happens to use. This leg holds the `workspace_id = ?` in
// collectionPrefixExistsQ.
other := doRequest(srv, "POST", "/api/v1/workspaces", map[string]string{"name": "Other"})
if other.Code != http.StatusCreated {
t.Fatalf("create second workspace: %d %s", other.Code, other.Body.String())
}
var otherWS models.Workspace
parseJSON(t, other, &otherWS)
if otherWS.ID == ws.ID {
t.Fatalf("the second workspace is the first one, so this leg would prove nothing")
}
elsewhere := mustSchemaCollection(t, srv, otherWS.ID, "Foreign", `{"fields":[]}`)
// Same prefix, different workspace. Nothing in THIS workspace carries
// it, so the ref is a leftover and must still resolve by number.
code, blob := createConventionWithRole(t, srv, wsSlug, ws, elsewhere.Prefix+"-"+itemNumberOf(t, target))
if code != http.StatusCreated {
t.Fatalf("a prefix that is live only in ANOTHER workspace must not suppress the fallback here; got %d", code)
}
if got, _ := blob["role"].(string); got != target.ID {
t.Fatalf("stored role = %q, want %q", got, target.ID)
}
})
t.Run("an item whose collection was soft-deleted still matches on prefix, and is refused downstream", func(t *testing.T) {
})
}
t.Run("exact ref in a deleted collection still gets downstream collection validation", func(t *testing.T) {
srv, wsSlug, ws, _ := relationRefFixture(t)
// The ref-matched query at the top of itemByRefQ does not filter on the
// COLLECTION's deleted_at — only the item's — so an item in a
// soft-deleted collection matches there and never reaches the new
// predicate. That asymmetry is deliberate and documented, and this leg
// is what makes the documented reason true rather than argued: it
// cannot produce a retarget, because the collection check downstream
// still refuses an item that is not in the declared collection.
archive := mustSchemaCollection(t, srv, ws.ID, "Archive", `{"fields":[]}`)
orphan, err := srv.store.CreateItem(ws.ID, archive.ID, models.ItemCreate{Title: "Orphaned Item"})
if err != nil {
t.Fatalf("CreateItem(orphan): %v", err)
t.Fatal(err)
}
if err := srv.store.DeleteCollection(archive.ID, ""); err != nil {
t.Fatalf("DeleteCollection(archive): %v", err)
t.Fatal(err)
}
// The ITEM must still be live, or this leg proves nothing about the
// collection filter — it would pass because the item is gone.
stillThere, err := srv.store.GetItem(orphan.ID)
if err != nil {
t.Fatalf("GetItem(orphan): %v", err)
}
if stillThere == nil {
t.Skipf("soft-deleting a collection also removes its items here, so the exact-match path this leg is about is unreachable")
}
// `role` declares the LIVE Secrets collection; the orphan's ref names
// an item in the dead Archive one.
code, blob, message := createConventionWithRoleDetailed(t, srv, wsSlug, ws, orphan.Ref)
if code == http.StatusCreated {
t.Fatalf("a ref naming an item outside the declared collection must be refused, got 201 storing role=%#v", blob["role"])
}
if code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", code)
}
// THE REASON, not just the refusal. A `not_found` would also be a 400,
// and would mean the exact-match path never resolved the orphan — in
// which case this leg would be passing without exercising the
// asymmetry it exists to document. `wrong_collection` is the message
// that proves the lookup DID find the item and the collection check is
// what turned it away.
if !strings.Contains(message, "is not an item in collection") {
t.Fatalf("expected the wrong-collection refusal, which is what proves the exact-match path resolved the orphan; got %q", message)
}
})
t.Run("RESIDUAL: a foreign prefix that names nothing here still resolves by number", func(t *testing.T) {
srv, wsSlug, ws, target := relationRefFixture(t)
// A ref pasted out of ANOTHER workspace. `ZZZZ` names no collection
// here, so it is indistinguishable from a rename's leftover — both are
// a live number under a prefix this workspace does not carry — and it
// resolves. This is NOT fixed, and it is pinned rather than left
// implied so that a reader of the fix does not conclude the class is
// closed. Closing it would cost the rename case the fallback exists
// for; it needs a different mechanism (a record of prior prefixes),
// not a tighter predicate here.
code, blob := createConventionWithRole(t, srv, wsSlug, ws, "ZZZZ-"+itemNumberOf(t, target))
if code != http.StatusCreated {
t.Fatalf("residual leg: expected the by-number fallback to still fire for an unknown prefix, got %d", code)
}
if got, _ := blob["role"].(string); got != target.ID {
t.Fatalf("residual leg: stored role = %q, want %q — if this now refuses, the residual is closed and this test's comment is stale", got, target.ID)
code, _, message := createConventionWithRoleDetailed(t, srv, wsSlug, ws, orphan.Ref)
if code != http.StatusBadRequest || !strings.Contains(message, "is not an item in collection") {
t.Fatalf("want wrong-collection refusal, got %d %q", code, message)
}
})
}
@@ -241,7 +95,7 @@ func createConventionWithRole(t *testing.T, srv *Server, wsSlug string, ws *mode
}
// renamePrefix changes a collection's prefix, leaving its items' numbers alone
// — the state BUG-2873 made reachable and the by-number fallback exists for.
// — the state BUG-2873 made reachable.
func renamePrefix(t *testing.T, srv *Server, workspaceID, slug, prefix string) {
t.Helper()
colls, err := srv.store.ListCollections(workspaceID)
+32 -12
View File
@@ -12,7 +12,6 @@ package render
import (
"fmt"
"html"
"net/url"
"regexp"
"strings"
@@ -158,6 +157,27 @@ func ShouldEmbedAsImage(meta *AttachmentMeta, wantVariant string) bool {
return imgPaintableMimes[mime]
}
// htmlEscaper escapes exactly the five characters the TS escapeHtml in
// web/src/lib/markdown/attachments.ts does, with the same replacements. It is
// a single-pass Replacer, so an emitted entity is never re-escaped (the same
// result as the TS chain that handles `&` first).
var htmlEscaper = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
)
// escapeHTML exists to keep byte parity with escapeHtml in
// web/src/lib/markdown/attachments.ts (TASK-4195 r2-M1). html.EscapeString
// emits `&#34;` for a double quote where the TS side emits `&quot;`, so the
// two renderers disagreed on any name or alt containing one. The shared
// fixture web/src/lib/markdown/testdata/file-chip-parity.json pins the output.
func escapeHTML(s string) string {
return htmlEscaper.Replace(s)
}
// FormatAttachmentSize returns a human-readable byte count ("832 B",
// "1.2 MB", "5 GB"). Negative sizes return "" — that's a corrupt-row
// signal, not something to print to the user.
@@ -206,9 +226,9 @@ func RenderAttachmentImage(meta *AttachmentMeta, alt, workspaceSlug string) stri
sizeAttrs = fmt.Sprintf(` width="%d" height="%d"`, *meta.Width, *meta.Height)
}
return fmt.Sprintf(`<img src="%s" data-attachment-id="%s" alt="%s"%s>`,
html.EscapeString(src),
html.EscapeString(meta.ID),
html.EscapeString(altText),
escapeHTML(src),
escapeHTML(meta.ID),
escapeHTML(altText),
sizeAttrs,
)
}
@@ -232,14 +252,14 @@ func RenderAttachmentChip(meta *AttachmentMeta, displayText, workspaceSlug strin
size := FormatAttachmentSize(meta.SizeBytes)
sizeSpan := ""
if size != "" {
sizeSpan = ` <span class="file-chip-size">· ` + html.EscapeString(size) + `</span>`
sizeSpan = ` <span class="file-chip-size">· ` + escapeHTML(size) + `</span>`
}
return fmt.Sprintf(
`<a class="file-chip" href="%s" data-attachment-id="%s" download="%s" target="_blank" rel="noopener noreferrer"><span class="file-chip-icon" aria-hidden="true">📄</span><span class="file-chip-name">%s</span>%s</a>`,
html.EscapeString(href),
html.EscapeString(meta.ID),
html.EscapeString(meta.Filename),
html.EscapeString(label),
`<a class="file-chip" href="%s" data-attachment-id="%s" download="%s" target="_blank" rel="noopener noreferrer"><span class="file-chip-icon" aria-hidden="true">📄</span><span class="file-chip-name"><bdi>%s</bdi></span>%s</a>`,
escapeHTML(href),
escapeHTML(meta.ID),
escapeHTML(meta.Filename),
escapeHTML(label),
sizeSpan,
)
}
@@ -256,8 +276,8 @@ func RenderAttachmentMissing(uuid, alt string) string {
}
return fmt.Sprintf(
`<span class="attachment-missing" data-attachment-id="%s" title="This attachment is missing or has been deleted">📎 %s</span>`,
html.EscapeString(uuid),
html.EscapeString(safeAlt),
escapeHTML(uuid),
escapeHTML(safeAlt),
)
}
+80 -3
View File
@@ -1,6 +1,8 @@
package render
import (
"encoding/json"
"os"
"strings"
"testing"
)
@@ -186,7 +188,7 @@ func TestRenderAttachmentChip(t *testing.T) {
func TestRenderAttachmentChip_DisplayTextOverridesFilename(t *testing.T) {
meta := &AttachmentMeta{ID: "x", MimeType: "application/zip", Filename: "release.zip", SizeBytes: 100}
got := RenderAttachmentChip(meta, "Latest release", "ws")
if !strings.Contains(got, ">Latest release</span>") {
if !strings.Contains(got, "><bdi>Latest release</bdi></span>") {
t.Errorf("expected display text override, got: %s", got)
}
// download attribute always uses canonical filename, not the display text.
@@ -423,14 +425,14 @@ func TestResolveAttachmentReferences_EscapedBrackets(t *testing.T) {
in: `[A \] B](pad-attachment:doc-1)`,
wantSubs: []string{
`data-attachment-id="doc-1"`,
`>A ] B</span>`,
`><bdi>A ] B</bdi></span>`,
},
},
{
name: "escaped backslash and bracket combo",
in: `[a \\ b \[ c \] d](pad-attachment:doc-1)`,
wantSubs: []string{
`>a \ b [ c ] d</span>`,
`><bdi>a \ b [ c ] d</bdi></span>`,
},
},
}
@@ -511,3 +513,78 @@ func TestExtractAttachmentRefs_None(t *testing.T) {
t.Errorf("expected nil for content with no refs, got %v", got)
}
}
// attachmentParityFixture is shared with web/src/lib/markdown/fileChipParity.test.ts:
// the Go and TS renderers must emit byte-identical chips, images and missing
// placeholders (TASK-4195).
//
// Scope of the URL half (TASK-4195 r3-M1): Go builds download URLs with
// url.PathEscape and TS with encodeURIComponent, which differ on !'()* and
// $&+=:@. The fixture pins parity only for UUID attachment ids (store newID)
// and workspace slugs minted since BUG-3769 ([a-z0-9-], where both encoders are
// the identity). A preserved legacy slug may hold those characters and encode
// differently on each side, but both forms decode to the same route.
type attachmentParityMeta struct {
ID string `json:"id"`
MimeType string `json:"mime_type"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
Width *int `json:"width"`
Height *int `json:"height"`
}
func (m attachmentParityMeta) toMeta() *AttachmentMeta {
return &AttachmentMeta{ID: m.ID, MimeType: m.MimeType, Filename: m.Filename, SizeBytes: m.SizeBytes, Width: m.Width, Height: m.Height}
}
type attachmentParityFixture struct {
Chips []struct {
Name string `json:"name"`
Workspace string `json:"workspace"`
Meta attachmentParityMeta `json:"meta"`
DisplayText string `json:"display_text"`
HTML string `json:"html"`
} `json:"chips"`
Images []struct {
Name string `json:"name"`
Workspace string `json:"workspace"`
Meta attachmentParityMeta `json:"meta"`
Alt string `json:"alt"`
HTML string `json:"html"`
} `json:"images"`
Missing []struct {
Name string `json:"name"`
UUID string `json:"uuid"`
Alt string `json:"alt"`
HTML string `json:"html"`
} `json:"missing"`
}
func TestRenderAttachment_ParityWithTypeScript(t *testing.T) {
raw, err := os.ReadFile("../../../web/src/lib/markdown/testdata/file-chip-parity.json")
if err != nil {
t.Fatal(err)
}
var fx attachmentParityFixture
if err := json.Unmarshal(raw, &fx); err != nil {
t.Fatal(err)
}
if len(fx.Chips) == 0 || len(fx.Images) == 0 || len(fx.Missing) == 0 {
t.Fatalf("fixture has an empty case array: chips=%d images=%d missing=%d", len(fx.Chips), len(fx.Images), len(fx.Missing))
}
for _, c := range fx.Chips {
if got := RenderAttachmentChip(c.Meta.toMeta(), c.DisplayText, c.Workspace); got != c.HTML {
t.Errorf("chip %s:\n got %s\n want %s", c.Name, got, c.HTML)
}
}
for _, c := range fx.Images {
if got := RenderAttachmentImage(c.Meta.toMeta(), c.Alt, c.Workspace); got != c.HTML {
t.Errorf("image %s:\n got %s\n want %s", c.Name, got, c.HTML)
}
}
for _, c := range fx.Missing {
if got := RenderAttachmentMissing(c.UUID, c.Alt); got != c.HTML {
t.Errorf("missing %s:\n got %s\n want %s", c.Name, got, c.HTML)
}
}
}
+2 -2
View File
@@ -353,8 +353,8 @@ type Server struct {
// 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
// retrying a mutating copy — a KEYLESS retry after a post-commit failure
// duplicates the item (a keyed one replays, TASK-3585) — 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
@@ -0,0 +1,42 @@
package server
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/agentchatcases"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-4186 PR C: the client guard (cli.CheckAgentChatTarget) is only a
// pre-check, so it must refuse exactly what the real ensure handler refuses.
// The cli test runs the same table through the guard; this half drives the
// real handler. A row the guard refuses and the server accepts is a false
// refusal; the reverse is a guard that stopped guarding.
func TestChat_AgentChatTargetGuardParity(t *testing.T) {
f := newChatFixture(t)
for _, tc := range agentchatcases.Cases() {
if tc.Key == "" {
continue // C<n> / <n> never reaches the ensure handler
}
t.Run(tc.Name, func(t *testing.T) {
rr := f.request("POST", chatBase, tc.Caller, map[string]any{"agent_key": tc.Key})
refused := rr.Code == 400
if !refused && rr.Code != 200 && rr.Code != 201 {
t.Fatalf("unexpected status %d: %s", rr.Code, rr.Body.String())
}
if refused != tc.Refuse {
t.Fatalf("server ensure(caller %q, key %q) status %d, want refuse=%v", tc.Caller, tc.Key, rr.Code, tc.Refuse)
}
})
}
}
func TestNormalizeAgentNameDelegatesToModels(t *testing.T) {
for _, tc := range agentchatcases.Cases() {
for _, in := range []string{tc.Caller, tc.Key} {
if got, want := normalizeAgentName(in), models.NormalizeAgentName(in); got != want {
t.Fatalf("normalizeAgentName(%q) = %q, models = %q", in, got, want)
}
}
}
}
+330
View File
@@ -0,0 +1,330 @@
package store_test
import (
"database/sql"
"fmt"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store/storetest"
)
func TestBUG4119_WikiAndBacklinks(t *testing.T) {
for _, pg := range []bool{false, true} {
for _, exists := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t/ident=%t", pg, exists), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, exists)
s := f.Store
source, err := s.CreateItem(f.Workspace.ID, f.Item.CollectionID, models.ItemCreate{Title: "Wiki source", Content: "[[" + f.WrongRef + "]]"})
if err != nil {
t.Fatal(err)
}
var target sql.NullString
if err := s.DB().QueryRow(s.D().Rebind(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), source.ID).Scan(&target); err != nil {
t.Fatal(err)
}
if target.Valid {
t.Errorf("wiki wrong-prefix resolved to %s, want NULL", target.String)
}
other, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Other"})
if err != nil {
t.Fatal(err)
}
c, err := s.CreateCollection(other.ID, models.CollectionCreate{Name: "Docs"})
if err != nil {
t.Fatal(err)
}
cross, err := s.CreateItem(other.ID, c.ID, models.ItemCreate{Title: "Cross source"})
if err != nil {
t.Fatal(err)
}
// Seed the persisted spelling directly: this is also the shape of
// pre-fix links, whose stored target_item_id cannot be trusted.
for pos, ref := range []string{f.WrongRef, f.Item.Ref} {
_, err = s.DB().Exec(s.D().Rebind(`INSERT INTO item_wiki_links (source_item_id,target_kind,target_workspace_id,target_ref,position) VALUES (?, 'workspace_ref', ?, ?, ?)`), cross.ID, f.Workspace.ID, ref, pos)
if err != nil {
t.Fatal(err)
}
}
u, err := s.CreateUser(models.UserCreate{Email: "backlinks@example.com", Name: "Reader", Password: "test-password-long-enough"})
if err != nil {
t.Fatal(err)
}
if err = s.AddWorkspaceMember(other.ID, u.ID, "owner"); err != nil {
t.Fatal(err)
}
backlinks, err := s.GetCrossWorkspaceBacklinks(f.Workspace.ID, f.Item.Ref, u.ID, nil, 50, 0, false)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 1 {
t.Errorf("cross-workspace backlinks: got %d, want 1 (exact only)", len(backlinks))
}
})
}
}
}
func TestBUG4119_MoveHistory(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f := storetest.NewRefPrefixFixture(t, pg, false)
s := f.Store
plan, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Plans", Prefix: "PLAN"})
if err != nil {
t.Fatal(err)
}
task, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Tasks", Prefix: "TASK"})
if err != nil {
t.Fatal(err)
}
last, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Done", Prefix: "DONE"})
if err != nil {
t.Fatal(err)
}
it, err := s.CreateItem(f.Workspace.ID, plan.ID, models.ItemCreate{Title: "Plan"})
if err != nil {
t.Fatal(err)
}
check := func(prefix string) {
t.Helper()
ref := fmt.Sprintf("%s-%d", prefix, *it.ItemNumber)
got, err := s.GetItemByRef(f.Workspace.ID, prefix, *it.ItemNumber)
if err != nil || got == nil || got.ID != it.ID {
t.Errorf("GetItemByRef %s: %v %v", ref, got, err)
}
got, err = s.ResolveItem(f.Workspace.ID, ref)
if err != nil || got == nil || got.ID != it.ID {
t.Errorf("ResolveItem %s: %v %v", ref, got, err)
}
}
check("PLAN")
if _, err = s.MoveItem(it.ID, task.ID, `{}`); err != nil {
t.Fatal(err)
}
check("PLAN")
check("TASK")
if err = s.DeleteCollection(plan.ID, ""); err != nil {
t.Fatal(err)
}
check("PLAN")
reused, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "New plans", Prefix: "PLAN"})
if err != nil {
t.Fatal(err)
}
newPlan, err := s.CreateItem(f.Workspace.ID, reused.ID, models.ItemCreate{Title: "New plan"})
if err != nil {
t.Fatal(err)
}
if got, err := s.ResolveItem(f.Workspace.ID, newPlan.Ref); err != nil || got == nil || got.ID != newPlan.ID {
t.Errorf("reused prefix exact ref: %v %v", got, err)
}
if got, err := s.ResolveItem(f.Workspace.ID, fmt.Sprintf("TASK-%d", *newPlan.ItemNumber)); err != nil || got != nil {
t.Errorf("new prefix holder misrouted: %v %v", got, err)
}
check("PLAN")
if _, err = s.MoveItem(it.ID, last.ID, `{}`); err != nil {
t.Fatal(err)
}
check("PLAN")
check("TASK")
check("DONE")
if got, err := s.ResolveItem(f.Workspace.ID, f.WrongRef); err != nil || got != nil {
t.Errorf("reused prefix must not legitimize unrelated refs: %v %v", got, err)
}
// Wiki resolution keeps the proven ref inside its write transaction,
// including a deleted/reused former collection.
source, err := s.CreateItem(f.Workspace.ID, last.ID, models.ItemCreate{Title: "Moved wiki", Content: "[[" + it.Ref + "]]"})
if err != nil {
t.Fatal(err)
}
var target string
if err = s.DB().QueryRow(s.D().Rebind(`SELECT target_item_id FROM item_wiki_links WHERE source_item_id = ?`), source.ID).Scan(&target); err != nil || target != it.ID {
t.Errorf("moved wiki: %q %v", target, err)
}
other, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Backlink source"})
if err != nil {
t.Fatal(err)
}
coll, err := s.CreateCollection(other.ID, models.CollectionCreate{Name: "Notes"})
if err != nil {
t.Fatal(err)
}
body := fmt.Sprintf("[[%s::PLAN-%d]] [[%s::TASK-%d]] [[%s::IDENT-%d]]", f.Workspace.Slug, *it.ItemNumber, f.Workspace.Slug, *it.ItemNumber, f.Workspace.Slug, *it.ItemNumber)
if _, err = s.CreateItem(other.ID, coll.ID, models.ItemCreate{Title: "Historical backlinks", Content: body}); err != nil {
t.Fatal(err)
}
u, err := s.CreateUser(models.UserCreate{Email: "moves@example.com", Name: "Reader", Password: "test-password-long-enough"})
if err != nil {
t.Fatal(err)
}
if err = s.AddWorkspaceMember(other.ID, u.ID, "owner"); err != nil {
t.Fatal(err)
}
backlinks, err := s.GetCrossWorkspaceBacklinks(f.Workspace.ID, fmt.Sprintf("DONE-%d", *it.ItemNumber), u.ID, nil, 50, 0, false)
if err != nil {
t.Fatal(err)
}
if len(backlinks) != 2 {
t.Errorf("historical backlinks: got %d, want 2 proven refs", len(backlinks))
}
if err = s.DeleteItem(it.ID); err != nil {
t.Fatal(err)
}
if got, err := s.ResolveItem(f.Workspace.ID, it.Ref); err != nil || got != nil {
t.Errorf("live resolution found deleted moved item: %v %v", got, err)
}
currentRef := fmt.Sprintf("DONE-%d", *it.ItemNumber)
if got, err := s.ResolveItemIncludeDeleted(f.Workspace.ID, currentRef); err != nil || got == nil || got.ID != it.ID {
t.Errorf("include-deleted exact ref: %v %v", got, err)
}
if got, err := s.ResolveItemIncludeDeleted(f.Workspace.ID, it.Ref); err != nil || got != nil {
t.Errorf("include-deleted must remain exact-only: %v %v", got, err)
}
backlinks, err = s.GetCrossWorkspaceBacklinks(f.Workspace.ID, currentRef, u.ID, nil, 50, 0, false)
if err != nil || len(backlinks) != 2 {
t.Errorf("archived target backlinks: %d %v", len(backlinks), err)
}
})
}
}
func TestBUG4119_LowercaseRefSlug(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f, slugged := storetest.NewRefSlugFixture(t, pg)
s := f.Store
// The exact number also exists, so BASE's number fallback is observable.
for n := 4; n <= 10; n++ {
if _, err := s.CreateItem(f.Workspace.ID, f.Item.CollectionID, models.ItemCreate{Title: fmt.Sprintf("Holder %d", n)}); err != nil {
t.Fatal(err)
}
}
ident, err := s.CreateItem(f.Workspace.ID, f.Item.CollectionID, models.ItemCreate{Title: "Ident 10"})
if err != nil {
t.Fatal(err)
}
if ident.Slug != "ident-10" {
t.Fatal(ident.Slug)
}
for name, resolve := range map[string]func(string, string) (*models.Item, error){"live": s.ResolveItem, "include-deleted": s.ResolveItemIncludeDeleted} {
t.Run(name, func(t *testing.T) {
for _, row := range []struct{ input, want string }{
{"release-2", slugged.ID}, {"RELEASE-2", ""}, {"Release-2", ""},
{"IDENT-10", ""}, {"ident-10", ident.ID},
{"ident-2", ""}, // no such slug; number 2 must never be a fallback
{"release-02", ""}, // slug lookup is exact, not normalized
} {
t.Run(row.input, func(t *testing.T) {
got, err := resolve(f.Workspace.ID, row.input)
id := ""
if got != nil {
id = got.ID
}
if err != nil || id != row.want {
t.Errorf("resolve %q: id=%q want=%q err=%v", row.input, id, row.want, err)
}
})
}
})
}
if err := s.DeleteItem(ident.ID); err != nil {
t.Fatal(err)
}
if got, err := s.ResolveItem(f.Workspace.ID, "ident-10"); err != nil || got != nil {
t.Errorf("deleted slug live: %v %v", got, err)
}
if got, err := s.ResolveItemIncludeDeleted(f.Workspace.ID, "ident-10"); err != nil || got == nil || got.ID != ident.ID {
t.Errorf("deleted slug include-deleted: %v %v", got, err)
}
// Remove the slug owner entirely to pin the requested no-ident-10 case.
if _, err := s.DB().Exec(s.D().Rebind(`UPDATE items SET slug = 'elsewhere' WHERE id = ?`), ident.ID); err != nil {
t.Fatal(err)
}
for _, resolve := range []func(string, string) (*models.Item, error){s.ResolveItem, s.ResolveItemIncludeDeleted} {
if got, err := resolve(f.Workspace.ID, "ident-10"); err != nil || got != nil {
t.Errorf("missing ident-10 slug: %v %v", got, err)
}
}
})
}
}
func TestBUG4119_ImportedUppercaseRefSlug(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f, _ := storetest.NewImportedUppercaseRefSlugFixture(t, pg)
for name, resolve := range map[string]func(string, string) (*models.Item, error){
"ResolveItem": f.Store.ResolveItem,
"ResolveItemIncludeDeleted": f.Store.ResolveItemIncludeDeleted,
} {
t.Run(name, func(t *testing.T) {
got, err := resolve(f.Workspace.ID, f.WrongRef)
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Errorf("%s(%q) reached imported uppercase slug %q; want nil", name, f.WrongRef, got.Slug)
}
})
}
})
}
}
func TestBUG4119_LowercaseRefMove(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
f, _ := storetest.NewRefSlugFixture(t, pg)
s := f.Store
plan, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Plans", Prefix: "PLAN"})
if err != nil {
t.Fatal(err)
}
task, err := s.CreateCollection(f.Workspace.ID, models.CollectionCreate{Name: "Tasks", Prefix: "TASK"})
if err != nil {
t.Fatal(err)
}
current, err := s.CreateItem(f.Workspace.ID, task.ID, models.ItemCreate{Title: "Filler"})
if err != nil {
t.Fatal(err)
}
current, err = s.CreateItem(f.Workspace.ID, task.ID, models.ItemCreate{Title: "Current task"})
if err != nil {
t.Fatal(err)
}
if current.Ref != "TASK-5" {
t.Fatal(current.Ref)
}
// Advance the fixture to the lead's concrete PLAN-42 example.
for n := 6; n < 42; n++ {
if _, err := s.CreateItem(f.Workspace.ID, task.ID, models.ItemCreate{Title: fmt.Sprintf("Filler %d", n)}); err != nil {
t.Fatal(err)
}
}
moved, err := s.CreateItem(f.Workspace.ID, plan.ID, models.ItemCreate{Title: "Moved plan"})
if err != nil {
t.Fatal(err)
}
if moved.Ref != "PLAN-42" {
t.Fatal(moved.Ref)
}
if _, err := s.MoveItem(moved.ID, task.ID, `{}`); err != nil {
t.Fatal(err)
}
for name, resolve := range map[string]func(string, string) (*models.Item, error){"live": s.ResolveItem, "include-deleted": s.ResolveItemIncludeDeleted} {
t.Run(name, func(t *testing.T) {
if got, err := resolve(f.Workspace.ID, "plan-42"); err != nil || got != nil {
t.Errorf("lowercase old ref used move log: %v %v", got, err)
}
if got, err := resolve(f.Workspace.ID, "task-5"); err != nil || got == nil || got.ID != current.ID {
t.Errorf("lowercase current ref: %v %v", got, err)
}
})
}
if got, err := s.ResolveItem(f.Workspace.ID, "PLAN-42"); err != nil || got == nil || got.ID != moved.ID {
t.Errorf("uppercase old ref: %v %v", got, err)
}
})
}
}
+57
View File
@@ -0,0 +1,57 @@
package store
import (
"fmt"
"os"
"testing"
)
func TestBUG4119_WikiScope(t *testing.T) {
for _, pg := range []bool{false, true} {
t.Run(fmt.Sprintf("pg=%t", pg), func(t *testing.T) {
var s *Store
if pg {
url := os.Getenv("PAD_TEST_POSTGRES_URL")
if url == "" {
t.Skip("PAD_TEST_POSTGRES_URL unset")
}
s = testStorePostgres(t, url)
} else {
s = testStoreSQLite(t)
}
ws := createTestWorkspace(t, s, "Scoped refs")
plan := createTestCollection(t, s, ws.ID, "Plans")
task := createTestCollection(t, s, ws.ID, "Tasks")
it := createTestItem(t, s, ws.ID, plan.ID, "Moved item", "")
if _, err := s.MoveItem(it.ID, task.ID, `{}`); err != nil {
t.Fatal(err)
}
tx, err := s.DB().Begin()
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
for _, tc := range []struct {
prefix string
scope linkScope
want bool
}{
{"PLAN", linkScope{}, true},
{"PLAN", linkScope{enabled: true, collections: []string{task.ID}}, true},
{"PLAN", linkScope{enabled: true, collections: []string{plan.ID}}, false},
{"PLAN", linkScope{enabled: true}, false},
{"IDENT", linkScope{}, false},
} {
got := resolveRefTx(tx, s, ws.ID, tc.prefix, *it.ItemNumber, tc.scope)
if got.Valid != tc.want || (got.Valid && got.String != it.ID) {
t.Errorf("%s scoped resolution: %v, want valid=%t", tc.prefix, got, tc.want)
}
}
// A relation ref resolves through the same transaction and provenance.
got, err := s.itemByRefQ(tx, ws.ID, "PLAN", *it.ItemNumber)
if err != nil || got == nil || got.ID != it.ID {
t.Errorf("moved relation in tx: %v %v", got, err)
}
})
}
}
+58
View File
@@ -0,0 +1,58 @@
package store
import (
"database/sql"
"fmt"
)
// itemRefCurrentMatchSQL proves current prefix ownership for item i and
// collection c. Workspace-unique numbers alone do not establish it (BUG-4119).
const itemRefCurrentMatchSQL = `LOWER(r.target_ref) = LOWER(c.prefix || '-' || CAST(i.item_number AS TEXT))`
// itemRefMatchesSQL also accepts durable move history. Former collections
// deliberately include deleted rows; prefix reuse must not invalidate a moved
// item's old ref. The CURRENT former prefix is used.
const itemRefMatchesSQL = `(` + itemRefCurrentMatchSQL + `
OR EXISTS (
SELECT 1 FROM item_collection_moves m
JOIN collections old ON old.id = m.from_collection_id
WHERE m.workspace_id = i.workspace_id AND m.item_id = i.id
AND old.workspace_id = i.workspace_id
AND LOWER(r.target_ref) = LOWER(old.prefix || '-' || CAST(i.item_number AS TEXT))
)
)`
// itemRefLookupSQL builds the one shared SELECT for both ordinary Queryer
// callers and the app door's concrete *sql.Tx path. allowMoves=false omits
// move history entirely for lowercase identifiers and include-deleted lookup.
func itemRefLookupSQL(workspaceID, prefix string, number int, scope linkScope, includeDeleted, allowMoves bool) (string, []any) {
restrict, scopeArgs := scope.clause("i")
args := []any{fmt.Sprintf("%s-%d", prefix, number), workspaceID, number, includeDeleted}
args = append(args, scopeArgs...)
match := itemRefCurrentMatchSQL
if allowMoves {
match = itemRefMatchesSQL
}
return `
SELECT i.id FROM items i
JOIN collections c ON c.id = i.collection_id
CROSS JOIN (SELECT ? AS target_ref) r
WHERE i.workspace_id = ? AND i.item_number = ?
AND (? OR i.deleted_at IS NULL)
AND ` + match + restrict, args
}
// itemRefIDQ retains the caller's executor, scope and liveness policy.
// The app door executes itemRefLookupSQL through its concrete *sql.Tx instead.
func (s *Store) itemRefIDQ(q Queryer, workspaceID, prefix string, number int, scope linkScope, includeDeleted, allowMoves bool) (string, error) {
query, args := itemRefLookupSQL(workspaceID, prefix, number, scope, includeDeleted, allowMoves)
var id string
err := q.QueryRow(s.q(query), args...).Scan(&id)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", fmt.Errorf("resolve item ref: %w", err)
}
return id, nil
}
+41 -95
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strings"
"time"
"unicode"
"github.com/PerpetualSoftware/pad/internal/collections"
"github.com/PerpetualSoftware/pad/internal/diff"
@@ -944,65 +945,23 @@ func (s *Store) GetItemBySlug(workspaceID, slug string) (*models.Item, error) {
return s.GetItem(id)
}
// GetItemByRef looks up an item by its PREFIX-NUMBER reference (e.g. "IDEA-15").
// Since item numbers are workspace-unique, this first tries an exact prefix match
// and falls back to a number-only lookup. This allows old refs to still resolve
// after an item has been moved to a different collection (e.g. PLAN-42 still
// finds the item even after it became TASK-42).
//
// THE RELATION RUNG NO LONGER SHARES THIS FALLBACK (BUG-3082). `itemByRefQ` in
// relation_referents.go takes the number-only lookup only when the written
// prefix names no LIVE collection in the workspace. The two diverge because
// they answer to different consequences: here a ref is a caller NAVIGATING, and
// landing on the moved item is the helpful answer; there it is a value being
// STORED into a typed field, where resolving CONVE-1 to whatever holds number 1
// writes a referent nobody asked for and nothing above it objects. Do not
// "restore consistency" by copying either behaviour onto the other without
// reading that rationale.
// GetItemByRef looks up a live item by PREFIX-NUMBER. A prefix miss resolves
// only when the item's durable move log names a former collection whose
// current prefix matches, including deleted collections (BUG-4119).
func (s *Store) GetItemByRef(workspaceID, prefix string, number int) (*models.Item, error) {
var id string
// Try exact prefix + number match first
err := s.db.QueryRow(s.q(`
SELECT i.id FROM items i
JOIN collections c ON c.id = i.collection_id
WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ? AND i.deleted_at IS NULL
`), workspaceID, prefix, number).Scan(&id)
if err == nil {
return s.GetItem(id)
}
if err != nil && err != sql.ErrNoRows {
return nil, fmt.Errorf("get item by ref: %w", err)
}
// Fallback: item numbers are workspace-unique, so look up by number alone.
// This handles the case where an item was moved to a different collection
// but is still being referenced by its old prefix.
err = s.db.QueryRow(s.q(`
SELECT id FROM items
WHERE workspace_id = ? AND item_number = ? AND deleted_at IS NULL
`), workspaceID, number).Scan(&id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get item by number: %w", err)
id, err := s.itemRefIDQ(s.db, workspaceID, prefix, number, linkScope{}, false, true)
if err != nil || id == "" {
return nil, err
}
return s.GetItem(id)
}
// ResolveItem looks up an item by UUID, PREFIX-NUMBER ref (e.g. "IDEA-15"),
// or slug. UUID is tried first, then ref, then slug.
// ResolveItem maps a caller-supplied identifier to an item, trying UUID, then
// PREFIX-NUMBER ref, then slug.
//
// THAT ORDER IS THE DOCUMENTED PRECEDENCE, and BUG-2943 made it matter more:
// parseItemRef now accepts a digit after the first prefix character, so more
// strings are ref-SHAPED than before — "ab1-42" among them. The rule is
// deterministic and stated here once so both server and client code can cite
// it rather than each deciding: a ref-shaped identifier is looked up AS A REF
// first, and falls through to the slug lookup when no such ref exists. An
// identifier that is both a live ref and a live slug is a naming collision the
// user created, and the precedence is how it is settled — the ref wins.
// ResolveItem tries UUID, then PREFIX-NUMBER ref, then an ordinary slug.
// A current ref wins over a colliding slug, including lowercase typed refs.
// Ref-shaped identifiers containing any uppercase rune may use move history;
// their misses are terminal so canonical refs cannot reach another item by slug.
// With no uppercase rune, try only the exact current ref, then the exact,
// case-sensitive slug. Never use move history or a number fallback (BUG-4119 r1).
func (s *Store) ResolveItem(workspaceID, identifier string) (*models.Item, error) {
// Try UUID lookup first (8-4-4-4-12 hex format)
if isUUID(identifier) {
@@ -1014,14 +973,16 @@ func (s *Store) ResolveItem(workspaceID, identifier string) (*models.Item, error
return item, nil
}
}
// Try PREFIX-NUMBER ref
if prefix, number, ok := parseItemRef(identifier); ok {
item, err := s.GetItemByRef(workspaceID, prefix, number)
if itemIdentifierHasUpper(identifier) {
return s.GetItemByRef(workspaceID, prefix, number)
}
id, err := s.itemRefIDQ(s.db, workspaceID, prefix, number, linkScope{}, false, false)
if err != nil {
return nil, err
}
if item != nil {
return item, nil
if id != "" {
return s.GetItem(id)
}
}
// Fall back to slug lookup
@@ -1059,49 +1020,34 @@ func (s *Store) ResolveItemIncludeDeleted(workspaceID, slugOrRef string) (*model
}
}
if prefix, number, ok := parseItemRef(slugOrRef); ok {
var item models.Item
var createdAt, updatedAt string
var deletedAt *string
var pinned bool
err := s.db.QueryRow(s.q(`
SELECT i.id, i.workspace_id, i.collection_id, i.title, i.slug, i.content, `+contentStateSQL+`, i.fields, i.tags,
i.pinned, i.sort_order, i.parent_id, i.assigned_user_id, i.agent_role_id, i.role_sort_order,
i.created_by, i.last_modified_by, i.source,
i.item_number, i.seq, i.created_at, i.updated_at, i.deleted_at,
c.slug, c.name, c.icon, c.prefix,
COALESCE(au.name, ''), COALESCE(au.email, ''),
COALESCE(ar.name, ''), COALESCE(ar.slug, ''), COALESCE(ar.icon, '')
FROM items i
JOIN collections c ON c.id = i.collection_id
LEFT JOIN users au ON au.id = i.assigned_user_id
LEFT JOIN agent_roles ar ON ar.id = i.agent_role_id
WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ?
`), workspaceID, prefix, number).Scan(
&item.ID, &item.WorkspaceID, &item.CollectionID, &item.Title, &item.Slug,
&item.Content, &item.ContentState, &item.Fields, &item.Tags,
&pinned, &item.SortOrder, &item.ParentID, &item.AssignedUserID, &item.AgentRoleID, &item.RoleSortOrder,
&item.CreatedBy, &item.LastModifiedBy, &item.Source,
&item.ItemNumber, &item.Seq, &createdAt, &updatedAt, &deletedAt,
&item.CollectionSlug, &item.CollectionName, &item.CollectionIcon, &item.CollectionPrefix,
&item.AssignedUserName, &item.AssignedUserEmail,
&item.AgentRoleName, &item.AgentRoleSlug, &item.AgentRoleIcon,
)
if err == nil {
item.Pinned = pinned
item.CreatedAt = parseTime(createdAt)
item.UpdatedAt = parseTime(updatedAt)
item.DeletedAt = parseTimePtr(deletedAt)
hydrateItemComputedMetadata(&item)
return &item, nil
// Include-deleted resolution has always used current refs only, even
// for uppercase input. HTTP show can separately try live move history.
id, err := s.itemRefIDQ(s.db, workspaceID, prefix, number, linkScope{}, true, false)
if err != nil {
return nil, err
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("resolve ref (include deleted): %w", err)
if id != "" {
return s.GetItemIncludeDeleted(id)
}
if itemIdentifierHasUpper(slugOrRef) {
return nil, nil
}
}
return s.GetItemBySlugIncludeDeleted(workspaceID, slugOrRef)
}
// itemIdentifierHasUpper inspects the original spelling before ref parsing
// uppercases it. A mixed-case identifier follows the canonical-ref policy.
func itemIdentifierHasUpper(identifier string) bool {
for _, r := range identifier {
if unicode.IsUpper(r) {
return true
}
}
return false
}
// parseItemRef parses "PREFIX-123" into ("PREFIX", 123, true).
// Returns false if the string is not a valid item ref.
// Case-insensitive: "task-5", "Task-5", and "TASK-5" all parse to ("TASK", 5, true).
+1 -1
View File
@@ -2569,7 +2569,7 @@ func TestOldRefResolvesAfterMove(t *testing.T) {
t.Fatalf("expected ref TASK-%d after move, got %s", originalNumber, moved.Ref)
}
// Old ref PLAN-1 should STILL resolve to the same item (fallback by number)
// BUG-4119: the move log proves the old PLAN ref belongs to this item.
found, err = s.GetItemByRef(ws.ID, "PLAN", originalNumber)
if err != nil {
t.Fatalf("GetItemByRef (old ref) error: %v", err)
+1
View File
@@ -39,6 +39,7 @@ func TestNativePostgresAgreesWithTheCorpus(t *testing.T) {
if _, err := db.Exec(`DROP TABLE IF EXISTS nul_differential`); err != nil {
t.Fatalf("drop: %v", err)
}
tracePGConnected(t, "TestNativePostgresAgreesWithTheCorpus")
// Both column classes, so each corpus case lands on the column its own
// classing names — the same choice the store's leg makes.
if _, err := db.Exec(`CREATE TABLE nul_differential (id TEXT PRIMARY KEY, txt TEXT, doc JSONB)`); err != nil {
@@ -135,6 +135,7 @@ func TestNativePostgresAcceptsEveryRepairedCorpusValue(t *testing.T) {
if _, err := db.Exec(`DROP TABLE IF EXISTS nul_repair_differential`); err != nil {
t.Fatalf("drop: %v", err)
}
tracePGConnected(t, "TestNativePostgresAcceptsEveryRepairedCorpusValue")
if _, err := db.Exec(
`CREATE TABLE nul_repair_differential (id TEXT PRIMARY KEY, txt TEXT, doc JSONB)`,
); err != nil {
+7
View File
@@ -437,6 +437,13 @@ func TestWrapperAdvertisesExactlyWhatTheBaseDoes(t *testing.T) {
return nil //nolint:nilerr // a driver that cannot prepare this tells us nothing
}
defer st.Close()
if p.base == "pgx" {
helper := "TestWrapperAdvertisesExactlyWhatTheBaseDoes.raw"
if db == wrapDB {
helper = "TestWrapperAdvertisesExactlyWhatTheBaseDoes.guarded"
}
tracePGConnected(t, helper)
}
out["StmtExecContext"] = isIface[driver.StmtExecContext](st)
out["StmtQueryContext"] = isIface[driver.StmtQueryContext](st)
out["stmt NamedValueChecker"] = isIface[driver.NamedValueChecker](st)
+16
View File
@@ -0,0 +1,16 @@
package store
import (
"os"
"testing"
)
// Duplicated from storetest.TracePGConnected: storetest imports store, so these
// white-box tests cannot import it. Keep the gate and marker format in sync.
// A nil tb is reserved for TestMain template teardown, after tests finish.
func tracePGConnected(tb testing.TB, helper string) {
if os.Getenv("PAD_TEST_PG_TRACE") == "1" && tb != nil {
tb.Helper()
tb.Log("PAD_PG_CONNECTED helper=" + helper)
}
}
+18 -9
View File
@@ -318,10 +318,10 @@ func TestImportWorkspace_DigitBearingPrefixIsLogged(t *testing.T) {
}
}
// The precedence BUG-2943's widening makes reachable for more strings, pinned
// in both directions. "ab1-42" is ref-SHAPED under the wider grammar, so the
// question "is a slug with that name still findable" has to have an answer
// that does not depend on reading ResolveItem.
// Current refs win over colliding slugs (BUG-2943). Ref-shaped misses with
// any uppercase rune are terminal, preserving canonical-ref safety (BUG-4119
// r1). Lowercase misses try only their exact, case-sensitive slug, so web slug
// addressing works without a number fallback or a move-log match.
func TestResolveItem_RefBeforeSlugPrecedence(t *testing.T) {
t.Parallel()
s := testStore(t)
@@ -347,14 +347,23 @@ func TestResolveItem_RefBeforeSlugPrecedence(t *testing.T) {
"a ref-shaped slug at all", slugged.Slug)
}
t.Run("with no such ref, the slug resolves", func(t *testing.T) {
got, err := s.ResolveItem(ws.ID, "ab1-42")
t.Run("uppercase ref misses are terminal; lowercase misses name their exact slug", func(t *testing.T) {
got, err := s.ResolveItem(ws.ID, "AB1-42")
if err != nil {
t.Fatalf("ResolveItem: %v", err)
}
if got == nil || got.ID != slugged.ID {
t.Fatalf("resolved %v, want the item slugged ab1-42 — a ref-shaped name that "+
"names no ref must still find the slug", got)
// BUG-4119 r1: a ref-shaped miss containing any uppercase rune is
// terminal, so a canonical ref cannot reach another item by slug.
// Lowercase tries only the current ref, then its exact case-sensitive
// slug: never a number fallback or move-log match.
if got != nil {
t.Fatalf("ref-shaped miss resolved to unrelated slug: %v", got)
}
for _, own := range []string{slugged.Ref, slugged.ID, slugged.Slug} {
item, err := s.ResolveItem(ws.ID, own)
if err != nil || item == nil || item.ID != slugged.ID {
t.Fatalf("own ref/UUID/slug %s: %v %v", own, item, err)
}
}
})
+9 -107
View File
@@ -1095,114 +1095,16 @@ func (s *Store) collectionIDBySlugQ(q Queryer, workspaceID, slug string) (string
return id, nil
}
// itemByRefQ is GetItemByRef on a caller-supplied executor, for the RELATION
// rung only — `resolveRelationTargetQ` is its one caller, and GetItemByRef's
// own read path is untouched by everything below.
//
// It keeps that helper's by-NUMBER fallback, but only for the case the
// fallback exists to serve. The fallback is there because item numbers are
// workspace-unique, so a relation written as COLO-3 must keep resolving after
// the target's collection is RENAMED and its prefix changes — which is
// precisely what BUG-2873 made possible.
//
// BUG-3082: unconditional, that fallback answered a question nobody asked. A
// ref whose prefix names no collection at all — a typo, or a ref pasted out of
// another workspace — fell through to `WHERE item_number = ?` with the prefix
// DISCARDED, and resolved to whatever item happened to hold that number. The
// retargeted item is usually inside the field's declared collection, so the
// `item.CollectionID != targetID` check above it passed and NOTHING was
// raised: a caller wrote CONVE-1, the item stored SECRE-1's UUID, and the
// write answered 201. That is the corruption PLAN-2857 U1 closed for free text
// ("red" resolving to whatever is slugged red today), still open for anything
// ref-SHAPED — which is the spelling the docs tell people to prefer.
//
// THE DISCRIMINATOR IS THE PREFIX, NOT THE ITEM (lead ruling, day 67). The
// fallback fires only when the written prefix names NO current collection in
// the workspace, because that is what a rename leaves behind. A prefix that IS
// live and simply has no item at that number is `not_found` — the collection
// exists, the caller named it, and it does not contain what they said.
//
// RESIDUAL, stated because it is not closed and a reader should not infer that
// it is: a ref pasted from ANOTHER workspace whose prefix also names no
// collection HERE still resolves by number. Nothing in the value distinguishes
// it from the rename case — both are a live number under a prefix this
// workspace does not have — so closing it would cost the rename case the
// fallback exists for. Pinned by a test rather than left implied.
// itemByRefQ resolves a live relation referent on the caller's executor.
// BUG-4119: exact refs and move-log-proven former refs share one statement.
// An absent live prefix alone proves nothing about an item's history. The
// former collection may be deleted or its prefix reused; neither prevents a
// genuine moved ref from resolving. Collection and visibility validation
// remain the caller's responsibility after identity resolution.
func (s *Store) itemByRefQ(q Queryer, workspaceID, prefix string, number int) (*models.Item, error) {
var id string
err := q.QueryRow(s.q(`
SELECT i.id FROM items i
JOIN collections c ON c.id = i.collection_id
WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ? AND i.deleted_at IS NULL
`), workspaceID, prefix, number).Scan(&id)
if err == nil {
return s.GetItemQ(q, id)
}
if err != sql.ErrNoRows {
return nil, fmt.Errorf("item by ref: %w", err)
}
// ONE STATEMENT, not a probe followed by a lookup (codex round 1). Asking
// "is this prefix live" and then "what holds this number" as two reads
// leaves a window where a collection is created or deleted between them,
// and under READ COMMITTED each read sees a different database: the probe
// says absent, the lookup then resolves a ref that by the time it lands is
// a ref into a live collection. The `NOT EXISTS` makes the two questions
// one read, so the answer is always about a single state.
//
// The prefix is compared RAW against `collections.prefix`, exactly as the
// ref-matched query above compares it — `parseItemRef` upper-cases what it
// returns, so the two agree by construction rather than by a second rule
// kept in step by hand.
//
// A SOFT-DELETED collection counts as absent HERE, in this predicate,
// deliberately: its prefix is no longer in use and its items' refs are
// precisely the leftovers this fallback exists to keep resolving. That is
// a claim about THIS query only. The ref-matched query above does not
// filter on the collection's own `deleted_at` — it never did — so an item
// whose collection was soft-deleted can still match there and never reach
// this predicate at all. Left alone rather than "made consistent", because
// it cannot produce the retarget this function is about: a field declaring
// that collection resolves no target id (collectionIDBySlugQ requires a
// LIVE collection) and the write is refused `target_missing`, and a field
// declaring a live one refuses on the collection check downstream. Pinned
// by a test rather than left as reasoning.
//
// THE WINDOW THAT REMAINS is between the ref-matched query above and this
// one, and it is NOT symmetric — an earlier version of this comment said
// it was, and codex round 2 was right that the claim is false in one
// direction:
//
// - A collection DISAPPEARING between the two reads makes this resolve by
// number, which is the right answer for the newer state.
// - A collection appearing — or being RENAMED to this prefix — makes this
// return nothing, and the newer state would have resolved that ref
// EXACTLY. So the answer is stale, not correct-for-either-state.
//
// It stays because the stale answer is `not_found`: a refusal the caller
// can see and retry, never a value silently bound to the wrong item, which
// is the failure this whole function exists to stop. Closing it properly
// means one statement for all three rungs or a snapshot across them, and
// neither is worth taking here for a window whose worst outcome is a
// retryable refusal.
err = q.QueryRow(s.q(`
SELECT id FROM items
WHERE workspace_id = ? AND item_number = ? AND deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM collections
WHERE workspace_id = ? AND prefix = ? AND deleted_at IS NULL
)
`), workspaceID, number, workspaceID, prefix).Scan(&id)
if err == sql.ErrNoRows {
// Either nothing holds that number, or the prefix names a collection
// that exists right now — in which case this ref is not a rename's
// leftover but a ref into a real collection that holds no such item,
// and answering it by number would hand back an item from somewhere
// else entirely. The two are not distinguished because neither
// resolves.
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("item by number: %w", err)
id, err := s.itemRefIDQ(q, workspaceID, prefix, number, linkScope{}, false, true)
if err != nil || id == "" {
return nil, err
}
return s.GetItemQ(q, id)
}
+8 -18
View File
@@ -848,17 +848,9 @@ func TestImportWorkspace_DoesNotMintIDsForOrphanRelationReferents(t *testing.T)
}
}
// BUG-3082, at the STORE level. The server-layer test that drives this through
// a real write door lives in internal/server, and `internal/server`'s test
// harness is hardwired to SQLite — so this is the leg that runs the new SQL on
// BOTH dialects under `make test-pg`. The predicate is a second query against
// `collections`, so a dialect difference is a real risk and not a hypothetical.
//
// The rule: a ref-shaped relation value falls back to matching by item NUMBER
// only when its prefix names NO live collection in the workspace. Item numbers
// are workspace-unique and sequential ACROSS collections, so `CARS-1` and
// `COLO-1` are never both real — and unconditionally, the fallback answered the
// wrong one of the two without raising anything.
// BUG-4119: neither a live prefix miss nor a prefix-only rename proves that
// a relation ref names this item. Only durable collection moves permit the
// shared resolver to accept a former prefix. Runs on both store dialects.
func TestResolveRelationReferents_RefDoesNotFallBackAcrossALivePrefix(t *testing.T) {
t.Parallel()
s := testStore(t)
@@ -884,9 +876,7 @@ func TestResolveRelationReferents_RefDoesNotFallBackAcrossALivePrefix(t *testing
t.Fatalf("an unresolvable ref must be left as the caller wrote it, got %v", got)
}
// THE CASE THE FALLBACK EXISTS FOR (BUG-2873): rename the target
// collection's prefix and the ref a caller already wrote down must still
// resolve, because its prefix now names nothing.
// BUG-4119: a prefix-only rename has no move-log provenance.
renamed := "VAULT"
if _, err := s.UpdateCollection(colors.ID, models.CollectionUpdate{Prefix: &renamed}); err != nil {
t.Fatalf("UpdateCollection(prefix): %v", err)
@@ -896,10 +886,10 @@ func TestResolveRelationReferents_RefDoesNotFallBackAcrossALivePrefix(t *testing
if err != nil {
t.Fatalf("ResolveRelationReferents(after rename): %v", err)
}
if len(issues) != 0 {
t.Fatalf("a ref left behind by a prefix rename must still resolve, got %+v", issues)
if len(issues) != 1 || issues[0].Reason != RelationTargetNotFound {
t.Fatalf("a prefix rename without move history must refuse, got %+v", issues)
}
if stale["color"] != red.ID {
t.Fatalf("after rename: canonicalised to %v, want %s", stale["color"], red.ID)
if stale["color"] != red.Ref {
t.Fatalf("unresolved ref was rewritten: %v, want %s", stale["color"], red.Ref)
}
}
+18 -13
View File
@@ -199,14 +199,14 @@ func testStorePostgres(t *testing.T, baseURL string) *Store {
defer pgTemplateMu.RUnlock()
pgTemplateOnce.Do(func() {
pgTemplateName, pgTemplateErr = buildPostgresTemplate(baseURL)
pgTemplateName, pgTemplateErr = buildPostgresTemplate(t, baseURL)
})
if pgTemplateErr != nil {
t.Fatalf("build postgres template: %v", pgTemplateErr)
}
// Connect to the default "pad" database to issue CREATE/DROP DATABASE.
adminStore, err := newPostgresConn(baseURL)
adminStore, err := newPostgresConn(t, baseURL)
if err != nil {
t.Fatalf("connect to postgres for admin: %v", err)
}
@@ -235,14 +235,15 @@ func testStorePostgres(t *testing.T, baseURL string) *Store {
// behind on exactly the path that most needs cleaning up. Pre-existing
// and asymmetric with the twin; corrected here because this change
// makes the two helpers a matched pair (codex round 2, P2).
dropPostgresTemplateDB(baseURL, dbName)
dropPostgresTemplateDB(t, baseURL, dbName)
t.Fatalf("open test postgres store: %v", err)
}
tracePGConnected(t, "testStorePostgres")
t.Cleanup(func() {
s.Close()
// Drop the test database.
if admin, err := newPostgresConn(baseURL); err == nil {
if admin, err := newPostgresConn(t, baseURL); err == nil {
admin.db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName))
admin.Close()
}
@@ -260,11 +261,11 @@ func testStorePostgres(t *testing.T, baseURL string) *Store {
// LEAK POSTURE: a binary killed mid-run leaves its pad_tmpl_* database behind,
// bounded by the container's lifetime — the test stack is per-worktree
// (TASK-2708) and `make test-pg` tears it down with -v.
func buildPostgresTemplate(baseURL string) (string, error) {
func buildPostgresTemplate(tb testing.TB, baseURL string) (string, error) {
atomic.AddInt32(&pgTemplateBuilds, 1)
name := "pad_tmpl_" + uuid.New().String()[:8]
admin, err := newPostgresConn(baseURL)
admin, err := newPostgresConn(tb, baseURL)
if err != nil {
return "", fmt.Errorf("connect to postgres for admin: %w", err)
}
@@ -277,9 +278,10 @@ func buildPostgresTemplate(baseURL string) (string, error) {
// The one time this binary pays for the migration chain.
s, err := NewPostgres(replaceDBName(baseURL, name))
if err != nil {
dropPostgresTemplateDB(baseURL, name)
dropPostgresTemplateDB(tb, baseURL, name)
return "", fmt.Errorf("migrate template database %s: %w", name, err)
}
tracePGConnected(tb, "buildPostgresTemplate")
// Stamp the template with a marker a freshly-migrated database cannot have.
// Without it this mechanism is untestable: a clone and a per-test migration
// produce otherwise identical databases, so reverting the TEMPLATE clause
@@ -293,11 +295,11 @@ func buildPostgresTemplate(baseURL string) (string, error) {
// reason. KEEP IN SYNC.
if _, err := s.db.Exec(fmt.Sprintf("COMMENT ON TABLE schema_migrations IS '%s'", pgTemplateMarker(name))); err != nil {
s.Close()
dropPostgresTemplateDB(baseURL, name)
dropPostgresTemplateDB(tb, baseURL, name)
return "", fmt.Errorf("stamp template %s: %w", name, err)
}
if err := s.Close(); err != nil {
dropPostgresTemplateDB(baseURL, name)
dropPostgresTemplateDB(tb, baseURL, name)
return "", fmt.Errorf("close template connection %s: %w", name, err)
}
return name, nil
@@ -318,24 +320,27 @@ func removePostgresTemplate() {
return
}
if baseURL := os.Getenv("PAD_TEST_POSTGRES_URL"); baseURL != "" {
dropPostgresTemplateDB(baseURL, pgTemplateName)
// TestMain runs after every testing.T has finished; no live TB can log.
dropPostgresTemplateDB(nil, baseURL, pgTemplateName)
}
pgTemplateName = ""
}
func dropPostgresTemplateDB(baseURL, name string) {
if admin, err := newPostgresConn(baseURL); err == nil {
func dropPostgresTemplateDB(tb testing.TB, baseURL, name string) {
if admin, err := newPostgresConn(tb, baseURL); err == nil {
admin.db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", name))
admin.Close()
}
}
// newPostgresConn opens a raw postgres connection (no migrations).
func newPostgresConn(connStr string) (*Store, error) {
func newPostgresConn(tb testing.TB, connStr string) (*Store, error) {
db, err := openPostgresDB(connStr)
if err != nil {
return nil, err
}
// openPostgresDB has already completed a successful Ping.
tracePGConnected(tb, "newPostgresConn")
return &Store{db: db, dialect: &postgresDialect{}}, nil
}
+19
View File
@@ -0,0 +1,19 @@
package storetest
import (
"os"
"testing"
)
// TracePGConnected records a test Postgres connection only when explicitly
// enabled. Call after a successful Ping, query, or migration, never sql.Open.
// helper must be a stable name, with no connection details.
// A nil tb is reserved for template teardown from TestMain, where no live test
// remains to receive a log. Those teardown-only connections cannot be traced
// through testing.TB; per-test and error-path cleanup always pass a live tb.
func TracePGConnected(tb testing.TB, helper string) {
if os.Getenv("PAD_TEST_PG_TRACE") == "1" && tb != nil {
tb.Helper()
tb.Log("PAD_PG_CONNECTED helper=" + helper)
}
}
+83
View File
@@ -0,0 +1,83 @@
package storetest
import (
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
type pgTraceRecorder struct {
testing.TB
lines []string
}
func (*pgTraceRecorder) Helper() {}
func (r *pgTraceRecorder) Log(args ...any) {
r.lines = append(r.lines, fmt.Sprint(args...))
}
func TestTracePGConnected(t *testing.T) {
// Merely configuring Postgres must never enable tracing.
t.Setenv("PAD_TEST_POSTGRES_URL", "configured-but-unused")
for _, tc := range []struct {
trace string
want string
}{
{"", ""},
{"0", ""},
{"true", ""},
{"1", "PAD_PG_CONNECTED helper=probe"},
} {
t.Run("trace="+tc.trace, func(t *testing.T) {
t.Setenv("PAD_TEST_PG_TRACE", tc.trace)
if tc.trace == "" {
if err := os.Unsetenv("PAD_TEST_PG_TRACE"); err != nil {
t.Fatal(err)
}
}
r := &pgTraceRecorder{}
TracePGConnected(r, "probe")
if got := strings.Join(r.lines, "\n"); got != tc.want {
t.Fatalf("log = %q, want %q", got, tc.want)
}
})
}
}
func TestNewPostgres_TraceGate(t *testing.T) {
if os.Getenv("PAD_TEST_POSTGRES_URL") == "" {
t.Skip("PAD_TEST_POSTGRES_URL not set")
}
if os.Getenv("PAD_PG_TRACE_TEST_CHILD") == "1" {
_ = NewPostgres(t)
return
}
for _, trace := range []string{"", "1"} {
t.Run("trace="+trace, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestNewPostgres_TraceGate$", "-test.v")
for _, entry := range os.Environ() {
if !strings.HasPrefix(entry, "PAD_TEST_PG_TRACE=") && !strings.HasPrefix(entry, "PAD_PG_TRACE_TEST_CHILD=") {
cmd.Env = append(cmd.Env, entry)
}
}
cmd.Env = append(cmd.Env, "PAD_PG_TRACE_TEST_CHILD=1")
if trace != "" {
cmd.Env = append(cmd.Env, "PAD_TEST_PG_TRACE="+trace)
}
output, err := cmd.CombinedOutput()
if err != nil {
// Connection errors can contain credentials; do not echo output.
t.Fatalf("Postgres trace probe failed: %v", err)
}
got := strings.Contains(string(output), "PAD_PG_CONNECTED")
if got != (trace == "1") {
t.Fatalf("marker present = %t for trace=%q", got, trace)
}
if trace == "1" && !strings.Contains(string(output), "PAD_PG_CONNECTED helper=storetest.NewPostgres\n") {
t.Fatal("missing marker from the connected cloned store")
}
})
}
}
+17 -10
View File
@@ -81,7 +81,7 @@ func NewPostgres(t *testing.T) *store.Store {
defer pgTemplateMu.RUnlock()
pgTemplateOnce.Do(func() {
pgTemplateName, pgTemplateErr = buildPGTemplate(baseURL)
pgTemplateName, pgTemplateErr = buildPGTemplate(t, baseURL)
})
if pgTemplateErr != nil {
t.Fatalf("storetest: build pg template: %v", pgTemplateErr)
@@ -99,6 +99,7 @@ func NewPostgres(t *testing.T) *store.Store {
if err != nil {
t.Fatalf("storetest: clone test database %s from template %s: %v", dbName, pgTemplateName, err)
}
TracePGConnected(t, "storetest.NewPostgres.admin")
// The clone arrives already migrated, so store.NewPostgres's own migrate()
// is a fast no-op — schema_migrations came with the copy. Deliberately NOT
@@ -106,12 +107,13 @@ func NewPostgres(t *testing.T) *store.Store {
// the fixture honest, and it is the same choice NewSQLite makes.
s, err := store.NewPostgres(replaceDBName(baseURL, dbName))
if err != nil {
dropPostgresDB(baseURL, dbName)
dropPostgresDB(t, baseURL, dbName)
t.Fatalf("storetest: open cloned test postgres store: %v", err)
}
TracePGConnected(t, "storetest.NewPostgres")
t.Cleanup(func() {
_ = s.Close()
dropPostgresDB(baseURL, dbName)
dropPostgresDB(t, baseURL, dbName)
})
return s
}
@@ -128,7 +130,7 @@ func NewPostgres(t *testing.T) *store.Store {
// the Postgres container is per-worktree (TASK-2708) and `make test-pg` tears
// its stack down with -v, so an orphaned template dies with the container that
// holds it. Not worth a reaper.
func buildPGTemplate(baseURL string) (string, error) {
func buildPGTemplate(tb testing.TB, baseURL string) (string, error) {
atomic.AddInt32(&pgBuildCount, 1)
name := "pad_tmpl_" + strings.ReplaceAll(uuid.New().String()[:8], "-", "")
@@ -140,14 +142,16 @@ func buildPGTemplate(baseURL string) (string, error) {
_ = admin.Close()
return "", fmt.Errorf("create template database %s: %w", name, err)
}
TracePGConnected(tb, "storetest.buildPGTemplate.admin")
_ = admin.Close()
// The one time this binary pays for the migration chain.
s, err := store.NewPostgres(replaceDBName(baseURL, name))
if err != nil {
dropPostgresDB(baseURL, name)
dropPostgresDB(tb, baseURL, name)
return "", fmt.Errorf("migrate template database %s: %w", name, err)
}
TracePGConnected(tb, "storetest.buildPGTemplate")
// Stamp the template with a marker a FRESHLY-MIGRATED database cannot
// have. This is what makes the mechanism testable at all: a clone and a
// per-test migration produce otherwise byte-identical databases, so no
@@ -169,11 +173,11 @@ func buildPGTemplate(baseURL string) (string, error) {
// inside the same second as the template's and produce an identical set.
if _, err := s.DB().Exec(fmt.Sprintf("COMMENT ON TABLE schema_migrations IS '%s'", templateMarker(name))); err != nil {
_ = s.Close()
dropPostgresDB(baseURL, name)
dropPostgresDB(tb, baseURL, name)
return "", fmt.Errorf("stamp template %s: %w", name, err)
}
if err := s.Close(); err != nil {
dropPostgresDB(baseURL, name)
dropPostgresDB(tb, baseURL, name)
return "", fmt.Errorf("close template connection %s: %w", name, err)
}
return name, nil
@@ -192,18 +196,21 @@ func dropPGTemplate() {
return
}
if baseURL := os.Getenv("PAD_TEST_POSTGRES_URL"); baseURL != "" {
dropPostgresDB(baseURL, pgTemplateName)
// TestMain runs after every testing.T has finished; no live TB can log.
dropPostgresDB(nil, baseURL, pgTemplateName)
}
pgTemplateName = ""
}
func dropPostgresDB(baseURL, dbName string) {
func dropPostgresDB(tb testing.TB, baseURL, dbName string) {
admin, err := sql.Open("pgx", baseURL)
if err != nil {
return
}
defer admin.Close()
_, _ = admin.Exec("DROP DATABASE IF EXISTS " + dbName + " WITH (FORCE)")
if _, err := admin.Exec("DROP DATABASE IF EXISTS " + dbName + " WITH (FORCE)"); err == nil {
TracePGConnected(tb, "storetest.dropPostgresDB")
}
}
// replaceDBName swaps the database name in a postgres connection URL, preserving
+152
View File
@@ -0,0 +1,152 @@
package storetest
import (
"encoding/json"
"fmt"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// RefPrefixFixture shares BUG-4119's integrity assertions across real HTTP,
// CLI and MCP tests. Each caller gets an isolated database.
type RefPrefixFixture struct {
Store *store.Store
Workspace *models.Workspace
Item *models.Item
WrongRef string
}
func NewRefPrefixFixture(t *testing.T, postgres, identExists bool) RefPrefixFixture {
t.Helper()
var s *store.Store
if postgres {
s = NewPostgres(t)
} else {
s = NewSQLite(t)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Prefix safety", Slug: "prefix-safety"})
if err != nil {
t.Fatal(err)
}
c, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Conversations", Prefix: "CONVE", Schema: `{"fields":[]}`})
if err != nil {
t.Fatal(err)
}
it, err := s.CreateItem(ws.ID, c.ID, models.ItemCreate{Title: "Number holder", Content: "Original content"})
if err != nil {
t.Fatal(err)
}
if identExists {
_, err = s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Identities", Prefix: "IDENT", Schema: `{"fields":[]}`})
if err != nil {
t.Fatal(err)
}
}
return RefPrefixFixture{s, ws, it, fmt.Sprintf("IDENT-%d", *it.ItemNumber)}
}
// Snapshot includes the target's persisted state and workspace-wide mutation
// counts, including rows belonging to soft-deleted items.
func (f RefPrefixFixture) Snapshot(t *testing.T) string {
t.Helper()
it, err := f.Store.GetItemIncludeDeleted(f.Item.ID)
if err != nil || it == nil {
t.Fatalf("snapshot item: %v", err)
}
counts := map[string]int{}
queries := map[string]string{
"comments": `SELECT COUNT(*) FROM comments WHERE workspace_id = ?`,
"versions": `SELECT COUNT(*) FROM item_versions WHERE item_id IN (SELECT id FROM items WHERE workspace_id = ?)`,
"activities": `SELECT COUNT(*) FROM activities WHERE workspace_id = ?`,
"links": `SELECT COUNT(*) FROM item_links WHERE workspace_id = ?`,
"wiki": `SELECT COUNT(*) FROM item_wiki_links WHERE source_item_id IN (SELECT id FROM items WHERE workspace_id = ?)`,
"outbox": `SELECT COUNT(*) FROM event_outbox WHERE workspace_id = ?`,
"idempotency": `SELECT COUNT(*) FROM idempotency_keys WHERE workspace_id = ?`,
}
for name, query := range queries {
var n int
if err := f.Store.DB().QueryRow(f.Store.D().Rebind(query), f.Workspace.ID).Scan(&n); err != nil {
t.Fatalf("snapshot %s: %v", name, err)
}
counts[name] = n
}
b, err := json.Marshal(struct {
Item *models.Item
Counts map[string]int
}{it, counts})
if err != nil {
t.Fatal(err)
}
return string(b)
}
func (f RefPrefixFixture) AssertUnchanged(t *testing.T, before string) {
t.Helper()
if after := f.Snapshot(t); after != before {
t.Errorf("BUG-4119: wrong-prefix request wrote data\nbefore: %s\nafter: %s", before, after)
}
}
// NewRefSlugFixture pairs item #2 with a different item owning release-2.
// There is deliberately no RELEASE collection.
func NewRefSlugFixture(t *testing.T, postgres bool) (RefPrefixFixture, *models.Item) {
t.Helper()
f := NewRefPrefixFixture(t, postgres, false)
holder, err := f.Store.CreateItem(f.Workspace.ID, f.Item.CollectionID, models.ItemCreate{Title: "Second number holder", Content: "Untouched number two"})
if err != nil {
t.Fatal(err)
}
if holder.ItemNumber == nil || *holder.ItemNumber != 2 {
t.Fatalf("number holder: %v", holder.ItemNumber)
}
f.Item = holder
slugged, err := f.Store.CreateItem(f.Workspace.ID, holder.CollectionID, models.ItemCreate{Title: "Release 2", Content: "Slug owner body"})
if err != nil {
t.Fatal(err)
}
if slugged.Slug != "release-2" {
t.Fatalf("slug: %q", slugged.Slug)
}
return f, slugged
}
// NewImportedUppercaseRefSlugFixture imports IDENT-10 verbatim, the real door
// for uppercase slugs. A different item owns #10; there is no IDENT collection.
func NewImportedUppercaseRefSlugFixture(t *testing.T, postgres bool) (RefPrefixFixture, *models.Item) {
t.Helper()
var s *store.Store
if postgres {
s = NewPostgres(t)
} else {
s = NewSQLite(t)
}
const timestamp = "2026-11-02T00:00:00Z"
bundle := &models.WorkspaceExport{
Version: 1,
Workspace: models.WorkspaceExportMeta{Name: "Uppercase slug import", Slug: "uppercase-slug-import", Settings: "{}"},
Collections: []models.CollectionExport{
{ID: "conversations", Name: "Conversations", Slug: "conversations", Prefix: "CONVE", Schema: `{"fields":[]}`, Settings: "{}", CreatedAt: timestamp, UpdatedAt: timestamp},
{ID: "docs", Name: "Docs", Slug: "docs", Prefix: "DOC", Schema: `{"fields":[]}`, Settings: "{}", CreatedAt: timestamp, UpdatedAt: timestamp},
},
Items: []models.ItemExport{
{ID: "number-holder", CollectionID: "conversations", Title: "Number holder", Slug: "number-holder", ItemNumber: 10, Content: "Untouched number ten", Fields: "{}", Tags: "[]", CreatedAt: timestamp, UpdatedAt: timestamp},
{ID: "slug-owner", CollectionID: "docs", Title: "Uppercase slug owner", Slug: "IDENT-10", ItemNumber: 11, Content: "Untouched uppercase slug", Fields: "{}", Tags: "[]", CreatedAt: timestamp, UpdatedAt: timestamp},
},
}
ws, err := s.ImportWorkspace(bundle, "", "", "")
if err != nil {
t.Fatal(err)
}
holder, err := s.GetItemByRef(ws.ID, "CONVE", 10)
if err != nil || holder == nil {
t.Fatalf("imported number holder CONVE-10: got=%v err=%v", holder, err)
}
// Control: a case-sensitive slug lookup really can find the imported row.
slugged, err := s.GetItemBySlug(ws.ID, "IDENT-10")
if err != nil || slugged == nil || slugged.Slug != "IDENT-10" || slugged.ID == holder.ID {
t.Fatalf("imported uppercase slug owner: got=%v err=%v", slugged, err)
}
return RefPrefixFixture{s, ws, holder, "IDENT-10"}, slugged
}
+34 -61
View File
@@ -36,7 +36,7 @@ func (s *Store) replaceWikiLinks(tx *sql.Tx, sourceItemID, workspaceID, content
// linkScope restricts which items a wiki link may resolve to. The zero value
// is unrestricted (every human path). An app write sets it to the install's
// companion collections (SPEC-6, TASK-3390): every resolution query, the
// number-only and collection-qualified fallbacks included, then sees only
// move-history and collection-qualified lookups included, then sees only
// companion items, so a hidden item can neither win a match nor change which
// fallback runs, and the link state reveals nothing about it.
type linkScope struct {
@@ -399,42 +399,14 @@ func (s *Store) replaceWikiLinksScoped(tx *sql.Tx, sourceItemID, workspaceID, co
return nil
}
// resolveRefTx looks up a (prefix, number) pair to an item ID within
// a workspace. Returns a NULL sql.NullString if the ref doesn't
// resolve — broken refs intentionally persist as unresolved rows
// (PLAN-1593's "broken refs persisted" decision), so they're easy
// to surface in a future broken-links report.
//
// Tries the exact prefix+number match first, then falls back to a
// number-only match (matching GetItemByRef's behavior so an item
// that has been moved between collections still resolves under its
// old prefix).
// resolveRefTx resolves a live exact or move-log-proven ref inside the
// caller's transaction and wiki scope (BUG-4119). Broken refs persist with
// NULL targets, including on database errors, as before (PLAN-1593).
func resolveRefTx(tx *sql.Tx, s *Store, workspaceID, prefix string, number int, scope linkScope) sql.NullString {
query, args := itemRefLookupSQL(workspaceID, prefix, number, scope, false, true)
var id string
restrictI, scopeArgs := scope.clause("i")
err := tx.QueryRow(s.q(`
SELECT i.id FROM items i
JOIN collections c ON c.id = i.collection_id
WHERE i.workspace_id = ? AND c.prefix = ? AND i.item_number = ?
AND i.deleted_at IS NULL`+restrictI), append([]any{workspaceID, prefix, number}, scopeArgs...)...).Scan(&id)
if err == nil {
return sql.NullString{String: id, Valid: true}
}
if err != sql.ErrNoRows {
// A real DB error during resolution is rare and not worth
// failing the whole item write over — log-and-skip would
// be ideal but we don't have a logger handle here. Return
// NULL so the row persists as unresolved; if the error
// repeats on every write the broken-links report (Phase 3+)
// will surface it.
return sql.NullString{}
}
// Number-only fallback for the cross-collection-move case.
restrict, _ := scope.clause("")
err = tx.QueryRow(s.q(`
SELECT id FROM items
WHERE workspace_id = ? AND item_number = ? AND deleted_at IS NULL`+restrict), append([]any{workspaceID, number}, scopeArgs...)...).Scan(&id)
if err != nil {
err := tx.QueryRow(s.q(query), args...).Scan(&id)
if err != nil || id == "" {
return sql.NullString{}
}
return sql.NullString{String: id, Valid: true}
@@ -1553,14 +1525,9 @@ func (s *Store) GetCrossWorkspaceBacklinks(targetWorkspaceID, targetRef, request
// (...) OR id IN (...) — with the unrestricted variant skipping the
// predicate.
//
// Ref matching is dual: the exact `target_ref` AND a number-only
// LIKE fallback. The fallback mirrors resolveRefTx's cross-prefix-
// move logic — `[[other-ws::OLD-42]]` written before the target
// moved from OLD to NEW collection still resolves in the renderer
// via item_number, so the cross-ws index query must too. The LIKE
// pattern `%-<N>` matches any prefix-N ref because Pad prefixes are
// alphanumeric (no internal `-`), so trailing `-N` uniquely
// identifies the suffix. Codex round 1 P2.
// BUG-4119: stored refs match only the target's exact current ref or a former
// ref proven by its durable move log. The shared identity predicate is applied
// before LIMIT, so unrelated same-number refs cannot consume the page.
//
// Each returned Backlink has SourceWorkspaceSlug populated so the
// caller can route the link to the correct workspace prefix.
@@ -1570,19 +1537,25 @@ func (s *Store) queryCrossWorkspaceBacklinksForWorkspace(
unrestricted bool, fullCollIDs, grantedItemIDs []string,
cap int,
) ([]models.Backlink, error) {
// Derive the number-only LIKE pattern. If targetRef doesn't have
// a `-N` suffix (defensive — shouldn't happen for canonical
// PREFIX-N inputs), fall back to exact-only matching.
likeFallback := ""
if dash := strings.LastIndexByte(targetRef, '-'); dash > 0 && dash < len(targetRef)-1 {
likeFallback = "%-" + targetRef[dash+1:]
}
refClause := "LOWER(wl.target_ref) = LOWER(?)"
refClause := "LOWER(r.target_ref) = LOWER(?)"
args := []interface{}{targetWorkspaceID}
if likeFallback != "" {
refClause = "(LOWER(wl.target_ref) = LOWER(?) OR LOWER(wl.target_ref) LIKE LOWER(?))"
args = append(args, targetRef, likeFallback)
if prefix, number, ok := parseItemRef(targetRef); ok {
// BUG-4119: a suffix coincidence is never a backlink. Resolve the
// target using the same helper, then apply its shared predicate to
// each stored spelling in SQL, before pagination. Archived targets
// remain queryable, matching this door's existing liveness policy.
id, err := s.itemRefIDQ(s.db, targetWorkspaceID, prefix, number, linkScope{}, true, true)
if err != nil {
return nil, err
}
if id == "" {
return nil, nil
}
refClause = `EXISTS (
SELECT 1 FROM items i JOIN collections c ON c.id = i.collection_id
WHERE i.workspace_id = ? AND i.id = ? AND ` + itemRefMatchesSQL + `
)`
args = append(args, targetWorkspaceID, id)
} else {
args = append(args, targetRef)
}
@@ -1615,19 +1588,19 @@ func (s *Store) queryCrossWorkspaceBacklinksForWorkspace(
rows, err := s.db.Query(s.q(`
SELECT s.id, c.prefix, s.item_number, s.title, c.slug, c.icon,
s.content, `+contentStateSQLFor("s")+`, wl.position, wl.display_text, s.updated_at
FROM item_wiki_links wl
JOIN items s ON s.id = wl.source_item_id
s.content, `+contentStateSQLFor("s")+`, r.position, r.display_text, s.updated_at
FROM item_wiki_links r
JOIN items s ON s.id = r.source_item_id
JOIN collections c ON c.id = s.collection_id
WHERE wl.target_kind = 'workspace_ref'
AND wl.target_workspace_id = ?
WHERE r.target_kind = 'workspace_ref'
AND r.target_workspace_id = ?
AND `+refClause+`
AND s.workspace_id = ?
AND s.deleted_at IS NULL
-- A soft-deleted COLLECTION leaves its items live (BUG-3344);
-- relation backlinks make the same check.
AND c.deleted_at IS NULL`+visClause+`
ORDER BY s.updated_at DESC, wl.position ASC
ORDER BY s.updated_at DESC, r.position ASC
LIMIT ?
`), args...)
if err != nil {
+4 -8
View File
@@ -537,12 +537,8 @@ func TestWikiLinks_CrossWorkspaceBearerAdminSeesMemberWorkspaces(t *testing.T) {
}
}
// TestWikiLinks_CrossWorkspaceRefNumberFallback regresses Codex round
// 1 P2: when a target item is moved to a different collection (changing
// its prefix), `[[other-ws::OLD-N]]` rows must still surface as
// backlinks under the new prefix. Mirrors resolveRefTx's same-ws
// fallback by item_number; cross-ws resolves at query time so the
// query SQL has the OR-LIKE fallback inline.
// BUG-4119: a stored old ref remains a backlink when the target's move log
// proves its former collection. Same-number refs without that history do not.
func TestWikiLinks_CrossWorkspaceRefNumberFallback(t *testing.T) {
t.Parallel()
s := testStore(t)
@@ -585,13 +581,13 @@ func TestWikiLinks_CrossWorkspaceRefNumberFallback(t *testing.T) {
}
// Query under the NEW ref — the stored row has OLD ref, but
// item_number suffix matches so the fallback should hit.
// the move log proves the stored ref names this item.
got, err := s.GetCrossWorkspaceBacklinks(wsA.ID, newRef, user.ID, nil, 50, 0, false)
if err != nil {
t.Fatalf("GetCrossWorkspaceBacklinks: %v", err)
}
if len(got) != 1 {
t.Errorf("expected number-fallback to surface old-prefix backlink, got %d", len(got))
t.Errorf("expected move history to surface old-prefix backlink, got %d", len(got))
}
}
+7
View File
@@ -0,0 +1,7 @@
# Package<TAB>reason. SQLite reviews are valid only while storetest usage is NewSQLite/Cleanup.
./internal/decision storetest.NewSQLite only; no PostgreSQL constructor.
./internal/oauth storetest.NewSQLite only; Cleanup cannot create a PostgreSQL template.
# no-tests: reviewed operator commands; valid only with zero *_test.go files and PG references.
./scripts/diagnose-version-ties no-tests: operator command; connects only to a DSN from its own flags, never under go test (BUG-3751 diagnostic).
# Hash-pinned graph boundary: sql.Open here only retrieves Driver(), then closes; it never dials.
./internal/store.registerGuardedDrivers sha256:ca328ba0209b1304b14957e6e36d0f45712ae7f2731bd5190fb9a05da1224190 Driver registration for SQLite and PG only; no Ping/Conn/query. Any source change requires review.
+10
View File
@@ -0,0 +1,10 @@
# The packages whose tests connect to the test PostgreSQL. The PR "Go (PostgreSQL)"
# job runs only these. Validated against a full traced PG run of ./... (TASK-4224).
# On push to main, ci-pg-trace.py --expect fails unless the packages that connected
# equal this list, in either direction.
./cmd/pad
./internal/appstore
./internal/mcp
./internal/server
./internal/store
./internal/store/storetest
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""Print packages with successful test Postgres connections from go test -json.
Usage: PAD_TEST_PG_TRACE=1 go test -json ... | python3 scripts/ci-pg-trace.py \
--expect scripts/ci-pg-packages.txt
With --expect, the connecting packages must EQUAL the list. A package that
connected but is not listed, a listed package that never connected, or no
connection at all exits 1, so the guard cannot pass vacuously when the trace
marker stops being emitted. Without --expect it only prints the packages.
This command does not check the test run's exit status. Preserve that
separately (for example, with shell pipefail).
"""
import argparse
import json
from pathlib import Path
import posixpath
import sys
def normalize_package(package, module):
if package == "." or package.startswith("./"):
package = module + "/" + package
return posixpath.normpath(package)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--expect", type=Path, help="expected package list")
parser.add_argument("--show-output", action="store_true",
help="also stream human-readable go test Output events")
args = parser.parse_args()
connected = set()
try:
for number, line in enumerate(sys.stdin, 1):
if not line.strip():
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
parser.error(f"invalid JSON on stdin line {number}")
if args.show_output and event.get("Action") == "output":
print(event.get("Output", ""), end="", flush=True)
if (event.get("Action") == "output"
and "PAD_PG_CONNECTED" in event.get("Output", "")
and event.get("Package")):
connected.add(event["Package"])
expected = None
if args.expect is not None:
go_mod = Path(__file__).resolve().parent.parent / "go.mod"
module = next(line.split()[1] for line in go_mod.read_text().splitlines()
if line.startswith("module "))
expected = {normalize_package(line.strip(), module)
for line in args.expect.read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")}
except OSError as err:
parser.error(str(err))
for package in sorted(connected):
print(package)
if expected is None:
return 0
unexpected = connected - expected
missing = expected - connected
for package in sorted(unexpected):
print(f"unexpected: {package}", file=sys.stderr)
for package in sorted(missing):
print(f"expected package never connected: {package}", file=sys.stderr)
if not connected:
print("no PAD_PG_CONNECTED markers: the trace saw no connection at all", file=sys.stderr)
return 1 if unexpected or missing or not connected else 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
"""CLI regression cases for the Postgres connection evidence extractor."""
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "ci-pg-trace.py"
MODULE = "github.com/PerpetualSoftware/pad"
def event(package, output="PAD_PG_CONNECTED helper=probe\n", action="output"):
return {"Action": action, "Package": package, "Test": "TestProbe", "Output": output}
class TraceTest(unittest.TestCase):
def test_cli(self):
cases = [
("empty", [], None, 0, [], []),
("sort and deduplicate", [event(MODULE + "/internal/store"),
event(MODULE + "/cmd/pad"),
event(MODULE + "/internal/store")],
None, 0, [MODULE + "/cmd/pad", MODULE + "/internal/store"], []),
("only output events with packages",
[event("ignored", action="pass"), event("ignored", "ordinary log\n"),
{"Action": "output", "Output": "PAD_PG_CONNECTED helper=probe"}],
None, 0, [], []),
("relative and import paths",
[event(MODULE + "/cmd/pad"), event(MODULE + "/internal/store")],
"# candidate\n\n./cmd/pad/\n" + MODULE + "/internal/store\n",
0, [MODULE + "/cmd/pad", MODULE + "/internal/store"], []),
("unexpected and missing",
[event(MODULE + "/internal/server"), event(MODULE + "/cmd/pad")],
"./internal/store\n",
1, [MODULE + "/cmd/pad", MODULE + "/internal/server"],
["unexpected: " + MODULE + "/cmd/pad",
"unexpected: " + MODULE + "/internal/server",
"expected package never connected: " + MODULE + "/internal/store"]),
# A listed package that stopped connecting means the list (or the
# trace marker) drifted: fatal, not a warning (TASK-4224 r1 H1).
("listed package missing while others connect",
[event(MODULE + "/cmd/pad")], "./cmd/pad\n./internal/store\n",
1, [MODULE + "/cmd/pad"],
["expected package never connected: " + MODULE + "/internal/store"]),
# No marker at all means the trace is broken (renamed helper, env
# gate lost): it must not pass vacuously.
("empty trace fails", [], "./internal/store\n", 1, [],
["expected package never connected: " + MODULE + "/internal/store",
"no PAD_PG_CONNECTED markers: the trace saw no connection at all"]),
("empty trace fails even against an empty list", [], "# only comments\n", 1, [],
["no PAD_PG_CONNECTED markers: the trace saw no connection at all"]),
("package output without a test",
[{"Action": "output", "Package": MODULE + "/internal/store",
"Output": "PAD_PG_CONNECTED helper=probe\n"}], "./internal/store\n",
0, [MODULE + "/internal/store"], []),
]
for name, events, expected, code, packages, diagnostics in cases:
with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp:
args = [sys.executable, str(SCRIPT)]
if expected is not None:
expect = Path(tmp) / "expected.txt"
expect.write_text(expected)
args += ["--expect", str(expect)]
result = subprocess.run(
args, input="".join(json.dumps(row) + "\n" for row in events),
text=True, capture_output=True, cwd=tmp, check=False,
)
self.assertEqual(result.returncode, code, result.stderr)
self.assertEqual(result.stdout.splitlines(), packages)
self.assertEqual(result.stderr.splitlines(), diagnostics)
class HumanOutputTest(unittest.TestCase):
def test_output_can_be_mirrored_without_losing_trace(self):
row = event(MODULE + '/internal/store', 'ordinary test output\n')
result = subprocess.run([sys.executable, str(SCRIPT), '--show-output'],
input=json.dumps(row) + '\n', text=True,
capture_output=True, check=False)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, 'ordinary test output\n')
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Validate the complete CI needs graph; the sole required check is CI result."""
import json
import os
import sys
SCOPED = {'go': 'go', 'go-postgres': 'go', 'go-server-shards': 'go',
'go-web-parity': 'parity', 'nix': 'nix'}
REQUIRED = {'changes', 'native-smoke', 'web', 'e2e', *SCOPED}
def evaluate(needs, event):
errors = []
if event not in ('push', 'pull_request'):
errors.append(f'unsupported event: {event}')
if not isinstance(needs, dict):
return ['needs must be an object']
if set(needs) != REQUIRED:
errors.append(f'dependency mismatch: missing={sorted(REQUIRED - set(needs))}, '
f'extra={sorted(set(needs) - REQUIRED)}')
changes = needs.get('changes', {})
outputs = changes.get('outputs', {}) if isinstance(changes, dict) else {}
if not isinstance(outputs, dict):
outputs = {}
for key in ('go', 'web', 'nix', 'parity'):
if outputs.get(key) not in ('true', 'false'):
errors.append(f'missing/invalid changes output: {key}')
elif event == 'push' and outputs[key] != 'true':
errors.append(f'push must enable {key}')
for job in sorted(REQUIRED):
record = needs.get(job)
status = record.get('result') if isinstance(record, dict) else None
if status == 'success':
continue
if (status == 'skipped' and event == 'pull_request' and
job in SCOPED and outputs.get(SCOPED[job]) == 'false'):
continue
errors.append(f'{job}: {status!r} is not an authorized result')
return errors
def main():
try:
errors = evaluate(json.loads(os.environ['CI_NEEDS']), os.environ['CI_EVENT_NAME'])
except (KeyError, ValueError) as err:
errors = [str(err)]
for error in errors:
print('::error::' + error, file=sys.stderr)
if not errors:
print('Every required CI dependency passed or was correctly scoped out.')
return bool(errors)
if __name__ == '__main__':
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
"""The merge gate must fail closed on skipped or incomplete dependencies."""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import unittest
SCRIPT = Path(__file__).with_name('ci-result.py')
spec = importlib.util.spec_from_file_location('result', SCRIPT)
result = importlib.util.module_from_spec(spec)
spec.loader.exec_module(result)
def needs():
data = {k: {'result': 'success'} for k in (
'changes', 'go', 'go-postgres', 'go-server-shards', 'go-web-parity',
'nix', 'native-smoke', 'web', 'e2e')}
data['changes']['outputs'] = {'go': 'false', 'web': 'true', 'nix': 'false', 'parity': 'false'}
return data
class ResultTest(unittest.TestCase):
def test_expected_skips(self):
data = needs()
for job in ('go', 'go-postgres', 'go-server-shards', 'go-web-parity', 'nix'):
data[job]['result'] = 'skipped'
self.assertEqual(result.evaluate(data, 'pull_request'), [])
def test_rejects(self):
cases = []
for job, flag in [('go', 'go'), ('go-postgres', 'go'), ('go-server-shards', 'go'),
('nix', 'nix'), ('go-web-parity', 'parity')]:
data = needs()
data['changes']['outputs'][flag] = 'true'
data[job]['result'] = 'skipped'
cases.append((f'unexpected {job} skip', data, 'pull_request'))
for job in needs():
for status in ('failure', 'cancelled', 'unknown', None):
data = needs()
data[job]['result'] = status
cases.append((f'{job} {status}', data, 'pull_request'))
data = needs()
del data[job]
cases.append((f'missing {job}', data, 'pull_request'))
for job in ('native-smoke', 'web', 'e2e', 'changes'):
data = needs()
data[job]['result'] = 'skipped'
cases.append((f'nonlisted skip {job}', data, 'pull_request'))
for job in ('go', 'nix', 'go-web-parity'):
data = needs()
data[job]['result'] = 'skipped'
cases.append(('push skip ' + job, data, 'push'))
for flag in ('go', 'web', 'nix', 'parity'):
data = needs()
del data['changes']['outputs'][flag]
cases.append(('missing output ' + flag, data, 'pull_request'))
cases.append(('unsupported event', needs(), 'schedule'))
for name, data, event in cases:
with self.subTest(name=name):
self.assertTrue(result.evaluate(data, event))
def test_push(self):
data = needs()
data['changes']['outputs'] = dict.fromkeys(('go', 'web', 'nix', 'parity'), 'true')
self.assertEqual(result.evaluate(data, 'push'), [])
def test_cli_contract(self):
for status, want in [('success', 0), ('failure', 1), ('cancelled', 1), ('skipped', 1)]:
data=needs()
data['changes']['outputs']['go']='true'
data['go']['result']=status
proc=subprocess.run([sys.executable,str(SCRIPT)], env={**os.environ,
'CI_NEEDS':json.dumps(data), 'CI_EVENT_NAME':'pull_request'},
capture_output=True, text=True)
self.assertEqual(proc.returncode,want,proc.stderr)
def test_cli_malformed(self):
for raw in ('not json', 'null', '[]', '{}'):
proc = subprocess.run([sys.executable, str(SCRIPT)], env={**os.environ,
'CI_NEEDS': raw, 'CI_EVENT_NAME': 'pull_request'},
capture_output=True, text=True)
self.assertNotEqual(proc.returncode, 0, proc.stdout)
if __name__ == '__main__':
unittest.main()
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Fail-closed CI scope. PRs compare exact head against merge-base(base, head).
GO: everything outside web/. PARITY: any web/ path. WEB: always.
NIX: dependency/packaging policy, deliberately not every derivation source.
Workflows and scripts/ci-* force all jobs. Pushes always run everything.
"""
import fnmatch
import json
import os
from pathlib import Path
import re
import subprocess
import sys
NIX_FILES = {'go.mod', 'go.sum', 'web/package.json', 'web/package-lock.json',
'web/npm-shrinkwrap.json', 'web/yarn.lock', 'web/pnpm-lock.yaml',
'web/bun.lock', 'web/bun.lockb'}
def classify(paths):
outputs = dict(go=False, web=True, nix=False, parity=False)
for path in paths:
if path.startswith(('.github/workflows/', 'scripts/ci-')):
return dict.fromkeys(outputs, True)
outputs['go'] |= not path.startswith('web/')
outputs['parity'] |= path.startswith('web/')
outputs['nix'] |= (path.startswith('nix/') or
('/' not in path and fnmatch.fnmatchcase(path, 'flake.*')) or
path in NIX_FILES)
return outputs
def git(*args, cwd=None):
return subprocess.check_output(['git', *args], cwd=cwd)
def decode_paths(data):
if not data:
return [] # A successful, genuinely empty diff is valid.
if not data.endswith(b'\0'):
raise ValueError('diff is not NUL terminated')
paths = [os.fsdecode(p) for p in data[:-1].split(b'\0')]
if any(not p or p.startswith('/') or any(s in ('.', '..') for s in p.split('/'))
for p in paths):
raise ValueError('invalid repository-relative diff path')
return paths
def pr_paths(base, head, cwd=None):
for sha in (base, head):
if not isinstance(sha, str) or not re.fullmatch(r'[0-9a-fA-F]{40}', sha):
raise ValueError('PR base and head must be exact commit SHAs')
git('cat-file', '-e', sha + '^{commit}', cwd=cwd)
ancestor = git('merge-base', base, head, cwd=cwd).decode('ascii').strip()
if not re.fullmatch(r'[0-9a-fA-F]{40}', ancestor):
raise ValueError('missing or malformed merge base')
return decode_paths(git('diff', '--name-only', '-z', '--no-renames', ancestor, head, cwd=cwd))
def has_local_dependencies(go_mod, package):
# A replacement can introduce extra local inputs. Until classified, keep
# Nix on for every PR; do the same for npm workspaces/file dependencies.
if re.search(r'^\s*replace\b', go_mod, re.MULTILINE) or package.get('workspaces'):
return True
return any(version.startswith(('file:', 'link:', 'workspace:'))
for kind in ('dependencies', 'devDependencies', 'optionalDependencies')
for version in package.get(kind, {}).values())
def for_event(event, payload):
if event == 'push':
return dict.fromkeys(('go', 'web', 'nix', 'parity'), True)
if event != 'pull_request':
raise ValueError('unsupported CI event: ' + event)
try:
pr = payload['pull_request']
outputs = classify(pr_paths(pr['base']['sha'], pr['head']['sha']))
root = Path(__file__).resolve().parent.parent
if has_local_dependencies((root / 'go.mod').read_text(),
json.loads((root / 'web/package.json').read_text())):
outputs['nix'] = True
return outputs
except (KeyError, TypeError) as err:
raise ValueError('PR event lacks exact base/head SHAs') from err
def main():
try:
payload = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text())
outputs = for_event(os.environ['GITHUB_EVENT_NAME'], payload)
text = ''.join(f'{key}={str(value).lower()}\n' for key, value in outputs.items())
print(text, end='')
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(text)
except (OSError, ValueError, KeyError, subprocess.CalledProcessError) as err:
print(f'::error::CI scope failed: {err}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+100
View File
@@ -0,0 +1,100 @@
"""Path policy and real git diff regression cases (including hostile filenames)."""
import importlib.util
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
SCRIPT = Path(__file__).with_name('ci-scope.py')
spec = importlib.util.spec_from_file_location('scope', SCRIPT)
scope = importlib.util.module_from_spec(spec)
spec.loader.exec_module(scope)
class ScopeTest(unittest.TestCase):
def test_paths(self):
for path, want in [
('web/static/a', (False, True, False, True)),
('web/src/a.ts', (False, True, False, True)),
('internal/store/a.go', (True, True, False, False)),
('go.mod', (True, True, True, False)),
('go.sum', (True, True, True, False)),
('.github/workflows/new.yml', (True, True, True, True)),
('scripts/ci-web-parity.txt', (True, True, True, True)),
('scripts/ci-web-census/main.go', (True, True, True, True)),
('README.md', (True, True, False, False)),
('nix/package.nix', (True, True, True, False)),
('flake.lock', (True, True, True, False)),
('web/package-lock.json', (False, True, True, True)),
('web/package.json', (False, True, True, True)),
('web/file with spaces\nand newline', (False, True, False, True)),
]:
with self.subTest(path=path):
got = scope.classify([path])
self.assertEqual(tuple(got[k] for k in ('go', 'web', 'nix', 'parity')), want)
def test_local_dependency_guard(self):
cases = [
('module example.invalid/p\n', {}, False),
('replace example.invalid/dep => ../dep\n', {}, True),
('replace (\n example.invalid/dep => ./dep\n)\n', {}, True),
('module example.invalid/p\n', {'workspaces': ['packages/*']}, True),
('module example.invalid/p\n', {'dependencies': {'dep': 'file:../dep'}}, True),
('module example.invalid/p\n', {'devDependencies': {'dep': 'workspace:*'}}, True),
]
for go_mod, package, want in cases:
with self.subTest(go_mod=go_mod, package=package):
self.assertEqual(scope.has_local_dependencies(go_mod, package), want)
def test_push_and_errors(self):
self.assertTrue(all(scope.for_event('push', {}).values()))
for event, payload in [('schedule', {}), ('pull_request', {}),
('pull_request', {'pull_request': {'base': {'sha': 'bad'}, 'head': {'sha': 'bad'}}})]:
with self.subTest(event=event, payload=payload), self.assertRaises(ValueError):
scope.for_event(event, payload)
for data in [b'file', b'\0', b'../outside\0', b'/absolute\0', b'a\0\0']:
with self.subTest(data=data), self.assertRaises(ValueError):
scope.decode_paths(data)
self.assertEqual(scope.decode_paths(b''), [])
for returns in (
[subprocess.CalledProcessError(1, 'cat-file')],
[b'', b'', subprocess.CalledProcessError(1, 'merge-base')],
[b'', b'', b'not-a-sha\n'],
[b'', b'', b'a'*40+b'\n', subprocess.CalledProcessError(1, 'diff')]):
with self.subTest(returns=returns), patch.object(scope, 'git', side_effect=returns):
with self.assertRaises((subprocess.CalledProcessError, ValueError)):
scope.pr_paths('a'*40, 'b'*40)
def test_merge_base_deletions_and_rename(self):
with tempfile.TemporaryDirectory() as tmp:
def git(*args):
return subprocess.check_output(['git', '-C', tmp, *args]).decode().strip()
def commit():
git('add', '-A')
git('-c', 'user.name=Test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture')
return git('rev-parse', 'HEAD')
git('init', '-q')
root = Path(tmp)
(root / 'web').mkdir()
(root / 'web' / 'old\n name').write_text('original')
ancestor = commit()
(root / 'base-only.go').write_text('base')
base = commit()
# Detached test-fixture history only; never changes the task worktree.
git('checkout', '-q', '--detach', ancestor)
(root / 'web' / 'old\n name').rename(root / 'new name.go')
commit()
(root / 'web' / 'other space').write_text('second commit')
head = commit()
paths = scope.pr_paths(base, head, cwd=tmp)
self.assertEqual(set(paths), {'web/old\n name', 'new name.go', 'web/other space'})
self.assertTrue(scope.classify(paths)['go'])
self.assertTrue(scope.classify(paths)['parity'])
with self.assertRaises(subprocess.CalledProcessError):
scope.pr_paths('f'*40, head, cwd=tmp)
if __name__ == '__main__':
unittest.main()
+9
View File
@@ -0,0 +1,9 @@
# File<TAB>exact literal or embed pattern<TAB>reviewed reason.
embed.go all:web/build Production frontend embed; parity builds web before compiling Go.
embed.go web/build-materializer/materializer.js Production materializer embed; parity builds and runs the real bundle.
cmd/pad/cmd_server.go web/build fs.Sub of the reviewed production frontend embed, exercised by parity's web build.
internal/store/items_test.go expected false when only web/skill items exist Diagnostic source labels (web versus skill), not a repository path.
internal/server/plan3555_unguarded_write_test.go %s: cli/content=%v cli/fields=%v web/content=%v, want %v/%v/%v Diagnostic counters by API surface, not repository paths.
internal/server/bug3372_attribution_test.go %s: author=%v created_by=%v source=%v user_id=%v, want Alice/user/web/%s Diagnostic attribution values, not a repository path.
internal/materialize/materialize.go materialize: bundle did not define Materializer (a placeholder? run `node scripts/build-materializer.mjs` in web/) Build-help diagnostic; the real embedded bundle is covered by the full internal/materialize parity entry.
internal/server/bug3372_attribution_test.go create: created_by=%v source=%v, want user/web Diagnostic attribution values ending in the web source label, not a repository directory.
+595
View File
@@ -0,0 +1,595 @@
// ci-web-census checks all tracked Go sources, independent of build tags.
// It conservatively checks references, including diagnostic strings and fixture
// paths. Constants, concatenations and path joins are resolved without executing
// code. A dynamic suffix after a known web root is an error, even in a listed
// package. Calls and function values propagate through local and imported helpers.
package main
import (
"crypto/sha256"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"os/exec"
"path"
"regexp"
"sort"
"strconv"
"strings"
)
const module = "github.com/PerpetualSoftware/pad"
const unknown = "\x00"
var webPath = regexp.MustCompile(`(^|[^a-zA-Z0-9_-])web/`)
type source struct {
file, pkg string
tree *ast.File
imports map[string]string
}
type unit struct {
src *source
name string
node ast.Node
refs map[string]bool
web, pg bool
}
func packagePath(file string) string {
dir := path.Dir(file)
if dir == "." {
return "."
}
return "./" + dir
}
func literal(e ast.Expr) string {
if lit, ok := e.(*ast.BasicLit); ok && lit.Kind == token.STRING {
value, _ := strconv.Unquote(lit.Value)
return value
}
return ""
}
// resolve preserves unknown pieces rather than losing a partially known path.
// Unknown prefixes (a repository or temp root) don't hide a known web suffix;
// unknown suffixes cannot be silently accepted as a resolved reference.
func resolve(e ast.Expr, globals map[string]ast.Expr, seen map[ast.Expr]bool) string {
if e == nil || seen[e] {
return unknown
}
seen[e] = true
defer delete(seen, e)
switch x := e.(type) {
case *ast.BasicLit:
if x.Kind == token.STRING {
return literal(x)
}
case *ast.ParenExpr:
return resolve(x.X, globals, seen)
case *ast.BinaryExpr:
if x.Op == token.ADD {
return resolve(x.X, globals, seen) + resolve(x.Y, globals, seen)
}
case *ast.Ident:
if x.Obj != nil {
switch d := x.Obj.Decl.(type) {
case *ast.ValueSpec:
for i, n := range d.Names {
if n.Name == x.Name && i < len(d.Values) {
return resolve(d.Values[i], globals, seen)
}
}
case *ast.AssignStmt:
for i, n := range d.Lhs {
if n == x || identName(n) == x.Name {
if i < len(d.Rhs) {
return resolve(d.Rhs[i], globals, seen)
}
}
}
}
}
if value, ok := globals[x.Name]; ok {
return resolve(value, globals, seen)
}
case *ast.CallExpr:
if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
base := identName(sel.X)
if imported, ok := globals["#import:"+base]; ok {
switch literal(imported) {
case "path/filepath":
base = "filepath"
case "path":
base = "path"
case "fmt":
base = "fmt"
default:
base = ""
}
}
if base == "fmt" && sel.Sel.Name == "Sprintf" && len(x.Args) > 0 {
format := resolve(x.Args[0], globals, seen)
args := []any{}
for _, arg := range x.Args[1:] {
value := resolve(arg, globals, seen)
if strings.Contains(value, unknown) {
return format + unknown
}
args = append(args, value)
}
return fmt.Sprintf(format, args...)
}
if (base == "filepath" || base == "path") && sel.Sel.Name == "Join" {
parts := []string{}
for _, arg := range x.Args {
parts = append(parts, resolve(arg, globals, seen))
}
return strings.Join(parts, "/")
}
if (base == "filepath" || base == "path") && (sel.Sel.Name == "Clean" || sel.Sel.Name == "FromSlash" || sel.Sel.Name == "Abs") && len(x.Args) == 1 {
return resolve(x.Args[0], globals, seen)
}
}
}
return unknown
}
func identName(e ast.Expr) string {
if x, ok := e.(*ast.Ident); ok {
return x.Name
}
return ""
}
func isWeb(value string) bool {
value = strings.ReplaceAll(value, "\\", "/")
return webPath.MatchString(value) || strings.HasSuffix(value, "/web")
}
func audit(files map[string]string, parity, allow map[string]string, pgPackages map[string]bool, pgAllow map[string]string) []string {
errors := []string{}
// Operator reviews classify packages, not graph boundaries. Keep them separate
// from SQLite reviews so they cannot suppress storetest.Cleanup propagation.
noTests := map[string]bool{}
sqliteAllow := map[string]string{}
for pkg, review := range pgAllow {
if strings.HasPrefix(review, "no-tests:") {
noTests[pkg] = true
} else {
sqliteAllow[pkg] = review
}
}
sources := []*source{}
globals := map[string]map[string]ast.Expr{}
units := []*unit{}
for file, text := range files {
pkg := packagePath(file)
if _, reviewed := noTests[pkg]; reviewed && strings.HasSuffix(file, "_test.go") {
noTests[pkg] = false
errors = append(errors, file+": no-tests entry invalid: package must be added to ci-pg-packages.txt, or re-reviewed")
}
tree, err := parser.ParseFile(token.NewFileSet(), file, text, parser.ParseComments)
if err != nil {
errors = append(errors, err.Error())
continue
}
if _, reviewed := noTests[pkg]; reviewed && tree.Name.Name != "main" {
noTests[pkg] = false
errors = append(errors, file+": no-tests entry requires package main")
}
src := &source{file: file, pkg: pkg, tree: tree, imports: map[string]string{}}
sources = append(sources, src)
if globals[src.pkg] == nil {
globals[src.pkg] = map[string]ast.Expr{}
}
for _, imp := range tree.Imports {
value := literal(imp.Path)
alias := path.Base(value)
if imp.Name != nil {
alias = imp.Name.Name
}
src.imports[alias] = value
if alias == "." && (value == "os" || value == "io/fs" || value == "path" || value == "path/filepath") {
errors = append(errors, file+": unresolved filesystem dot import")
}
}
for _, decl := range tree.Decls {
if gen, ok := decl.(*ast.GenDecl); ok && (gen.Tok == token.CONST || gen.Tok == token.VAR) {
for _, spec := range gen.Specs {
v := spec.(*ast.ValueSpec)
for i, n := range v.Names {
if i < len(v.Values) {
globals[src.pkg][n.Name] = v.Values[i]
}
}
}
}
}
}
for _, src := range sources {
for _, decl := range src.tree.Decls {
switch d := decl.(type) {
case *ast.FuncDecl:
units = append(units, &unit{src: src, name: d.Name.Name, node: d, refs: map[string]bool{}})
case *ast.GenDecl:
if d.Tok == token.IMPORT {
continue
}
for _, spec := range d.Specs {
if typ, ok := spec.(*ast.TypeSpec); ok {
units = append(units, &unit{src: src, name: typ.Name.Name, node: typ.Type, refs: map[string]bool{}})
}
if v, ok := spec.(*ast.ValueSpec); ok {
// Each binding has its own node in the reference graph, so constants
// and function aliases used from another file cannot disappear.
for i, n := range v.Names {
if i < len(v.Values) {
units = append(units, &unit{src: src, name: n.Name, node: v.Values[i], refs: map[string]bool{}})
}
}
}
}
}
}
for _, group := range src.tree.Comments {
for _, comment := range group.List {
if !strings.HasPrefix(comment.Text, "//go:embed ") {
continue
}
for _, pattern := range strings.Fields(strings.TrimPrefix(comment.Text, "//go:embed ")) {
pattern = strings.Trim(pattern, "\"`")
if (isWeb(strings.TrimPrefix(pattern, "all:")) || pattern == "web" || pattern == "all:web") && allow[src.file+"\t"+pattern] == "" {
// Embeds cannot be selected with -run. Cover the whole package or
// explicitly review the production embed in the allow-file.
if filter, ok := parity[src.pkg]; !ok || filter != "" {
errors = append(errors, src.file+": uncovered web embed "+pattern)
}
}
}
}
}
}
byID := map[string][]*unit{}
for _, u := range units {
byID[u.src.pkg+"."+u.name] = append(byID[u.src.pkg+"."+u.name], u)
}
for _, u := range units {
bindings := map[string]ast.Expr{}
for name, value := range globals[u.src.pkg] {
bindings[name] = value
}
for alias, imported := range u.src.imports {
bindings["#import:"+alias] = &ast.BasicLit{Kind: token.STRING, Value: strconv.Quote(imported)}
}
usesSQL, pgDriverPiece := false, false
selectorNames := map[*ast.Ident]bool{}
ast.Inspect(u.node, func(n ast.Node) bool {
if n == nil {
return true
}
if expr, ok := n.(ast.Expr); ok {
value := resolve(expr, bindings, map[ast.Expr]bool{})
if strings.Contains(value, "pgx") || strings.Contains(value, "postgres") {
pgDriverPiece = true
}
if isWeb(value) && allow[u.src.file+"\t"+value] == "" {
u.web = true
// An unknown root before web is safe for scope classification; a
// dynamic component beneath it must be made mechanically resolvable.
start := strings.Index(strings.ReplaceAll(value, "\\", "/"), "web/")
if start >= 0 && strings.Contains(value[start:], unknown) {
errors = append(errors, u.src.file+": unresolved web path in "+u.name)
}
}
}
switch x := n.(type) {
case *ast.BasicLit:
value := literal(x)
if value == "PAD_TEST_"+"POSTGRES_URL" || value == "PAD_TEST_"+"POSTGRES_TOOLS_URL" {
u.pg = true
}
case *ast.Ident:
if selectorNames[x] {
break
}
u.refs[u.src.pkg+"."+x.Name] = true
if dotted := u.src.imports["."]; strings.HasPrefix(dotted, module+"/") {
u.refs["./"+strings.TrimPrefix(dotted, module+"/")+"."+x.Name] = true
}
if x.Name == "NewPostgres" || x.Name == "testStorePostgres" || x.Name == "newPostgresConn" {
u.pg = true
}
case *ast.SelectorExpr:
selectorNames[x.Sel] = true
imported := u.src.imports[identName(x.X)]
if imported == "" {
id := u.src.pkg + "." + x.Sel.Name
for _, target := range byID[id] {
if fn, ok := target.node.(*ast.FuncDecl); ok && fn.Recv != nil {
u.refs[id] = true
}
}
}
if strings.HasPrefix(imported, module+"/") {
u.refs["./"+strings.TrimPrefix(imported, module+"/")+"."+x.Sel.Name] = true
}
if x.Sel.Name == "NewPostgres" || (strings.Contains(imported, "jackc/pgx") &&
(x.Sel.Name == "Connect" || x.Sel.Name == "ConnectConfig" || x.Sel.Name == "ConnectWithOptions" || x.Sel.Name == "New" || x.Sel.Name == "NewWithConfig")) {
u.pg = true
}
case *ast.CallExpr:
if sel, ok := x.Fun.(*ast.SelectorExpr); ok {
imported := u.src.imports[identName(sel.X)]
if (imported == "os" || imported == "io/fs") && len(x.Args) > 0 {
if resolve(x.Args[0], bindings, map[ast.Expr]bool{}) == "web" {
u.web = true
}
}
if imported == "os" && (sel.Sel.Name == "Getenv" || sel.Sel.Name == "LookupEnv") && len(x.Args) > 0 {
env := resolve(x.Args[0], bindings, map[ast.Expr]bool{})
if strings.HasPrefix(env, "PAD_TEST_POSTGRES") {
u.pg = true
}
}
if imported == "database/sql" && (sel.Sel.Name == "Open" || sel.Sel.Name == "OpenDB") {
usesSQL = true
driver := unknown
if len(x.Args) > 0 {
driver = resolve(x.Args[0], bindings, map[ast.Expr]bool{})
}
if strings.Contains(driver, "pg") || strings.Contains(driver, "postgres") || strings.Contains(driver, unknown) {
u.pg = true
}
}
}
}
return true
})
if usesSQL && pgDriverPiece {
u.pg = true
}
}
// Reviewed nonconnecting helpers are graph boundaries only while their
// exact source hash matches. Their own PG references still need coverage.
boundaries := map[string]bool{}
for id, review := range sqliteAllow {
if !strings.HasPrefix(review, "sha256:") {
continue
}
for _, u := range byID[id] {
text := files[u.src.file][int(u.node.Pos())-1 : int(u.node.End())-1]
want := strings.Fields(review)[0]
if fmt.Sprintf("sha256:%x", sha256.Sum256([]byte(text))) != want {
errors = append(errors, u.src.file+": reviewed nonconnecting helper changed: "+u.name)
} else {
boundaries[id] = true
}
}
}
// Resolve wrappers and function values to a fixed point, across packages too.
for changed := true; changed; {
changed = false
for _, u := range units {
for ref := range u.refs {
if boundaries[ref] {
continue
}
if sqliteAllow[u.src.pkg] != "" && ref == "./internal/store/storetest.Cleanup" {
continue
}
for _, target := range byID[ref] {
if target.pg && !u.pg {
u.pg = true
changed = true
}
if target.web && !u.web {
u.web = true
changed = true
}
}
}
}
}
// Without go/types, a selector on an imported receiver cannot be resolved
// safely. Importing a package exposing PG-capable methods requires review.
pgMethods := map[string]bool{}
pgReferences := map[string]bool{}
for _, u := range units {
if u.pg {
pgReferences[u.src.pkg] = true
}
if fn, ok := u.node.(*ast.FuncDecl); ok && fn.Recv != nil && ast.IsExported(u.name) && u.pg {
pgMethods[u.src.pkg] = true
}
}
for _, src := range sources {
for _, imported := range src.imports {
pkg := "./" + strings.TrimPrefix(imported, module+"/")
if pgMethods[pkg] {
pgReferences[src.pkg] = true
if !pgPackages[src.pkg] && !noTests[src.pkg] {
errors = append(errors, src.file+": unresolved PostgreSQL-capable receiver import "+imported)
}
}
}
// Imports of PG test helpers require review even if calls are indirect or
// unresolved. SQLite exceptions are deliberately restricted to two symbols.
for alias, imp := range src.imports {
if imp != module+"/internal/store/storetest" {
continue
}
pgReferences[src.pkg] = true
if pgPackages[src.pkg] || noTests[src.pkg] {
continue
}
if sqliteAllow[src.pkg] == "" {
errors = append(errors, src.file+": unclassified PostgreSQL-capable storetest import")
continue
}
ast.Inspect(src.tree, func(n ast.Node) bool {
if sel, ok := n.(*ast.SelectorExpr); ok && identName(sel.X) == alias && sel.Sel.Name != "NewSQLite" && sel.Sel.Name != "Cleanup" {
errors = append(errors, src.file+": SQLite-only exception used "+sel.Sel.Name)
}
return true
})
}
}
for pkg := range noTests {
if !pgReferences[pkg] {
errors = append(errors, pkg+": stale no-tests entry: package has no PostgreSQL references")
}
}
for _, u := range units {
if u.pg && !pgPackages[u.src.pkg] && !noTests[u.src.pkg] {
errors = append(errors, u.src.file+": unlisted PostgreSQL reference/wrapper "+u.name)
}
if !u.web {
continue
}
filter, covered := parity[u.src.pkg]
if !covered {
errors = append(errors, u.src.file+": uncovered web reference in "+u.name)
continue
}
if filter == "" {
continue
}
re, err := regexp.Compile(filter)
if err != nil {
errors = append(errors, "invalid parity regex: "+err.Error())
continue
}
if strings.HasPrefix(u.name, "Test") && !re.MatchString(u.name) {
errors = append(errors, u.src.file+": web reader "+u.name+" is outside parity -run")
} else if !strings.HasPrefix(u.name, "Test") {
found := false
for _, caller := range units {
if caller.src.pkg == u.src.pkg && strings.HasPrefix(caller.name, "Test") && caller.web {
found = true
}
}
if !found {
errors = append(errors, u.src.file+": unresolved web helper caller "+u.name)
}
}
}
sort.Strings(errors)
return errors
}
func readLines(file string) ([]string, error) {
data, err := os.ReadFile(file)
if err != nil {
return nil, err
}
lines := []string{}
for _, line := range strings.Split(string(data), "\n") {
if strings.TrimSpace(line) != "" && !strings.HasPrefix(strings.TrimSpace(line), "#") {
lines = append(lines, line)
}
}
return lines, nil
}
func run() error {
lines, err := readLines("scripts/ci-web-parity.txt")
if err != nil {
return err
}
parity := map[string]string{}
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) > 2 || !strings.HasPrefix(fields[0], "./") {
return fmt.Errorf("invalid parity entry: %q", line)
}
filter := ""
if len(fields) == 2 {
filter = fields[1]
if _, err := regexp.Compile(filter); err != nil {
return err
}
}
if _, exists := parity[fields[0]]; exists {
return fmt.Errorf("duplicate parity package: %s", fields[0])
}
parity[fields[0]] = filter
}
allow := map[string]string{}
lines, err = readLines("scripts/ci-web-census-allow.txt")
if err != nil {
return err
}
for _, line := range lines {
fields := strings.SplitN(line, "\t", 3)
if len(fields) != 3 || fields[2] == "" {
return fmt.Errorf("allow entry needs file, exact pattern, reason: %q", line)
}
allow[fields[0]+"\t"+fields[1]] = fields[2]
}
pg := map[string]bool{}
lines, err = readLines("scripts/ci-pg-packages.txt")
if err != nil {
return err
}
for _, line := range lines {
pg[strings.TrimSpace(line)] = true
}
pgAllow := map[string]string{}
lines, err = readLines("scripts/ci-pg-census-allow.txt")
if err != nil {
return err
}
for _, line := range lines {
fields := strings.SplitN(line, "\t", 2)
if len(fields) != 2 || fields[1] == "" {
return fmt.Errorf("PostgreSQL allow entry needs package and reason: %q", line)
}
pgAllow[fields[0]] = fields[1]
if strings.HasPrefix(fields[1], "no-tests:") {
// Check the directory too: even untracked or ignored test files invalidate
// the review. The source audit below also checks tracked, build-tagged tests.
entries, err := os.ReadDir(fields[0])
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), "_test.go") {
return fmt.Errorf("%s: no-tests entry invalid: package must be added to ci-pg-packages.txt, or re-reviewed (%s exists)", fields[0], entry.Name())
}
}
}
}
output, err := exec.Command("git", "ls-files", "-z", "--", "*.go").Output()
if err != nil {
return err
}
files := map[string]string{}
for _, file := range strings.Split(strings.TrimSuffix(string(output), "\x00"), "\x00") {
data, err := os.ReadFile(file)
if err != nil {
return err
}
files[file] = string(data)
}
if len(files) == 0 {
return fmt.Errorf("no tracked Go sources")
}
errors := audit(files, parity, allow, pg, pgAllow)
if len(errors) > 0 {
return fmt.Errorf("source census failed:\n%s", strings.Join(errors, "\n"))
}
fmt.Printf("Web and PostgreSQL census passed (%d tracked Go files).\n", len(files))
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"crypto/sha256"
"fmt"
"strings"
"testing"
)
func TestCensus(t *testing.T) {
tests := []struct {
name, source, manifest, allow string
pg bool
want string
}{
{name: "unlisted web reader", source: `package p; import "os"; func TestRead(t *testing.T) { os.ReadFile("../../web/foo") }`, want: "web"},
{name: "covered reader", source: `package p; func TestRead(t *testing.T) { read("../../web/foo") }`, manifest: "^TestRead$"},
{name: "wrong test filter", source: `package p; func TestRead(t *testing.T) { read("../../web/foo") }`, manifest: "^TestOther$", want: "TestRead"},
{name: "embed", source: "package p\n//go:embed web/build\nvar ui string", want: "web"},
{name: "reviewed embed", source: "package p\n//go:embed web/build\nvar ui string", allow: "web/build"},
{name: "unknown formatted path", source: `package p; import "fmt"; func TestRead(t *testing.T) { read(fmt.Sprintf("web/%s", fromEnv())) }`, manifest: ".*", want: "unresolved"},
{name: "unknown suffix", source: `package p; func TestRead(t *testing.T) { read(filepath.Join("..", "web", fromEnv())) }`, manifest: ".*", want: "unresolved"},
{name: "filesystem dot import", source: `package p; import . "path/filepath"; func TestRead(t *testing.T) { read(Join("..","web",fromEnv())) }`, manifest: ".*", want: "unresolved"},
{name: "unrelated dot import", source: `package p; import . "net/http"; var method=MethodGet`},
{name: "alias join", source: `package p; import fp "path/filepath"; const dir="web"; func TestRead(t *testing.T) { read(fp.Join("..", dir, "foo")) }`, want: "web"},
{name: "alias unknown suffix", source: `package p; import fp "path/filepath"; const dir="web"; func TestRead(t *testing.T) { read(fp.Join("..", dir, fromEnv())) }`, manifest: ".*", want: "unresolved"},
{name: "joined pieces", source: `package p; func TestRead(t *testing.T) { read(path.Join("..", "web", "foo")) }`, want: "web"},
{name: "constant and concatenation", source: `package p; const root = "we" + "b/"; func TestRead(t *testing.T) { read(root + "foo") }`, want: "web"},
{name: "helper covered", source: `package p; func helper() { read("web/foo") }; func TestRead(t *testing.T) { helper() }`, manifest: "^TestRead$"},
{name: "helper uncovered caller", source: `package p; func helper() { read("web/foo") }; func TestRead(t *testing.T) { helper() }; func TestOther(t *testing.T) { helper() }`, manifest: "^TestRead$", want: "TestOther"},
{name: "pg wrapper", source: `package p; func wrapper() { storetest.NewPostgres(t) }; func TestRead(t *testing.T) { wrapper() }`, want: "PostgreSQL"},
{name: "listed pg", source: `package p; func wrapper() { storetest.NewPostgres(t) }`, pg: true},
{name: "pg env getter alias", source: `package p; import "os"; var getter=os.Getenv; func TestRead(t *testing.T) { getter("PAD_TEST_POSTGRES_URL") }`, want: "PostgreSQL"},
{name: "pg env", source: `package p; import "os"; var dsn = os.Getenv("PAD_TEST_POSTGRES_TOOLS_URL")`, want: "PostgreSQL"},
{name: "PG error type cannot connect", source: `package p; import "github.com/jackc/pgx/v5/pgconn"; func check() { var err *pgconn.PgError; _=err }`},
{name: "conditional PG driver", source: `package p; import "database/sql"; func wrapper(pg bool) { driver:="sqlite"; if pg { driver="pgx" }; sql.Open(driver,dsn) }`, want: "PostgreSQL"},
{name: "pg driver", source: `package p; import "database/sql"; func wrapper() { sql.Open("pgx", dsn) }`, want: "PostgreSQL"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parity := map[string]string{}
if tt.manifest != "" {
parity["./probe"] = tt.manifest
}
allow := map[string]string{}
if tt.allow != "" {
allow["probe/reader_test.go\t"+tt.allow] = "reviewed fixture"
}
pg := map[string]bool{}
if tt.pg {
pg["./probe"] = true
}
errs := audit(map[string]string{"probe/reader_test.go": tt.source}, parity, allow, pg, nil)
got := strings.Join(errs, "\n")
if tt.want == "" && got != "" {
t.Fatal(got)
}
if tt.want != "" && !strings.Contains(got, tt.want) {
t.Fatalf("wanted %q; got %q", tt.want, got)
}
})
}
}
func TestPGTransitiveImports(t *testing.T) {
files := map[string]string{
"helper/helper.go": `package helper; func Open() { storetest.NewPostgres(t) }`,
"other/use_test.go": `package other; import h "github.com/PerpetualSoftware/pad/helper"; func TestUse(t *testing.T) { h.Open() }`,
}
errs := audit(files, nil, nil, map[string]bool{"./helper": true}, nil)
if !strings.Contains(strings.Join(errs, "\n"), "other") {
t.Fatalf("unlisted indirect PG user passed: %v", errs)
}
}
func TestSQLiteException(t *testing.T) {
for _, constructor := range []string{"NewSQLite", "NewPostgres"} {
files := map[string]string{"internal/decision/use_test.go": `package decision; import "github.com/PerpetualSoftware/pad/internal/store/storetest"; func TestUse(t *testing.T) { storetest.` + constructor + `(t) }`}
errs := audit(files, nil, nil, map[string]bool{"./internal/store/storetest": true}, map[string]string{"./internal/decision": "NewSQLite only"})
if (len(errs) == 0) != (constructor == "NewSQLite") {
t.Fatalf("%s: %v", constructor, errs)
}
}
}
func TestPGNoTestsException(t *testing.T) {
for _, tt := range []struct {
name, testFile, source, want string
}{
{name: "operator command without tests"},
{name: "operator command gains tests", testFile: "operator/main_test.go", want: "ci-pg-packages.txt, or re-reviewed"},
{name: "build excluded external test still invalidates review", testFile: "operator/main_windows_test.go", want: "ci-pg-packages.txt, or re-reviewed"},
{name: "stale entry without PG references", source: `package main; func main() {}`, want: "stale no-tests"},
{name: "library is not an operator command", source: `package operator; func Open() { storetest.NewPostgres(t) }`, want: "no-tests entry requires package main"},
} {
t.Run(tt.name, func(t *testing.T) {
source := tt.source
if source == "" {
source = `package main; import "database/sql"; func main() { sql.Open("pgx", dsn) }`
}
files := map[string]string{"operator/main.go": source}
if tt.testFile != "" {
files[tt.testFile] = "package main\n"
if strings.HasSuffix(tt.testFile, "_windows_test.go") {
files[tt.testFile] = "//go:build windows\n\npackage main_test\n"
}
}
errs := audit(files, nil, nil, nil, map[string]string{"./operator": "no-tests: reviewed operator command"})
got := strings.Join(errs, "\n")
if tt.want == "" && got != "" {
t.Fatal(got)
}
if tt.want != "" && !strings.Contains(got, tt.want) {
t.Fatalf("wanted %q; got %q", tt.want, got)
}
})
}
}
func TestReviewedNonconnectingBoundary(t *testing.T) {
body := `func Register() { sql.Open("pgx", "") }`
files := map[string]string{
"helper/helper.go": `package helper; import "database/sql"; ` + body + `; func SQLite() { Register() }`,
"other/use_test.go": `package other; import "github.com/PerpetualSoftware/pad/helper"; func TestUse(t *testing.T) { helper.SQLite() }`,
}
review := map[string]string{"./helper.Register": fmt.Sprintf("sha256:%x registration only", sha256.Sum256([]byte(body)))}
pg := map[string]bool{"./helper": true}
if errs := audit(files, nil, nil, pg, review); len(errs) != 0 {
t.Fatal(errs)
}
files["helper/helper.go"] = strings.Replace(files["helper/helper.go"], `sql.Open("pgx", "")`, `sql.Open("pgx", ""); db.Ping()`, 1)
if errs := audit(files, nil, nil, pg, review); len(errs) == 0 {
t.Fatal("changed reviewed helper passed")
}
}
func TestPGMethodWrapper(t *testing.T) {
files := map[string]string{
"helper/helper.go": `package helper; type Helper struct{}; func (h Helper) Open() { storetest.NewPostgres(t) }`,
"other/use_test.go": `package other; import h "github.com/PerpetualSoftware/pad/helper"; func TestUse(t *testing.T) { x:=h.Helper{}; x.Open() }`,
}
errs := audit(files, nil, nil, map[string]bool{"./helper": true}, nil)
if !strings.Contains(strings.Join(errs, "\n"), "other") {
t.Fatalf("unlisted PG method user passed: %v", errs)
}
}
func TestTestingCleanupIsNotStoretestCleanup(t *testing.T) {
files := map[string]string{
"internal/store/storetest/helper.go": `package storetest; import "os"; func NewSQLite(t *testing.T) { t.Cleanup(func(){}) }; func Cleanup() { os.Getenv("PAD_TEST_POSTGRES_URL") }`,
"internal/decision/use_test.go": `package decision; import "github.com/PerpetualSoftware/pad/internal/store/storetest"; func TestUse(t *testing.T) { storetest.NewSQLite(t) }`,
}
errs := audit(files, nil, nil, map[string]bool{"./internal/store/storetest": true}, map[string]string{"./internal/decision": "NewSQLite only"})
if len(errs) != 0 {
t.Fatal(errs)
}
}
+14
View File
@@ -0,0 +1,14 @@
# One package per line; optional second field is the exact go test -run regex.
# The census checks each web-referencing test and every caller of its helpers.
./cmd/pad ^TestPushRefusalCodesMatchTheWebAllowList$
./cmd/depages
./internal/attachments ^TestMIMEFamilies(CoverAllowlist|FixtureHasNoStrays|KeysAreNormalized)$
./internal/cli ^Test(FormatChangesForDisplayMatchesTheJSFixture|AgentNameCopyMatchesResolverOrder|DetectProjectCapturesMakeAndNestedWebSignals|BuildWorkspaceContext)$
./internal/collab ^TestSchemaVersionMatchesWebClient$
./internal/links ^TestMarkdownCitationsNameLiveSymbols$
./internal/models ^Test(TerminalListsMatchWeb|NegativeTerminalsMatchTheWebCopy|TitleLimit.*)$
./internal/server ^Test(CopyPreflightDropReasonsAreRenderedByTheDialog|SharePageDoesNotImportCollab|NotificationEnrichment_PathMatchesTheWebRouteTree|DerivedSSENamesMatchTheLegacyWireVocabulary)$
./internal/server/render ^Test(ImgPaintableMimes_MatchesTypeScript|RenderAttachment_ParityWithTypeScript)$
./internal/materialize
./internal/buildtools ^TestInstallRefresh_WebStampCheckUsesTheCallersCheckout$
./scripts/ci-web-census
+1 -1
View File
@@ -256,7 +256,7 @@ test.describe('attachment viewer — 3c-i surface chrome (TASK-2484)', () => {
// DR-13: the FULL value is in title, and the box CLIPS WITH AN ELLIPSIS — the
// resolved style, not just overflow (a clip without `text-overflow: ellipsis`
// would still overflow but drop the "…", which jsdom cannot show).
await expect(name).toHaveAttribute('title', long);
await expect(name).toHaveAttribute('title', `\u2068${long}\u2069`);
const clip = await name.evaluate((el) => {
const cs = getComputedStyle(el);
return {
+1 -1
View File
@@ -222,7 +222,7 @@ async function openViewer(page: Page): Promise<void> {
async function openStripDeleteSheet(page: Page, filename: string): Promise<void> {
await page
.locator('.attachment-strip .att-delete')
.and(page.getByRole('button', { name: `Delete ${filename}` }))
.and(page.getByRole('button', { name: `Delete \u2068${filename}\u2069` }))
.evaluate((el) => (el as HTMLElement).click());
await expect(page.locator('.bs-sheet')).toBeVisible();
}
+3 -3
View File
@@ -397,7 +397,7 @@ test.describe('attachment viewer — four-surface parity (TASK-2436)', () => {
// tile's delete control carries the same filename, so an unanchored
// name matches two controls) and on the comma that follows it in the
// label, so a truncated or mangled name cannot satisfy the match.
const tile = page.getByRole('button', { name: new RegExp(`^View ${escapeRe(name)},`) });
const tile = page.getByRole('button', { name: new RegExp(`^View \\u2068?${escapeRe(name)}\\u2069?,`) });
await expect(tile).toHaveCount(1);
await tile.click();
// The dialog names itself for the file (3c-ii T2b): the name now LEADS with
@@ -410,7 +410,7 @@ test.describe('attachment viewer — four-surface parity (TASK-2436)', () => {
// is caught here — the tolerant addressing matcher above would accept it.
await expect(page.locator(VIEWER)).toHaveAttribute(
'aria-label',
new RegExp(`^${escapeRe(name)}, .+`)
new RegExp(`^\\u2068?${escapeRe(name)}\\u2069?, .+`)
);
await page.keyboard.press('Escape');
await expect(page.locator(VIEWER)).toHaveCount(0);
@@ -421,7 +421,7 @@ test.describe('attachment viewer — four-surface parity (TASK-2436)', () => {
// horizontal overflow — the failure a physical `left`/`right` pair would
// produce if the layout depended on it.
await page.evaluate(() => document.documentElement.setAttribute('dir', 'rtl'));
await page.getByRole('button', { name: new RegExp(`^View ${escapeRe(bidiStored)},`) }).click();
await page.getByRole('button', { name: new RegExp(`^View \\u2068?${escapeRe(bidiStored)}\\u2069?,`) }).click();
await expect(page.locator(VIEWER)).toHaveCount(1);
const rtl = await page.evaluate(() => {
const el = document.querySelector<HTMLElement>('.attachment-viewer')!;
+1 -1
View File
@@ -364,7 +364,7 @@ test.describe('item attachment strip', () => {
// A file tile is a real button naming its action, not a download link —
// the whole point of the change (DR-1, DR-12).
await expect(tile).toHaveJSProperty('tagName', 'BUTTON');
await expect(tile).toHaveAttribute('aria-label', /^Options for notes\.zip/);
await expect(tile).toHaveAttribute('aria-label', /^Options for \u2068notes\.zip/);
await tile.click();
// The converged surface opens — a role="dialog" naming the file, NOT a menu.
+1 -1
View File
@@ -207,7 +207,7 @@ export function escapeRe(s: string): string {
* lives in ONE place.
*/
export const viewerDialog = (page: Page, name: string) =>
page.getByRole('dialog', { name: new RegExp(`^${escapeRe(name)}(,|$)`) });
page.getByRole('dialog', { name: new RegExp(`^\\u2068?${escapeRe(name)}\\u2069?(,|$)`) });
/**
* The frontmost viewer's TABBABLE controls, in DOM (trap) order, as their
@@ -0,0 +1,206 @@
// TASK-4195: a file name is data a stranger controls; wherever it is shown it
// must be isolated so a right-to-left override inside it cannot reorder the
// text around it. Per-component sites have their own tests (strip, storage,
// lightbox, chip); this file covers the helpers and the string/markup sites
// that need no component mount.
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Markdown } from 'tiptap-markdown';
import { AttachmentImage } from '$lib/components/editor/attachment-image';
import {
displayFilename,
displayFilenameIsolated,
isolateText
} from './display';
import {
EVIL_NAME,
FSI,
PDI,
expectFsiPdi,
expectIsolated,
expectNoIsolates
} from './bidiIsolation.testutil';
import {
attachmentDeletePrompt,
workspaceAttachmentDeletePrompt
} from '$lib/components/attachments/AttachmentDeleteConfirm.svelte';
import {
buildPlaceholder,
storedNotInsertedMessage,
uploadFailedMessage
} from '$lib/components/editor/attachment-upload';
import { renderAttachmentChip, renderAttachmentImage } from '$lib/markdown/attachments';
import {
renderMarkedWithAttachments,
renderMarkdown,
sanitizeMarkdownHtml
} from '$lib/utils/markdown';
import { restrictShareMarkup } from '$lib/components/share/shareRender';
const ID = '11111111-2222-3333-4444-555555555555';
const meta = (filename: string) => ({
id: ID,
mime_type: 'application/pdf',
filename,
size_bytes: 10,
width: null,
height: null
});
describe('isolateText / displayFilenameIsolated', () => {
it('wraps in FSI .. PDI', () => {
expect(isolateText('x')).toBe(FSI + 'x' + PDI);
expect(FSI).toBe(String.fromCharCode(0x2068));
expect(PDI).toBe(String.fromCharCode(0x2069));
});
it('isolates the normalized name, including the blank fallback', () => {
expectFsiPdi(displayFilenameIsolated(EVIL_NAME), EVIL_NAME);
expect(displayFilenameIsolated(' ')).toBe(FSI + 'Untitled file' + PDI);
});
it('GUARD: displayFilename itself stays isolate-free (it feeds data seeds)', () => {
expectNoIsolates(displayFilename(EVIL_NAME));
expect(displayFilename(EVIL_NAME)).toBe(EVIL_NAME);
});
});
describe('delete prompts (site 15)', () => {
it('isolate the name in both prompts', () => {
expectFsiPdi(attachmentDeletePrompt(EVIL_NAME, true), EVIL_NAME);
expectFsiPdi(attachmentDeletePrompt(EVIL_NAME, false), EVIL_NAME);
expectFsiPdi(workspaceAttachmentDeletePrompt(EVIL_NAME), EVIL_NAME);
});
});
describe('attachment-upload (sites 7, 16)', () => {
it('the Uploading placeholder keeps the name inside a bdi', () => {
const el = buildPlaceholder(EVIL_NAME);
expectIsolated(el, EVIL_NAME);
expect(el.textContent).toContain('Uploading ');
});
it('the stored-not-inserted notice isolates the name only', () => {
const msg = storedNotInsertedMessage(EVIL_NAME);
expectFsiPdi(msg, EVIL_NAME);
expect(msg.startsWith(FSI + EVIL_NAME + PDI + ' was uploaded')).toBe(true);
});
// Site 17 (TASK-4195 r1-L2): the upload-error toast in Editor and
// CommentEditor. The name is isolated; the message is shown as received.
it('the upload-failed toast isolates the name only', () => {
const msg = uploadFailedMessage(EVIL_NAME, 'too large');
expectFsiPdi(msg, EVIL_NAME);
expect(msg).toBe("Couldn't upload " + FSI + EVIL_NAME + PDI + ': too large');
});
});
// Site 17 binding (CONVE-19): no test mounts the full Editor's upload error
// path, so pin that both toast sites build their text with the tested helper
// rather than an inline template that could drop the isolation.
const toastSites = import.meta.glob(
['../components/editor/Editor.svelte', '../components/CommentEditor.svelte'],
{ query: '?raw', import: 'default', eager: true }
) as Record<string, string>;
describe('upload-error toast sites (site 17)', () => {
it('finds both sources', () => {
expect(Object.keys(toastSites)).toHaveLength(2);
});
it.each(Object.entries(toastSites))('%s toasts through uploadFailedMessage', (_name, src) => {
expect(src).toContain('toastStore.show(uploadFailedMessage(filename, message), ');
expect(src).not.toContain("Couldn't upload");
});
});
describe('markdown file chip (site 6, 12)', () => {
function mount(html: string): HTMLElement {
const d = document.createElement('div');
d.innerHTML = html;
return d;
}
it('keeps the bdi through the sanitizer, with download= untouched', () => {
const raw = renderAttachmentChip(meta(EVIL_NAME), EVIL_NAME, 'ws');
const clean = sanitizeMarkdownHtml(raw);
expectIsolated(mount(clean), EVIL_NAME);
const a = mount(clean).querySelector('a.file-chip')!;
expect(a.getAttribute('download')).toBe(EVIL_NAME);
expectNoIsolates(a.getAttribute('download'));
expectNoIsolates(clean);
});
it('keeps the bdi through the share restrict pass', () => {
const raw = renderAttachmentChip(meta(EVIL_NAME), EVIL_NAME, 'ws');
const shared = restrictShareMarkup(sanitizeMarkdownHtml(raw));
expectIsolated(mount(shared), EVIL_NAME);
});
it('renders through the full body pipeline', () => {
const html = renderMarkedWithAttachments(`[${EVIL_NAME}](pad-attachment:${ID})`, {
resolver: () => meta(EVIL_NAME),
workspaceSlug: 'ws',
urlBuilder: (u: string) => `/a/${u}`
});
expectIsolated(mount(sanitizeMarkdownHtml(html)), EVIL_NAME);
});
it('never isolates an image alt: the filename fallback and authored alt are both data', () => {
const img = { ...meta(EVIL_NAME), mime_type: 'image/png', width: 1, height: 1 };
const fallback = mount(renderAttachmentImage(img, '', 'ws')).querySelector('img')!;
expect(fallback.getAttribute('alt')).toBe(EVIL_NAME);
expectNoIsolates(fallback.getAttribute('alt'));
const authored = mount(renderAttachmentImage(img, 'my alt', 'ws')).querySelector('img')!;
expect(authored.getAttribute('alt')).toBe('my alt');
});
it('an empty authored alt AND an empty filename renders alt="" with no isolates', () => {
const img = { ...meta(''), mime_type: 'image/png', width: 1, height: 1 };
const el = mount(renderAttachmentImage(img, '', 'ws')).querySelector('img')!;
expect(el.getAttribute('alt')).toBe('');
expectNoIsolates(el.outerHTML);
});
});
describe('image alt round trip through the editor (site 12)', () => {
function roundTrip(html: string): { alt: string | null; markdown: string } {
const editor = new Editor({
element: null,
extensions: [
StarterKit,
Markdown.configure({ html: true }),
AttachmentImage.configure({ workspaceSlug: 'ws', getDownloadUrl: (u: string) => `/a/${u}` })
],
content: `<p>${html}</p>`
});
let alt: string | null = null;
editor.state.doc.descendants((n) => {
if (n.type.name === 'attachmentImage') alt = n.attrs.alt as string | null;
});
const markdown = (editor.storage as { markdown: { getMarkdown(): string } }).markdown.getMarkdown();
editor.destroy();
return { alt, markdown };
}
const img = { ...meta('photo.png'), mime_type: 'image/png', width: 1, height: 1 };
it.each([
['the filename fallback', '', 'photo.png'],
['authored alt', 'my alt', 'my alt']
])('%s comes back byte-identical, with no isolates', (_n, alt, want) => {
const out = roundTrip(renderAttachmentImage(img, alt, 'ws'));
expect(out.alt).toBe(want);
expectNoIsolates(out.alt);
expectNoIsolates(out.markdown);
expect(out.markdown).toBe(`![${want}](pad-attachment:${ID})`);
});
});
describe('bdi in the markdown sanitizer allowlist (Q2)', () => {
it('survives, with every attribute it does not allowlist stripped, and is inert', () => {
const dirty = '<bdi onclick="alert(1)" style="position:fixed" dir="rtl" onmouseover="x()">hi</bdi>';
for (const out of [sanitizeMarkdownHtml(dirty), renderMarkdown(dirty)]) {
const bdi = new DOMParser().parseFromString(out, 'text/html').querySelector('bdi')!;
expect(bdi, out).not.toBeNull();
expect(bdi.textContent).toBe('hi');
for (const attr of ['onclick', 'style', 'dir', 'onmouseover']) {
expect(bdi.hasAttribute(attr), attr).toBe(false);
}
}
});
it('does not let a script ride along inside a user-authored bdi', () => {
const out = sanitizeMarkdownHtml('<bdi><script>alert(1)</script><img src=x onerror=alert(1)></bdi>');
expect(out).not.toContain('<script');
expect(out).not.toContain('onerror');
});
});
@@ -0,0 +1,41 @@
/**
* Shared fixtures and assertions for the file-name bidi-isolation tests
* (TASK-4195). Every bidi control is written as an escape: no literal
* right-to-left override or isolate character may appear in the source.
*/
import { expect } from 'vitest';
/** FSI / PDI: the first-strong isolate pair `isolateText` emits. */
export const FSI = String.fromCharCode(0x2068);
export const PDI = String.fromCharCode(0x2069);
/** A name carrying a right-to-left override (U+202E): "evil" + RLO + "gnp.exe". */
export const EVIL_NAME = 'evil' + String.fromCharCode(0x202e) + 'gnp.exe';
/** The text node under `root` whose data contains `name`. */
function textNodeContaining(root: Node, name: string): Text | null {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let n: Node | null;
while ((n = walker.nextNode())) {
if ((n as Text).data.includes(name)) return n as Text;
}
return null;
}
/** The name's text node sits inside a `<bdi>` (visible-text sites). */
export function expectIsolated(root: Node, name: string): void {
const node = textNodeContaining(root, name);
expect(node, `no text node containing the name`).not.toBeNull();
expect(node!.parentElement!.closest('bdi'), 'name text is not inside a <bdi>').not.toBeNull();
}
/** The string carries FSI, then the name, then PDI (attribute and toast sites). */
export function expectFsiPdi(str: string | null | undefined, name: string): void {
expect(str ?? '').toContain(FSI + name + PDI);
}
/** The string carries no isolate controls (data sites: download=, node attrs). */
export function expectNoIsolates(str: string | null | undefined): void {
expect(str ?? '').not.toContain(FSI);
expect(str ?? '').not.toContain(PDI);
}
+16
View File
@@ -44,6 +44,22 @@ export function displayFilename(filename: string | null | undefined): string {
return trimmed || 'Untitled file';
}
/**
* Wrap text in a first-strong isolate (U+2068 .. U+2069) so a file name with
* bidi controls (a right-to-left override, say) cannot reorder the text around
* it. For attribute and plain-text strings (title, aria-label, toasts), where
* `<bdi>` markup cannot go. Display only: never use it for `download=`, node
* attrs, data attributes or any value that is stored or parsed back.
*/
export function isolateText(s: string): string {
return '\u2068' + s + '\u2069';
}
/** `displayFilename`, isolated for a sentence, attribute or toast. */
export function displayFilenameIsolated(filename: string | null | undefined): string {
return isolateText(displayFilename(filename));
}
export function formatBytes(bytes: number): string {
if (bytes < 0) return `${bytes} B`;
const KB = 1024;
+2 -2
View File
@@ -30,7 +30,7 @@
import { unescapeDocLinks } from '$lib/utils/markdown';
import { AttachmentImage } from './editor/attachment-image';
import { AttachmentChip } from './editor/attachment-chip';
import { AttachmentUpload } from './editor/attachment-upload';
import { AttachmentUpload, uploadFailedMessage } from './editor/attachment-upload';
import type { AttachmentHostAddress } from '$lib/attachments/hostAddress';
interface Props {
@@ -290,7 +290,7 @@
if (message === PLAN_LIMIT_PROMPTED) return;
console.error(`[comment attachment] ${filename}: ${message}`);
// A toast, not a blocking window.alert (TASK-2221, audit C39).
toastStore.show(`Couldn't upload ${filename}: ${message}`, 'error');
toastStore.show(uploadFailedMessage(filename, message), 'error');
},
// An upload that finished after this composer unmounted: when a
// pane opens over the timeline, when the user navigates away. The
@@ -38,7 +38,7 @@
a change to one is read alongside the other.
-->
<script lang="ts" module>
import { displayFilename } from '$lib/attachments/display';
import { displayFilenameIsolated } from '$lib/attachments/display';
/**
* The two arms of the ITEM-surface delete warning.
@@ -58,7 +58,7 @@
// confirmation reading "Delete ?" is the one place it actually matters,
// because the user is being asked to approve destroying something the
// prompt cannot name (final review round 4).
const displayName = displayFilename(filename);
const displayName = displayFilenameIsolated(filename);
return referencedHere
? `Delete ${displayName}? It's still used in this item's content — deleting it will leave a "missing attachment" placeholder where it appears.`
: `Delete ${displayName}? It isn't referenced in this item's content, but it may still be referenced by another item or a comment. This cannot be undone.`;
@@ -73,7 +73,7 @@
* worth saying here is what actually happens to the bytes.
*/
export function workspaceAttachmentDeletePrompt(filename: string | null | undefined): string {
return `Delete ${displayFilename(filename)}? The blob is reclaimed by garbage collection after a grace period.`;
return `Delete ${displayFilenameIsolated(filename)}? The blob is reclaimed by garbage collection after a grace period.`;
}
</script>
@@ -26,6 +26,8 @@
];
let copied = $state<Record<string, boolean>>({});
// Shared across commands: with a second copyable command this must become
// per-command, or one command's outcome would overwrite the other's (TASK-4210 L3).
let status = $state('');
let destroyed = false;
@@ -41,10 +43,12 @@
// outcome fades: it stays until the next click on that button.
const attempt: Record<string, number> = {};
// A live region announces a change; 'Copied' -> 'Copied' is none. Clear, let
// the DOM update, then set, so every outcome is announced.
// A live region announces a change; 'Copied' -> 'Copied' is none. The click
// already cleared status, and only the live attempt writes it, so no clear is
// needed here. The tick still matters: when navigator.clipboard is undefined,
// writeText throws synchronously, so without it the click's '' and the failure
// text would batch into one flush and the live region would not change.
async function announce(cmd: string, n: number, text: string) {
status = '';
await tick();
if (destroyed || attempt[cmd] !== n) return;
status = text;
@@ -161,7 +161,8 @@ describe('ChatEmptyState', () => {
await flush();
mo.disconnect();
expect(live.textContent).toBe('Copied');
// Without the clear, the second copy changes nothing and seen has one entry.
// Without the click's reset of status, the second copy changes nothing and seen
// has one entry.
expect(seen.filter((t) => t === 'Copied').length).toBe(2);
expect(seen).toContain('');
});
@@ -298,8 +299,10 @@ describe('ChatEmptyState', () => {
const btn = getByRole('button', COPY);
btn.click();
await flush();
// writeText is already rejected: without announce()'s own clear-then-tick, the
// click's reset and the failure text land in one flush and the DOM never changes.
// The second click's reset of status ('') and the failure text must reach the DOM
// as separate changes; the click's reset provides the '' (announce() no longer
// clears), and announce()'s tick keeps it from batching with the failure text.
// The sync-throw path, where the tick matters, is pinned below.
btn.click();
await flush();
mo.disconnect();
@@ -308,6 +311,26 @@ describe('ChatEmptyState', () => {
expect(seen).toContain('');
});
it('a synchronous writeText throw, clicked twice, re-announces the failure each time', async () => {
// navigator.clipboard undefined: the throw is synchronous, so only announce()'s
// tick separates the click's '' from the failure text.
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
const { getByRole, container } = render(ChatEmptyState);
const live = container.querySelector('[aria-live="polite"]')!;
const seen: string[] = [];
const mo = new MutationObserver(() => seen.push(live.textContent ?? ''));
mo.observe(live, { childList: true, characterData: true, subtree: true });
const btn = getByRole('button', COPY);
btn.click();
await flush();
btn.click();
await flush();
mo.disconnect();
expect(live.textContent).toBe(FAIL);
expect(seen.filter((t) => t === FAIL).length).toBe(2);
expect(seen.filter((t) => t === '').length).toBe(1);
});
it('writes no state and calls no tick() when writeText settles after unmount', async () => {
let resolve!: () => void;
writeText.mockImplementation(() => new Promise<void>((r) => (resolve = r)));
+12 -9
View File
@@ -110,7 +110,8 @@
describeAttachmentType,
formatBytes,
iconForAttachment,
isMarkdownAttachment
isMarkdownAttachment,
isolateText
} from '$lib/attachments/display';
import { createSurfaceMetadata } from '$lib/attachments/surfaceMetadata.svelte';
import { backdropDismiss } from '$lib/utils/backdropDismiss';
@@ -445,12 +446,14 @@
// string. Shared by the metadata header, the download attribute and the delete
// prompt, so all three name the file identically.
let displayName = $derived(img?.filename ?? blankToNull(img?.alt) ?? 'Attachment');
// FSI..PDI-wrapped copy for attribute / title text, where markup (`<bdi>`) cannot go.
let displayNameIsolated = $derived(isolateText(displayName));
// The dialog's accessible NAME (3c-ii T2b): the file's display name plus the
// type · size the header shows, so a screen reader announces WHAT is open — the
// metadata the retired panel exposed — not a bare alt. Never empty (`displayName`
// always resolves), so an unnamed `role="dialog"` — announced as nothing — can't
// happen.
let dialogLabel = $derived([displayName, headerDetail].filter(Boolean).join(', '));
let dialogLabel = $derived([displayNameIsolated, headerDetail].filter(Boolean).join(', '));
// Inline, transient error from a button action (a failed clipboard write). Sits
// in the toolbar beside the controls, never a blocking dialog.
let toolbarError = $state<string | null>(null);
@@ -2783,7 +2786,7 @@
<div
class="lightbox-text-scroll"
role="document"
aria-label={displayName}
aria-label={displayNameIsolated}
tabindex={shownRenderer === 'table' ? undefined : 0}
>
{#if textLoader.phase === 'loading'}
@@ -2820,7 +2823,7 @@
</div>
{:else if textLoader.phase === 'ready'}
{#if shownRenderer === 'table'}
<AttachmentTablePreview text={textLoader.text} mime={resolvedMime!} compact={isSheet} label={`${displayName} table`} />
<AttachmentTablePreview text={textLoader.text} mime={resolvedMime!} compact={isSheet} label={`${displayNameIsolated} table`} />
{:else}
<div class="lightbox-text-controls" role="group" aria-label="Preview display">
{#if markdownAttachment}
@@ -2854,7 +2857,7 @@
-->
{#if shownRenderer === 'pdf' && !soleMissing}
<div class="lightbox-pdf">
<div class="lightbox-pdf-scroll" role="document" aria-label={displayName}>
<div class="lightbox-pdf-scroll" role="document" aria-label={displayNameIsolated}>
{#if bytesLoader.phase === 'loading'}
<p class="lightbox-text-status" role="status">Loading preview…</p>
{:else if bytesLoader.phase === 'too-large'}
@@ -2886,7 +2889,7 @@
bind:this={pdfPreview}
bytes={bytesLoader.bytes}
compact={isSheet}
label={`${displayName} PDF`}
label={`${displayNameIsolated} PDF`}
/>
{/key}
{/if}
@@ -2917,7 +2920,7 @@
<span class="lightbox-fallback-icon" aria-hidden="true">
<AttachmentIcon id={fallbackIconId} size={72} />
</span>
<p class="lightbox-fallback-name" title={displayName}>{displayName}</p>
<p class="lightbox-fallback-name" title={displayNameIsolated}><bdi>{displayName}</bdi></p>
{#if headerDetail}
<p class="lightbox-fallback-detail">{headerDetail}</p>
{/if}
@@ -2939,7 +2942,7 @@
<span class="lightbox-fallback-icon" aria-hidden="true">
<AttachmentIcon id={fallbackIconId} size={72} />
</span>
<p class="lightbox-fallback-name" title={displayName}>{displayName}</p>
<p class="lightbox-fallback-name" title={displayNameIsolated}><bdi>{displayName}</bdi></p>
<p class="lightbox-fallback-note">This file is no longer available. It may have been deleted.</p>
</div>
{/if}
@@ -2963,7 +2966,7 @@
-->
{#if img}
<div class="lightbox-meta">
<div class="lightbox-meta-name" title={displayName}>{displayName}</div>
<div class="lightbox-meta-name" title={displayNameIsolated}><bdi>{displayName}</bdi></div>
{#if headerDetail}
<div class="lightbox-meta-detail">{headerDetail}</div>
{/if}
@@ -1,6 +1,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import Lightbox from './Lightbox.svelte';
import { renderAttachmentImage } from '$lib/markdown/attachments';
import {
EVIL_NAME,
FSI,
PDI,
expectFsiPdi,
expectIsolated
} from '$lib/attachments/bidiIsolation.testutil';
import type { LightboxImage } from '$lib/attachments/events';
// TASK-2477 — the deletion bus is REAL in this suite (the viewer subscribes to
// it); firing `notifyAttachmentDeleted` drives the survivor logic directly.
@@ -312,7 +320,7 @@ describe('Lightbox — dialog semantics', () => {
// is absent here (the seed carries none and the probe is async), so the label
// is "name, type".
const type = describeAttachmentType('image/png', null);
expect(root().getAttribute('aria-label')).toBe(`a diagram, ${type}`);
expect(root().getAttribute('aria-label')).toBe(`${FSI}a diagram${PDI}, ${type}`);
});
it('falls back to a generic name when the image has no alt', () => {
@@ -321,7 +329,7 @@ describe('Lightbox — dialog semantics', () => {
// ("Attachment") plus the type.
mountViewer({ images: [image(IMG_A, '')] });
const type = describeAttachmentType('image/png', null);
expect(root().getAttribute('aria-label')).toBe(`Attachment, ${type}`);
expect(root().getAttribute('aria-label')).toBe(`${FSI}Attachment${PDI}, ${type}`);
});
it('gives every control a real accessible name, not a glyph', () => {
@@ -352,10 +360,10 @@ describe('Lightbox — dialog semantics', () => {
],
});
const type = describeAttachmentType('image/png', null);
expect(root().getAttribute('aria-label')).toBe(`first, ${type}`);
expect(root().getAttribute('aria-label')).toBe(`${FSI}first${PDI}, ${type}`);
root().querySelector<HTMLButtonElement>('.lightbox-nav.next')!.click();
flushSync();
expect(root().getAttribute('aria-label')).toBe(`second, ${type}`);
expect(root().getAttribute('aria-label')).toBe(`${FSI}second${PDI}, ${type}`);
});
});
@@ -4548,6 +4556,21 @@ describe('Lightbox — metadata header (TASK-2475)', () => {
expect(metaName()).toBe('Attachment');
});
it('an image rendered with empty alt and empty filename seeds the generic label, not isolates', async () => {
// The timeline seeds this image from the rendered <img>'s alt attribute (viewerImageFor).
const d = document.createElement('div');
d.innerHTML = renderAttachmentImage(
{ id: 'a1', mime_type: 'image/png', filename: '', size_bytes: 1, width: 1, height: 1 },
'',
'ws'
);
const seededAlt = d.querySelector('img')!.getAttribute('alt') ?? '';
expect(seededAlt).toBe('');
mountViewer({ images: [metaImage({ filename: null, alt: seededAlt })] });
await settleAsync();
expect(metaName()).toBe('Attachment');
});
it('treats a whitespace-only alt as blank too, reaching the generic label', async () => {
// The chain blank-normalizes alt, not just filename — an all-spaces alt must
// not win over 'Attachment' as an empty-but-present string would.
@@ -4629,7 +4652,7 @@ describe('Lightbox — metadata header (TASK-2475)', () => {
// (overflow/text-overflow/nowrap on `.lightbox-meta-name`) is not applied by
// jsdom's `getComputedStyle` (it ignores scoped `<style>` rules), so it is a
// browser-suite concern, like every other measured-layout claim in this file.
expect(nameEl.getAttribute('title')).toBe(long);
expect(nameEl.getAttribute('title')).toBe(FSI + long + PDI);
expect(nameEl.textContent).toBe(long);
});
});
@@ -4695,7 +4718,7 @@ describe('Lightbox — the fallback arm (TASK-2476)', () => {
// The display name, full value also in title (DR-13).
const nameEl = fb.querySelector('.lightbox-fallback-name');
expect(nameEl?.textContent).toBe('plans.svg');
expect(nameEl?.getAttribute('title')).toBe('plans.svg');
expect(nameEl?.getAttribute('title')).toBe(FSI + 'plans.svg' + PDI);
// Type · size and the honest note.
expect(fb.querySelector('.lightbox-fallback-detail')?.textContent).toContain(formatBytes(4096));
expect(fb.textContent).toContain('No preview available');
@@ -5295,7 +5318,7 @@ describe('Lightbox — the text arm', () => {
region.focus();
for (const key of ['ArrowRight', 'ArrowLeft']) {
expect(press(key)).toBe(false);
expect(region.getAttribute('aria-label')).toBe('first.csv table');
expect(region.getAttribute('aria-label')).toBe(FSI + 'first.csv' + PDI + ' table');
expect(root().querySelector('.attachment-table-scroll')).toBe(region);
}
region.scrollLeft = 1500;
@@ -5307,13 +5330,13 @@ describe('Lightbox — the text arm', () => {
expect(press('ArrowRight')).toBe(true);
await settleAsync();
const second = root().querySelector<HTMLElement>('.attachment-table-scroll')!;
expect(second.getAttribute('aria-label')).toBe('second.csv table');
expect(second.getAttribute('aria-label')).toBe(FSI + 'second.csv' + PDI + ' table');
Object.defineProperty(second, 'scrollWidth', { value: 2000 });
Object.defineProperty(second, 'clientWidth', { value: 500 });
closeButton().focus();
expect(press('ArrowRight')).toBe(true);
await settleAsync();
expect(root().querySelector('.attachment-table-scroll')?.getAttribute('aria-label')).toBe('first.csv table');
expect(root().querySelector('.attachment-table-scroll')?.getAttribute('aria-label')).toBe(FSI + 'first.csv' + PDI + ' table');
});
it('T2: exposes one focusable, labelled table scroll region', async () => {
@@ -5323,7 +5346,7 @@ describe('Lightbox — the text arm', () => {
expect(region).not.toBeNull();
expect(region.getAttribute('tabindex')).toBe('0');
expect(region.getAttribute('role')).toBe('region');
expect(region.getAttribute('aria-label')).toBe('report.csv table');
expect(region.getAttribute('aria-label')).toBe(FSI + 'report.csv' + PDI + ' table');
region.focus();
expect(document.activeElement).toBe(region);
expect(root().querySelector('[role="document"]')!.hasAttribute('tabindex')).toBe(false);
@@ -5766,12 +5789,12 @@ describe('Lightbox — the PDF arm (TASK-4075)', () => {
Object.defineProperty(region, 'clientWidth', { configurable: true, value: 500 });
region.focus();
for (const key of ['ArrowRight', 'ArrowLeft']) expect(press(key), key).toBe(false);
expect(root().querySelector('.lightbox-pdf-scroll')!.getAttribute('aria-label')).toBe('first.pdf');
expect(root().querySelector('.lightbox-pdf-scroll')!.getAttribute('aria-label')).toBe(FSI + 'first.pdf' + PDI);
// Without horizontal overflow the arrows page the gallery.
Object.defineProperty(region, 'scrollWidth', { configurable: true, value: 500 });
expect(press('ArrowRight')).toBe(true);
await settleAsync();
expect(root().querySelector('.lightbox-pdf-scroll')!.getAttribute('aria-label')).toBe('second.pdf');
expect(root().querySelector('.lightbox-pdf-scroll')!.getAttribute('aria-label')).toBe(FSI + 'second.pdf' + PDI);
});
it('U6: the arrows move the caret in the page-number box instead of paging the gallery', async () => {
@@ -5781,7 +5804,7 @@ describe('Lightbox — the PDF arm (TASK-4075)', () => {
box.focus();
expect(press('ArrowRight')).toBe(false);
expect(press('ArrowLeft')).toBe(false);
expect(root().querySelector('.lightbox-pdf-scroll')!.getAttribute('aria-label')).toBe('doc.pdf');
expect(root().querySelector('.lightbox-pdf-scroll')!.getAttribute('aria-label')).toBe(FSI + 'doc.pdf' + PDI);
});
it('H8: 0, -, + and = reach the page-number box instead of being swallowed as zoom keys', async () => {
@@ -6001,3 +6024,35 @@ describe('Lightbox — a pending delete confirmation owns the pointer gestures (
ptr('pointerup', 700, 500, { buttons: 0 });
});
});
// TASK-4195: a right-to-left override in a file name must not reorder the viewer's
// title, fallback name or accessible name.
describe('Lightbox — file names are bidi-isolated (TASK-4195)', () => {
it('isolates the fallback name, the metadata name, and their title / aria-label', () => {
liveProps.images = [image(IMG_A, 'png')];
const app = mount(Lightbox, { target: appRoot, props: liveProps });
mounted.push(app);
flushSync();
liveProps.images = [
{
id: IMG_A,
alt: 'plans',
filename: EVIL_NAME,
mime_type: 'image/svg+xml',
size_bytes: 4096,
width: null,
height: null,
},
];
flushSync();
const fallbackName = root().querySelector<HTMLElement>('.lightbox-fallback-name')!;
expectIsolated(fallbackName, EVIL_NAME);
expectFsiPdi(fallbackName.getAttribute('title'), EVIL_NAME);
expectFsiPdi(root().getAttribute('aria-label'), EVIL_NAME);
const metaName = root().querySelector<HTMLElement>('.lightbox-meta-name')!;
expectIsolated(metaName, EVIL_NAME);
expectFsiPdi(metaName.getAttribute('title'), EVIL_NAME);
});
});
+2 -2
View File
@@ -581,7 +581,7 @@
} from './attachment-image';
import { AttachmentChip } from './attachment-chip';
import type { AttachmentHostAddress } from '$lib/attachments/hostAddress';
import { AttachmentUpload } from './attachment-upload';
import { AttachmentUpload, uploadFailedMessage } from './attachment-upload';
// The upload callback's refusal across an identity change (BUG-3105), matched
// by `onError` so that refusal is not presented as a failed upload.
@@ -1200,7 +1200,7 @@
// the main mobile upload path, and an alert steals focus and the
// keyboard mid-edit (TASK-2221, audit C39).
console.error(`[attachment upload] ${filename}: ${message}`);
toastStore.show(`Couldn't upload ${filename}: ${message}`, 'error');
toastStore.show(uploadFailedMessage(filename, message), 'error');
},
// A stored upload whose reference could not be inserted (BUG-2177).
// The identity fence above rejects a previous user's upload before
@@ -62,6 +62,7 @@ import {
import {
describeAttachmentType,
displayFilename,
displayFilenameIsolated,
formatBytes,
iconForAttachment,
} from '$lib/attachments/display';
@@ -235,6 +236,9 @@ export const AttachmentChip = Node.create<AttachmentChipOptions>({
const nameEl = document.createElement('span');
nameEl.className = 'file-chip-name';
// <bdi> so a name with bidi controls cannot reorder the chip around it.
const bdiEl = document.createElement('bdi');
nameEl.append(bdiEl);
const sizeEl = document.createElement('span');
sizeEl.className = 'file-chip-size';
@@ -262,7 +266,7 @@ export const AttachmentChip = Node.create<AttachmentChipOptions>({
} else {
wrapper.removeAttribute('data-filename');
}
nameEl.textContent = displayFilename(currentFilename);
bdiEl.textContent = displayFilename(currentFilename);
};
// Icon resolution is the shared mapper's (TASK-2417): MIME first,
@@ -295,7 +299,7 @@ export const AttachmentChip = Node.create<AttachmentChipOptions>({
* read after it is initialised.)
*/
const refreshAccessibleName = (): void => {
const name = displayFilename(currentFilename);
const name = displayFilenameIsolated(currentFilename);
if (deleted) {
wrapper.setAttribute('aria-label', `${name} — this attachment has been deleted`);
return;
@@ -42,6 +42,7 @@ import { Extension } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
import type { AttachmentUploadResult } from '$lib/types';
import { isolateText } from '$lib/attachments/display';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
@@ -104,7 +105,17 @@ export interface AttachmentUploadOptions {
/** The notice for a stored upload whose reference never landed (BUG-2177). */
export function storedNotInsertedMessage(filename: string): string {
return `${filename} was uploaded but not inserted into the document: the editor closed, or the place it was dropped was removed, before it could be inserted. Attach it again to use it.`;
return `${isolateText(filename)} was uploaded but not inserted into the document: the editor closed, or the place it was dropped was removed, before it could be inserted. Attach it again to use it.`;
}
/**
* The toast for a failed upload, shared by Editor and CommentEditor (TASK-4195
* site 17). Only the file name is isolated. `message` is the upload call's
* Error.message (client text, or the server's error text) and is shown as
* received, not isolated (TASK-4195 r5-L1, r6-L1).
*/
export function uploadFailedMessage(filename: string, message: string): string {
return `Couldn't upload ${isolateText(filename)}: ${message}`;
}
const pluginKey = new PluginKey<UploadState>('attachmentUpload');
@@ -123,8 +134,8 @@ function newId(): string {
return `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** Build the placeholder DOM. Kept tiny — purely visual, ignored by ProseMirror's selection model. */
function buildPlaceholder(filename: string): HTMLElement {
/** Build the placeholder DOM (exported for the TASK-4195 bidi test). Kept tiny — purely visual, ignored by ProseMirror's selection model. */
export function buildPlaceholder(filename: string): HTMLElement {
const wrapper = document.createElement('span');
wrapper.className = 'attachment-upload-placeholder';
wrapper.contentEditable = 'false';
@@ -135,7 +146,9 @@ function buildPlaceholder(filename: string): HTMLElement {
const label = document.createElement('span');
label.className = 'attachment-upload-label';
label.textContent = `Uploading ${filename}…`;
const name = document.createElement('bdi');
name.textContent = filename;
label.append('Uploading ', name, '…');
wrapper.append(spinner, label);
return wrapper;
@@ -15,6 +15,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import {
EVIL_NAME,
FSI,
PDI,
expectFsiPdi,
expectIsolated,
expectNoIsolates
} from '$lib/attachments/bidiIsolation.testutil';
const panelOpenMock = vi.fn<(event: Record<string, unknown>) => void>();
const deletionListeners = new Set<(uuid: string) => void>();
@@ -219,7 +227,7 @@ describe('editor chip → options panel', () => {
it('names the action, not just the file (DR-12)', () => {
// Type comes from the filename here, since no HEAD probe ran; size is
// omitted rather than reported as a confident "0 B".
expect(chip().getAttribute('aria-label')).toBe('Options for spec.pdf, PDF');
expect(chip().getAttribute('aria-label')).toBe('Options for ' + FSI + 'spec.pdf' + PDI + ', PDF');
});
it('probes under the CURRENT workspace after a pane workspace switch', async () => {
@@ -485,7 +493,7 @@ describe('editor chip → options panel', () => {
// happened rather than promising options.
expect(el.disabled).toBe(true);
expect(el.hasAttribute('tabindex')).toBe(false);
expect(el.getAttribute('aria-label')).toBe('spec.pdf — this attachment has been deleted');
expect(el.getAttribute('aria-label')).toBe(FSI + 'spec.pdf' + PDI + ' — this attachment has been deleted');
expect(el.classList.contains('attachment-missing')).toBe(true);
const click = new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1 });
@@ -493,4 +501,29 @@ describe('editor chip → options panel', () => {
el.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }));
expect(panelOpenMock).not.toHaveBeenCalled();
});
// TASK-4195: the NodeView isolates the name for DISPLAY; the stored attrs, the
// clipboard HTML (renderHTML) and the markdown serializer stay isolate-free,
// because parseHTML reads the name back from them.
it('isolates the chip name and accessible name, leaving every stored form clean', () => {
const ed = (editor ??= makeEditor(target));
repointChip('uuid-1', EVIL_NAME);
const el = chip();
expectIsolated(el.querySelector('.file-chip-name')!, EVIL_NAME);
expectFsiPdi(el.getAttribute('aria-label'), EVIL_NAME);
expect(el.getAttribute('data-filename')).toBe(EVIL_NAME);
const node = ed.state.doc.firstChild!.firstChild!;
expect(node.attrs.filename).toBe(EVIL_NAME);
expectNoIsolates(node.attrs.filename);
expectNoIsolates(ed.getHTML());
let written = '';
(ed.storage as unknown as {
attachmentChip: { markdown: { serialize: (s: unknown, n: unknown) => void } };
}).attachmentChip.markdown.serialize({ write: (t: string) => (written += t) }, node);
expect(written).toBe(`[${EVIL_NAME}](pad-attachment:uuid-1)`);
expectNoIsolates(written);
});
});
@@ -667,7 +667,7 @@ handlers — onchange is never called.
// `store.uniqueSlug`). Nothing auto-retries — the retry would be a
// person clicking Create again, seeing the picker's current state —
// and the repo's standing rule for the same shape is exactly that
// (`item copy` "NEVER retry it automatically"). Filed as IDEA-2880
// (PLAN-2357 DR-13: never a keyless automatic retry). Filed as IDEA-2880
// rather than papered over with a client-side guess — checking for a
// same-title item before retrying would rest on the same ranked,
// paged, possibly-stale evidence the create row itself rests on, and
@@ -48,6 +48,7 @@
canOpenInViewer,
describeAttachmentType,
displayFilename,
displayFilenameIsolated,
} from '$lib/attachments/display';
import AttachmentIcon from '$lib/attachments/icons/AttachmentIcon.svelte';
import Menu from '$lib/components/common/Menu.svelte';
@@ -972,7 +973,7 @@
/** What the file IS — the tooltip, and the base of the accessible name. */
function tileLabel(att: StripAttachment): string {
return `${displayFilename(att.filename)}, ${describeAttachmentType(att.mime_type, att.filename)}, ${formatBytes(att.size_bytes)}`;
return `${displayFilenameIsolated(att.filename)}, ${describeAttachmentType(att.mime_type, att.filename)}, ${formatBytes(att.size_bytes)}`;
}
/**
@@ -1235,8 +1236,8 @@
}
toastStore.show(
code === 'forbidden'
? `You don't have permission to delete ${displayFilename(att.filename)}.`
: `Couldn't delete ${displayFilename(att.filename)}.`,
? `You don't have permission to delete ${displayFilenameIsolated(att.filename)}.`
: `Couldn't delete ${displayFilenameIsolated(att.filename)}.`,
'error'
);
} finally {
@@ -1320,7 +1321,7 @@
<span class="att-icon">
<AttachmentIcon id={iconForAttachment(att.mime_type, att.filename)} />
</span>
<span class="att-name" aria-hidden="true">{displayFilename(att.filename)}</span>
<span class="att-name" aria-hidden="true"><bdi>{displayFilename(att.filename)}</bdi></span>
</button>
{/if}
@@ -1331,8 +1332,8 @@
<button
type="button"
class="att-delete"
title="Delete {displayFilename(att.filename)}"
aria-label="Delete {displayFilename(att.filename)}"
title="Delete {displayFilenameIsolated(att.filename)}"
aria-label="Delete {displayFilenameIsolated(att.filename)}"
onclick={(e) => requestDelete(att, e.currentTarget)}
>
×
@@ -1394,8 +1395,8 @@
mode="portal"
width={272}
sheetOnMobile
sheetTitle="Delete {displayFilename(pendingDelete?.att.filename)}"
ariaLabel="Delete {displayFilename(pendingDelete?.att.filename)}"
sheetTitle="Delete {displayFilenameIsolated(pendingDelete?.att.filename)}"
ariaLabel="Delete {displayFilenameIsolated(pendingDelete?.att.filename)}"
focusKey={pendingDelete?.att.id}
>
<AttachmentDeleteConfirm
@@ -2,6 +2,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import type { AttachmentListItem, AttachmentListResponse } from '$lib/types';
import type { UploadedAttachment } from '$lib/attachments/events';
import {
EVIL_NAME,
FSI,
PDI,
expectFsiPdi,
expectIsolated
} from '$lib/attachments/bidiIsolation.testutil';
import { runTopEscape, _resetEscapeStackForTests } from '$lib/stores/escapeStack';
import { __resetViewerBackdropForTests } from '$lib/a11y/viewerBackdrop';
@@ -292,8 +299,8 @@ describe('ItemAttachmentStrip', () => {
expect(tile.getAttribute('download')).toBeNull();
// The accessible name carries filename, TYPE and the ACTION (DR-12) —
// the only signpost for the changed behaviour, since DR-1 adds no `⋯`.
expect(tile.getAttribute('aria-label')).toBe('Options for spec.pdf, PDF, 1.5 KB');
expect(tile.getAttribute('title')).toBe('spec.pdf, PDF, 1.5 KB');
expect(tile.getAttribute('aria-label')).toBe('Options for ' + FSI + 'spec.pdf' + PDI + ', PDF, 1.5 KB');
expect(tile.getAttribute('title')).toBe(FSI + 'spec.pdf' + PDI + ', PDF, 1.5 KB');
});
it('emits the open-surface event with the invoker and all three metadata fields', async () => {
@@ -416,7 +423,7 @@ describe('ItemAttachmentStrip', () => {
const [svgTile, pngTile] = tiles();
// The SVG got the file path: an icon + name, no thumbnail request.
expect(svgTile.querySelector('img')).toBeNull();
expect(svgTile.getAttribute('aria-label')).toContain('Options for logo.svg');
expect(svgTile.getAttribute('aria-label')).toContain('Options for ' + FSI + 'logo.svg' + PDI);
svgTile.click();
await settle();
@@ -1047,7 +1054,7 @@ describe('ItemAttachmentStrip', () => {
const buttons = deleteButtons();
expect(buttons).toHaveLength(2);
expect(buttons[0].getAttribute('aria-label')).toBe('Delete a1.png');
expect(buttons[0].getAttribute('aria-label')).toBe('Delete ' + FSI + 'a1.png' + PDI);
// Actually focusable — not merely present. The earlier assertion checked
// the `hidden` PROPERTY, which a `visibility: hidden` rule never sets,
// so it passed while the control was in fact unreachable by keyboard
@@ -2667,4 +2674,45 @@ describe('ItemAttachmentStrip', () => {
expect(tiles()[0].getAttribute('aria-label')).toContain('a1.png');
expect(toastMock).toHaveBeenCalled();
});
// TASK-4195: a file name with a right-to-left override must be isolated
// wherever the strip shows it.
describe('bidi isolation of file names (TASK-4195)', () => {
it('isolates the visible tile name, and the title / aria-label / delete labels', async () => {
listMock.mockResolvedValue(response([att({ id: 'a1', filename: EVIL_NAME, mime_type: 'application/pdf' })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
expectIsolated(target.querySelector('.att-name')!, EVIL_NAME);
const tile = tiles()[0];
expectFsiPdi(tile.getAttribute('title'), EVIL_NAME);
expectFsiPdi(tile.getAttribute('aria-label'), EVIL_NAME);
const del = deleteButtons()[0];
expectFsiPdi(del.getAttribute('title'), EVIL_NAME);
expectFsiPdi(del.getAttribute('aria-label'), EVIL_NAME);
});
it('isolates the name in the delete confirmation (sheet / menu label and prompt)', async () => {
listMock.mockResolvedValue(response([att({ id: 'a1', filename: EVIL_NAME })]));
props.canDelete = true;
mountStrip('item-a');
await settle();
openConfirm();
expectFsiPdi(confirmPanel()?.getAttribute('aria-label'), EVIL_NAME);
expectFsiPdi(promptText(), EVIL_NAME);
});
it('isolates only the name in the delete-failure toast', async () => {
listMock.mockResolvedValue(response([att({ id: 'a1', filename: EVIL_NAME })]));
deleteMock.mockRejectedValue(new Error('boom'));
props.canDelete = true;
mountStrip('item-a');
await settle();
openConfirm();
clickConfirm();
await settle();
expect(toastMock).toHaveBeenCalledWith("Couldn't delete " + FSI + EVIL_NAME + PDI + '.', 'error');
});
});
});
@@ -18,6 +18,7 @@
isImage,
canOpenInViewer,
displayFilename,
displayFilenameIsolated,
} from '$lib/attachments/display';
import {
buildStorageFilters,
@@ -533,7 +534,7 @@
if (paint.isCurrent() && !painted.changed()) await reload();
return;
}
toastStore.show(`Deleted ${displayFilename(att.filename)}`, 'success');
toastStore.show(`Deleted ${displayFilenameIsolated(att.filename)}`, 'success');
await reload();
} catch (err) {
// A 404 is authoritative that the row is gone — the list was simply
@@ -550,7 +551,7 @@
if (paint.isCurrent() && !painted.changed()) await reload();
return;
}
toastStore.show(`${displayFilename(att.filename)} was already deleted`, 'info');
toastStore.show(`${displayFilenameIsolated(att.filename)} was already deleted`, 'info');
await reload();
return;
}
@@ -776,13 +777,13 @@
target={canOpenInViewer(att.mime_type) ? '_blank' : undefined}
rel={canOpenInViewer(att.mime_type) ? 'noopener' : undefined}
aria-label={canOpenInViewer(att.mime_type)
? `Open ${displayFilename(att.filename)}`
? `Open ${displayFilenameIsolated(att.filename)}`
: undefined}
>
{#if isImage(att.mime_type)}
<img
src={api.attachments.downloadUrl(wsSlug, att.id, 'thumb-sm')}
alt={displayFilename(att.filename)}
alt={displayFilenameIsolated(att.filename)}
loading="lazy"
/>
{:else}
@@ -794,8 +795,8 @@
<div class="att-meta">
<div class="att-line1">
<span class="att-filename" title={displayFilename(att.filename)}
>{displayFilename(att.filename)}</span
<span class="att-filename" title={displayFilenameIsolated(att.filename)}
><bdi>{displayFilename(att.filename)}</bdi></span
>
</div>
<div class="att-line2">
@@ -827,8 +828,8 @@
type="button"
class="btn btn-small btn-remove"
onclick={(e) => requestDelete(att, e.currentTarget)}
title="Delete {displayFilename(att.filename)}"
aria-label="Delete {displayFilename(att.filename)}"
title="Delete {displayFilenameIsolated(att.filename)}"
aria-label="Delete {displayFilenameIsolated(att.filename)}"
>
Delete
</button>
@@ -877,8 +878,8 @@
mode="portal"
width={272}
sheetOnMobile
sheetTitle="Delete {displayFilename(pendingDelete?.att.filename)}"
ariaLabel="Delete {displayFilename(pendingDelete?.att.filename)}"
sheetTitle="Delete {displayFilenameIsolated(pendingDelete?.att.filename)}"
ariaLabel="Delete {displayFilenameIsolated(pendingDelete?.att.filename)}"
focusKey={pendingDelete?.att.id}
>
<AttachmentDeleteConfirm
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import {
EVIL_NAME,
FSI,
PDI,
expectFsiPdi,
expectIsolated
} from '$lib/attachments/bidiIsolation.testutil';
import type {
AttachmentListItem,
AttachmentListResponse,
@@ -496,4 +503,43 @@ describe('StorageTab workspace switching', () => {
expect(text()).toContain('222 B');
expect(toastMock).not.toHaveBeenCalled();
});
// TASK-4195: a right-to-left override in a file name must not reorder the row.
it('isolates the file name in the row, its attributes and its toasts', async () => {
listMock.mockResolvedValueOnce(
response([
att({ id: 'a1', filename: EVIL_NAME }),
att({ id: 'a2', filename: EVIL_NAME, mime_type: 'image/png' }),
])
);
mountTab();
await settle();
expectIsolated(target.querySelector('.att-filename')!, EVIL_NAME);
expectFsiPdi(target.querySelector('.att-filename')!.getAttribute('title'), EVIL_NAME);
expectFsiPdi(target.querySelector('img')!.getAttribute('alt'), EVIL_NAME);
const remove = target.querySelector<HTMLElement>('.btn-remove')!;
expectFsiPdi(remove.getAttribute('aria-label'), EVIL_NAME);
deleteAndConfirm();
await settle();
expect(toastMock).toHaveBeenCalledWith('Deleted ' + FSI + EVIL_NAME + PDI, 'success');
});
it('isolates the name in the already-deleted toast and the confirmation', async () => {
listMock.mockResolvedValueOnce(response([att({ id: 'a1', filename: EVIL_NAME })]));
mountTab();
await settle();
deleteMock.mockRejectedValueOnce(new FakeApiError('not_found'));
listMock.mockResolvedValueOnce(response([]));
Array.from(target.querySelectorAll<HTMLButtonElement>('.btn-remove'))[0].click();
flushSync();
expectFsiPdi(confirmPanel()?.getAttribute('aria-label'), EVIL_NAME);
Array.from(document.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'))
.find((el) => el.querySelector('.mi-label')?.textContent?.trim() === 'Delete file')!
.click();
flushSync();
await settle();
expect(toastMock).toHaveBeenCalledWith(FSI + EVIL_NAME + PDI + ' was already deleted', 'info');
});
});
+3 -1
View File
@@ -235,6 +235,8 @@ export function renderAttachmentImage(
const src = urlBuilder
? urlBuilder(meta.id, variant)
: attachmentDownloadUrl(workspaceSlug, meta.id, variant);
// alt is DATA, never isolated: ItemTimeline seeds the viewer from getAttribute('alt')
// and the editor's image parseHTML reads it back into node.attrs.alt.
const altText = alt && alt.trim() !== '' ? alt : meta.filename;
const sizeAttrs =
meta.width && meta.height && meta.width > 0 && meta.height > 0
@@ -258,7 +260,7 @@ export function renderAttachmentChip(
const label = displayText && displayText.trim() !== '' ? displayText : meta.filename;
const size = formatAttachmentSize(meta.size_bytes);
const sizeSpan = size ? ` <span class="file-chip-size">· ${escapeHtml(size)}</span>` : '';
return `<a class="file-chip" href="${escapeHtml(href)}" data-attachment-id="${escapeHtml(meta.id)}" download="${escapeHtml(meta.filename)}" target="_blank" rel="noopener noreferrer"><span class="file-chip-icon" aria-hidden="true">📄</span><span class="file-chip-name">${escapeHtml(label)}</span>${sizeSpan}</a>`;
return `<a class="file-chip" href="${escapeHtml(href)}" data-attachment-id="${escapeHtml(meta.id)}" download="${escapeHtml(meta.filename)}" target="_blank" rel="noopener noreferrer"><span class="file-chip-icon" aria-hidden="true">📄</span><span class="file-chip-name"><bdi>${escapeHtml(label)}</bdi></span>${sizeSpan}</a>`;
}
/**
@@ -0,0 +1,49 @@
// TASK-4195: the Go renderer (internal/server/render/attachments.go) and this
// one must emit byte-identical file chips, images and missing placeholders.
// Both assert the same fixture, so a change on either side fails its own suite.
//
// Scope of the URL half (TASK-4195 r3-M1): TS builds download URLs with
// encodeURIComponent and Go with url.PathEscape, which differ on !'()* and
// $&+=:@. The fixture pins parity only for UUID attachment ids and workspace
// slugs minted since BUG-3769 ([a-z0-9-], where both encoders are the
// identity). A preserved legacy slug may hold those characters and encode
// differently on each side, but both forms decode to the same route.
import { describe, expect, it } from 'vitest';
import fixture from './testdata/file-chip-parity.json';
import { renderAttachmentChip, renderAttachmentImage, renderAttachmentMissing } from './attachments';
describe('attachment renderers: byte parity with the Go renderer', () => {
it('every case array is non-empty', () => {
expect(fixture.chips.length).toBeGreaterThan(0);
expect(fixture.images.length).toBeGreaterThan(0);
expect(fixture.missing.length).toBeGreaterThan(0);
});
describe('file chip', () => {
it.each(fixture.chips)('$name', (c) => {
const html = renderAttachmentChip(
{ ...c.meta, width: null, height: null },
c.display_text || undefined,
c.workspace
);
expect(html).toBe(c.html);
});
});
describe('image', () => {
it.each(fixture.images)('$name', (c) => {
const html = renderAttachmentImage(
{ ...c.meta, width: c.meta.width ?? null, height: c.meta.height ?? null },
c.alt,
c.workspace
);
expect(html).toBe(c.html);
});
});
describe('missing placeholder', () => {
it.each(fixture.missing)('$name', (c) => {
expect(renderAttachmentMissing(c.uuid, c.alt)).toBe(c.html);
});
});
});
+31
View File
@@ -0,0 +1,31 @@
{
"_comment": "Shared by internal/server/render (Go) and web/src/lib/markdown (vitest): both renderers must emit exactly `html` for each case, so drift on either side fails a test. Bidi controls and non-ASCII are JSON escapes only.",
"chips": [
{"name": "plain filename with size", "workspace": "acme", "meta": {"id": "abc-123", "mime_type": "application/pdf", "filename": "report.pdf", "size_bytes": 5242880}, "display_text": "", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/acme/attachments/abc-123\" data-attachment-id=\"abc-123\" download=\"report.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>report.pdf</bdi></span> <span class=\"file-chip-size\">\u00b7 5.0 MB</span></a>"},
{"name": "filename carrying a right-to-left override, and markup in a size-free label", "workspace": "ws", "meta": {"id": "id-1", "mime_type": "application/pdf", "filename": "evil\u202egnp.exe", "size_bytes": -1}, "display_text": "a<b>&c", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-1\" data-attachment-id=\"id-1\" download=\"evil\u202egnp.exe\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>a&lt;b&gt;&amp;c</bdi></span></a>"},
{"name": "filename label with override", "workspace": "ws", "meta": {"id": "id-2", "mime_type": "application/pdf", "filename": "evil\u202egnp.exe", "size_bytes": 10}, "display_text": "", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-2\" data-attachment-id=\"id-2\" download=\"evil\u202egnp.exe\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>evil\u202egnp.exe</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "double quote in filename and label", "workspace": "ws", "meta": {"id": "id-3", "mime_type": "application/pdf", "filename": "say \"hi\".pdf", "size_bytes": 10}, "display_text": "the \"label\"", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-3\" data-attachment-id=\"id-3\" download=\"say &quot;hi&quot;.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>the &quot;label&quot;</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "apostrophe in filename and label", "workspace": "ws", "meta": {"id": "id-4", "mime_type": "application/pdf", "filename": "it's.pdf", "size_bytes": 10}, "display_text": "Bob's file", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-4\" data-attachment-id=\"id-4\" download=\"it&#39;s.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>Bob&#39;s file</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "ampersand in filename and label", "workspace": "ws", "meta": {"id": "id-5", "mime_type": "application/pdf", "filename": "a&b.pdf", "size_bytes": 10}, "display_text": "R&D", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-5\" data-attachment-id=\"id-5\" download=\"a&amp;b.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>R&amp;D</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "angle brackets in filename and label", "workspace": "ws", "meta": {"id": "id-6", "mime_type": "application/pdf", "filename": "<x>.pdf", "size_bytes": 10}, "display_text": "<b>bold</b>", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-6\" data-attachment-id=\"id-6\" download=\"&lt;x&gt;.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>&lt;b&gt;bold&lt;/b&gt;</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "non-ASCII filename and label", "workspace": "ws", "meta": {"id": "id-7", "mime_type": "application/pdf", "filename": "r\u00e9sum\u00e9.pdf", "size_bytes": 10}, "display_text": "r\u00e9sum\u00e9 \u65e5\u672c", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-7\" data-attachment-id=\"id-7\" download=\"r\u00e9sum\u00e9.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>r\u00e9sum\u00e9 \u65e5\u672c</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "bidi isolate and embedding controls in filename and label", "workspace": "ws", "meta": {"id": "id-8", "mime_type": "application/pdf", "filename": "a\u2066b\u2069\u202bc.pdf", "size_bytes": 10}, "display_text": "x\u202dy\u200fz", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-8\" data-attachment-id=\"id-8\" download=\"a\u2066b\u2069\u202bc.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>x\u202dy\u200fz</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"},
{"name": "every escaped character together", "workspace": "ws", "meta": {"id": "id-10", "mime_type": "application/pdf", "filename": "&<>\"'.pdf", "size_bytes": 10}, "display_text": "&<>\"'", "html": "<a class=\"file-chip\" href=\"/api/v1/workspaces/ws/attachments/id-10\" data-attachment-id=\"id-10\" download=\"&amp;&lt;&gt;&quot;&#39;.pdf\" target=\"_blank\" rel=\"noopener noreferrer\"><span class=\"file-chip-icon\" aria-hidden=\"true\">\ud83d\udcc4</span><span class=\"file-chip-name\"><bdi>&amp;&lt;&gt;&quot;&#39;</bdi></span> <span class=\"file-chip-size\">\u00b7 10 B</span></a>"}
],
"images": [
{"name": "image with size and dimensions", "workspace": "acme", "meta": {"id": "img-1", "mime_type": "image/png", "filename": "photo.png", "size_bytes": 100, "width": 640, "height": 480}, "alt": "a photo", "html": "<img src=\"/api/v1/workspaces/acme/attachments/img-1?variant=thumb-md\" data-attachment-id=\"img-1\" alt=\"a photo\" width=\"640\" height=\"480\">"},
{"name": "alt with a double quote", "workspace": "ws", "meta": {"id": "img-2", "mime_type": "image/png", "filename": "p.png", "size_bytes": 100}, "alt": "the \"best\" shot", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-2?variant=thumb-md\" data-attachment-id=\"img-2\" alt=\"the &quot;best&quot; shot\">"},
{"name": "empty alt falls back to the filename", "workspace": "ws", "meta": {"id": "img-3", "mime_type": "image/png", "filename": "fallback.png", "size_bytes": 100}, "alt": "", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-3?variant=thumb-md\" data-attachment-id=\"img-3\" alt=\"fallback.png\">"},
{"name": "whitespace alt falls back to a filename with quote, apostrophe and ampersand", "workspace": "ws", "meta": {"id": "img-4", "mime_type": "image/png", "filename": "a\"b'c&d<e>.png", "size_bytes": 100}, "alt": " ", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-4?variant=thumb-md\" data-attachment-id=\"img-4\" alt=\"a&quot;b&#39;c&amp;d&lt;e&gt;.png\">"},
{"name": "bidi control in filename fallback and alt", "workspace": "ws", "meta": {"id": "img-5", "mime_type": "image/png", "filename": "evil\u202egnp.png", "size_bytes": 100}, "alt": "", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-5?variant=thumb-md\" data-attachment-id=\"img-5\" alt=\"evil\u202egnp.png\">"},
{"name": "bidi control in an authored alt", "workspace": "ws", "meta": {"id": "img-6", "mime_type": "image/png", "filename": "p.png", "size_bytes": 100}, "alt": "x\u202ey", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-6?variant=thumb-md\" data-attachment-id=\"img-6\" alt=\"x\u202ey\">"},
{"name": "non-ASCII alt", "workspace": "ws", "meta": {"id": "img-7", "mime_type": "image/png", "filename": "p.png", "size_bytes": 100}, "alt": "r\u00e9sum\u00e9", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-7?variant=thumb-md\" data-attachment-id=\"img-7\" alt=\"r\u00e9sum\u00e9\">"},
{"name": "only one dimension is known", "workspace": "ws", "meta": {"id": "img-8", "mime_type": "image/png", "filename": "p.png", "size_bytes": 100, "width": 10}, "alt": "half", "html": "<img src=\"/api/v1/workspaces/ws/attachments/img-8?variant=thumb-md\" data-attachment-id=\"img-8\" alt=\"half\">"}
],
"missing": [
{"name": "missing with alt", "uuid": "u-1", "alt": "gone", "html": "<span class=\"attachment-missing\" data-attachment-id=\"u-1\" title=\"This attachment is missing or has been deleted\">\ud83d\udcce gone</span>"},
{"name": "missing with empty alt", "uuid": "u-2", "alt": "", "html": "<span class=\"attachment-missing\" data-attachment-id=\"u-2\" title=\"This attachment is missing or has been deleted\">\ud83d\udcce Missing attachment</span>"},
{"name": "missing with every escaped character in uuid and alt", "uuid": "u\"'&<>", "alt": "a\"b'c&d<e>", "html": "<span class=\"attachment-missing\" data-attachment-id=\"u&quot;&#39;&amp;&lt;&gt;\" title=\"This attachment is missing or has been deleted\">\ud83d\udcce a&quot;b&#39;c&amp;d&lt;e&gt;</span>"},
{"name": "missing with a bidi control and non-ASCII alt", "uuid": "u-4", "alt": "\u202e r\u00e9sum\u00e9", "html": "<span class=\"attachment-missing\" data-attachment-id=\"u-4\" title=\"This attachment is missing or has been deleted\">\ud83d\udcce \u202e r\u00e9sum\u00e9</span>"}
]
}
+3 -1
View File
@@ -180,7 +180,9 @@ const MARKDOWN_ALLOWED_TAGS = [
'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'div', 'em',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd',
'li', 'ol', 'p', 'pre', 's', 'span', 'strong', 'sub', 'sup',
'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', 'input'
'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', 'input',
// File-name isolation (TASK-4195): attributes are still stripped, so it only sets direction.
'bdi'
] as const;
// Attributes we emit from markdown + wiki-links. DOMPurify already