feat(tags): moderators and admins can delete public tags

Add DELETE /api/tags/:name (gated by isModerator(), which also passes
for admins per the admin-implies-moderator invariant in roles.ts).
The endpoint normalises the name the same way creation does so the
URL casing doesn't matter, then deletes the tag and detaches it from
every activity_tags row in one transaction.

UI: new "Etiketter" nav entry visible to moderators + admins, opens
a ModerateTags.svelte view with search-as-you-type (reusing the
/api/tags suggestion endpoint) and a Slett button per row. Private
tags are unaffected — they're encrypted in the activity payload and
never reach the server tag table.

Tests: 3 new cases on top of the admin suite — moderator can delete,
plain user gets 403 (anonymous gets 401), unknown tag gets 404.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 17:57:33 +02:00
commit 54d8ed22f4
5 changed files with 231 additions and 2 deletions

View file

@ -152,6 +152,66 @@ describe('admin user-list shape', () => {
});
});
describe('tag deletion (moderator + admin)', () => {
ttest('moderator can delete a public tag and it detaches from activities', async () => {
const admin = await signupAndGetCookie(ctx, 'tag-admin@test.invalid');
getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id);
const mod = await signupAndGetCookie(ctx, 'tag-mod@test.invalid');
getDb().prepare('UPDATE users SET is_moderator = 1 WHERE id = ?').run(mod.me.id);
// Two activities sharing a tag.
await createActivity(ctx, admin.cookie, {
visibility: 'public', title: 'with-tag-1', tags: ['Skismøring'],
});
await createActivity(ctx, admin.cookie, {
visibility: 'semi', title: 'with-tag-2', tags: ['skismøring'],
});
// Tag was normalised to lowercase.
const before = await reqJson<{ name: string; usage_count: number }[]>(
ctx, 'GET', '/api/tags?q=ski',
);
expect(before.some((t) => t.name === 'skismøring')).toBe(true);
const res = await req(ctx, 'DELETE', '/api/tags/skism%C3%B8ring', { cookie: mod.cookie });
expect(res.status).toBe(204);
// Tag is gone from the suggestion list.
const after = await reqJson<{ name: string }[]>(ctx, 'GET', '/api/tags?q=ski');
expect(after.some((t) => t.name === 'skismøring')).toBe(false);
// activity_tags rows for the tag are also gone — verify the join surface.
const db = getDb();
const tagRow = db.prepare("SELECT id FROM tags WHERE name = 'skismøring'").get();
expect(tagRow).toBeNull();
});
ttest('plain user cannot delete a tag', async () => {
const admin = await signupAndGetCookie(ctx, 'tag-admin2@test.invalid');
getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id);
const plain = await signupAndGetCookie(ctx, 'tag-plain@test.invalid');
await createActivity(ctx, admin.cookie, {
visibility: 'public', title: 'plain-cannot', tags: ['victim-tag'],
});
const res = await req(ctx, 'DELETE', '/api/tags/victim-tag', { cookie: plain.cookie });
expect(res.status).toBe(403);
// Anonymous: 401 (requireAuth fires before the role check).
const anon = await req(ctx, 'DELETE', '/api/tags/victim-tag');
expect(anon.status).toBe(401);
});
ttest('deleting an unknown tag returns 404', async () => {
const admin = await signupAndGetCookie(ctx, 'tag-admin3@test.invalid');
getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id);
const res = await req(ctx, 'DELETE', '/api/tags/does-not-exist', { cookie: admin.cookie });
expect(res.status).toBe(404);
});
});
describe('role activity-delete crossover (admin/moderator can delete semi)', () => {
ttest('admin can delete another user semi activity', async () => {
const admin = await signupAndGetCookie(ctx, 'cross-admin@test.invalid');