Columns are spelled out rather than selected with a wildcard, so a column added to item_drafts later does not silently start reaching the browser. That matters most for the join to upload_links, which carries the token digest — only the label is taken, and a test asserts the digest never appears in a response. Discarded rows are excluded by default rather than deleted. Discard has to be recoverable because it is one click away in what amounts to an inbox, but a discarded row left in the default view would compete for attention with work that still needs doing. The gate goes on the mount in app.ts rather than inside the router, matching every other admin router. Since ADMIN_GATE_SECRET is unset for integration runs the gate is disabled there, so the test that asserts the mount is actually gated sets the secret for its own duration — leaving requireAdminGate off a new mount is otherwise a silent hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.4 KiB
TypeScript
133 lines
4.4 KiB
TypeScript
import request from 'supertest';
|
|
import app from '../../src/app';
|
|
import { pool } from '../../src/db';
|
|
import { resetDb, closeDb } from './setup/testDb';
|
|
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await pool.end();
|
|
await closeDb();
|
|
});
|
|
|
|
interface SeedOptions {
|
|
state?: string;
|
|
aiName?: string | null;
|
|
priceSource?: string;
|
|
}
|
|
|
|
/**
|
|
* A submission as the intake route leaves it: a pending item priced at the
|
|
* migration's 8000 default, a draft beside it, and one photo.
|
|
*/
|
|
async function seedDraft(overrides: SeedOptions = {}): Promise<number> {
|
|
const { rows } = await pool.query<{ id: number }>(
|
|
`INSERT INTO items (name, status) VALUES ('Submission 2026-09-01', 'pending') RETURNING id`
|
|
);
|
|
const itemId = rows[0]!.id;
|
|
|
|
await pool.query(
|
|
`INSERT INTO item_drafts (item_id, submitter_note, state, ai_name, ai_description, price_source)
|
|
VALUES ($1, 'found in a loft', $2, $3, 'A blue vase.', $4)`,
|
|
[
|
|
itemId,
|
|
overrides.state ?? 'ready',
|
|
overrides.aiName === undefined ? 'Blue vase' : overrides.aiName,
|
|
overrides.priceSource ?? 'ai'
|
|
]
|
|
);
|
|
await pool.query(
|
|
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0)`,
|
|
[itemId]
|
|
);
|
|
return itemId;
|
|
}
|
|
|
|
const itemIds = (body: { drafts: { item_id: number }[] }): number[] =>
|
|
body.drafts.map((draft) => draft.item_id);
|
|
|
|
describe('GET /api/admin/item-drafts', () => {
|
|
it('returns the draft with its item, photos and note', async () => {
|
|
const itemId = await seedDraft();
|
|
|
|
const res = await request(app).get('/api/admin/item-drafts');
|
|
|
|
expect(res.status).toBe(200);
|
|
const draft = res.body.drafts.find((d: { item_id: number }) => d.item_id === itemId);
|
|
expect(draft).toBeDefined();
|
|
expect(draft.submitter_note).toBe('found in a loft');
|
|
expect(draft.ai_name).toBe('Blue vase');
|
|
expect(draft.price_cents).toBe(8000);
|
|
expect(draft.price_source).toBe('ai');
|
|
expect(draft.images).toHaveLength(1);
|
|
});
|
|
|
|
// The token digest lives on upload_links and must never be selected into a
|
|
// response. Spelling the columns is what prevents that; this is the assertion
|
|
// that keeps it spelled.
|
|
it('never sends the upload link token digest', async () => {
|
|
await seedDraft();
|
|
|
|
const res = await request(app).get('/api/admin/item-drafts');
|
|
|
|
expect(JSON.stringify(res.body)).not.toContain('token_hash');
|
|
});
|
|
|
|
it('filters by state', async () => {
|
|
const ready = await seedDraft({ state: 'ready' });
|
|
const failed = await seedDraft({ state: 'failed' });
|
|
|
|
const res = await request(app).get('/api/admin/item-drafts?state=failed');
|
|
|
|
expect(itemIds(res.body)).toContain(failed);
|
|
expect(itemIds(res.body)).not.toContain(ready);
|
|
});
|
|
|
|
// Discarded is recoverable, so it has to be reachable — but it must not sit
|
|
// in the default view competing with work that still needs doing.
|
|
it('hides discarded drafts unless they are asked for', async () => {
|
|
const discarded = await seedDraft({ state: 'discarded' });
|
|
|
|
const def = await request(app).get('/api/admin/item-drafts');
|
|
expect(itemIds(def.body)).not.toContain(discarded);
|
|
|
|
const asked = await request(app).get('/api/admin/item-drafts?state=discarded');
|
|
expect(itemIds(asked.body)).toContain(discarded);
|
|
});
|
|
|
|
/**
|
|
* The gate is disabled when ADMIN_GATE_SECRET is unset, which is how the rest
|
|
* of this suite runs, so it is set here for the length of this test alone.
|
|
* Worth asserting: the gate goes on the mount in app.ts rather than inside the
|
|
* router, and leaving it off a new mount is a silent hole.
|
|
*/
|
|
describe('with the admin gate configured', () => {
|
|
const original = process.env.ADMIN_GATE_SECRET;
|
|
|
|
beforeAll(() => {
|
|
process.env.ADMIN_GATE_SECRET = 'integration-secret';
|
|
});
|
|
|
|
afterAll(() => {
|
|
if (original === undefined) delete process.env.ADMIN_GATE_SECRET;
|
|
else process.env.ADMIN_GATE_SECRET = original;
|
|
});
|
|
|
|
it('refuses a request with no gate header', async () => {
|
|
await seedDraft();
|
|
const res = await request(app).get('/api/admin/item-drafts');
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('allows a request carrying the secret', async () => {
|
|
await seedDraft();
|
|
const res = await request(app)
|
|
.get('/api/admin/item-drafts')
|
|
.set('X-Admin-Gate', 'integration-secret');
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
});
|