Files
picpeak/backend/__tests__/routes/galleryDraftPreviewResolve.test.js
Paul NothaftandPaul Nothaft 7382e13371 fix(gallery): let an admin preview a draft through its short share URL (stable) (#1407)
* fix(gallery): let an admin preview a draft through its short share URL (stable)

Stable twin of the main-branch fix.

/resolve/:identifier filtered drafts out through ACTIVE_EVENT_FILTER and
/:slug/verify-token/:token repeated the filter inline, so with "use short
gallery URLs" on the admin's own View Gallery link answered "Gallery Not
Found" for an unpublished gallery. With the setting off the link carries the
slug, /info serves it, and the preview worked — which is why this looked like
a short-URL bug rather than a draft one.

The mechanism differs from main by branch: stable identifies an admin preview
by a signed admin JWT in ?preview=, so this uses isAdminPreview, the same
predicate /info already uses for its draft gate. Both routes now match /info
rather than being stricter than the branch they live on.

The draft lookup only runs after isAdminPreview accepts the caller, so the
published path keeps its single query and an unverified caller never learns
the draft exists. GHSA-rh8r is unchanged and pinned by test: a bare slug
lookup still never returns share_token.

Relates to issue 1386

* fix(gallery): carry the admin preview credential to the API on stable

External review found the backend half of the previous commit was unreachable:
`preview=` appeared in exactly two places in the whole frontend — building the
View Gallery link and reading the token — and nothing forwarded it into the API
calls the gallery page then makes. So the new /resolve fallback exited at its
guard for every real browser request, and the /info draft escape that has been
there all along was equally inert. Draft preview on this branch was broken for
both URL forms, not just short ones.

The request interceptor now forwards the credential as x-admin-preview, and
isAdminPreview accepts it there as well as in ?preview=. A header rather than a
query parameter because the credential is the admin's own session JWT, and
query strings reach nginx access logs, browser history and Referer headers.
?preview= stays accepted: the gallery PAGE url is what the browser navigates
to, and hand-built links rely on it.

The tests only exercised ?preview=, which the browser never sends on an API
call — so they passed while the feature stayed broken end to end. They now
cover the header transport across /resolve, /verify-token and /info.

Relates to issue 1386

* fix(gallery): authenticate the draft preview by the admin cookie on stable

The header transport in the previous commit could not work. `admin_token`
appears exactly once in this frontend — the read inside getPreviewToken() —
and nothing ever writes it: AdminAuthContext stores only admin_user and the
JWT lives in an HttpOnly cookie. So getPreviewToken() always returned null,
the View Gallery link was built as `?preview=` with an empty value, and every
transport downstream had nothing to carry. Draft preview on this branch has
never worked from the UI, by either URL form.

The machinery was already there: verifyGalleryAccess drops the is_draft
constraint for a preview in three places. Only delivery was missing.

isAdminPreview now also accepts `admin_preview=1` as an intent flag,
authenticated by the admin_token cookie the browser already sends. That fixes
every caller at once, including the native fetch() in AuthenticatedImage and
AuthenticatedVideo, which bypasses the axios interceptor entirely — without
the flag on the media URL a preview loaded its metadata and then showed no
thumbnails, hero or lightbox media at all. The flag alone authorizes nothing:
with no valid admin token the check fails closed.

`?preview=<jwt>` keeps working for hand-built links, but nothing emits it any
more, so the admin's own session JWT no longer travels in a query string where
nginx access logs, browser history and Referer headers can see it.
getPreviewToken() is deleted along with its now-orphaned import.

Relates to issue 1386

* fix(gallery): carry the preview flag on every non-axios gallery URL

Third review round found the flag still missing on the paths that never touch
the axios interceptor:

- PhotoLightbox renders VideoPlayer, which assigns the photo URL straight to
  <video src>. Draft video playback 404'd. The previous commit had put the flag
  in AuthenticatedVideo, which has no consumers on this branch at all — dead
  code fixing nothing. Reverted; VideoPlayer carries it now, for both src and
  poster.
- savePhotoToDevice builds a native anchor from api.getUri(), and
  downloadAllPhotos uses a direct anchor when a zip is ready. Both downloads
  404'd inside a preview.

The three call sites plus AuthenticatedImage now share utils/adminPreview.ts
rather than repeating the check. It refuses absolute URLs, and the flag is
applied while the URL is still relative — buildResourceUrl can turn it
absolute in split deployments, which would have dropped it silently.

Relates to issue 1386

* fix(gallery): authorize the admin preview against the event, not just the token

isAdminPreview verified the JWT signature and `type === 'admin'` and checked
nothing else — not that the account still exists, not that the token is
unrevoked, and not that this admin may see this event. verifyGalleryAccess
then dropped the is_draft constraint on that basis, so any valid admin token
previewed any draft gallery and its photos, including one created by a
different photographer and including an account whose role grants neither
events.view nor photos.view. main closes this through access.authorize; this
applies the same rule where this branch keeps its checks.

The predicate could not simply be tightened in place: it runs while the event
lookup is being shaped, before there is an event to authorize against. So it
splits in two. previewClaimed() stays synchronous and signature-only, and its
one legitimate use is deciding whether the lookup includes drafts.
verifyAdminPreview(req, event) then applies the real rules — revocation, an
active account, ownership (super_admin, ownerless, or own event) and
events.view + photos.view — and assertDraftPreviewAllowed gates every loaded
event behind it. Both query branches in verifyGalleryAccess converge on one
`if (!event)`, so two gates cover all three lookups.

Fails closed on a transient database fault in the revocation or permission
check, rather than treating an error as a pass. The roles-table fallback
mirrors adminAuth: an install predating that schema has admins but no role to
check, so ownership is the only gate that applies there.

The suite previously carried a test documenting the hole — "accepts any valid
admin token, matching /info on this branch". That is replaced by the three
cases it was standing in for: a non-owning admin, an admin with no gallery
permissions, and a deactivated account, each 404 now and 200 before.

Relates to issue 1411

* fix(gallery): close two gaps in the draft-preview authorization

Found by a fourth review round.

verify-token selected its own columns and omitted created_by, so
verifyAdminPreview saw an ownerless event and allowed any admin holding
events.view and photos.view — including one who does not own the draft, and
while /resolve and /info were correctly refusing them. The ownership check was
running; it just had nothing to check against.

savePhotoToDevice applied the preview flag to the output of api.getUri().
With an absolute VITE_API_URL that is an absolute URL, which withAdminPreview
refuses by design, so the flag was silently dropped and desktop and Android
preview downloads 404'd. Applied to the relative path before getUri expands it.

Relates to issue 1386
Relates to issue 1411

* fix(gallery): keep an admin draft preview out of the guest share-login flow (stable)

Twin of the main-branch fix. Making verify-token pass for a draft preview
opened a path that did not exist before it: the gallery bootstrap then called
shareLinkLogin, whose share lookup excludes drafts, so it 404'd and recorded a
failed login attempt against the caller's IP on the way out. Five preview opens
inside the attempt window locked share-link logins out for that IP — for real
guests too, and after publishing.

An admin preview needs no guest session: the admin cookie plus admin_preview=1
already authorizes every gallery call. The preview path loads the gallery
directly and never touches the login endpoint.

Relates to issue 1386

* test(gallery): move the preview revocation tests onto the new predicates

The revocation hardening that landed in the meantime shipped unit tests
against isAdminPreview, which this branch replaces with previewClaimed plus
verifyAdminPreview. They were asserting the old shape — including which where()
calls the event lookup makes — so they broke on the merge.

Rewritten against the contract that actually matters rather than the query
shape: previewClaimed is signature-only by design and deliberately does not
consult revocation, and verifyAdminPreview refuses a revoked token, fails
closed when the revocation store cannot be read, and refuses when there is no
event to authorize against. The end-to-end case is asserted through
verifyGalleryAccess: a revoked preview token widens the lookup and still gets
404 for the draft.

Found by CI, not locally — these live in backend/src/__tests__, a second test
root that the suites I had been running do not cover.

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-09-11 12:14:24 +02:00

269 lines
11 KiB
JavaScript

/**
* Previewing an unpublished gallery through its SHORT share URL (#1386).
*
* /info has honoured admin_preview since #868, but two sibling routes never
* did, and both sit on the short-URL path:
*
* GET /resolve/:identifier — filtered drafts out via ACTIVE_EVENT_FILTER
* GET /:slug/verify-token/:token — same, inline
*
* With "use short gallery URLs" OFF the admin's View Gallery link carries the
* slug, GalleryPage never calls /resolve, and the preview worked. With it ON
* the link is the token form, GalleryPage resolves it first, and the draft
* 404'd as "Gallery Not Found" — which is exactly what was reported.
*
* The relaxation is admin-preview-only, so the other half of these tests is
* the part that must NOT move: anonymous callers still get 404 for a draft,
* and GHSA-rh8r's rule (never hand a share_token back on a bare slug lookup)
* has to survive the new path too.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'draft-preview-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-draft-preview-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
// Share-token fixtures, deliberately low-entropy and obviously fake. They
// have to satisfy SHARE_TOKEN_REGEX (32 hex chars), and random-looking hex of
// that shape is exactly what secret scanners flag — GitGuardian raised two
// "Generic High Entropy Secret" findings on the first version of this file.
const DRAFT_SLUG = 'draft-preview-event';
const DRAFT_TOKEN = 'deadbeefdeadbeefdeadbeefdeadbeef';
const LIVE_SLUG = 'published-event';
const LIVE_TOKEN = 'feedfacefeedfacefeedfacefeedface';
describe('draft preview through the short share URL (#1386)', () => {
let db; let cleanup; let app; let adminId; let foreignId;
// Two transports. admin_preview=1 is an intent flag authenticated by the
// admin cookie — what the frontend sends. ?preview=<jwt> is the legacy
// hand-built-link form, kept working.
const preview = (id = adminId) => `preview=${mintAdminToken(id)}`;
const asAdmin = (req, id = adminId) => req.set('Cookie', `admin_token=${mintAdminToken(id)}`);
async function insertEvent({ slug, token, isDraft }) {
await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-09-01',
host_email: '[email protected]',
admin_email: '[email protected]',
password_hash: 'x',
share_link: `/gallery/${slug}/${token}`,
share_token: token,
require_password: 0,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: isDraft ? 1 : 0,
created_by: adminId,
created_at: new Date().toISOString(),
});
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId);
const [row] = await db('admin_users').insert({
username: 'foreign', email: '[email protected]', password_hash: 'unused', is_active: 1,
}).returning('id');
foreignId = row?.id ?? row;
await assignAdminRole(db, foreignId, 'viewer');
await insertEvent({ slug: DRAFT_SLUG, token: DRAFT_TOKEN, isDraft: true });
await insertEvent({ slug: LIVE_SLUG, token: LIVE_TOKEN, isDraft: false });
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('the reported case — admin previewing a draft', () => {
it('resolves the draft by share token (was 404 "Gallery Not Found")', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?${preview()}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(DRAFT_SLUG);
expect(res.body.matchType).toBe('token');
});
it('resolves the draft by full share link', async () => {
const identifier = encodeURIComponent(`/gallery/${DRAFT_SLUG}/${DRAFT_TOKEN}`);
const res = await request(app).get(`/api/gallery/resolve/${identifier}?${preview()}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(DRAFT_SLUG);
});
it('clears verify-token for the draft, the next step of the same flow', async () => {
const res = await request(app)
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?${preview()}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
});
// The transport the SHIPPED frontend uses. The first cut of this fix only
// tested ?preview=, which the browser never sends on an API call — so the
// suite passed while the feature stayed broken end to end. Caught in review.
describe('admin_preview=1 authenticated by the admin cookie', () => {
it('resolves the draft', async () => {
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(DRAFT_SLUG);
});
it('clears verify-token', async () => {
const res = await asAdmin(
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('serves /info for the draft', async () => {
const res = await asAdmin(
request(app).get(`/api/gallery/${DRAFT_SLUG}/info?admin_preview=1`),
);
expect(res.status).toBe(200);
});
it('serves draft MEDIA, which is what the flag on the URL is for', async () => {
// AuthenticatedImage/Video use native fetch and never see the axios
// interceptor, so the flag has to travel on the media URL itself. Without
// it the preview loaded metadata and showed no images at all.
const res = await asAdmin(
request(app).get(`/api/gallery/${DRAFT_SLUG}/photos?admin_preview=1`),
);
expect(res.status).toBe(200);
});
it('404s with the flag but no admin cookie — the flag authorizes nothing', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`);
expect(res.status).toBe(404);
});
it('404s with the flag and a cookie that is not an admin JWT', async () => {
const res = await request(app)
.get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`)
.set('Cookie', 'admin_token=not-a-jwt');
expect(res.status).toBe(404);
});
});
describe('what must not move', () => {
it('404s an anonymous resolve of the draft token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}`);
expect(res.status).toBe(404);
});
it('404s when ?preview= carries a token that is not a valid admin JWT', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?preview=not-a-jwt`);
expect(res.status).toBe(404);
});
it('404s when ?preview= is absent entirely', async () => {
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?preview=`);
expect(res.status).toBe(404);
});
it('404s a non-owning admin on verify-token too (#1411)', async () => {
// This route selected its own columns and omitted created_by, so the
// ownership check saw an ownerless event and waved the caller through
// while /resolve and /info refused them.
const res = await asAdmin(
request(app).get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}?admin_preview=1`),
foreignId,
);
expect(res.status).toBe(404);
});
it('404s an admin who does not own the event (#1411)', async () => {
// Was 200: a valid signature was the whole check, so any admin previewed
// any draft, including another photographer's. Now ownership applies —
// the same rule requireEventOwnership enforces everywhere else.
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
foreignId,
);
expect(res.status).toBe(404);
const info = await asAdmin(
request(app).get(`/api/gallery/${DRAFT_SLUG}/info?admin_preview=1`),
foreignId,
);
expect(info.status).toBe(404);
});
it('404s an admin whose role grants no gallery permissions (#1411)', async () => {
// The owner, but stripped of events.view/photos.view.
const original = (await db('admin_users').where({ id: adminId }).first()).role_id;
await db('admin_users').where({ id: adminId }).update({ role_id: null });
try {
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
);
expect(res.status).toBe(404);
} finally {
await db('admin_users').where({ id: adminId }).update({ role_id: original });
}
});
it('404s an admin whose account has been deactivated (#1411)', async () => {
await db('admin_users').where({ id: adminId }).update({ is_active: 0 });
try {
const res = await asAdmin(
request(app).get(`/api/gallery/resolve/${DRAFT_TOKEN}?admin_preview=1`),
);
expect(res.status).toBe(404);
} finally {
await db('admin_users').where({ id: adminId }).update({ is_active: 1 });
}
});
it('404s an anonymous verify-token for the draft', async () => {
const res = await request(app)
.get(`/api/gallery/${DRAFT_SLUG}/verify-token/${DRAFT_TOKEN}`);
expect(res.status).toBe(404);
});
it('still withholds the share_token on a bare slug lookup (GHSA-rh8r)', async () => {
// The draft path must not become a way around the token-withholding rule.
const res = await request(app).get(`/api/gallery/resolve/${DRAFT_SLUG}?${preview()}`);
expect(res.status).toBe(200);
expect(res.body.matchType).toBe('slug');
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(DRAFT_TOKEN);
});
it('leaves the published gallery resolving anonymously, as before', async () => {
const res = await request(app).get(`/api/gallery/resolve/${LIVE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(LIVE_SLUG);
expect(res.body.token).toBe(LIVE_TOKEN);
});
it('still 404s an identifier that matches nothing', async () => {
const res = await request(app).get(`/api/gallery/resolve/no-such-gallery?${preview()}`);
expect(res.status).toBe(404);
});
});
});