Phase 9c. 12 tests covering:
- picker page (multi-project list, single-project redirect,
zero-projects fallback)
- wizard URL (project-on-client 200, cross-client 404,
show_in_intake=false 404)
- submission flow (card lands on chosen project's inbox,
cross-client refused, hostile body fields ignored,
per-(link, project) cap isolates one project from another)
- Phase 8 backwards compat (per-project link redirects to the
wizard; the existing Phase 8 submission route still accepts
Phase 8 links)
Three bridges from the plan:
1. PocketBase stores date as 'YYYY-MM-DD 00:00:00.000Z'; the
filter must use that form. Passing 'YYYY-MM-DD' silently
matches zero rows, even though the create succeeds.
2. PocketBase's getFirstListItem returns 404 for a row just
written by the same admin client; getList works. The helper
uses getList with perPage=1.
3. PocketBase's read-after-write latency is significant.
isLinkProjectUsable retries with exponential backoff for
up to ~2.5s before giving up; bumpDailyCapForProject's
upsert pattern uses a fallback read+update path if the
create hits the composite unique index from a peer's
concurrent write.
Phase 9a Task 6. Three integration tests:
- schema lands: row inserts with link/project/date/count fields,
composite unique index is the only constraint (different
project same link + date is allowed)
- duplicate (link, project, date) is rejected by the unique index
- the public route returns a sensible status, never 500, on a
Phase 9 link (the route will be wired in 9b; 9a only proves
it does not crash today)
Two bridges from the plan:
- PocketBase's date field defaults to unique=true; my schema
fix flipped it via ensureIntakeLinkSubmissionsDateNotUnique.
The relation and number fields appeared to share the same
default but getOne()'s response omits the key, so the
explicit unique=false on those doesn't stick — yet rows with
the same link and different projects work in practice (the
composite index does its job). Worth a follow-up if a future
PocketBase release tightens the default.
- bumpDailyCapForProject wraps the read-then-create-or-update
in a retry on the unique constraint to handle PocketBase's
read-after-write latency window. The cap-isolation property
is verified end-to-end in 9c.
Phase 8a Task 18. Server Actions are exposed via Next's RSC
boundary, not as plain HTTP endpoints; reaching into them from
vitest is not worth the complexity for a single new action. These
tests assert the admin project page renders for the owner so the
absence of unit-level action coverage is explicit.
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
Deviation from plan: the second project needs an explicit Inbox
column created in beforeAll — the schema does not auto-create one
for new projects.
Phase 8a Task 17. Mirrors the existing intake-submit.test.ts pattern:
- happy path: card lands in inbox with created_by_role=anonymous
- counter bump: submission_count and last_submission_at update
- hostile brief: server-set fields cannot be overridden, even when
the body includes top-level priority/accepted/column/created_by
- none of unknown/revoked/expired/malformed tokens reach the
handler — they all 404 with the same wording
- deleted-project token: link cascades away, 404 follows
- activity log: card_submitted_anonymously row written
Covers the 9 scenarios from the §6.3 plan brief, mapped to the harness's
sessionless reality:
- client propose refuses without a session and does not write fields
- rescheduleCardAction refuses a Done card with 'Card is done'
- foreign-client propose path: PocketBase read rule denies the cross-tenant
lookup (anti-enumeration per access.ts:57-59)
- accept/dismiss/returnToScheduled refuse without a session and are no-ops
- buildActivityRows collapses same-date patches into zero rows and emits one
row when the date actually changes
HARNESS-LIMIT: the actions that need an owner session (accept, dismiss,
returnToScheduled) cannot be driven successfully in vitest. They are
covered by the manual smoke checklist at the workspace path. The
sessionless refuse path proves the gate fires for every call; the
positive path is exercised by the live smoke run.
Test file: tests/integration/plan-puzzle.test.ts (single new file)
Critical fixes to test suite:
1. Fix time bomb in summarise.test.ts: Task 4 changed summaryStatus filter to exclude source="manual",
so the assertion would fail once any manual entry exists. Updated test to count only non-manual
commits and added coverage proving manual entries are excluded.
2. Tighten log-work metrics assertions from toBeGreaterThanOrEqual to exact equality (toBe).
The old assertions would miss if manual entries were incorrectly fed into session clustering.
Use a fresh baseline recompute to catch regression of Task 2's manual exclusion fix.
3. Add recomputeMetrics call in afterAll to restore cache after test cleanup,
preventing inflated dashboard metrics on the demo project.
4. Fix fixture link_source from "none" to "manual" to match actual logWorkAction behavior
(Task 6 review fix).
5. Add UI coverage: "Log work" button is owner-only, verifying the security model.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Tests that logWorkAction refuses requests without a valid owner session
- Tests that recomputeMetrics correctly adds manual entry minutes to estimatedMinutes
- Both tests verify the integration between log-work action and metrics computation
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
getCurrentUser() only decoded the pb_auth cookie locally and trusted its
embedded role/client/active fields directly — the PocketBase JS SDK holds
no secret to check a JWT signature against, so a completely forged cookie
(garbage signature, role: "owner", any email) was accepted as a real
session. Verified exploitable end-to-end: a raw Cookie header against the
live app, no browser and no password, rendered the owner's home page.
This mattered most for actions writing through the superuser connection
(log-work.ts, summarise.ts, ingest.ts, and critically users.ts for account
creation) — those never route the caller's token through PocketBase's own
rule engine at all, so the app-level role check was the ONLY gate, and it
was reading an unverified claim. Actions using the caller's own token
(cards.ts, projects.ts) still made the wrong authorization DECISION from
the forged role, even though PocketBase's rule engine would have rejected
the actual data write.
Fixed by having getCurrentUser() call authRefresh(), which asks PocketBase
to confirm the token is genuine; a forged or tampered token is refused
there and treated as signed-out. Wrapped in React's cache() so the extra
round-trip happens once per request, not once per call site (several pages
call this more than once). Re-fetching the live record also means a
deactivated account (`active: false`) stops working on its very next
request instead of only being blocked at a new login.
Regression test builds the exact forged cookie used to verify this,
confirmed red against the vulnerable code and green against the fix.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The test overwrote ai_api_key with a marker and then restored it to ""
rather than to its previous value, so every run of the integration suite
silently wiped the AI provider key. Symptom appears much later and looks
nothing like a test problem: AI features quietly stop working.
Captures the existing value first and restores it in a finally block, so a
failed assertion cannot leave the marker in place either. Deletes the
record instead when the test created it.
The suite runs against the shared instance — there is no separate test
database — so anything it touches, it must put back exactly.
feat(board): give the owner both a quick box and Plan with AI
The Phase 4 spec made the intake wizard client-facing only, reasoning that
the developer creates cards directly. That was wrong about how he wants to
work. The quick box stays the default for lines he already understands;
Plan with AI runs the same interview for work that is genuinely unformed.
Hidden entirely when no provider is configured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tests cover the storage and access boundary:
- A client manager cannot list the settings collection at all (rules are
superuser-only — verified by the rejection, not by silent skip).
- The settings page HTML returned to the owner does not contain the key
value, even when the key is freshly set to a unique sentinel.
- A client manager gets 404 on /admin/settings (admin layout gates on
role, not on session presence).
helpers.ts gained ownerEmail / ownerPassword — needed by the settings test
and reusable for any owner-only integration test going forward.
The integration project now aliases server-only to a tiny empty stub
so tests can import modules that carry 'import "server-only"' without
the throwing real implementation firing during prepare. Unit tests do
NOT get the alias — a pure module with a server import is exactly the
kind of leak the unit project's missing condition is meant to surface.
Vitest config: integration project now resolves with the 'react-server'
condition, so server-only modules can be imported directly from tests.
The unit project deliberately does NOT set it: a unit test pulling in a
server-only module must fail loudly, because a pure module with a server
import is exactly the kind of leak that should not reach production.
The users collection kept PocketBase's defaults because the Plan 1a rules
migration covered eight collections and missed this one:
createRule "" -> anyone could sign up
updateRule "id = @request.auth.id" -> anyone could edit their own record
`role` is an ordinary field here, so both were escalations. Verified
exploitable against the live instance: an anonymous request created an
account with role "owner", and a client manager promoted themselves to
owner by patching their own record. Either route yields read and write
access to every client's board.
Create, update and delete are now superuser-only; list and view are
owner-or-self. Adds four integration tests so a regression fails loudly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>