divan/api/src/moderation.test.ts
Anas Rashid 93825fe5d9 Publishing commits to divan-data git (#34); public names only; 'last edited' on poems
- api/src/git.ts: each published version is a commit in the divan-data checkout (queued, one at a
  time): the moderator as author by public name with a placeholder email, the summary and version
  in the message, Reviewed-by / Approved-by / Divan-Revision / Divan-Version trailers;
  DIVAN_GIT_PUSH=1 pushes. The commit id is kept with the revision.
- Privacy fix: published files and commits carry public names (profile name, else 'موڈریٹر <id>'),
  never email addresses (divan-data is public).
- Poem pages: 'دیوان کا ورژن …' with who edited, reviewed and published, the date, and a link to
  the commit diff (DIVAN_DATA_COMMIT_URL).
- deploy/sync.sh: pull --rebase so publishing commits are kept.
- Admins are super moderators (documented).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:37:39 +02:00

131 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Fastify from 'fastify';
import { authRoutes } from './auth.ts';
import { adminRoutes } from './admin.ts';
import { permissionRoutes } from './permissions.ts';
import { moderationRoutes } from './moderation.ts';
import { diffLines } from './diff.ts';
import { execFileSync } from 'node:child_process';
import { pool } from './db.ts';
after(() => pool.end());
test('line diff: kept, removed and added lines', () => {
assert.deepEqual(diffLines('ا\nب\nج', 'ا\nد\nج'), [{ op: '=', text: 'ا' }, { op: '-', text: 'ب' }, { op: '+', text: 'د' }, { op: '=', text: 'ج' }]);
});
test('pipeline: L2 drafts, L1 approves, admin publishes; returns, rejects, permissions, conflicts, history', async () => {
const data = await mkdtemp(join(tmpdir(), 'divan-data-'));
process.env.DIVAN_DATA_DIR = data;
const git = (...a: string[]) => execFileSync('git', ['-C', data, ...a], { encoding: 'utf8' });
git('init', '-q'); git('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'start');
const app = Fastify();
authRoutes(app); adminRoutes(app); permissionRoutes(app); moderationRoutes(app);
const run = Date.now();
const call = (method: string, url: string, body?: object, token?: string) =>
app.inject({ method: method as any, url, payload: body, headers: { ...(token && { authorization: `Bearer ${token}` }), 'x-client-ip': `mod-${run}` } });
const person = async (name: string, role: string) => {
const s = (await call('POST', '/api/auth/signup', { email: `${name}-${run}@divan.test`, password: 'pass-word-1' })).json();
await pool.query('UPDATE users SET role = $2 WHERE id = $1', [s.user.id, role]);
return { ...s, id: s.user.id };
};
const admin = await person('admin', 'admin'), l1 = await person('l1', 'mod-l1'), l2 = await person('l2', 'mod-l2');
await call('POST', '/api/auth/profile', { full_name: 'نیا موڈریٹر' }, l2.token); // a public name; the L1 has none
const other = await person('l1other', 'mod-l1'), reader = await person('reader', 'reader');
// a Ghalib ghazal; the work is restored at the end
const poem = (await pool.query(`SELECT p.id, p.url, p.title FROM poems p JOIN categories c ON c.id = p.category_id WHERE c.url = '/p266/ghazal' ORDER BY p.position LIMIT 1`)).rows[0];
const before = (await pool.query('SELECT vorder, position, couplet, text FROM verses WHERE poem_id = $1 ORDER BY vorder', [poem.id])).rows;
const searchBefore = (await pool.query('SELECT search_text FROM poems WHERE id = $1', [poem.id])).rows[0].search_text;
try {
// grants: L2 and L1 on Ghalib's ghazals; the other L1 on Iqbal only
for (const [m, target] of [[l2, '/p266/ghazal'], [l1, '/p266/ghazal'], [other, '/p238']] as const)
await call('POST', `/api/admin/users/${m.id}/grants`, { scope: target === '/p238' ? 'poet' : 'category', target, content: ['works'], actions: ['edit'] }, admin.token);
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, reader.token)).statusCode, 403, 'readers cannot moderate');
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, other.token)).statusCode, 403, 'outside the grant');
assert.deepEqual((await call('GET', `/api/mod/can?poem=${poem.id}`, undefined, l2.token)).json(), { edit: true, review: false, publish: false });
// L2 drafts: the draft starts from the current text; reopening returns the same draft
const { id } = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json();
assert.equal((await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l2.token)).json().id, id);
let rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
assert.equal(rev.changes, 0);
const misra = before.find((v: any) => v.couplet === 1 && v.position === 'Left').text;
const edited = rev.revision.content.replace(misra, misra + ' (ترمیم)');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: 'متن بغیر شعر کے {{', summary: 'x' }, l2.token)).statusCode, 200, 'prose is a paragraph');
assert.equal((await call('POST', `/api/mod/revisions/${id}/save`, { content: edited, summary: 'ایک مصرع درست کیا' }, l2.token)).statusCode, 200);
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, l2.token)).json();
assert.deepEqual(rev.diff.filter((d: any) => d.op !== '=').map((d: any) => d.op), ['-', '+'], 'one line changed');
assert.equal(rev.may.approve, false, 'not my own draft');
// L2 submits; the out-of-scope L1 cannot see it; L1 returns it, L2 resubmits, L1 approves
assert.equal((await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token)).json().status, 'submitted');
assert.equal((await call('GET', `/api/mod/revisions/${id}`, undefined, other.token)).statusCode, 403);
assert.equal((await call('GET', '/api/mod/queue', undefined, l1.token)).json().review.some((r: any) => r.id === id), true);
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, {}, l1.token)).statusCode, 400, 'a reason is required');
assert.equal((await call('POST', `/api/mod/revisions/${id}/return`, { comment: 'وزن دیکھیں' }, l1.token)).json().status, 'returned');
assert.equal((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).statusCode, 403, 'not yet approved');
await call('POST', `/api/mod/revisions/${id}/submit`, {}, l2.token);
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, {}, l2.token)).statusCode, 403, 'L2 cannot approve');
assert.equal((await call('POST', `/api/mod/revisions/${id}/approve`, { comment: 'درست' }, l1.token)).json().status, 'approved');
assert.equal((await call('GET', '/api/mod/queue', undefined, admin.token)).json().publish.some((r: any) => r.id === id), true);
// admin publishes: version 1 in divan-data and on the site
assert.deepEqual((await call('POST', `/api/mod/revisions/${id}/publish`, {}, admin.token)).json(), { status: 'published', version: 1 });
const file = JSON.parse(await readFile(join(data, 'divan', poem.url.slice(1) + '.json'), 'utf8'));
// public names only in divan-data (it is public); a commit with the moderator as author and review trailers
assert.equal(file.Edited.by, 'نیا موڈریٹر');
assert.equal(file.Edited.reviewedBy, `موڈریٹر ${l1.id}`);
assert.equal(file.Edited.publishedBy, `موڈریٹر ${admin.id}`);
assert.ok(!JSON.stringify(file).includes('@'), 'no email addresses');
const log = git('log', '-1', '--format=%an <%ae>%n%B');
assert.match(log, new RegExp(`^نیا موڈریٹر <moderator-${l2.id}@users\\.noreply\\.divan>`));
assert.match(log, /\(ورژن 1\)/);
assert.match(log, new RegExp(`Reviewed-by: موڈریٹر ${l1.id} <moderator-${l1.id}@`));
assert.match(log, new RegExp(`Approved-by: موڈریٹر ${admin.id} <moderator-${admin.id}@`));
assert.ok(!log.includes('divan.test'), 'no real emails in git');
assert.deepEqual(git('show', '--name-only', '--format=', 'HEAD').trim().split('\n').sort(), [`divan${poem.url}.dtx`, `divan${poem.url}.json`]);
const committed = (await pool.query(`SELECT commit FROM revisions WHERE id = $1`, [id])).rows[0].commit;
assert.equal(committed, git('rev-parse', 'HEAD').trim(), 'the commit is recorded with the version');
assert.ok((await readFile(join(data, 'divan', poem.url.slice(1) + '.dtx'), 'utf8')).includes(misra + ' (ترمیم)'));
assert.equal((await pool.query('SELECT text FROM verses WHERE poem_id = $1 AND couplet = 1 AND position = $2', [poem.id, 'Left'])).rows[0].text, misra + ' (ترمیم)');
// the full event trail, in order
rev = (await call('GET', `/api/mod/revisions/${id}`, undefined, admin.token)).json();
assert.deepEqual(rev.events.map((e: any) => e.action), ['created', 'saved', 'saved', 'submitted', 'returned', 'submitted', 'approved', 'published']);
assert.equal(rev.events.find((e: any) => e.action === 'returned').comment, 'وزن دیکھیں');
// conflict: an L1 draft started from version 1, but an admin publishes version 2 first
const d2 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, l1.token)).json().id;
const d3 = (await call('POST', `/api/mod/work/${poem.id}/draft`, undefined, admin.token)).json().id;
const cur = (await call('GET', `/api/mod/work/${poem.id}`, undefined, admin.token)).json();
assert.equal(cur.version, 1);
await call('POST', `/api/mod/revisions/${d3}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم دوم)') }, admin.token);
assert.deepEqual((await call('POST', `/api/mod/revisions/${d3}/submit`, {}, admin.token)).json(), { status: 'published', version: 2 }, "an admin's own edit publishes");
await call('POST', `/api/mod/revisions/${d2}/save`, { content: cur.content.replace('(ترمیم)', '(ترمیم سوم)') }, l1.token);
assert.equal((await call('POST', `/api/mod/revisions/${d2}/submit`, {}, l1.token)).json().status, 'approved', "an L1's own draft goes to the admin");
const conflict = await call('POST', `/api/mod/revisions/${d2}/publish`, {}, admin.token);
assert.equal(conflict.statusCode, 409, 'a newer version was published meanwhile');
assert.equal((await call('POST', `/api/mod/revisions/${d2}/reject`, { comment: 'پرانا متن' }, admin.token)).json().status, 'rejected');
const hist = (await call('GET', `/api/mod/work/${poem.id}`, undefined, l1.token)).json();
assert.deepEqual(hist.history.filter((h: any) => h.version).map((h: any) => h.version), [2, 1]);
assert.ok((await call('GET', '/api/mod/log', undefined, l2.token)).json().entries.length >= 10);
} finally {
// restore the work and remove the test people and their revisions
await pool.query('DELETE FROM verses WHERE poem_id = $1', [poem.id]);
for (const v of before) await pool.query('INSERT INTO verses (poem_id, vorder, position, couplet, text) VALUES ($1, $2, $3, $4, $5)', [poem.id, v.vorder, v.position, v.couplet, v.text]);
await pool.query('UPDATE poems SET title = $2, search_text = $3 WHERE id = $1', [poem.id, poem.title, searchBefore]);
await pool.query(`DELETE FROM revisions WHERE entity = 'work' AND entity_id = $1 AND author_email LIKE $2`, [poem.id, `%-${run}@divan.test`]);
await pool.query('DELETE FROM users WHERE email LIKE $1', [`%-${run}@divan.test`]);
await pool.query('DELETE FROM audit_log WHERE actor_email LIKE $1 OR target_email LIKE $1', [`%-${run}@divan.test`]);
await rm(data, { recursive: true, force: true });
await app.close();
}
});