Feature/224 notification impl #265
@@ -11,6 +11,7 @@ import adminCategoriesRouter from './routes/adminCategories';
|
|||||||
import adminTagsRouter from './routes/adminTags';
|
import adminTagsRouter from './routes/adminTags';
|
||||||
import adminUploadLinksRouter from './routes/adminUploadLinks';
|
import adminUploadLinksRouter from './routes/adminUploadLinks';
|
||||||
import adminItemDraftsRouter from './routes/adminItemDrafts';
|
import adminItemDraftsRouter from './routes/adminItemDrafts';
|
||||||
|
import intakeActionsRouter from './routes/intakeActions';
|
||||||
import intakeRouter from './routes/intake';
|
import intakeRouter from './routes/intake';
|
||||||
import adminVersionRouter from './routes/adminVersion';
|
import adminVersionRouter from './routes/adminVersion';
|
||||||
import filtersRouter from './routes/filters';
|
import filtersRouter from './routes/filters';
|
||||||
@@ -68,6 +69,7 @@ app.use('/api/cart', cartRouter);
|
|||||||
// Public and unauthenticated by design (#222). No requireAdminGate: the token
|
// Public and unauthenticated by design (#222). No requireAdminGate: the token
|
||||||
// in the path is the whole access control, and every refusal is a 404.
|
// in the path is the whole access control, and every refusal is a 404.
|
||||||
app.use('/api/intake', intakeRouter);
|
app.use('/api/intake', intakeRouter);
|
||||||
|
app.use('/api/intake-actions', intakeActionsRouter);
|
||||||
app.use('/api/checkout/cart', cartCheckoutRouter);
|
app.use('/api/checkout/cart', cartCheckoutRouter);
|
||||||
// requireAdminGate is attached to each admin router rather than to a path
|
// requireAdminGate is attached to each admin router rather than to a path
|
||||||
// prefix. Attached to the router, an admin router added later at some other
|
// prefix. Attached to the router, an admin router added later at some other
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { pool } from '../db';
|
||||||
|
import { asyncRoute } from '../asyncRoute';
|
||||||
|
import { IntakeAction, verifyAction } from '../intake/actionLinks';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const ACTIONS: readonly IntakeAction[] = ['regenerate', 'discard'];
|
||||||
|
|
||||||
|
function isAction(value: string): value is IntakeAction {
|
||||||
|
return (ACTIONS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Checked {
|
||||||
|
itemId: number;
|
||||||
|
action: IntakeAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, and protected by the signature rather than by the admin gate.
|
||||||
|
*
|
||||||
|
* These are clicked from an inbox by someone who is not signed in, which is the
|
||||||
|
* whole point of them. Neither action can publish: the worst outcome of a
|
||||||
|
* leaked link is a wasted API call or a hide the review queue can undo, and
|
||||||
|
* that is exactly what makes putting them in an email acceptable.
|
||||||
|
*/
|
||||||
|
function check(req: Request, res: Response): Checked | null {
|
||||||
|
const action = req.params.action ?? '';
|
||||||
|
if (!isAction(action)) {
|
||||||
|
res.status(404).json({ error: 'unknown action' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = Number(req.params.itemId);
|
||||||
|
const expiresAt = Number(req.query.expires);
|
||||||
|
const sig = typeof req.query.sig === 'string' ? req.query.sig : '';
|
||||||
|
|
||||||
|
if (!Number.isInteger(itemId) || !verifyAction(itemId, action, expiresAt, sig)) {
|
||||||
|
// One response for a forged signature, an expired link and an unconfigured
|
||||||
|
// secret alike. Distinguishing them would tell somebody probing which of
|
||||||
|
// those they had achieved.
|
||||||
|
res.status(403).json({ error: 'this link is not valid, or has expired' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { itemId, action };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms, and changes nothing.
|
||||||
|
*
|
||||||
|
* Mail scanners and corporate link-rewriting gateways issue a GET against every
|
||||||
|
* URL in a message before a human ever sees it. A GET that discarded a draft
|
||||||
|
* would therefore fire itself on delivery, carrying a valid signature and
|
||||||
|
* looking entirely legitimate in the log — and nobody would know to go and
|
||||||
|
* recover it. So the state change lives on POST, and this exists only to let a
|
||||||
|
* person confirm what they are about to do.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
'/:itemId/:action',
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const checked = check(req, res);
|
||||||
|
if (!checked) return;
|
||||||
|
|
||||||
|
const { rows } = await pool.query<{ item_name: string; state: string }>(
|
||||||
|
`SELECT i.name AS item_name, d.state
|
||||||
|
FROM item_drafts d JOIN items i ON i.id = d.item_id
|
||||||
|
WHERE d.item_id = $1`,
|
||||||
|
[checked.itemId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) return res.status(404).json({ error: 'no draft for this item' });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
itemId: checked.itemId,
|
||||||
|
action: checked.action,
|
||||||
|
itemName: rows[0].item_name,
|
||||||
|
state: rows[0].state,
|
||||||
|
confirmWith: 'POST to this same url'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/:itemId/:action',
|
||||||
|
asyncRoute(async (req: Request, res: Response) => {
|
||||||
|
const checked = check(req, res);
|
||||||
|
if (!checked) return;
|
||||||
|
|
||||||
|
if (checked.action === 'regenerate') {
|
||||||
|
// attempts cleared with the state, for the same reason the admin route
|
||||||
|
// does it: the worker only picks up rows below the attempt cap, so
|
||||||
|
// re-queueing an exhausted draft without clearing them would do nothing
|
||||||
|
// and say nothing.
|
||||||
|
const { rowCount } = await pool.query(
|
||||||
|
`UPDATE item_drafts SET state = 'queued', attempts = 0, ai_error = NULL WHERE item_id = $1`,
|
||||||
|
[checked.itemId]
|
||||||
|
);
|
||||||
|
if (rowCount === 0) return res.status(404).json({ error: 'no draft for this item' });
|
||||||
|
return res.json({ state: 'queued' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
const { rowCount } = await client.query(
|
||||||
|
`UPDATE item_drafts SET state = 'discarded' WHERE item_id = $1`,
|
||||||
|
[checked.itemId]
|
||||||
|
);
|
||||||
|
if (rowCount === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(404).json({ error: 'no draft for this item' });
|
||||||
|
}
|
||||||
|
// Nothing is deleted, here or in the admin route. Discard is reachable in
|
||||||
|
// one click from an inbox, and the photographs are often the only copy of
|
||||||
|
// something no longer in the sender's hands.
|
||||||
|
await client.query(`UPDATE items SET status = 'pending' WHERE id = $1`, [checked.itemId]);
|
||||||
|
await client.query('COMMIT');
|
||||||
|
res.json({ state: 'discarded' });
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'internal error' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -30,7 +30,10 @@ describe('GET /api/admin/settings', () => {
|
|||||||
passwordResetHours: 1,
|
passwordResetHours: 1,
|
||||||
greetingFormat: 'Hi {{firstName}},',
|
greetingFormat: 'Hi {{firstName}},',
|
||||||
greetingFallback: 'Hi,',
|
greetingFallback: 'Hi,',
|
||||||
draftingModel: 'claude-sonnet-5'
|
draftingModel: 'claude-sonnet-5',
|
||||||
|
// Empty by default: nowhere to send the intake notification is a working
|
||||||
|
// configuration, and means simply do not send one (#224).
|
||||||
|
intakeNotifyEmail: ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('GET /api/admin/email-templates', () => {
|
|||||||
'emailChanged',
|
'emailChanged',
|
||||||
'favoriteSold',
|
'favoriteSold',
|
||||||
'favoriteWithdrawn',
|
'favoriteWithdrawn',
|
||||||
|
'intakeDraft',
|
||||||
'passwordReset',
|
'passwordReset',
|
||||||
'verification'
|
'verification'
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../src/app';
|
||||||
|
import { pool } from '../../src/db';
|
||||||
|
import { resetDb, closeDb } from './setup/testDb';
|
||||||
|
import { signAction, ACTION_TTL_MS } from '../../src/intake/actionLinks';
|
||||||
|
import { notifyDraftReady } from '../../src/intake/notifyDraft';
|
||||||
|
|
||||||
|
const SECRET = 'integration-intake-secret';
|
||||||
|
const original = process.env.INTAKE_ACTION_SECRET;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.INTAKE_ACTION_SECRET = SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (original === undefined) delete process.env.INTAKE_ACTION_SECRET;
|
||||||
|
else process.env.INTAKE_ACTION_SECRET = original;
|
||||||
|
await pool.end();
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedDraft(): Promise<number> {
|
||||||
|
const { rows } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO items (name, status) VALUES ('Submission', 'pending') RETURNING id`
|
||||||
|
);
|
||||||
|
const itemId = rows[0]!.id;
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO item_drafts (item_id, state, ai_name) VALUES ($1, 'ready', 'Blue vase')`,
|
||||||
|
[itemId]
|
||||||
|
);
|
||||||
|
return itemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const soon = (): number => Date.now() + ACTION_TTL_MS;
|
||||||
|
|
||||||
|
function link(itemId: number, action: 'regenerate' | 'discard', expiresAt: number): string {
|
||||||
|
const sig = signAction(itemId, action, expiresAt);
|
||||||
|
return `/api/intake-actions/${itemId}/${action}?expires=${expiresAt}&sig=${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateOf = async (itemId: number): Promise<string | undefined> =>
|
||||||
|
(await pool.query<{ state: string }>(`SELECT state FROM item_drafts WHERE item_id = $1`, [itemId]))
|
||||||
|
.rows[0]?.state;
|
||||||
|
|
||||||
|
describe('the signed action links', () => {
|
||||||
|
/**
|
||||||
|
* The reason GET does not act. Mail scanners and link-rewriting gateways
|
||||||
|
* issue a GET against every URL in a message before a human sees it, so a GET
|
||||||
|
* that discarded a draft would fire itself on delivery — with a valid
|
||||||
|
* signature, looking entirely legitimate in the log, and nobody would know to
|
||||||
|
* go and recover it.
|
||||||
|
*/
|
||||||
|
it('GET confirms without changing anything', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
|
||||||
|
const res = await request(app).get(link(itemId, 'discard', soon()));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.action).toBe('discard');
|
||||||
|
expect(await stateOf(itemId)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST discards and unpublishes the item', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
|
||||||
|
const res = await request(app).post(link(itemId, 'discard', soon()));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await stateOf(itemId)).toBe('discarded');
|
||||||
|
const item = await pool.query(`SELECT status FROM items WHERE id = $1`, [itemId]);
|
||||||
|
expect(item.rows[0]?.status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nothing is deleted, which is what makes a link in an inbox acceptable.
|
||||||
|
it('POST discard keeps the item and its row', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
await request(app).post(link(itemId, 'discard', soon()));
|
||||||
|
|
||||||
|
const item = await pool.query(`SELECT id FROM items WHERE id = $1`, [itemId]);
|
||||||
|
expect(item.rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST regenerates and clears the attempts', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
await pool.query(`UPDATE item_drafts SET attempts = 3 WHERE item_id = $1`, [itemId]);
|
||||||
|
|
||||||
|
await request(app).post(link(itemId, 'regenerate', soon()));
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT state, attempts FROM item_drafts WHERE item_id = $1`,
|
||||||
|
[itemId]
|
||||||
|
);
|
||||||
|
expect(rows[0]).toMatchObject({ state: 'queued', attempts: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a forged signature', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
|
||||||
|
const res = await request(app).post(
|
||||||
|
`/api/intake-actions/${itemId}/discard?expires=${soon()}&sig=forged`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(await stateOf(itemId)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The signature names the item, so one link must not act on another.
|
||||||
|
it('refuses a signature minted for a different item', async () => {
|
||||||
|
const mine = await seedDraft();
|
||||||
|
const other = await seedDraft();
|
||||||
|
const expires = soon();
|
||||||
|
const sig = signAction(other, 'discard', expires);
|
||||||
|
|
||||||
|
const res = await request(app).post(
|
||||||
|
`/api/intake-actions/${mine}/discard?expires=${expires}&sig=${sig}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(await stateOf(mine)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
// And it names the action, so a regenerate link cannot be upgraded.
|
||||||
|
it('refuses a signature minted for a different action', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
const expires = soon();
|
||||||
|
const sig = signAction(itemId, 'regenerate', expires);
|
||||||
|
|
||||||
|
const res = await request(app).post(
|
||||||
|
`/api/intake-actions/${itemId}/discard?expires=${expires}&sig=${sig}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(await stateOf(itemId)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an expired link', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
|
||||||
|
const res = await request(app).post(link(itemId, 'discard', Date.now() - 1000));
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(await stateOf(itemId)).toBe('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
// There is no signable publish, and asking for one must not find a handler.
|
||||||
|
it('refuses an action it does not recognise', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
|
||||||
|
const res = await request(app).post(
|
||||||
|
`/api/intake-actions/${itemId}/publish?expires=${soon()}&sig=anything`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s for an item with no draft', async () => {
|
||||||
|
const { rows } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO items (name) VALUES ('ordinary') RETURNING id`
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await request(app).post(link(rows[0]!.id, 'discard', soon()));
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the notification itself', () => {
|
||||||
|
// Nowhere to send it is a working configuration, and must not throw into the
|
||||||
|
// worker and fail a draft that was written correctly.
|
||||||
|
it('does nothing when no recipient is configured', async () => {
|
||||||
|
const itemId = await seedDraft();
|
||||||
|
await expect(notifyDraftReady(itemId)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when the item has no draft', async () => {
|
||||||
|
const { rows } = await pool.query<{ id: number }>(
|
||||||
|
`INSERT INTO items (name) VALUES ('ordinary') RETURNING id`
|
||||||
|
);
|
||||||
|
await expect(notifyDraftReady(rows[0]!.id)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user