PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 32s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m9s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m15s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m26s
Review findings on #140. Drafts were reachable. Posts granted unconditional public read and the _status filter lived only in the pages that query the collection — which is a convenience, not a control. Payload's documentation is explicit: "The `draft` argument alone does not restrict documents with _status: 'draft' from being returned by the API." A direct GET /cms-api/posts would have handed every unpublished draft to any visitor. Read access now returns a query constraint for anonymous callers, which is the documented mechanism. The --drop claim was asserted across four files while the spec still listed it as an open question. Now verified rather than assumed: run_full_migration drops exactly ["school_results", "schools"] by name, there is no drop_all() or DROP SCHEMA anywhere in backend/, the only other drop is schema-qualified to marts, and nothing sets search_path. The guarantee is stronger than schema isolation alone — those two table names do not exist in Payload — so the claim stands, but it now rests on cited code. The spec records the evidence and closes the open item. findPost is wrapped in React's cache(): Next calls generateMetadata and the page separately for one request, so every post view ran the same query against Postgres twice. The bare .lede rule was dead — .prose p scores (0,1,1) and outranks it — so only .prose .lede ever applied. Removed, with the specificity noted so the surviving selector is not "simplified" back into a silent regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
71 lines
2.8 KiB
TypeScript
71 lines
2.8 KiB
TypeScript
/**
|
|
* Payload is ESM-only and next/jest will not transform it, so the collections
|
|
* cannot be imported and their sanitised config inspected here (see
|
|
* lib/payloadRoutes.ts for the full reasoning). These assert the source of the
|
|
* collection definitions instead — enough to catch the settings whose loss is
|
|
* silent, and cheap. Behaviour is proved by the e2e journeys against staging.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const read = (file: string) =>
|
|
fs.readFileSync(path.join(__dirname, '..', '..', 'collections', file), 'utf8');
|
|
|
|
const POSTS = read('Posts.ts');
|
|
const MEDIA = read('Media.ts');
|
|
const CONFIG = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'payload.config.ts'),
|
|
'utf8',
|
|
);
|
|
|
|
describe('posts collection', () => {
|
|
it('supports drafts, so saving is not publishing', () => {
|
|
expect(POSTS).toMatch(/drafts:\s*true/);
|
|
});
|
|
|
|
it('has a unique, indexed slug for stable URLs', () => {
|
|
const slugField = POSTS.slice(POSTS.indexOf("name: 'slug'"));
|
|
expect(slugField).toMatch(/unique:\s*true/);
|
|
expect(slugField).toMatch(/index:\s*true/);
|
|
});
|
|
|
|
it('hides drafts from anonymous readers at the access layer', () => {
|
|
// Payload's docs are explicit: "The `draft` argument alone does not
|
|
// restrict documents with _status: 'draft' from being returned by the
|
|
// API." The blog pages' where-clause is not enforcement — a direct GET
|
|
// /cms-api/posts would return unpublished drafts to anyone. Access
|
|
// control returning a query constraint is the only thing that stops it.
|
|
expect(POSTS).toMatch(/_status:\s*\{\s*equals:\s*'published'\s*\}/);
|
|
expect(POSTS).toMatch(/if\s*\(req\.user\)\s*return true/);
|
|
});
|
|
|
|
it('revalidates the post page when a post changes or is deleted', () => {
|
|
// /blog/[slug] is ISR — generated on first request and cached — so an edit
|
|
// to an already-published post would otherwise not appear until the
|
|
// revalidate window expired, up to an hour of a writer concluding that
|
|
// saving is broken. The index and feeds are force-dynamic and need no hook.
|
|
expect(POSTS).toContain('afterChange');
|
|
expect(POSTS).toContain('afterDelete');
|
|
expect(POSTS).toMatch(/revalidatePath\(`\/blog\/\$\{[^}]+\}`\)/);
|
|
});
|
|
});
|
|
|
|
describe('media collection', () => {
|
|
it('writes uploads to the mounted volume, by absolute path', () => {
|
|
// Must match the payload_media mount in docker-compose.portainer.yml.
|
|
// Payload 3 requires staticDir to be absolute.
|
|
expect(MEDIA).toMatch(/staticDir:\s*'\/app\/media'/);
|
|
});
|
|
|
|
it('requires alt text on every upload', () => {
|
|
const altField = MEDIA.slice(MEDIA.indexOf("name: 'alt'"));
|
|
expect(altField).toMatch(/required:\s*true/);
|
|
});
|
|
});
|
|
|
|
describe('payload config', () => {
|
|
it('registers every collection', () => {
|
|
expect(CONFIG).toMatch(/collections:\s*\[Users,\s*Posts,\s*Media\]/);
|
|
});
|
|
});
|