From 9392e163a5b53f9929a37bc859851b308f688e97 Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:03:00 -0700 Subject: [PATCH] Read a snapshot's content only when it is asked for A stored snapshot can be 15 MB, and Payload read all of it for every list, every relationship it filled in and every admin page. A snapshot's edit page carried the whole thing in a JSON editor under the panel. A read, create, update or delete that names no select now answers with every field except the content. A select that names it gets it, and a caller's own select is left as asked. The content field is on the new-snapshot form only. The lock on sealed content doesn't depend on this: an update is still compared with the stored document, read whole. Also fixes the admin panels showing the old status after Payload's own Save. They read from the site again when Payload reports the document saved. The plugin has no release, so nothing in use changes. Tested: 631 unit tests (247 in the plugin, 10 new), 58 smoke checks on the built example site, and five deliberate breaks, each caught. Run in headless Chrome from a scratch script, outside CI. Not explained: in that run Payload's read-only Counts group once showed no fields under its heading after a snapshot was made through the form; four shorter runs drew them. Not tested: Postgres, SQLite, a browser with a window. Co-Authored-By: Claude --- CHANGELOG.md | 1 + apps/payload-example/README.md | 6 +- apps/payload-example/scripts/smoke.mjs | 8 ++ docs/architecture/adr/0012-payload-plugin.md | 16 +++- docs/test-audit.md | 29 +++++++ packages/payload/README.md | 28 +++++-- packages/payload/src/client/wired.tsx | 33 ++++---- packages/payload/src/collections.ts | 29 ++++++- packages/payload/test/helpers.ts | 3 + packages/payload/test/http.test.ts | 19 +++++ packages/payload/test/imports.test.ts | 4 +- packages/payload/test/panels-wired.test.tsx | 32 +++++++- packages/payload/test/proposals-plain.test.ts | 4 +- packages/payload/test/proposals.test.ts | 11 +-- packages/payload/test/snapshots.test.ts | 82 ++++++++++++++++++- packages/payload/test/summary.test.ts | 4 +- packages/payload/test/view-reads.test.ts | 18 ++++ 17 files changed, 282 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e1054..10fd36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Payload plugin: a snapshot's content is read only when it is asked for by name (changes the unreleased plugin). A stored snapshot can be 15 MB, and Payload read all of it for every list, every relationship it filled in and every admin page. Now a read, create, update or delete that names no `select` answers with every field except `snapshot`; `select: { snapshot: true }`, REST `?select[snapshot]=true` or GraphQL's `select: true` gives the content, and the export endpoint is as before. A caller's own `select` is left exactly as asked. In the admin the content field is on the new-snapshot form only: a stored snapshot's page is sent without the content and without the JSON editor, and Payload's own Save there sends the status alone. Hosts' code that read `doc.snapshot` from a plain read, or from the document an after-read or after-change hook is given, has to select it. The lock on sealed content doesn't depend on this. Fixed on the way: the admin panels showed the old status after Payload's own Save; they now read from the site again whenever Payload reports the document saved. Tested: 10 new tests (8 on a real Payload with an in-memory MongoDB, one of them over HTTP, and 2 in jsdom), 3 new checks over HTTP in the example site (58 in all), and five deliberate breaks, each caught. Also run in headless Chrome on the example site, outside CI: a stored snapshot's page has no content and no editor, a sealed snapshot pasted into the new-snapshot form is stored with the same checksum, and Payload's Save sends one small request. Not explained: in that run, after a snapshot was made through the form and its page loaded again, Payload's read-only Counts group showed no fields under its heading; four shorter runs of the same steps drew them. Not tested: Postgres, SQLite, a browser with a window. See [ADR 0012](docs/architecture/adr/0012-payload-plugin.md), point 17. - Payload plugin, part 5 of M7: admin panels (still not on npm). Each of the plugin's four collections gets a panel at the top of its edit page, on unless `admin: { panels: false }`. The snapshot panel says the status in words, what the snapshot holds (counts, a line per classification and phylogeny, sources with their licenses) and, for one made by accepting a proposal, which proposal and the two sealed person ids; a manager gets Publish and Withdraw. The view panel draws the saved view (the first 200 nodes) with the same tree as an outline for the keyboard and the lines readers are told, and holds Publish, with the reason, until the form is saved and the snapshot is published. The import panel shows the steps of a run, its warnings and diagnostics, looks again every 3 seconds while a job runs, and offers to run a queued, failed or interrupted job. The proposal panel says what a proposal would change, and a manager accepts it (naming the new version) or declines it. Every button asks first in the page and says what can't be undone; accepting lists what gets sealed for good and asks the manager to check it for personal details. The panels have no powers of their own: they call the host's site from the browser as the signed-in person, so the host's `access` callbacks decide, and a refusal reads "Nothing was changed." with the site's reason. New: the `@captiveorigins/payload-plugin-onetaxonomy/client` entry (found through Payload's import map, so run `payload generate:importmap` after adding the plugin), the `admin.panels` option, and one endpoint, `GET /api//:id/summary` (and `summarizeSnapshot`), which answers counts and capped lists, never names or trees, with the same 404 as the other endpoints. For a snapshot made from a proposal it gives the name the host shows for each sealed id to signed-in requesters only, looked up per request and never stored. `@payloadcms/ui` and `react` are new optional peer dependencies, and the plugin now depends on `@captiveorigins/onetaxonomy-react` for the drawing. Colours come from Payload's theme variables; a test reads the installed `@payloadcms/ui` stylesheets and checks text at 4.5:1 and control edges at 3:1 in light and dark, and a status is never colour alone. The example site shows the panels to its curator and turns Payload's Gravatar avatar off. Tested: 94 new tests (74 render the panels in jsdom with Payload's hooks and `fetch` replaced by stand-ins, 11 on the stylesheet, 9 on the summary against a real Payload with an in-memory MongoDB), and 8 new checks over HTTP in the example site (55 in all). The snapshot, view and proposal panels were also clicked through once in headless Chrome on the example site, in light and dark; that run is not in CI. Not tested: the import panel in a real admin, a screen reader, Safari, Firefox, a phone, or a custom Payload theme. Found on the way and written in the README: Payload's own admin loads its JSON editor from cdn.jsdelivr.net and, unless `admin.avatar` is `'default'`, asks gravatar.com with a hash of the signed-in person's email address. See [ADR 0012](docs/architecture/adr/0012-payload-plugin.md), point 16. - Benchmarks: frame times in a browser. `apps/playground/bench.html` (built with the playground) pans, zooms and scrolls made-up trees of 125 to 10,000 tips as SVG and on the canvas and times every frame; open it in any browser, press Run and save the numbers. `node scripts/bench-browser.mjs` runs it in the Chrome already installed, headless, at pixel density 1 and 2 and with the main thread slowed 4 times, and writes `docs/benchmarks-browser.md`; `--from ` adds a section from numbers saved in another browser. It uses a throwaway profile that it deletes, listens on this machine only and sends nothing anywhere. First numbers (headless Chrome 157, M2 Max, other work running): the canvas held 60 frames a second at every size up to 19,999 nodes, slowed thread included; SVG held it through 1,999 nodes, and through 499 on the slowed thread. A second section is from a headless build of WebKit 27.2 (Playwright's: the engine Safari and the Mac app use, but neither program), driven by hand as the doc describes, at normal speed only: the canvas held 60 frames a second at every size, and SVG held it through 1,999 nodes. When a browser's clock ticks in whole milliseconds, as WebKit's does, the script says so in the section and prints no decimal places. The 1,000-node switch is unchanged. Not measured: Safari itself, the Mac app's web view, Firefox, a window on a real screen, a real slow device, radial layouts. The benchmark page and script have no tests of their own. - Payload plugin docs: moving a departed person's proposals to another account. The README's "When someone leaves" now says that `author` and `decidedBy` can't be changed through Payload, that the lock is a hook and so doesn't cover a host's own database code, and that a host making that move there must change only those two fields and leave `proposal`, `checksum` and the snapshot alone. A new test makes the move on MongoDB and checks that the proposal still matches its checksum, the version made from it still verifies, and the proposal stays locked. No code changed; an option to allow the move through Payload was considered and not built (ADR 0012). diff --git a/apps/payload-example/README.md b/apps/payload-example/README.md index 7e20519..ce2bcfa 100644 --- a/apps/payload-example/README.md +++ b/apps/payload-example/README.md @@ -2,7 +2,7 @@ A small Payload and Next.js site that uses [`@captiveorigins/payload-plugin-onetaxonomy`](../../packages/payload). It shows the plugin set up the way a real site would: accounts with two roles, public pages that draw published views, and proposals that record a person by an id, with a public handle shown beside it and no email address. -> **Status: an example, not a product.** It runs on this machine only, on a throwaway in-memory MongoDB, with made-up accounts and the repository's illustrative fixtures. Tested: 55 checks over HTTP with sign-in cookies (`pnpm smoke`), on `next dev` and on the built site, with Payload 3.93 and Next.js 16; the three public pages were looked at in headless Chrome, in light and dark, at desktop width and at 520 px; and the admin's panels were clicked through once in headless Chrome as the curator, in light and dark (publishing a snapshot with the keyboard, a view, and accepting a proposal). That last run is not in CI. Not tested: a screen reader, Safari, Firefox, a phone, Postgres or SQLite, or a deployed site. +> **Status: an example, not a product.** It runs on this machine only, on a throwaway in-memory MongoDB, with made-up accounts and the repository's illustrative fixtures. Tested: 58 checks over HTTP with sign-in cookies (`pnpm smoke`), on `next dev` and on the built site, with Payload 3.93 and Next.js 16; the three public pages were looked at in headless Chrome, in light and dark, at desktop width and at 520 px; and the admin's panels were clicked through in headless Chrome as the curator, in light and dark (publishing a snapshot with the keyboard, a view, accepting a proposal, and storing a snapshot pasted into the new-snapshot form). Those runs are not in CI. Not tested: a screen reader, Safari, Firefox, a phone, Postgres or SQLite, or a deployed site. ## What it shows @@ -50,6 +50,7 @@ pnpm --filter @captiveorigins/onetaxonomy-payload-example smoke This builds the site, starts it on a free port with its own database, and sends it requests the way a browser would. It takes about 15 seconds on a recent Mac. It checks that: - a visitor reads the published snapshot and views, through the pages, REST and GraphQL, and gets "not found" for anything staged; +- a visitor's plain read and list of snapshots hold no content, and a read that selects the content gets it; - a visitor can't store a snapshot, make a proposal, or list accounts or proposals; - a sign-in cookie can't be read by scripts, and counts for nothing from another site; - a member can't make themself a curator, sees only their own account, and is sent away from the admin; @@ -58,6 +59,7 @@ This builds the site, starts it on a free port with its own database, and sends - a changed handle shows on the accepted proposal at once, and the sealed version doesn't change; - an accepted proposal can't be deleted; - the curator's edit pages in the admin carry the plugin's panels; +- a stored snapshot's admin page is sent without its content and without the content field, and the new-snapshot form still has that field; - the new version's summary shows the curator both people by id with their handles, is "not found" for a visitor and the member while it is staged, and once published gives a visitor the ids and no names; - a member can't publish it, and the curator's publish answers without the snapshot's content. @@ -75,7 +77,7 @@ Set `DATABASE_URI` to a MongoDB address and the site uses it instead of the in-m - **No page for making a proposal.** A member makes one through the API. The curator decides on it in the admin, on the proposal's panel. - **No imports.** The `imports` option is off, so there are no import jobs and the import panel doesn't appear. It is tested in the plugin's own package. -- **Two requests Payload's admin makes to other sites.** The admin's JSON fields load their code editor (Monaco) from cdn.jsdelivr.net. That is Payload's, and this example leaves it. Payload's default avatar would also ask gravatar.com with a hash of the signed-in address; this example turns that off (`admin.avatar: 'default'`). +- **Two requests Payload's admin makes to other sites.** The admin's JSON fields load their code editor (Monaco) from cdn.jsdelivr.net, on the new-snapshot form and on a view's and a proposal's page. That is Payload's, and this example leaves it. Payload's default avatar would also ask gravatar.com with a hash of the signed-in address; this example turns that off (`admin.avatar: 'default'`). - **No tenants.** The plugin's tenant scope is tested in its own package, not shown here. - **No sign-up.** A curator adds accounts in the admin. - **Nothing for production.** No email, no rate limits, no backups, no deployment setup. diff --git a/apps/payload-example/scripts/smoke.mjs b/apps/payload-example/scripts/smoke.mjs index b7a1d72..e1c4e57 100644 --- a/apps/payload-example/scripts/smoke.mjs +++ b/apps/payload-example/scripts/smoke.mjs @@ -113,6 +113,10 @@ async function checks() { const published = publicSnaps.json?.docs ?? [] check('a visitor sees the published snapshot and not the staged one', publicSnaps.status === 200 && published.length === 1 && published[0].snapshotId === 'fixture-amniotes' && published[0].status === 'published', published.map((d) => [d.snapshotId, d.status])) const snap = published[0]?.id + // A snapshot's content comes only when it is asked for by name; the list and a plain read leave it out. + const plain = await get(`/api/taxonomy-snapshots/${snap}?depth=0`) + const named = await get(`/api/taxonomy-snapshots/${snap}?depth=0&select[snapshot]=true`) + check('a list and a plain read of a snapshot leave its content out, and a read that selects it gets it', plain.status === 200 && plain.json?.checksum === published[0]?.checksum && !('snapshot' in plain.json) && !publicSnaps.text.includes('schemaVersion') && !plain.text.includes('schemaVersion') && named.json?.snapshot?.checksum === published[0]?.checksum && named.json?.snapshot?.concepts?.length > 0, { plain: plain.text.length, list: publicSnaps.text.length, named: named.text.length }) const search = await get(`/api/taxonomy-snapshots/${snap}/search?q=aves`) check('a visitor can search the published snapshot', search.status === 200 && JSON.stringify(search.json).includes('Aves'), { status: search.status }) const viewId = links[0]?.split('/').pop() @@ -215,7 +219,11 @@ async function checks() { for (const [what, pathname] of [['snapshot', `taxonomy-snapshots/${next?.doc}`], ['view', `taxonomy-views/${viewId}`], ['proposal', `taxonomy-proposals/${proposal?.id}`]]) { const page = await get(`/admin/collections/${pathname}`, curator) check(`the curator's ${what} page in the admin carries the plugin's panel`, page.status === 200 && page.text.includes('onetaxonomy-panel'), { status: page.status }) + // The stored snapshot can be 15 MB. Its page must not carry it, in the form or anywhere else in what is sent. + if (what === 'snapshot') check('the snapshot\'s admin page is sent without the snapshot\'s content and without the field for it', !page.text.includes('schemaVersion') && !page.text.includes('id="field-snapshot"'), { length: page.text.length, content: page.text.includes('schemaVersion'), field: (page.text.match(/.{60}id="field-snapshot".{60}/) ?? [])[0] }) } + const blank = await get('/admin/collections/taxonomy-snapshots/create', curator) + check('the new-snapshot form in the admin still has the field to paste one into', blank.status === 200 && blank.text.includes('id="field-snapshot"'), { status: blank.status }) const summary = await get(`/api/taxonomy-snapshots/${next?.doc}/summary`, curator) const from = summary.json?.derivedFrom?.proposal check('version 2\'s summary shows the curator both people by id, with their handles looked up beside them', summary.status === 200 && from?.author === String(me?.id) && from?.authorName === 'Morgan Renamed' && from?.acceptedBy === String(curatorId) && from?.acceptedByName === 'Cara Curator', from ?? { status: summary.status }) diff --git a/docs/architecture/adr/0012-payload-plugin.md b/docs/architecture/adr/0012-payload-plugin.md index 82b205c..f3d0aa8 100644 --- a/docs/architecture/adr/0012-payload-plugin.md +++ b/docs/architecture/adr/0012-payload-plugin.md @@ -40,7 +40,7 @@ - **Limits.** Each acceptance is made from the published base alone: two proposals accepted on one snapshot give two versions, not one with both. To combine them, publish the first and propose again on it. Without transactions, a crash between storing the snapshot and claiming the proposal can leave a staged version no proposal points to; a manager deletes it. The note from an earlier decision stays on a proposal that is shared again, until the next decision. The conditional update is one statement on MongoDB; it hasn't been checked on Postgres or SQLite. One person keeps at most `maxPerUser` proposals (default 200, not counting accepted ones) of at most `maxMoves` moves (default 500). In the admin a proposal's content is still a JSON field, under its panel (point 16). Only a snapshot's summary (point 16) turns a sealed id back into the name the host shows for it now, and only for a signed-in requester; a visitor gets the ids alone. 16. **The admin panels are a page over the same API, with no powers of their own (part 5).** Each of the plugin's four collections gets a panel at the top of its edit page, on unless `admin: { panels: false }`. - - **A field on Payload's own edit page, not a custom view.** The panel is a `ui` field. Payload's save button, permissions, versions and the fields under the panel stay as they are, and there is no second edit page to keep in step with Payload. The cost is that a snapshot's edit page still loads the whole snapshot into Payload's JSON field below the panel. + - **A field on Payload's own edit page, not a custom view.** The panel is a `ui` field. Payload's save button, permissions, versions and the fields under the panel stay as they are, and there is no second edit page to keep in step with Payload. At first the cost was that a snapshot's edit page loaded the whole snapshot into Payload's JSON field below the panel; point 17 takes that away. - **Browser code is a separate entry.** The panels are client components in `@captiveorigins/payload-plugin-onetaxonomy/client`, which Payload finds through its import map. `@payloadcms/ui` and `react` are optional peer dependencies, so a host that turns the panels off, or uses the plugin without an admin, needs neither. A test checks that the server entry imports no browser code and the client entry no server code. - **Every answer is the site's.** The panels call the host's own site from the browser, as the signed-in person, with their cookie. Publishing and withdrawing are Payload's standard `PATCH` of `status`, asking back only the fields the panel shows, so the answer never carries a snapshot's content. Running an import, accepting and declining are the endpoints of points 12 and 15. Which buttons appear follows Payload's own word on whether this person may save the document; that is a courtesy, and a button shown by mistake still gets the site's 403 or 404. A refusal is shown as "Nothing was changed." with the site's reason. - **One new endpoint.** `GET /api//:id/summary` (and `summarizeSnapshot`) answers what a snapshot holds, small enough for a page: its status, counts, sources with their licenses, a line per classification and phylogeny, and what it was made from. Lists are capped (20 by default, never past `limits.maxResults`) and say when they were cut. It never returns names, concepts or trees. Same access and the same 404 as point 8, after the checksum check of point 3. For a snapshot made from an accepted proposal it gives the two sealed person ids, and beside each the name `proposals.displayName` gives now: for a signed-in requester only, looked up per request, never stored. A visitor who may read the snapshot gets the ids and no names. @@ -48,15 +48,23 @@ - **A view's drawing is bounded and has an outline.** The view panel draws the saved view from its projection, cut at 200 nodes and saying so, with the same tree as an outline for the keyboard and the lines readers are told about it. Publish waits, with the reason beside it, until the form is saved and the snapshot is published. - **An import's progress is asked for, not pushed.** While a job is running the panel reads its few run fields again every 3 seconds, and stops when it isn't or when the page is left. A job running past `staleAfterMinutes` is called probably interrupted and can be run again; a running one can't be started twice from the panel, and point 12's claim refuses it anyway. - **Readable without colour, in Payload's themes.** A status is a word, with a coloured edge beside it; the state colours are used for edges only, never for text. The panels take their colours from Payload's theme variables, and a test reads the installed `@payloadcms/ui` stylesheets and checks text and muted text at 4.5:1 and control edges at 3:1 in the light and dark themes. The drawing's colours are copies of the React package's, kept equal by a test, and switch on Payload's own theme marker, because the admin's theme is the person's choice in Payload and not the system's. - - **Limits.** Tested in jsdom with Payload's hooks and `fetch` replaced by stand-ins, over HTTP in the example site, and clicked through once in headless Chrome on that site (snapshot, view and proposal panels, light and dark); that last run isn't in CI. The import panel hasn't run in a real admin. No screen reader, Safari, Firefox or phone was tried. Payload's admin makes two requests of its own to other sites on these pages: its JSON field loads its code editor from cdn.jsdelivr.net, and its default avatar asks gravatar.com with a hash of the signed-in person's email address unless the host sets `admin.avatar: 'default'`. The panels themselves ask only the host's site. + - **Limits.** Tested in jsdom with Payload's hooks and `fetch` replaced by stand-ins, over HTTP in the example site, and clicked through in headless Chrome on that site (snapshot, view and proposal panels, light and dark); those runs aren't in CI. The import panel hasn't run in a real admin. No screen reader, Safari, Firefox or phone was tried. Payload's admin makes two requests of its own to other sites on these pages: its JSON field loads its code editor from cdn.jsdelivr.net (not on a stored snapshot's page, which has no JSON field since point 17), and its default avatar asks gravatar.com with a hash of the signed-in person's email address unless the host sets `admin.avatar: 'default'`. The panels themselves ask only the host's site. + +17. **A snapshot's content is read only when it is asked for by name.** A stored snapshot can be 15 MB, and Payload reads whole documents by default: for a list, for a relationship it fills in, and for every admin page. + - **The rule.** A read, create, update or delete of a snapshot that names no `select` answers with every field except `snapshot`. `select: { snapshot: true }` (REST `?select[snapshot]=true`, GraphQL `select: true`) gives the content. A caller's own `select` is never changed, so an exclude-mode select such as `{ status: false }` still carries the content. The export endpoint and the read API of points 8 and 13 are as before. + - **Where it is done.** One `beforeOperation` hook on the snapshots collection, which covers REST, GraphQL, the Local API, populated relationships and the admin alike. An `afterRead` hook would be too late (the document is already loaded), and a field-level `access.read` would hide the content from the people entitled to it. This is about weight, not access: whoever may read a snapshot may select its content. + - **The lock doesn't lean on it.** Point 2 compares an update with the stored document, which Payload reads whole whatever the caller selected. Every place in the plugin that needs the content (the cache of point 3, a view's checks, imports, proposals, the export) selects it by name; a test fails if the view hook stops doing so. + - **The admin.** The content field is shown on the new-snapshot form only (`admin.condition`), where a manager pastes a sealed snapshot. A stored snapshot's page is sent without the content and without the editor; the panel says what the snapshot holds. Payload's own Save there sends the status and no content. The same condition is what makes the field nullable in GraphQL, so a plain query answers `null` for it instead of failing. + - **The panels follow Payload's Save.** The panels read their own data from the site, so they went stale when a manager changed the status with Payload's own field and Save. They now read again whenever Payload reports the document saved. + - **For hosts.** Code that read `doc.snapshot` from a plain read, or from the document an after-read or after-change hook is given, has to select it. The plugin has no release yet, so nothing in use changes. + - **Limits.** A stored snapshot's content can't be looked at in the admin; the export gives it. Tested on a real Payload with an in-memory MongoDB, over HTTP in the example site, and in headless Chrome on that site from a scratch script that isn't in CI. In that script, after a snapshot was made through the form and its page loaded again, Payload's read-only Counts group showed its heading with no fields under it; four shorter runs of the same steps drew them, and the cause wasn't found. Not checked: Postgres or SQLite, a browser with a window. **Later parts of M7, not built yet.** - The rest of the admin views: list views, conflicts, tree authoring, profiles, lessons. Part 5 covers import progress, publishing and a manager's look at a proposal before deciding. - A page for making a proposal in the admin, and a drawing of one. A proposal is made through the API or in the React explorer; the proposal panel says what it would change in words and tables. -- A snapshot edit page that doesn't carry the whole snapshot. Payload's JSON field loads all of it, up to 15 MB, under the panel. - The panels in a screen reader, Safari, Firefox and on a phone, and a browser run in CI. - The atomic claims (import jobs, proposals) on Postgres and SQLite, which need those adapters in the tests. -- Very large snapshots. A MongoDB document holds at most 16 MB, so a snapshot past that can't be stored this way (imports refuse past `maxBytes`); the likely answer is the host's upload collection for the JSON, with the checksum kept here. Reading the collection over REST also returns the whole JSON, which is slow for big snapshots. +- Very large snapshots. A MongoDB document holds at most 16 MB, so a snapshot past that can't be stored this way (imports refuse past `maxBytes`); the likely answer is the host's upload collection for the JSON, with the checksum kept here. **Consequences.** One document per snapshot keeps the checksum meaningful and the plugin small, at the cost of loading a whole snapshot to answer the first question about it (later ones use the cache). Locking content in hooks, not access, means even trusted server code can't edit a sealed snapshot; corrections are new versions, which is what views pinning `{ id, version, checksum }` already assume. Hosts must write two callbacks to open anything to anyone, which is deliberate: the defaults show nothing to anonymous visitors and let no one but server code manage. Proposals put two person ids into a sealed record for good, which is what makes an accepted change traceable; who those ids are is answered by the host, for as long as it chooses to. diff --git a/docs/test-audit.md b/docs/test-audit.md index 53c8f1a..3364acc 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -2,6 +2,35 @@ Each audit lists what this repo promises, the tests that prove it, and the gaps: those closed in the same pass and those left, with why. Audits run after each feature or fix, and at least every 14 days while the repo has commits. Newest first. +## 2026-11-11: a snapshot's content only on request + +Scope: `packages/payload`: the `contentOnRequest` hook and the content field's admin condition (`src/collections.ts`), the panels reading again after Payload's own Save (`src/client/wired.tsx`); and `apps/payload-example`. ADR 0012 point 17. Plugin tests went from 237 to 247, all unit tests from 621 to 631, the example's smoke checks from 55 to 58. + +### Covered + +- **No read carries the content unless it names it** (`snapshots.test.ts`, 5 new tests on a real Payload with an in-memory MongoDB): what storing answers; a read, a list, an update's answer and a delete's answer, for a manager and for trusted code; a select that names the content gets it exactly as sealed, and a caller's own select is left alone, whether it names fields or only leaves some out; the update lock (below); the content field is shown in the admin on the new-snapshot form only. +- **The same over REST and GraphQL** (`http.test.ts`, `view-reads.test.ts`, 3 new tests): a snapshot, a list, a manager's `PATCH` and a view with its snapshot filled in answer without the content; `?select[snapshot]=true` and GraphQL's `select: true` give it; a plain GraphQL query answers `null` and no error. +- **The lock on sealed content still holds** (`snapshots.test.ts`, one of the 5): an update that changes the content is refused when the caller read and selected nothing of it, because the comparison is with the stored document. The earlier lock tests ran unchanged. +- **Everything in the plugin that needs the content still gets it**: views, imports, proposals, the summary, the export and the read API all ran their existing tests after `test/helpers.ts` stopped reading whole documents by default. +- **The panels follow Payload's own Save** (`panels-wired.test.tsx`, 2 new tests in jsdom): the snapshot panel reads its summary again, and the view panel its view and drawing, when Payload reports the document saved, and not otherwise. +- **Over HTTP, in the example site** (`scripts/smoke.mjs`, 3 new checks): a visitor's plain read and list hold no content and a select gets it; the curator's stored-snapshot admin page is sent without the content and without the field; the new-snapshot form still has the field. + +### Checked by breaking it + +Five deliberate breaks, one at a time, each failing at least one test: the hook taken off the collection (6 tests); the admin condition removed (2, one of them GraphQL, where the condition is what lets a plain query answer `null`); the hook changing a caller's own select (52); the view hook no longer selecting the content it checks (7); the panels ignoring Payload's Save (2). + +### Gaps left + +- **The browser run is still a scratch script, not in CI.** 28 checks in headless Chrome on the example site as the made-up curator: a stored snapshot's page is sent without content and has no editor; a sealed fixture pasted into the new-snapshot form is stored with the same checksum; Payload's own Save sends one `PATCH` under 4 kB with the status and no content, and the stored content is untouched; the panel then says the new status. +- **One thing in that run isn't explained.** After a snapshot was made through the form and its page loaded again, Payload's read-only Counts group showed its heading with no fields under it, and they didn't appear after two seconds, a scroll or a Save. Payload draws a group's fields only once the group is in view. Four shorter runs of the same steps on the same build drew them, as did seeded snapshots and ones made through the API. It may be headless Chrome and Payload's lazy drawing, and it may not: the cause wasn't found, and it hasn't been looked at in a browser with a window. +- **Hooks a host adds** that run after a read or a change get the document without `snapshot`. That is documented, not tested with a host's hook. +- **Postgres and SQLite:** as before, not run. + +### Left out as overkill + +- Every operation with every kind of select: the hook is one line, and the tests cover no select, a select naming the content, an include-mode select naming other fields and an exclude-mode one. +- Timing a 15 MB snapshot's admin page before and after. The test checks the content isn't sent, which is the cause. + ## 2026-11-11: Payload plugin part 5, admin panels Scope: `packages/payload`: the panels (`src/client/`), a snapshot's summary (`src/summary.ts`), the `admin.panels` option and the panel field on each collection; and `apps/payload-example`, which now shows the panels. ADR 0012 point 16. Plugin tests went from 143 to 237, all unit tests from 527 to 621, the example's smoke checks from 47 to 55. diff --git a/packages/payload/README.md b/packages/payload/README.md index 3530a87..878a3eb 100644 --- a/packages/payload/README.md +++ b/packages/payload/README.md @@ -2,11 +2,11 @@ OneTaxonomy for Payload: sealed taxonomy snapshots, views pinned to them, import jobs that turn source files into staged snapshots, people's own proposals that a manager can accept into a new staged version, and a small read API for search, ancestors, descendants, MRCA, coverage, projected views and exports, all behind your own access rules. -> **Status: early, parts 1 to 5 of M7.** Not on npm yet. The collections, publishing, tenant scope, import jobs, proposals, the read endpoints and the admin panels work. The server side is tested on a real Payload 3.93 with an in-memory MongoDB (152 tests): bad checksums refused, sealed snapshots locked, an import of each format, re-runs that store nothing new, malformed files refused with their line, interrupted jobs resumed, runs started together claimed by one, every endpoint's results, a view's `source` through the Local API and GraphQL, proposals kept private, shared, accepted into a staged version once and declined, access refusals and cross-tenant denial. Twelve of those tests send real requests through Payload's own REST router, signed in with a token from the login route; the rest call the endpoint handlers and the Local API directly. It also runs in a small real site, [`apps/payload-example`](../../apps/payload-example) (Next.js 16, on this machine only), where 55 more checks go over HTTP with sign-in cookies. The [admin panels](#admin-panels) have 74 tests in jsdom and were clicked through once in headless Chrome; no screen reader, Safari or Firefox has been tried on them. It hasn't run on a deployed site, or on Postgres or SQLite, and no URL import has fetched over a real network. See [ADR 0012](../../docs/architecture/adr/0012-payload-plugin.md). +> **Status: early, parts 1 to 5 of M7.** Not on npm yet. The collections, publishing, tenant scope, import jobs, proposals, the read endpoints and the admin panels work. The server side is tested on a real Payload 3.93 with an in-memory MongoDB (160 tests): bad checksums refused, sealed snapshots locked, a snapshot's content left out of every read that doesn't name it, an import of each format, re-runs that store nothing new, malformed files refused with their line, interrupted jobs resumed, runs started together claimed by one, every endpoint's results, a view's `source` through the Local API and GraphQL, proposals kept private, shared, accepted into a staged version once and declined, access refusals and cross-tenant denial. Thirteen of those tests send real requests through Payload's own REST router, signed in with a token from the login route; the rest call the endpoint handlers and the Local API directly. It also runs in a small real site, [`apps/payload-example`](../../apps/payload-example) (Next.js 16, on this machine only), where 58 more checks go over HTTP with sign-in cookies. The [admin panels](#admin-panels) have 76 tests in jsdom and were clicked through in headless Chrome, outside CI; no screen reader, Safari or Firefox has been tried on them. It hasn't run on a deployed site, or on Postgres or SQLite, and no URL import has fetched over a real network. See [ADR 0012](../../docs/architecture/adr/0012-payload-plugin.md). ## What it does -- **Snapshots** (`taxonomy-snapshots`): one document per sealed `DatasetSnapshot`, stored whole. A snapshot without a checksum, with a checksum that doesn't match, of another schema version, or with validation errors is refused. Its content never changes after that, not even from server code. The same id and version can't be stored twice. The checksum is checked again the first time the API reads it, and a snapshot changed in the database is refused (500), not answered from. +- **Snapshots** (`taxonomy-snapshots`): one document per sealed `DatasetSnapshot`, stored whole. A snapshot without a checksum, with a checksum that doesn't match, of another schema version, or with validation errors is refused. Its content never changes after that, not even from server code. The same id and version can't be stored twice. The checksum is checked again the first time the API reads it, and a snapshot changed in the database is refused (500), not answered from. A read answers without the content unless it asks for it by name: see [Reading a snapshot's content](#reading-a-snapshots-content). - **Staged, published, withdrawn.** New snapshots are staged, seen only by managers. Publishing needs a signed-in person and records who and when. Withdrawn hides it from readers. Nothing moves back, and only staged snapshots can be deleted. - **Views** (`taxonomy-views`): a OneTaxonomy view over one snapshot. It must pin that snapshot's id, version and checksum, and core's `validateView` must find no errors. Drafts can change; published views can't. A view is published only over a published snapshot. - **Import jobs** (`taxonomy-imports`, with the `imports` option): one job reads one file, a CSV/TSV checklist, a Darwin Core Archive, a OneTaxonomy JSON snapshot, or Newick, NEXUS or phyloXML trees, with the matching OneTaxonomy adapter. It seals the result with core's `sealSnapshot`, checks the checksum, validates it, and stores it as a staged snapshot for a manager to check and publish. The job records its progress, the adapter's warnings, and any diagnostics, with the line or position in the file. A file that can't be read, or a dataset with validation errors, fails the job and stores nothing. The same file with the same settings stores nothing new: the job says "already imported" and links the snapshot. A job that was interrupted can be run again and starts from the file; if it had already stored its snapshot, it finds it rather than storing a second. When two runs of one job start at the same moment, one runs it and the other is refused (409). Only managers see or run jobs. @@ -94,6 +94,19 @@ const job = await payload.create({ Host pages can call `searchNames`, `ancestorsOf`, `descendantsOf`, `mrcaOf`, `coverageOf`, `exportSnapshot`, `projectedView` and `exportView` with a request (from `createLocalReq`). They apply the same access and return `{ ok, body }` or `{ ok: false, status, error }`. +## Reading a snapshot's content + +A snapshot's document holds its content in the `snapshot` field, and that can be 15 MB. So a read answers without that field unless it names it. A list of snapshots, a view with its snapshot filled in, and the admin's pages all name nothing, and none of them carries the content. + +- **REST:** `GET /api/taxonomy-snapshots/:id` answers with every field except `snapshot`. Add `?select[snapshot]=true` for the content, and name any other field you want with it. +- **Local API:** `payload.findByID({ collection: 'taxonomy-snapshots', id, select: { snapshot: true } })`. `find` is the same, and so is what `create`, `update` and `delete` answer with. +- **GraphQL:** `snapshot` is `null` in a plain query. Pass Payload's `select: true` argument, which reads the fields the query names: `TaxonomySnapshot(id: "…", select: true) { checksum snapshot }`. +- **Export:** `GET /api/taxonomy-snapshots/:id/export` and `exportSnapshot(req, id)` still give the whole sealed snapshot, after its checksum is checked. The search, ancestors and other endpoints read the content themselves. + +Only a read with no `select` at all is changed. One that names its own is left exactly as asked, so `select: { status: true }` gives the status, and `select: { status: false }` gives everything but the status, content included. + +This is about weight, not secrecy: whoever may read a snapshot may ask for its content. Hooks you add that run after a read or a change (`afterRead`, `afterChange`, `afterDelete`) get the document the same way, without `snapshot` unless the caller selected it. A `beforeChange` hook's `originalDoc` is still whole. The lock on sealed content doesn't rely on any of this. An update is still compared with the stored document, read whole. + ## Proposals Turn them on with `proposals: true` or `proposals: { personId, displayName, maxMoves, maxPerUser }`. @@ -153,7 +166,7 @@ Options: `slugs` (`snapshots`, `views`, `imports`, `proposals`), `access` (`read Each of the plugin's collections gets a panel at the top of its edit page. It is on unless you set `admin: { panels: false }`. -- **Snapshot.** Status in words, the dataset, version, checksum and date, how many names, concepts, classifications, phylogenies and sources it holds, a table of its classifications and phylogenies, and its sources with their licenses. A snapshot made by accepting a proposal says which proposal, who proposed it and who accepted it. A manager gets **Publish this snapshot** on a staged one and **Withdraw this snapshot** on a published one. +- **Snapshot.** Status in words, the dataset, version, checksum and date, how many names, concepts, classifications, phylogenies and sources it holds, a table of its classifications and phylogenies, and its sources with their licenses. A snapshot made by accepting a proposal says which proposal, who proposed it and who accepted it. A manager gets **Publish this snapshot** on a staged one and **Withdraw this snapshot** on a published one. The page doesn't load the snapshot's content, and there is no content field on it: that field is on the new-snapshot form only, where a sealed snapshot is pasted in. To look at a stored snapshot's content, use the export. - **View.** Status, the snapshot it is drawn over and that snapshot's status, a drawing of the saved view (the first 200 nodes) with the same tree as an outline for the keyboard, and the lines readers are told about it. **Publish this view** waits until the form is saved and the snapshot is published, and says which it is waiting for. - **Import job.** The steps of the run with the one it is on, the result, the adapter's warnings and the diagnostics with their line or position. While a job is running the panel asks again every 3 seconds. A manager gets **Run this import** on a queued job and **Run it again** on a failed one, and on a running one only after `imports.staleAfterMinutes`. - **Proposal.** Whose it is, what it is drawn over, and what it would change: taxa with a new parent, groups that gain or lose members, groups only one side has. A manager gets **Accept this proposal**, which asks for the new version's name and an optional note, and **Decline this proposal**, which asks for a note the author will see. @@ -164,6 +177,8 @@ The panels are a page over the same API as everything else. They run in the brow A person's name appears beside their id only if you give `proposals.displayName`, only to a signed-in requester, and it is looked up each time the page loads. It is never stored. +A panel reads from the site again after Payload's own Save, so it shows what was saved and not what was there when the page opened. + **Setting up.** The panels need `@payloadcms/ui` and `react`, which a Payload admin already has. Payload finds them through its import map, so regenerate it after adding the plugin or changing `admin.panels`: ```sh @@ -174,9 +189,9 @@ Without that the edit pages load with nothing where the panel should be, and Pay **Colours.** The panels take their colours from Payload's theme variables, so they follow the admin's light and dark themes and a host's own theme. A status is always a word with a coloured edge, never colour alone. A test reads the installed `@payloadcms/ui` stylesheets and checks that text and muted text reach 4.5:1 and control edges 3:1 in both themes. -**What was checked.** 74 tests render the panels in jsdom with Payload's hooks and `fetch` replaced by stand-ins: which buttons each person gets, that nothing is sent before the confirm, what a refusal says, and where Escape and the focus go. The example site's smoke test checks over HTTP that the built admin pages carry the panels and that the routes they call refuse a member. The panels were also clicked through once in headless Chrome on the example site, in light and dark: publishing a snapshot with the keyboard, the wait on an unsaved view, and accepting a proposal into a new version. That run is not in CI. The import panel has not run in a real admin, because the example site has no imports. Not checked: a screen reader, Safari, Firefox, a phone, or a custom Payload theme. +**What was checked.** 76 tests render the panels in jsdom with Payload's hooks and `fetch` replaced by stand-ins: which buttons each person gets, that nothing is sent before the confirm, what a refusal says, and where Escape and the focus go. The example site's smoke test checks over HTTP that the built admin pages carry the panels and that the routes they call refuse a member. The panels were also clicked through in headless Chrome on the example site, in light and dark: publishing a snapshot with the keyboard, the wait on an unsaved view, accepting a proposal into a new version, storing a sealed snapshot by pasting it into the new-snapshot form, and Payload's own Save on a stored snapshot, which sent the status and no content. Those runs are not in CI. One thing in them isn't explained: after a snapshot was made through the form and its page loaded again, Payload's read-only Counts group showed its heading with no fields under it. Four shorter runs of the same steps drew the fields, and it hasn't been seen in a browser with a window. The import panel has not run in a real admin, because the example site has no imports. Not checked: a screen reader, Safari, Firefox, a phone, or a custom Payload theme. -**What Payload's own admin asks other sites for.** The panels ask only your site. Payload's JSON field loads its code editor (Monaco) from cdn.jsdelivr.net, and its default avatar asks gravatar.com with a hash of the signed-in person's email address. Both are Payload's, and both happen on these edit pages because they hold JSON fields. Set `admin.avatar: 'default'` in your Payload config to stop the second. +**What Payload's own admin asks other sites for.** The panels ask only your site. Payload's JSON field loads its code editor (Monaco) from cdn.jsdelivr.net, and its default avatar asks gravatar.com with a hash of the signed-in person's email address. Both are Payload's. The editor loads on pages with a JSON field you can see: the new-snapshot form and the view, import and proposal pages. A stored snapshot's page has none, so it doesn't ask. The avatar is asked for on every admin page; set `admin.avatar: 'default'` in your Payload config to stop it. ## Limits @@ -191,7 +206,8 @@ Without that the edit pages load with nothing where the panel should be, and Pay - The claim that lets one manager's decision win is one statement on MongoDB. It hasn't been checked on Postgres or SQLite. - A proposal that is shared again after a decision keeps that decision's note until the next one. - The panels sit on Payload's default edit pages; there are no list views, no tree authoring and no page for making a proposal in the admin. A proposal is still made through the API, or in the React explorer. -- A snapshot's edit page still carries the whole snapshot in Payload's JSON field under the panel, so a snapshot of several MB makes a heavy page. The panel itself asks only for the summary. +- A stored snapshot's content can't be looked at in the admin. The panel shows what it holds; the export endpoint gives the content. +- Code that reads `doc.snapshot` has to select it, in a read and in an after-hook alike. See [Reading a snapshot's content](#reading-a-snapshots-content). - The panels haven't been run with a screen reader, in Safari or Firefox, or on a phone. See [Admin panels](#admin-panels) for what was checked. ## Commands diff --git a/packages/payload/src/client/wired.tsx b/packages/payload/src/client/wired.tsx index d3d4b94..758aeb5 100644 --- a/packages/payload/src/client/wired.tsx +++ b/packages/payload/src/client/wired.tsx @@ -34,22 +34,23 @@ const POLL_MS = 3000 const idIn = (v: unknown): string | null => (typeof v === 'string' || typeof v === 'number' ? String(v) : v && typeof v === 'object' && 'id' in v ? idIn((v as { id: unknown }).id) : null) const select = (fields: string[]) => fields.map((f) => `select[${encodeURIComponent(f)}]=true`).join('&') -/** Where the site's API and admin are, and this document. */ +/** Where the site's API and admin are, this document, and when Payload's own form last saved it. */ function usePlace() { const { config } = useConfig() - const { id, collectionSlug, hasSavePermission } = useDocumentInfo() + const { id, collectionSlug, hasSavePermission, lastUpdateTime } = useDocumentInfo() const api = `${config.serverURL ?? ''}${config.routes.api}` const admin = config.routes.admin.replace(/\/$/, '') const doc = id !== undefined && id !== null && collectionSlug ? `${api}/${collectionSlug}/${encodeURIComponent(String(id))}` : null const hrefOf = (slug: string, docId: string | null | undefined) => (docId ? `${admin}/collections/${slug}/${encodeURIComponent(docId)}` : null) - return { doc, hrefOf, mayWrite: Boolean(hasSavePermission) } + return { doc, hrefOf, mayWrite: Boolean(hasSavePermission), savedAt: lastUpdateTime } } /** * Fetches `url` as the signed-in person. `reload` fetches it again and keeps showing what is there until the new - * answer arrives. A null `url` waits. + * answer arrives. A null `url` waits. A change in `savedAt` fetches it again too: that is Payload's own Save button, + * which changes the document without the panel doing anything. */ -function useLoad(url: string | null): [Loadable, () => void] { +function useLoad(url: string | null, savedAt: unknown): [Loadable, () => void] { const [state, setState] = useState>({ state: 'loading' }) const [turn, setTurn] = useState(0) const shown = useRef(null) @@ -69,7 +70,7 @@ function useLoad(url: string | null): [Loadable, () => void] { }, ) return () => stop.abort() - }, [url, turn]) + }, [url, turn, savedAt]) return [state, useCallback(() => setTurn((n) => n + 1), [])] } @@ -116,8 +117,8 @@ function useSetStatus(doc: string | null, what: string, after: () => void): [Out /** On a snapshot's edit page: what it holds and where it came from, and the buttons to publish or withdraw it. */ export function SnapshotPanel(_props: PanelProps) { - const { doc, mayWrite } = usePlace() - const [summary, reload] = useLoad(doc ? `${doc}/summary` : null) + const { doc, mayWrite, savedAt } = usePlace() + const [summary, reload] = useLoad(doc ? `${doc}/summary` : null, savedAt) const [outcome, setStatus] = useSetStatus(doc, 'snapshot', reload) if (!doc) return null return @@ -139,10 +140,10 @@ const savedView = (d: ViewDoc): SavedView => { /** On a view's edit page: its status, its snapshot, a small drawing of the saved view, and the buttons to publish or withdraw it. */ export function ViewPanel({ slugs }: PanelProps) { - const { doc, hrefOf, mayWrite } = usePlace() + const { doc, hrefOf, mayWrite, savedAt } = usePlace() const unsaved = useFormModified() - const [view, reloadView] = useLoad(doc ? `${doc}?depth=0&${select(VIEW_FIELDS)}` : null) - const [preview, reloadPreview] = useLoad(doc ? `${doc}/projection?limit=${PREVIEW_NODES}` : null) + const [view, reloadView] = useLoad(doc ? `${doc}?depth=0&${select(VIEW_FIELDS)}` : null, savedAt) + const [preview, reloadPreview] = useLoad(doc ? `${doc}/projection?limit=${PREVIEW_NODES}` : null, savedAt) const reload = () => { reloadView() reloadPreview() @@ -189,8 +190,8 @@ const jobOf = (d: JobDoc): ImportJob => ({ /** On an import job's edit page: how it is going, what it found, the snapshot it made, and a button to run it. */ export function ImportPanel({ slugs, staleAfterMinutes = 15 }: PanelProps) { - const { doc, hrefOf, mayWrite } = usePlace() - const [raw, reload] = useLoad(doc ? `${doc}?depth=0&${select(JOB_FIELDS)}` : null) + const { doc, hrefOf, mayWrite, savedAt } = usePlace() + const [raw, reload] = useLoad(doc ? `${doc}?depth=0&${select(JOB_FIELDS)}` : null, savedAt) const [starting, setStarting] = useState(false) const [outcome, setOutcome] = useState(null) const [now, setNow] = useState(() => Date.now()) @@ -256,9 +257,9 @@ type DecisionDoc = { status?: string; decisionNote?: string | null; decidedAt?: /** On a proposal's edit page: what it would change, in names, and for a manager the buttons to accept or decline it. */ export function ProposalPanel({ slugs, limits = { version: 100, note: 2000 } }: PanelProps) { - const { doc, hrefOf } = usePlace() - const [preview, reloadPreview] = useLoad(doc ? `${doc}/preview` : null) - const [decided, reloadDecided] = useLoad(doc ? `${doc}?depth=0&${select(DECISION_FIELDS)}` : null) + const { doc, hrefOf, savedAt } = usePlace() + const [preview, reloadPreview] = useLoad(doc ? `${doc}/preview` : null, savedAt) + const [decided, reloadDecided] = useLoad(doc ? `${doc}?depth=0&${select(DECISION_FIELDS)}` : null, savedAt) const [outcome, setOutcome] = useState(null) const saved = useSaved() diff --git a/packages/payload/src/collections.ts b/packages/payload/src/collections.ts index fa81f05..e5b3fb2 100644 --- a/packages/payload/src/collections.ts +++ b/packages/payload/src/collections.ts @@ -9,7 +9,7 @@ import { type DatasetSnapshot, type TaxonomicView, } from '@captiveorigins/onetaxonomy-core' -import { APIError, type CollectionBeforeChangeHook, type CollectionBeforeDeleteHook, type CollectionBeforeValidateHook, type CollectionConfig, type Field, type Where } from 'payload' +import { APIError, type CollectionBeforeChangeHook, type CollectionBeforeDeleteHook, type CollectionBeforeOperationHook, type CollectionBeforeValidateHook, type CollectionConfig, type Field, type Where } from 'payload' import { createAccess, idOf, manageAccess, readAccess, tenantOf } from './access.js' import { panelField, type ResolvedOptions } from './types.js' @@ -109,6 +109,16 @@ const snapshotBeforeChange = (opts: ResolvedOptions): CollectionBeforeChangeHook return data } +/** + * A stored snapshot can be 15 MB, so its content is read only when it is asked for by name. A read, create, update or + * delete that names no `select` answers with every field except `snapshot`; `select: { snapshot: true }` reads it. + * Payload's admin pages, its lists and a populated relationship name no `select`, so none of them carries the content. + * The lock on sealed content doesn't depend on this: an update compares against the stored document, read whole. + */ +const CONTENT_OPERATIONS = new Set(['read', 'create', 'update', 'delete']) +export const contentOnRequest: CollectionBeforeOperationHook = ({ args, operation }) => + CONTENT_OPERATIONS.has(operation) && args && (args as { select?: unknown }).select == null ? ({ ...args, select: { snapshot: false } } as typeof args) : args + /** Only staged snapshots and draft views can be deleted: published ones may be pinned by views and pages. */ const onlyUnpublishedDeleted = (slug: string, deletable: string, what: string): CollectionBeforeDeleteHook => async ({ id, req }) => { const doc = (await req.payload.findByID({ collection: slug as never, id, depth: 0, req, overrideAccess: true, disableErrors: true, select: { status: true } as never })) as { status?: string } | null @@ -129,7 +139,9 @@ const viewBeforeChange = (opts: ResolvedOptions): CollectionBeforeChangeHook => const to = String(data.status ?? status) if (contentChanged || (to === 'published' && status !== 'published')) { if (!snapshotRef) fail('A view needs the snapshot it is shown over.') - const snap = (await req.payload.findByID({ collection: opts.slugs.snapshots as never, id: snapshotRef!, depth: 0, req, overrideAccess: true, disableErrors: true })) as Record | null + // The content is only read when the view has to be checked against it. + const select = { snapshotId: true, version: true, checksum: true, status: true, ...(opts.tenant ? { [opts.tenant.field]: true } : {}), ...(contentChanged ? { snapshot: true } : {}) } + const snap = (await req.payload.findByID({ collection: opts.slugs.snapshots as never, id: snapshotRef!, depth: 0, req, overrideAccess: true, disableErrors: true, select: select as never })) as Record | null if (!snap) fail('The view\'s snapshot doesn\'t exist.') if (opts.tenant) { const tenant = idOf(data[opts.tenant.field] ?? originalDoc?.[opts.tenant.field]) @@ -177,7 +189,17 @@ export function snapshotsCollection(opts: ResolvedOptions): CollectionConfig { fields: [ ...panelField(opts, 'SnapshotPanel'), { name: 'label', type: 'text', admin: { readOnly: true, description: 'The snapshot\'s id and version. Set from the snapshot.' } }, - { name: 'snapshot', type: 'json', required: true, admin: { description: 'The sealed OneTaxonomy snapshot, stored exactly as given. Its checksum is checked when it is stored and when it is read.' } }, + { + name: 'snapshot', + type: 'json', + required: true, + admin: { + description: 'Paste a sealed OneTaxonomy snapshot. It is stored exactly as given, and its checksum is checked when it is stored and when it is read.', + // Only on the new-snapshot form. A stored snapshot's page never loads the content: the panel above shows what it holds. + // A condition also makes a required field nullable in GraphQL, so a plain query answers null for it and not an error. + condition: (_data, _sibling, { operation }) => operation !== 'update', + }, + }, { name: 'snapshotId', type: 'text', index: true, admin: { readOnly: true } }, { name: 'version', type: 'text', admin: { readOnly: true } }, { name: 'checksum', type: 'text', index: true, admin: { readOnly: true } }, @@ -192,6 +214,7 @@ export function snapshotsCollection(opts: ResolvedOptions): CollectionConfig { ...tenantField(opts), ], hooks: { + beforeOperation: [contentOnRequest], beforeValidate: [fillTenant(opts)], beforeChange: [snapshotBeforeChange(opts)], beforeDelete: [onlyUnpublishedDeleted(slug, 'staged', 'snapshot')], diff --git a/packages/payload/test/helpers.ts b/packages/payload/test/helpers.ts index 4a371fa..706e6c5 100644 --- a/packages/payload/test/helpers.ts +++ b/packages/payload/test/helpers.ts @@ -82,5 +82,8 @@ export const sealed = { structures: () => sealSnapshot(clone(structures) as unknown as DatasetSnapshot) as DatasetSnapshot, checklist: () => sealSnapshot(clone(checklist) as unknown as DatasetSnapshot) as DatasetSnapshot, } +/** Every stored field of a snapshot document, content included. A read that names no `select` leaves the content out. */ +export const WITH_CONTENT = { snapshot: true, label: true, snapshotId: true, version: true, checksum: true, counts: true, status: true, publishedBy: true, publishedAt: true } as never + /** A sealed snapshot, copied so a test can change it (which breaks its checksum). */ export const unfrozen = (s: DatasetSnapshot) => clone(s) diff --git a/packages/payload/test/http.test.ts b/packages/payload/test/http.test.ts index ef84b7a..607aae3 100644 --- a/packages/payload/test/http.test.ts +++ b/packages/payload/test/http.test.ts @@ -114,6 +114,25 @@ describe('over HTTP: who you are comes from the token', () => { expect((await get(`/${SNAPSHOTS}/${staged}`, managerA)).status).toBe(200) }) + it('answers a snapshot and a list without the content, and with it when the query selects it', async () => { + const one = await get(`/${SNAPSHOTS}/${snapA}?depth=0`, ann) + expect(one.body).toMatchObject({ id: snapA, snapshotId: 'fixture-amniotes', checksum: sealed.amniotes().checksum }) + expect(one.body).not.toHaveProperty('snapshot') + expect(one.text).not.toContain('"schemaVersion"') + expect((await get(`/${SNAPSHOTS}?depth=0&limit=50`, managerA)).text).not.toContain('"schemaVersion"') + const named = await get(`/${SNAPSHOTS}/${snapA}?depth=0&select[snapshot]=true`, ann) + expect(named.body.snapshot).toEqual(sealed.amniotes()) + // A view at depth 1 fills its snapshot in, still without the content. + const view = await get(`/${VIEWS}/${viewPublished}?depth=1`, ann) + expect(view.body.snapshot).toMatchObject({ id: snapA, version: '1' }) + expect(view.body.snapshot).not.toHaveProperty('snapshot') + // A manager's PATCH that names no select answers without it too. + const patched = await http('PATCH', `/${SNAPSHOTS}/${staged}?depth=0`, { as: managerA, body: {} }) + expect(patched.status).toBe(200) + expect(patched.body.doc).toMatchObject({ id: staged, status: 'staged' }) + expect(patched.text).not.toContain('"schemaVersion"') + }) + it('treats a token that isn\'t one, or is for nobody, as anonymous', async () => { for (const token of ['not-a-token', `${tokens.get(ann)!.slice(0, -4)}AAAA`]) { const res = await http('GET', `/${SNAPSHOTS}/${snapA}/search?q=aves`, { token }) diff --git a/packages/payload/test/imports.test.ts b/packages/payload/test/imports.test.ts index 70787ad..d324ad3 100644 --- a/packages/payload/test/imports.test.ts +++ b/packages/payload/test/imports.test.ts @@ -8,7 +8,7 @@ import type { Payload, TypedUser } from 'payload' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { oneTaxonomy, runImport } from '../src' -import { access, callEndpoint, makeUser, sealed, startPayload, usersCollection } from './helpers' +import { access, callEndpoint, makeUser, sealed, startPayload, usersCollection, WITH_CONTENT } from './helpers' import checklistJson from '../../../fixtures/checklist.json' @@ -74,7 +74,7 @@ type Job = Record const makeJob = (data: Record, user: TypedUser | null = manager, context: Record = {}) => payload.create({ collection: IMPORTS as never, data: data as never, user, overrideAccess: false, context }) as Promise const snapshotCount = async () => (await payload.count({ collection: SNAPSHOTS as never })).totalDocs -const storedSnapshot = async (id: unknown) => (await payload.findByID({ collection: SNAPSHOTS as never, id: id as string, depth: 0 })) as Record +const storedSnapshot = async (id: unknown) => (await payload.findByID({ collection: SNAPSHOTS as never, id: id as string, depth: 0, select: WITH_CONTENT })) as Record const raw = () => (payload.db as unknown as { collections: Record Promise; findOne: (q: unknown) => { lean: () => Promise | null> } }> }).collections[IMPORTS]! describe('importing each format into a staged, sealed snapshot', () => { diff --git a/packages/payload/test/panels-wired.test.tsx b/packages/payload/test/panels-wired.test.tsx index dc83de0..5f91c54 100644 --- a/packages/payload/test/panels-wired.test.tsx +++ b/packages/payload/test/panels-wired.test.tsx @@ -15,7 +15,7 @@ import type { ProposalAcceptance, ProposalDecision } from '../src/proposals.js' const admin = vi.hoisted(() => ({ config: { serverURL: '', routes: { api: '/api', admin: '/admin' } } as { serverURL?: string; routes: { api: string; admin: string } }, - doc: { id: 'doc1' as string | number | undefined, collectionSlug: 'taxonomy-snapshots', hasSavePermission: true }, + doc: { id: 'doc1', collectionSlug: 'taxonomy-snapshots', hasSavePermission: true } as { id: string | number | undefined; collectionSlug: string; hasSavePermission: boolean; lastUpdateTime?: number }, modified: false, dispatchFields: vi.fn(), })) @@ -95,6 +95,25 @@ describe('snapshot panel on a site', () => { expect(calls).toEqual([{ method: 'GET', url: `${at}/summary`, body: undefined, credentials: 'include' }]) }) + it('reads the summary again when Payload’s own Save has changed the document, and not otherwise', async () => { + site[`GET ${at}/summary`] = { body: summary('staged') } + admin.doc = { ...admin.doc, lastUpdateTime: 1 } + const { rerender } = render() + await screen.findByRole('button', { name: 'Publish this snapshot' }) + rerender() + expect(called('GET')).toEqual([`${at}/summary`]) + // Payload's Save button: the document changes on the site and the form's save time moves on. + site[`GET ${at}/summary`] = { body: summary('published') } + admin.doc = { ...admin.doc, lastUpdateTime: 2 } + rerender() + // What was shown stays until the new answer is in: no flash back to "Loading". + expect(screen.queryByText('Loading the snapshot’s summary…')).toBeNull() + await screen.findByRole('button', { name: 'Withdraw this snapshot' }) + expect(screen.queryByRole('button', { name: 'Publish this snapshot' })).toBeNull() + expect(called('GET')).toEqual([`${at}/summary`, `${at}/summary`]) + expect(called('PATCH')).toEqual([]) + }) + it('uses the site’s own address and API path, and an id that is safe in an address', async () => { admin.config = { serverURL: 'https://site.example', routes: { api: '/cms-api', admin: '/admin' } } admin.doc = { id: 'a b/c', collectionSlug: 'taxonomy-snapshots', hasSavePermission: true } @@ -184,6 +203,17 @@ describe('view panel on a site', () => { expect(document.body.textContent).toContain('Save your changes first.') }) + it('draws the view again after Payload’s own Save', async () => { + admin.doc = { ...admin.doc, lastUpdateTime: 1 } + const { rerender } = render() + await screen.findByRole('group', { name: 'Drawing of Traditional classes' }) + site[`GET ${at}${FIELDS}`] = { body: { id: 'doc1', title: 'Classes, renamed', status: 'draft', source, view: { layout: { kind: 'radial' }, highlights: [] } } } + admin.doc = { ...admin.doc, lastUpdateTime: 2 } + rerender() + await screen.findByRole('group', { name: 'Drawing of Classes, renamed' }) + expect(called('GET').sort()).toEqual([`${at}/projection?limit=200`, `${at}/projection?limit=200`, `${at}${FIELDS}`, `${at}${FIELDS}`].sort()) + }) + it('publishes with one PATCH and reads both the view and its preview again', async () => { site[`PATCH ${at}${PUBLISH}`] = { body: { doc: { id: 'doc1', status: 'published', publishedBy: 'user9', publishedAt: '2026-11-11T12:00:00.000Z' } } } render() diff --git a/packages/payload/test/proposals-plain.test.ts b/packages/payload/test/proposals-plain.test.ts index 2d49f97..27b4d1d 100644 --- a/packages/payload/test/proposals-plain.test.ts +++ b/packages/payload/test/proposals-plain.test.ts @@ -4,7 +4,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { oneTaxonomy } from '../src' import { resolveOptions } from '../src/types' -import { access, callEndpoint, makeUser, sealed, startPayload, usersCollection } from './helpers' +import { access, callEndpoint, makeUser, sealed, startPayload, usersCollection, WITH_CONTENT } from './helpers' // One site, no tenants, on a database without transactions. const SNAPSHOTS = 'taxonomy-snapshots' @@ -81,7 +81,7 @@ describe('proposals on a site without tenants', () => { const done = await accept(p.id, manager, { version: '2', checksum: shared.checksum }) expect(done).toMatchObject({ status: 200, body: { snapshot: { version: '2', status: 'staged' } } }) - const made = (await payload.findByID({ collection: SNAPSHOTS as never, id: done.body.snapshot.doc, depth: 0 })) as Doc + const made = (await payload.findByID({ collection: SNAPSHOTS as never, id: done.body.snapshot.doc, depth: 0, select: WITH_CONTENT })) as Doc expect((made.snapshot as DatasetSnapshot).derivedFrom!.proposal).toMatchObject({ author: `keeper-${String(ann.id).slice(-4)}`, acceptedBy: `keeper-${String(manager.id).slice(-4)}` }) // No `displayName` on this site: a proposal shows its author's id and no name. expect(((await payload.findByID({ collection: PROPOSALS as never, id: p.id, depth: 0, user: ben, overrideAccess: false })) as Doc).authorName ?? null).toBeNull() diff --git a/packages/payload/test/proposals.test.ts b/packages/payload/test/proposals.test.ts index 56b061a..4b75080 100644 --- a/packages/payload/test/proposals.test.ts +++ b/packages/payload/test/proposals.test.ts @@ -3,7 +3,7 @@ import type { Payload, TypedUser } from 'payload' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { oneTaxonomy, PERSON_ID, proposalChecksum } from '../src' -import { access, callEndpoint, makeUser, roleOf, sealed, startPayload, tenantsCollection, usersCollection } from './helpers' +import { access, callEndpoint, makeUser, roleOf, sealed, startPayload, tenantsCollection, usersCollection, WITH_CONTENT } from './helpers' const SNAPSHOTS = 'taxonomy-snapshots' const PROPOSALS = 'taxonomy-proposals' @@ -55,7 +55,7 @@ const readAs = async (id: unknown, user: TypedUser) => (await payload.findByID({ const seen = async (user: TypedUser) => (await payload.find({ collection: PROPOSALS as never, user, overrideAccess: false, depth: 0, limit: 100 })).docs.map((d) => (d as Doc).id as string) const snapshotsSeen = async (user: TypedUser) => (await payload.find({ collection: SNAPSHOTS as never, user, overrideAccess: false, depth: 0, limit: 100 })).docs.map((d) => String((d as Doc).id)) const snapshotCount = async () => (await payload.count({ collection: SNAPSHOTS as never })).totalDocs -const snapshot = async (id: unknown) => (await payload.findByID({ collection: SNAPSHOTS as never, id: id as string, depth: 0 })) as Doc +const snapshot = async (id: unknown) => (await payload.findByID({ collection: SNAPSHOTS as never, id: id as string, depth: 0, select: { ...(WITH_CONTENT as object), tenant: true } as never })) as Doc const preview = (id: unknown, user: TypedUser | null, query: Record = {}) => callEndpoint(payload, PROPOSALS, '/:id/preview', String(id), query, user) const accept = (id: unknown, user: TypedUser | null, body?: unknown) => callEndpoint(payload, PROPOSALS, '/:id/accept', String(id), {}, user, body) const decline = (id: unknown, user: TypedUser | null, body?: unknown) => callEndpoint(payload, PROPOSALS, '/:id/decline', String(id), {}, user, body) @@ -259,8 +259,9 @@ describe('private and shared', () => { await expect(update(empty.id, { status: 'shared' }, ann)).rejects.toThrow(/moves nothing/) await remove(empty.id, ann) // A manager can draft over a staged snapshot, but not share it until the snapshot is published. - const staged = (await payload.create({ collection: SNAPSHOTS as never, data: { snapshot: sealed.checklist() } as never, user: managerA, overrideAccess: false })) as Doc - const c = (staged.snapshot as DatasetSnapshot).classifications[0]! + const checklist = sealed.checklist() + const staged = (await payload.create({ collection: SNAPSHOTS as never, data: { snapshot: checklist } as never, user: managerA, overrideAccess: false })) as Doc + const c = checklist.classifications[0]! const first = c.placements.find((x) => x.parent !== null)! const early = await propose(managerA, { snapshot: staged.id, proposal: { title: 'Early', base: { kind: 'classification', id: c.id, version: c.version }, moves: [{ node: first.concept, from: first.parent, to: null }] } }) await expect(update(early.id, { status: 'shared' }, managerA)).rejects.toThrow(/while its snapshot is published/) @@ -570,7 +571,7 @@ describe('accepting', () => { const doc = await stored(q.id) expect(doc.status).toBe('accepted') expect(String(idOf(doc.acceptedInto))).toBe(won.body.snapshot.doc) - const all = (await payload.find({ collection: SNAPSHOTS as never, depth: 0, limit: 100 })).docs as Doc[] + const all = (await payload.find({ collection: SNAPSHOTS as never, depth: 0, limit: 100, select: WITH_CONTENT })).docs as Doc[] expect(all.filter((s) => (s.snapshot as DatasetSnapshot).derivedFrom?.proposal?.id === q.proposalId)).toHaveLength(1) }) }) diff --git a/packages/payload/test/snapshots.test.ts b/packages/payload/test/snapshots.test.ts index db31718..d5eb82f 100644 --- a/packages/payload/test/snapshots.test.ts +++ b/packages/payload/test/snapshots.test.ts @@ -4,7 +4,7 @@ import type { Payload, TypedUser } from 'payload' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { oneTaxonomy } from '../src' -import { access, callEndpoint, makeUser, sealed, startPayload, unfrozen, usersCollection } from './helpers' +import { access, callEndpoint, makeUser, sealed, startPayload, unfrozen, usersCollection, WITH_CONTENT } from './helpers' const SNAPSHOTS = 'taxonomy-snapshots' let payload: Payload @@ -63,7 +63,9 @@ describe('storing a snapshot', () => { const doc = (await create(s)) as Record expect(doc).toMatchObject({ status: 'staged', snapshotId: 'fixture-amniotes', version: '1', checksum: s.checksum, label: 'fixture-amniotes 1' }) expect(doc.counts).toMatchObject({ names: s.names.length, concepts: s.concepts.length, classifications: 1, phylogenies: 2 }) - expect(doc.snapshot.checksum).toBe(snapshotChecksum(doc.snapshot)) + const kept = (await payload.findByID({ collection: SNAPSHOTS as never, id: doc.id, depth: 0, select: { snapshot: true } as never })) as Record + expect(kept.snapshot).toEqual(s) + expect(kept.snapshot.checksum).toBe(snapshotChecksum(kept.snapshot)) }) it('refuses a snapshot changed after sealing (bad checksum)', async () => { @@ -113,6 +115,82 @@ describe('storing a snapshot', () => { }) }) +describe('a stored snapshot\'s content is read only when it is asked for by name', () => { + let id: string | number + const s = (() => { + const draft = unfrozen(sealed.checklist()) + draft.version = 'content-test' + return sealSnapshot(draft) + })() + // Something only the content holds, never the fields beside it. + const INSIDE = '"schemaVersion"' + + it('leaves it out of what storing answers', async () => { + const made = (await create(s)) as Record + id = made.id as string + expect(made).toMatchObject({ snapshotId: s.id, version: 'content-test', checksum: s.checksum, status: 'staged' }) + expect(made).not.toHaveProperty('snapshot') + expect(JSON.stringify(made)).not.toContain(INSIDE) + }) + + it('leaves it out of a read, a list, an update\'s answer and a delete\'s answer when no select is named, for a manager and for trusted code', async () => { + for (const as of [{ user: manager, overrideAccess: false }, {}]) { + const one = (await payload.findByID({ collection: SNAPSHOTS as never, id, depth: 0, ...as })) as Record + expect(one).toMatchObject({ id, checksum: s.checksum, counts: { concepts: s.concepts.length } }) + expect(JSON.stringify(one)).not.toContain(INSIDE) + const list = await payload.find({ collection: SNAPSHOTS as never, depth: 0, limit: 100, ...as }) + expect(list.docs.map((d) => (d as { id: unknown }).id)).toContain(id) + expect(JSON.stringify(list.docs)).not.toContain(INSIDE) + } + const published = (await update(id, { status: 'published' })) as Record + expect(published).toMatchObject({ status: 'published', checksum: s.checksum }) + expect(JSON.stringify(published)).not.toContain(INSIDE) + const many = await payload.update({ collection: SNAPSHOTS as never, where: { id: { equals: id } }, data: { status: 'withdrawn' } as never, user: manager, overrideAccess: false }) + expect(many.docs).toHaveLength(1) + expect(JSON.stringify(many.docs)).not.toContain(INSIDE) + const other = unfrozen(sealed.checklist()) + other.version = 'content-test-deleted' + const staged = (await create(sealSnapshot(other))) as { id: string | number } + const gone = await payload.delete({ collection: SNAPSHOTS as never, id: staged.id, user: manager, overrideAccess: false }) + expect(gone).toMatchObject({ id: staged.id, version: 'content-test-deleted' }) + expect(JSON.stringify(gone)).not.toContain(INSIDE) + }) + + it('gives it, exactly as sealed, to a select that names it', async () => { + const only = (await payload.findByID({ collection: SNAPSHOTS as never, id, depth: 0, select: { snapshot: true } as never, user: manager, overrideAccess: false })) as Record + expect(only.snapshot).toEqual(s) + expect(only).not.toHaveProperty('checksum') + const whole = (await payload.findByID({ collection: SNAPSHOTS as never, id, depth: 0, select: WITH_CONTENT })) as Record + expect(whole).toMatchObject({ snapshot: s, checksum: s.checksum, status: 'withdrawn' }) + const listed = await payload.find({ collection: SNAPSHOTS as never, where: { id: { equals: id } }, depth: 0, select: { snapshot: true } as never }) + expect((listed.docs[0] as unknown as Record).snapshot).toEqual(s) + // A select that names other fields is left alone: it already says what is wanted. + const some = (await payload.findByID({ collection: SNAPSHOTS as never, id, depth: 0, select: { version: true } as never })) as Record + expect(some).toEqual({ id, version: 'content-test' }) + // So is one that only leaves fields out: everything else comes, the content included. + const allBut = (await payload.findByID({ collection: SNAPSHOTS as never, id, depth: 0, select: { label: false } as never })) as Record + expect(allBut).not.toHaveProperty('label') + expect(allBut).toMatchObject({ snapshot: s, checksum: s.checksum }) + }) + + it('still locks the content: an update is compared with what is stored, not with what was read', async () => { + const fresh = unfrozen(sealed.checklist()) + fresh.version = 'content-test-locked' + const doc = (await create(sealSnapshot(fresh))) as { id: string | number } + await expect(update(doc.id, { snapshot: sealed.structures() })).rejects.toThrow(/can't be edited/) + await expect(update(doc.id, { snapshot: null })).rejects.toThrow(/can't be edited/) + // The same content sent back is no change. + await expect(update(doc.id, { snapshot: sealSnapshot(fresh) })).resolves.toMatchObject({ status: 'staged' }) + await payload.delete({ collection: SNAPSHOTS as never, id: doc.id }) + }) + + it('shows the content field in the admin only on the new-snapshot form', () => { + const field = payload.collections[SNAPSHOTS as never]!.config.fields.find((f) => 'name' in f && f.name === 'snapshot') as unknown as { admin: { condition: (data: unknown, sibling: unknown, more: { operation: string }) => boolean } } + expect(field.admin.condition({}, {}, { operation: 'create' })).toBe(true) + expect(field.admin.condition({ id }, {}, { operation: 'update' })).toBe(false) + }) +}) + describe('publishing and editing', () => { let id: string | number beforeAll(async () => { diff --git a/packages/payload/test/summary.test.ts b/packages/payload/test/summary.test.ts index 29741c2..1789787 100644 --- a/packages/payload/test/summary.test.ts +++ b/packages/payload/test/summary.test.ts @@ -2,7 +2,7 @@ import type { Payload, TypedUser } from 'payload' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { importsCollection, oneTaxonomy, proposalsCollection, resolveOptions, snapshotsCollection, viewsCollection } from '../src' -import { access, callEndpoint, makeUser, roleOf, sealed, startPayload, tenantsCollection, usersCollection } from './helpers' +import { access, callEndpoint, makeUser, roleOf, sealed, startPayload, tenantsCollection, usersCollection, WITH_CONTENT } from './helpers' const SNAPSHOTS = 'taxonomy-snapshots' const PROPOSALS = 'taxonomy-proposals' @@ -180,7 +180,7 @@ describe('a snapshot made from an accepted proposal', () => { names.set('P-2003', 'Ann K.') // No name, and no email address, in what is stored. - const kept = JSON.stringify(await payload.findByID({ collection: SNAPSHOTS as never, id: made, depth: 0 })) + const kept = JSON.stringify(await payload.findByID({ collection: SNAPSHOTS as never, id: made, depth: 0, select: WITH_CONTENT })) expect(kept).not.toMatch(/Ann K|Cara Curator|Kowalczyk|@/) expect(kept).toContain('P-2003') }) diff --git a/packages/payload/test/view-reads.test.ts b/packages/payload/test/view-reads.test.ts index ccaf4c1..972a237 100644 --- a/packages/payload/test/view-reads.test.ts +++ b/packages/payload/test/view-reads.test.ts @@ -90,6 +90,15 @@ describe('a view read through Payload says what its snapshot\'s status is', () = } }) + it('fills a view\'s snapshot in without its content when the relationship is populated', async () => { + for (const user of [reader, manager]) { + const deep = await readView(views.current!, user, { depth: 1 }) + expect(deep.snapshot).toMatchObject({ id: snapshots.current, snapshotId: 'fixture-amniotes', status: 'published' }) + expect(deep.snapshot).not.toHaveProperty('snapshot') + expect(JSON.stringify(deep.snapshot)).not.toContain('"schemaVersion"') + } + }) + it('keeps the withdrawn snapshot itself hidden from readers, at any depth', async () => { const deep = await readView(views.old!, reader, { depth: 2 }) expect(deep.source.withdrawn).toBe(true) @@ -169,6 +178,15 @@ describe('GraphQL', () => { expect(one.data.TaxonomyView.source).toEqual((await readView(views.old!)).source) }) + it('gives a snapshot\'s content only with the select argument, which reads the fields the query names', async () => { + const plain = await run(`{ TaxonomySnapshot(id: "${snapshots.current}") { checksum snapshot } }`, reader) + expect(plain.errors).toBeUndefined() + expect(plain.data.TaxonomySnapshot).toMatchObject({ checksum: sealed.amniotes().checksum, snapshot: null }) + const named = await run(`{ TaxonomySnapshot(id: "${snapshots.current}", select: true) { checksum snapshot } }`, reader) + expect(named.errors).toBeUndefined() + expect(named.data.TaxonomySnapshot.snapshot).toEqual(sealed.amniotes()) + }) + it('refuses anonymous readers as before', async () => { const anonymous = await run('{ TaxonomyViews { docs { viewId source } } }', null) expect(anonymous.errors?.[0]?.message).toMatch(/not allowed/i)