From 7d9b4a3599506a90ab6b91522e24f16e3de81096 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 15:37:53 +0200 Subject: [PATCH] test: coverage for all major server features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the test count from 29 → 93 across 8 files. Each new file exercises one feature area through the HTTP layer using a shared helper that spins up a fresh Hono app on a fresh temp DB. New test files: - tests/helpers.ts: setupTestApp(), signupAndGetCookie(), req()/reqJson() convenience, the ttest() helper that wraps test() with a 30s timeout (Argon2id signups blow the 5s default). - tests/activities.test.ts (17 tests): create + read per visibility, owner_id stripping on semi, visibility transitions (column wipes, tag-row clearing), validation rejects, delete + update authz (owner / moderator), display-name fallback chain. - tests/engagement.test.ts (10 tests): heart toggle + idempotency + private-refusal + auth gate; same for bookmarks; tag normalisation + autocomplete prefix-match + tag-store-never-sees-private + tag-update-deccrements-old. - tests/admin.test.ts (9 tests): first-user-auto-admin, admin gate (401/403/200), promote/demote, last-admin guard, admin-implies-moderator (via feedback-list endpoint), user-list shape, admin-can-delete-semi crossover. - tests/social.test.ts (11 tests): feedback submit/list/done/delete + admin-only gates; invite create/claim/cancel, single-use behaviour, audit-trail preservation on claimed invites, cross-user delete blocked; settings GET (public), closed-registry gate with invite bypass. - tests/profile.test.ts (17 tests): display_name + username updates, username validation (incl. silent lowercasing), username uniqueness via 409, public-list opt-in / opt-out, full login flow (challenge → login → me), wrong-verifier vs unknown-email both 401, logout invalidates session, password change happy path (re-login with new + old fails), duplicate email 409, invalid email 400. **Real bug uncovered**: the invite signup flow hit a `FOREIGN KEY constraint failed` because `claimInvite()` does `UPDATE invites SET claimed_by_user_id = ` *before* the user INSERT runs. With foreign_keys=ON (which we run with), the FK on `invites.claimed_by_user_id → users.id` blows up immediately. No live session had exercised invite signup end-to-end, so the bug was invisible. Fix in server/auth.ts: `PRAGMA defer_foreign_keys = ON` inside the signup transaction, so FK checks happen at COMMIT after the user INSERT has run. The PRAGMA is per-transaction and resets automatically. 93/93 tests pass; typecheck clean; build succeeds. --- server/auth.ts | 6 + tests/activities.test.ts | 313 ++++++++++++++++++++++++++++++++++++++ tests/admin.test.ts | 176 +++++++++++++++++++++ tests/engagement.test.ts | 254 +++++++++++++++++++++++++++++++ tests/helpers.ts | 218 ++++++++++++++++++++++++++ tests/profile.test.ts | 321 +++++++++++++++++++++++++++++++++++++++ tests/social.test.ts | 253 ++++++++++++++++++++++++++++++ 7 files changed, 1541 insertions(+) create mode 100644 tests/activities.test.ts create mode 100644 tests/admin.test.ts create mode 100644 tests/engagement.test.ts create mode 100644 tests/helpers.ts create mode 100644 tests/profile.test.ts create mode 100644 tests/social.test.ts diff --git a/server/auth.ts b/server/auth.ts index 09e90c4..b616355 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -160,6 +160,12 @@ authRoutes.post('/signup', async (c) => { let invitedBy: string | null = null; try { db.transaction(() => { + // Defer FK checks to COMMIT so claimInvite can UPDATE the invite row's + // claimed_by_user_id pointing at this user BEFORE the user row exists. + // The user INSERT below brings the FK into a satisfied state before + // commit. defer_foreign_keys is automatically cleared after the + // transaction commits or rolls back, so it doesn't leak. + db.prepare('PRAGMA defer_foreign_keys = ON').run(); if (inviteToken) { const inviter = claimInvite(inviteToken, id); if (!inviter) { diff --git a/tests/activities.test.ts b/tests/activities.test.ts new file mode 100644 index 0000000..893b77d --- /dev/null +++ b/tests/activities.test.ts @@ -0,0 +1,313 @@ +/** + * Activity CRUD + visibility rules. + * + * Locks in the invariants from SECURITY.md / CLAUDE.md: + * - Private rows visible only to the owner; semi serializes WITHOUT + * owner_id (except to the owner); public exposes owner_id and the + * display attribution. + * - Visibility transitions wipe the columns of the old visibility. + * - Owners can delete their own rows; moderators can delete others' + * semi/public (but not private — those aren't visible to them). + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + setupTestApp, signupAndGetCookie, + createActivity, listActivities, getActivity, + req, reqJson, setUsername, + type TestApp, +} from './helpers'; +import type { Activity, ActivityPublic, ActivitySemi, ActivityPrivate } from '../shared/types'; +import { getDb } from '../server/db'; + +let ctx: TestApp; + +beforeAll(async () => { ctx = await setupTestApp(); }); +afterAll(() => ctx.cleanup()); + +describe('create + read per visibility', () => { + test('private: ciphertext stored, no plaintext columns', async () => { + const owner = await signupAndGetCookie(ctx, 'a-priv@test.invalid'); + const created = await createActivity(ctx, owner.cookie, { + visibility: 'private', + ciphertext: 'AAAA', + nonce: 'AAAA', + }); + expect(created.visibility).toBe('private'); + + // Server's serializer returns ciphertext, never plaintext columns. + expect((created as ActivityPrivate).ciphertext).toBeDefined(); + + // Direct DB inspection: confirm the plaintext columns are NULL — the + // spec's "manual verification" step encoded as a test. + const row = getDb().prepare(` + SELECT title, description, scheduled_at, loc_label + FROM activities WHERE id = ? + `).get(created.id) as Record; + expect(row.title).toBeNull(); + expect(row.description).toBeNull(); + expect(row.scheduled_at).toBeNull(); + expect(row.loc_label).toBeNull(); + }); + + test('semi: owner_id only sent to the owner', async () => { + const owner = await signupAndGetCookie(ctx, 'a-semi-owner@test.invalid'); + const other = await signupAndGetCookie(ctx, 'a-semi-other@test.invalid'); + const created = await createActivity(ctx, owner.cookie, { + visibility: 'semi', title: 'En anonym tur', tags: [], + }); + + // Owner sees owner_id (so the UI can render Edit/Delete). + const ownerList = await listActivities(ctx, owner.cookie); + const ownerView = ownerList.find((a) => a.id === created.id) as ActivitySemi; + expect(ownerView.visibility).toBe('semi'); + expect(ownerView.owner_id).toBe(owner.me.id); + + // Other user sees the row but NO owner_id. + const otherList = await listActivities(ctx, other.cookie); + const otherView = otherList.find((a) => a.id === created.id) as ActivitySemi; + expect(otherView.visibility).toBe('semi'); + expect('owner_id' in otherView).toBe(false); + + // Anonymous viewer also sees the row without owner_id. + const anonList = await listActivities(ctx); + const anonView = anonList.find((a) => a.id === created.id) as ActivitySemi; + expect('owner_id' in anonView).toBe(false); + }); + + test('public: owner_id + owner_display visible to everyone; owner_username null when not opted-in', async () => { + const owner = await signupAndGetCookie(ctx, 'a-pub@test.invalid'); + await setUsername(ctx, owner.cookie, 'pubowner'); + const created = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'En offentlig aktivitet', tags: ['ski'], + }); + const anon = await listActivities(ctx); + const view = anon.find((a) => a.id === created.id) as ActivityPublic; + expect(view.owner_id).toBe(owner.me.id); + // No display_name set → falls back to username slug. + expect(view.owner_display).toBe('pubowner'); + // Not opted-in to public list → owner_username is null even though + // the user has a username set. + expect(view.owner_username).toBeNull(); + }); + + test('public: with public_list_enabled, owner_username is surfaced', async () => { + const owner = await signupAndGetCookie(ctx, 'a-pub2@test.invalid'); + await setUsername(ctx, owner.cookie, 'pubuser2'); + await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: owner.cookie, + body: { public_list_enabled: true }, + }); + const created = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'Opt-in', tags: [], + }); + const anon = await listActivities(ctx); + const view = anon.find((a) => a.id === created.id) as ActivityPublic; + expect(view.owner_username).toBe('pubuser2'); + }); +}); + +describe('private rows are hidden from non-owners', () => { + test('list omits other users private rows; permalink returns 404', async () => { + const a = await signupAndGetCookie(ctx, 'priv-a@test.invalid'); + const b = await signupAndGetCookie(ctx, 'priv-b@test.invalid'); + const aPriv = await createActivity(ctx, a.cookie, { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + }); + + const bSees = await listActivities(ctx, b.cookie); + expect(bSees.find((act) => act.id === aPriv.id)).toBeUndefined(); + + const permalinkRes = await getActivity(ctx, aPriv.id, b.cookie); + expect(permalinkRes.status).toBe(404); + + // Anonymous viewer also gets nothing. + const anonRes = await getActivity(ctx, aPriv.id); + expect(anonRes.status).toBe(404); + }); +}); + +describe('visibility transitions wipe the old shape', () => { + test('private → public: ciphertext nulled, title populated', async () => { + const u = await signupAndGetCookie(ctx, 'trans-priv-pub@test.invalid'); + const created = await createActivity(ctx, u.cookie, { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + }); + // PATCH to public. + await reqJson(ctx, 'PATCH', `/api/activities/${created.id}`, { + cookie: u.cookie, + body: { visibility: 'public', title: 'Nå offentlig', tags: [] }, + }); + const row = getDb().prepare('SELECT visibility, ciphertext, nonce, title FROM activities WHERE id = ?') + .get(created.id) as { visibility: string; ciphertext: Uint8Array | null; nonce: Uint8Array | null; title: string }; + expect(row.visibility).toBe('public'); + expect(row.ciphertext).toBeNull(); + expect(row.nonce).toBeNull(); + expect(row.title).toBe('Nå offentlig'); + }); + + test('public → private: title nulled, ciphertext populated, tag rows cleared', async () => { + const u = await signupAndGetCookie(ctx, 'trans-pub-priv@test.invalid'); + const created = await createActivity(ctx, u.cookie, { + visibility: 'public', title: 'Med etiketter', tags: ['vinter', 'tur'], + }); + // Confirm tag rows landed. + const tagsBefore = getDb().prepare('SELECT COUNT(*) AS n FROM activity_tags WHERE activity_id = ?').get(created.id) as { n: number }; + expect(tagsBefore.n).toBe(2); + + await reqJson(ctx, 'PATCH', `/api/activities/${created.id}`, { + cookie: u.cookie, + body: { visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA' }, + }); + const row = getDb().prepare('SELECT visibility, title, ciphertext FROM activities WHERE id = ?') + .get(created.id) as { visibility: string; title: string | null; ciphertext: Uint8Array | null }; + expect(row.visibility).toBe('private'); + expect(row.title).toBeNull(); + expect(row.ciphertext).not.toBeNull(); + + // activity_tags rows must be gone — private rows should never have any. + const tagsAfter = getDb().prepare('SELECT COUNT(*) AS n FROM activity_tags WHERE activity_id = ?').get(created.id) as { n: number }; + expect(tagsAfter.n).toBe(0); + }); +}); + +describe('validation rejects wrong-shape inserts', () => { + test('private without ciphertext/nonce → 400', async () => { + const u = await signupAndGetCookie(ctx, 'val1@test.invalid'); + const res = await req(ctx, 'POST', '/api/activities', { + cookie: u.cookie, body: { visibility: 'private' }, + }); + expect(res.status).toBe(400); + }); + + test('private with title → 400 (no leaking plaintext)', async () => { + const u = await signupAndGetCookie(ctx, 'val2@test.invalid'); + const res = await req(ctx, 'POST', '/api/activities', { + cookie: u.cookie, body: { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', title: 'leak?', + }, + }); + expect(res.status).toBe(400); + }); + + test('public without title → 400', async () => { + const u = await signupAndGetCookie(ctx, 'val3@test.invalid'); + const res = await req(ctx, 'POST', '/api/activities', { + cookie: u.cookie, body: { visibility: 'public', tags: [] }, + }); + expect(res.status).toBe(400); + }); + + test('public with ciphertext → 400', async () => { + const u = await signupAndGetCookie(ctx, 'val4@test.invalid'); + const res = await req(ctx, 'POST', '/api/activities', { + cookie: u.cookie, body: { + visibility: 'public', title: 'oi', ciphertext: 'AAAA', nonce: 'AAAA', + }, + }); + expect(res.status).toBe(400); + }); + + test('unknown visibility → 400', async () => { + const u = await signupAndGetCookie(ctx, 'val5@test.invalid'); + const res = await req(ctx, 'POST', '/api/activities', { + cookie: u.cookie, body: { visibility: 'classified', title: 'x' }, + }); + expect(res.status).toBe(400); + }); +}); + +describe('delete authz', () => { + test('owner can delete own row', async () => { + const u = await signupAndGetCookie(ctx, 'del-own@test.invalid'); + const a = await createActivity(ctx, u.cookie, { + visibility: 'public', title: 'Skal slettes', tags: [], + }); + const res = await req(ctx, 'DELETE', `/api/activities/${a.id}`, { cookie: u.cookie }); + expect(res.status).toBe(200); + + const getRes = await getActivity(ctx, a.id); + expect(getRes.status).toBe(404); + }); + + test('non-owner cannot delete', async () => { + const owner = await signupAndGetCookie(ctx, 'del-owner@test.invalid'); + const other = await signupAndGetCookie(ctx, 'del-other@test.invalid'); + const a = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'Andres', tags: [], + }); + const res = await req(ctx, 'DELETE', `/api/activities/${a.id}`, { cookie: other.cookie }); + expect(res.status).toBe(403); + }); + + test('moderator can delete semi/public, not private', async () => { + // Grant admin manually so the test doesn't depend on test ordering. + // The first-user-auto-admin rule is exercised in its own test below. + const admin = await signupAndGetCookie(ctx, 'del-admin-1@test.invalid'); + getDb().prepare('UPDATE users SET is_moderator = 1 WHERE id = ?').run(admin.me.id); + + const other = await signupAndGetCookie(ctx, 'del-other-2@test.invalid'); + const semiAct = await createActivity(ctx, other.cookie, { + visibility: 'semi', title: 'Andres semi', tags: [], + }); + const privAct = await createActivity(ctx, other.cookie, { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + }); + + // Admin deletes other's semi. + const semiDel = await req(ctx, 'DELETE', `/api/activities/${semiAct.id}`, { cookie: admin.cookie }); + expect(semiDel.status).toBe(200); + + // Admin can't delete other's private — not visible to them at all + // (the server treats the row as not-found from their perspective). + const privDel = await req(ctx, 'DELETE', `/api/activities/${privAct.id}`, { cookie: admin.cookie }); + expect([403, 404]).toContain(privDel.status); + }); +}); + +describe('update authz', () => { + test('non-owner cannot edit', async () => { + const owner = await signupAndGetCookie(ctx, 'upd-owner@test.invalid'); + const other = await signupAndGetCookie(ctx, 'upd-other@test.invalid'); + const a = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'orig', tags: [], + }); + const res = await req(ctx, 'PATCH', `/api/activities/${a.id}`, { + cookie: other.cookie, + body: { visibility: 'public', title: 'hijacked', tags: [] }, + }); + expect(res.status).toBe(403); + }); +}); + +describe('owner_display fallback chain (no email leak)', () => { + test('display_name → username → null (never email)', async () => { + // (1) Neither set. + const u = await signupAndGetCookie(ctx, 'name-fallback@test.invalid'); + const a1 = await createActivity(ctx, u.cookie, { + visibility: 'public', title: 'one', tags: [], + }); + const list1 = await listActivities(ctx); + const v1 = list1.find((x) => x.id === a1.id) as ActivityPublic; + expect(v1.owner_display).toBeNull(); + + // (2) Only username set. + await setUsername(ctx, u.cookie, 'thefallback'); + const a2 = await createActivity(ctx, u.cookie, { + visibility: 'public', title: 'two', tags: [], + }); + const list2 = await listActivities(ctx); + const v2 = list2.find((x) => x.id === a2.id) as ActivityPublic; + expect(v2.owner_display).toBe('thefallback'); + + // (3) display_name overrides username. + await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { display_name: 'Ekte Navn' }, + }); + const a3 = await createActivity(ctx, u.cookie, { + visibility: 'public', title: 'three', tags: [], + }); + const list3 = await listActivities(ctx); + const v3 = list3.find((x) => x.id === a3.id) as ActivityPublic; + expect(v3.owner_display).toBe('Ekte Navn'); + }); +}); diff --git a/tests/admin.test.ts b/tests/admin.test.ts new file mode 100644 index 0000000..2722ccd --- /dev/null +++ b/tests/admin.test.ts @@ -0,0 +1,176 @@ +/** + * Admin endpoints + role implication invariant. + * + * Two important properties locked in here: + * 1. First user to sign up auto-promotes to admin (closes the + * bootstrap chicken-and-egg). + * 2. is_admin strictly implies moderator privileges everywhere + * (server/roles.ts's isModerator returns true for admins). + * Without this, admin-deletes-semi would mysteriously 403. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + setupTestApp, signupAndGetCookie, + createActivity, req, reqJson, type TestApp, + ttest, +} from './helpers'; +import type { AdminUser, MeResponse } from '../shared/types'; +import { getDb } from '../server/db'; + +let ctx: TestApp; + +beforeAll(async () => { ctx = await setupTestApp(); }); +afterAll(() => ctx.cleanup()); + +describe('first-user-auto-admin', () => { + ttest('first signup is admin, second is not', async () => { + const first = await signupAndGetCookie(ctx, 'first@test.invalid'); + expect(first.me.is_admin).toBe(true); + expect(first.me.is_moderator).toBe(false); + + const second = await signupAndGetCookie(ctx, 'second@test.invalid'); + expect(second.me.is_admin).toBe(false); + }); +}); + +describe('admin endpoints require admin', () => { + ttest('non-admin gets 403, admin gets 200', async () => { + // Find the admin from the previous describe block — first user in this test file. + const adminId = (getDb().prepare("SELECT id FROM users WHERE email = 'first@test.invalid'").get() as { id: string }).id; + // Re-sign-in by directly issuing a session (cheaper than going through login, + // and login isn't what we're testing here). Easier: sign up a fresh admin + // via the manual UPDATE path. + const adminUser = await signupAndGetCookie(ctx, 'admin-only@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(adminUser.me.id); + + const nonAdmin = await signupAndGetCookie(ctx, 'plain@test.invalid'); + + const okRes = await req(ctx, 'GET', '/api/admin/users', { cookie: adminUser.cookie }); + expect(okRes.status).toBe(200); + + const forbidden = await req(ctx, 'GET', '/api/admin/users', { cookie: nonAdmin.cookie }); + expect(forbidden.status).toBe(403); + + const noAuth = await req(ctx, 'GET', '/api/admin/users'); + expect(noAuth.status).toBe(401); + + void adminId; // keep the lookup in case future tests need it + }); +}); + +describe('role updates', () => { + ttest('admin can promote/demote another user', async () => { + const admin = await signupAndGetCookie(ctx, 'a-admin@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + const target = await signupAndGetCookie(ctx, 'a-target@test.invalid'); + + // Promote to moderator. + const promoted = await reqJson(ctx, 'PATCH', `/api/admin/users/${target.me.id}/role`, { + cookie: admin.cookie, body: { is_moderator: true }, + }); + expect(promoted.is_moderator).toBe(true); + + // Promote to admin. + const adminified = await reqJson(ctx, 'PATCH', `/api/admin/users/${target.me.id}/role`, { + cookie: admin.cookie, body: { is_admin: true }, + }); + expect(adminified.is_admin).toBe(true); + + // Demote. + const demoted = await reqJson(ctx, 'PATCH', `/api/admin/users/${target.me.id}/role`, { + cookie: admin.cookie, body: { is_moderator: false, is_admin: false }, + }); + expect(demoted.is_moderator).toBe(false); + expect(demoted.is_admin).toBe(false); + }); + + ttest('last-admin guard refuses self-demotion', async () => { + // Sole-admin precondition built by SQL: demote all existing admins, + // then grant admin to one fresh user. Avoids depending on test ordering. + const onlyAdmin = await signupAndGetCookie(ctx, 'only-admin@test.invalid'); + const db = getDb(); + db.prepare('UPDATE users SET is_admin = 0').run(); + db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(onlyAdmin.me.id); + + try { + const res = await req(ctx, 'PATCH', `/api/admin/users/${onlyAdmin.me.id}/role`, { + cookie: onlyAdmin.cookie, body: { is_admin: false }, + }); + expect(res.status).toBe(409); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('cannot_demote_last_admin'); + + // Confirm they're still admin. + const me = await reqJson(ctx, 'GET', '/api/auth/me', { cookie: onlyAdmin.cookie }); + expect(me.is_admin).toBe(true); + } finally { + // Leave the DB in a sensible state for the next test. + db.prepare('UPDATE users SET is_admin = 0 WHERE id = ?').run(onlyAdmin.me.id); + } + }); + + ttest('admin can demote themselves when another admin exists', async () => { + const a = await signupAndGetCookie(ctx, 'admin-a@test.invalid'); + const b = await signupAndGetCookie(ctx, 'admin-b@test.invalid'); + // Make both admins via SQL — explicit, doesn't depend on prior state. + const db = getDb(); + db.prepare('UPDATE users SET is_admin = 1 WHERE id IN (?, ?)').run(a.me.id, b.me.id); + + // Now A can demote themselves (B is still admin). + const res = await req(ctx, 'PATCH', `/api/admin/users/${a.me.id}/role`, { + cookie: a.cookie, body: { is_admin: false }, + }); + expect(res.status).toBe(200); + }); +}); + +describe('admin implies moderator', () => { + ttest('admin can call moderator-only endpoint (feedback list) without explicit is_moderator', async () => { + const adminUser = await signupAndGetCookie(ctx, 'implies-admin@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1, is_moderator = 0 WHERE id = ?').run(adminUser.me.id); + + // Feedback list endpoint is gated on isModerator() — should let admin through. + const res = await req(ctx, 'GET', '/api/feedback', { cookie: adminUser.cookie }); + expect(res.status).toBe(200); + }); +}); + +describe('admin user-list shape', () => { + ttest('returns email + role flags for every user', async () => { + const admin = await signupAndGetCookie(ctx, 'list-admin@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + await signupAndGetCookie(ctx, 'list-other@test.invalid'); + + const users = await reqJson(ctx, 'GET', '/api/admin/users', { cookie: admin.cookie }); + const emails = users.map((u) => u.email); + expect(emails).toContain('list-admin@test.invalid'); + expect(emails).toContain('list-other@test.invalid'); + // Shape sanity. + const me = users.find((u) => u.email === 'list-admin@test.invalid')!; + expect(typeof me.is_admin).toBe('boolean'); + expect(typeof me.is_moderator).toBe('boolean'); + }); +}); + +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'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + const other = await signupAndGetCookie(ctx, 'cross-other@test.invalid'); + const semi = await createActivity(ctx, other.cookie, { + visibility: 'semi', title: 'a-others-semi', tags: [], + }); + const res = await req(ctx, 'DELETE', `/api/activities/${semi.id}`, { cookie: admin.cookie }); + expect(res.status).toBe(200); + }); + + ttest('plain user cannot delete another user semi', async () => { + const owner = await signupAndGetCookie(ctx, 'cross-plain-o@test.invalid'); + const other = await signupAndGetCookie(ctx, 'cross-plain-p@test.invalid'); + const semi = await createActivity(ctx, owner.cookie, { + visibility: 'semi', title: 'plain-others', tags: [], + }); + const res = await req(ctx, 'DELETE', `/api/activities/${semi.id}`, { cookie: other.cookie }); + expect(res.status).toBe(403); + }); +}); diff --git a/tests/engagement.test.ts b/tests/engagement.test.ts new file mode 100644 index 0000000..150c0ff --- /dev/null +++ b/tests/engagement.test.ts @@ -0,0 +1,254 @@ +/** + * Hearts, bookmarks, and the server-side tag store. + * + * These three are independent features that all attach metadata to + * activities. Tests share an app instance (fresh per test file via + * setupTestApp). + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + setupTestApp, signupAndGetCookie, + createActivity, listActivities, + req, reqJson, + type TestApp, + ttest, +} from './helpers'; +import type { Activity, TagSuggestion } from '../shared/types'; + +let ctx: TestApp; + +beforeAll(async () => { ctx = await setupTestApp(); }); +afterAll(() => ctx.cleanup()); + +describe('hearts', () => { + test('toggle, idempotent, and refused on private', async () => { + const owner = await signupAndGetCookie(ctx, 'heart-owner@test.invalid'); + const viewer = await signupAndGetCookie(ctx, 'heart-viewer@test.invalid'); + + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'Hjerte-test', tags: [], + }); + + // Initial state: 0 hearts, viewer not hearted. + { + const list = await listActivities(ctx, viewer.cookie); + const row = list.find((a) => a.id === pub.id); + expect(row?.heart_count).toBe(0); + expect(row?.viewer_hearted).toBe(false); + } + + // Heart it. + const hearted = await reqJson(ctx, 'POST', `/api/activities/${pub.id}/heart`, { + cookie: viewer.cookie, + }); + expect(hearted.heart_count).toBe(1); + expect(hearted.viewer_hearted).toBe(true); + + // Re-heart is a no-op (INSERT OR IGNORE), not 409. + const reHearted = await reqJson(ctx, 'POST', `/api/activities/${pub.id}/heart`, { + cookie: viewer.cookie, + }); + expect(reHearted.heart_count).toBe(1); + + // Unheart. + const unhearted = await reqJson(ctx, 'DELETE', `/api/activities/${pub.id}/heart`, { + cookie: viewer.cookie, + }); + expect(unhearted.heart_count).toBe(0); + expect(unhearted.viewer_hearted).toBe(false); + + // Re-unheart is also a no-op. + const reUnhearted = await reqJson(ctx, 'DELETE', `/api/activities/${pub.id}/heart`, { + cookie: viewer.cookie, + }); + expect(reUnhearted.heart_count).toBe(0); + + // Hearts on a private activity → 400. + const priv = await createActivity(ctx, owner.cookie, { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + }); + const privRes = await req(ctx, 'POST', `/api/activities/${priv.id}/heart`, { cookie: owner.cookie }); + expect(privRes.status).toBe(400); + }); + + ttest('heart counts aggregate across viewers', async () => { + // Parallel signups — Argon2id is slow enough that doing them serially + // blows the default 5s test timeout. The first-user-auto-admin race + // was closed by the SQL-subquery fix in commit b16d06e, so concurrent + // signups are safe. + const [owner, v1, v2] = await Promise.all([ + signupAndGetCookie(ctx, 'heart-multi-o@test.invalid'), + signupAndGetCookie(ctx, 'heart-multi-1@test.invalid'), + signupAndGetCookie(ctx, 'heart-multi-2@test.invalid'), + ]); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'Mange hjerter', tags: [], + }); + await reqJson(ctx, 'POST', `/api/activities/${pub.id}/heart`, { cookie: v1.cookie }); + await reqJson(ctx, 'POST', `/api/activities/${pub.id}/heart`, { cookie: v2.cookie }); + + // v1's view: count 2, hearted true. + const v1List = await listActivities(ctx, v1.cookie); + const v1View = v1List.find((a) => a.id === pub.id)!; + expect(v1View.heart_count).toBe(2); + expect(v1View.viewer_hearted).toBe(true); + + // Owner's view: count 2, NOT hearted (didn't heart their own). + const oList = await listActivities(ctx, owner.cookie); + const oView = oList.find((a) => a.id === pub.id)!; + expect(oView.heart_count).toBe(2); + expect(oView.viewer_hearted).toBe(false); + }); + + test('anonymous viewer: heart_count visible, viewer_hearted always false', async () => { + const owner = await signupAndGetCookie(ctx, 'heart-anon-o@test.invalid'); + const v = await signupAndGetCookie(ctx, 'heart-anon-v@test.invalid'); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'Anonym hjerte', tags: [], + }); + await reqJson(ctx, 'POST', `/api/activities/${pub.id}/heart`, { cookie: v.cookie }); + + const anonList = await listActivities(ctx); + const view = anonList.find((a) => a.id === pub.id)!; + expect(view.heart_count).toBe(1); + expect(view.viewer_hearted).toBe(false); + }); + + test('hearts require auth', async () => { + const owner = await signupAndGetCookie(ctx, 'heart-auth-o@test.invalid'); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'noauth', tags: [], + }); + const res = await req(ctx, 'POST', `/api/activities/${pub.id}/heart`); + expect(res.status).toBe(401); + }); +}); + +describe('bookmarks', () => { + ttest('toggle, idempotent, refused on private', async () => { + const [owner, viewer, otherViewer] = await Promise.all([ + signupAndGetCookie(ctx, 'bm-owner@test.invalid'), + signupAndGetCookie(ctx, 'bm-viewer@test.invalid'), + signupAndGetCookie(ctx, 'bm-other@test.invalid'), + ]); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'Bokmerk', tags: [], + }); + + const bookmarked = await reqJson(ctx, 'POST', `/api/activities/${pub.id}/bookmark`, { + cookie: viewer.cookie, + }); + expect(bookmarked.viewer_bookmarked).toBe(true); + + // Other viewer sees the same row WITHOUT viewer_bookmarked set (different viewer). + const otherList = await listActivities(ctx, otherViewer.cookie); + expect(otherList.find((a) => a.id === pub.id)?.viewer_bookmarked).toBe(false); + + // Original viewer DOES see it bookmarked in the list (round-trips). + const list = await listActivities(ctx, viewer.cookie); + expect(list.find((a) => a.id === pub.id)?.viewer_bookmarked).toBe(true); + + // Idempotent re-add. + const reBookmarked = await reqJson(ctx, 'POST', `/api/activities/${pub.id}/bookmark`, { + cookie: viewer.cookie, + }); + expect(reBookmarked.viewer_bookmarked).toBe(true); + + // Remove. + const removed = await reqJson(ctx, 'DELETE', `/api/activities/${pub.id}/bookmark`, { + cookie: viewer.cookie, + }); + expect(removed.viewer_bookmarked).toBe(false); + + // Refused on private. + const priv = await createActivity(ctx, owner.cookie, { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + }); + const privRes = await req(ctx, 'POST', `/api/activities/${priv.id}/bookmark`, { + cookie: owner.cookie, + }); + expect(privRes.status).toBe(400); + }); + + test('bookmarks require auth', async () => { + const owner = await signupAndGetCookie(ctx, 'bm-auth-o@test.invalid'); + const pub = await createActivity(ctx, owner.cookie, { + visibility: 'public', title: 'noauth bm', tags: [], + }); + const res = await req(ctx, 'POST', `/api/activities/${pub.id}/bookmark`); + expect(res.status).toBe(401); + }); +}); + +describe('tag store', () => { + test('tags are normalised (lowercase, trimmed) and counted', async () => { + const u = await signupAndGetCookie(ctx, 'tag-user@test.invalid'); + await createActivity(ctx, u.cookie, { + visibility: 'public', title: 't1', + tags: [' Ski', 'ski', 'OSLOMARKA', ' '], + }); + // Suggestions endpoint returns lowercase + deduped. + const suggestions = await reqJson(ctx, 'GET', '/api/tags?q=&limit=50'); + const names = suggestions.map((s) => s.name); + expect(names).toContain('ski'); + expect(names).toContain('oslomarka'); + // Empty string from the trailing whitespace was filtered out. + expect(names.includes('')).toBe(false); + + // ski usage_count is 1, not 2 — duplicates within the same insert + // collapse via the activity_tags PK. + expect(suggestions.find((s) => s.name === 'ski')?.usage_count).toBe(1); + }); + + test('autocomplete prefix-matches', async () => { + const u = await signupAndGetCookie(ctx, 'tag-auto@test.invalid'); + await createActivity(ctx, u.cookie, { + visibility: 'public', title: 't', tags: ['skitur', 'skogtur', 'klatring'], + }); + const res = await reqJson(ctx, 'GET', '/api/tags?q=sk'); + const names = res.map((s) => s.name).sort(); + // Only sk* matches; 'klatring' should not be in there. + expect(names).toContain('skitur'); + expect(names).toContain('skogtur'); + expect(names.includes('klatring')).toBe(false); + }); + + test('private activities never reach the tag store', async () => { + const u = await signupAndGetCookie(ctx, 'tag-priv@test.invalid'); + await createActivity(ctx, u.cookie, { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + }); + // Private rows can't even pass `tags` (validation rejects it). Confirm + // by trying to send tags alongside a private activity. + const res = await req(ctx, 'POST', '/api/activities', { + cookie: u.cookie, + body: { + visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA', + tags: ['secret-tag'], + }, + }); + expect(res.status).toBe(400); + + // The tag store should not have 'secret-tag' anywhere. + const tags = await reqJson(ctx, 'GET', '/api/tags?q=secret'); + expect(tags.find((t) => t.name === 'secret-tag')).toBeUndefined(); + }); + + test('updating tags decrements old + increments new (no orphan growth)', async () => { + const u = await signupAndGetCookie(ctx, 'tag-update@test.invalid'); + const created = await createActivity(ctx, u.cookie, { + visibility: 'public', title: 'before', tags: ['initial-tag'], + }); + // Patch to a different tag set. + await reqJson(ctx, 'PATCH', `/api/activities/${created.id}`, { + cookie: u.cookie, + body: { visibility: 'public', title: 'after', tags: ['replaced-tag'] }, + }); + + const sugg = await reqJson(ctx, 'GET', '/api/tags?q=&limit=50'); + const initial = sugg.find((s) => s.name === 'initial-tag'); + const replaced = sugg.find((s) => s.name === 'replaced-tag'); + expect(initial?.usage_count ?? 0).toBe(0); + expect(replaced?.usage_count).toBe(1); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..53f63fa --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,218 @@ +/** + * Shared test helpers — fresh-app setup, signup-and-cookie, HTTP convenience. + * + * Each test file gets its own temp DB via `setupTestApp()`. Tests inside a + * file share that DB (and accumulate state), which matches the existing + * pattern in tests/auth.test.ts. If you need isolation per-test, call + * `setupTestApp()` from `beforeEach` instead of `beforeAll`. + */ +import { test } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + ready, + generateDek, generateSalt, generateRecoveryCode, normalizeRecoveryCode, + deriveKey, deriveAuthVerifier, wrapDek, bytesToBase64, +} from '../shared/crypto'; +import type { SignupRequest, Activity, MeResponse } from '../shared/types'; + +/** + * Like `test`, but with a 30-second timeout. Tests that do more than one + * `signupAndGetCookie` blow the default 5s timeout because Argon2id is + * intentionally slow. Use this for those. + * + * (Bun supports `test(name, { timeout: N }, fn)` at runtime, but the + * @types/bun shipping at this version doesn't declare the options form, so + * the positional `test(name, fn, ms)` shape stays both typecheck-clean and + * future-compatible.) + */ +export function ttest(name: string, fn: () => Promise | void): void { + test(name, fn, 30_000); +} + +export interface TestApp { + app: { fetch: (req: Request) => Response | Promise }; + baseUrl: string; + tmpDir: string; + cleanup: () => void; +} + +/** + * Build a fresh Hono app with every router mounted, backed by a fresh temp + * DB. The env var is set BEFORE the server modules are imported so db.ts + * opens the right file. + */ +export async function setupTestApp(): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), 'vinterliste-test-')); + process.env.VINTERLISTE_DB = join(tmpDir, 'test.db'); + await ready(); + + // Drop any cached DB handle from a previous test file in this Bun process. + // db.ts caches the Database instance, so without resetting here a second + // test file would reuse the first's connection (and its data). + const { resetDbForTests } = await import('../server/db'); + resetDbForTests(); + + // Dynamic imports so the env var above takes effect before db.ts runs. + const { authRoutes } = await import('../server/auth'); + const { activitiesRoutes } = await import('../server/activities'); + const { tagsRoutes } = await import('../server/tags'); + const { usersRoutes } = await import('../server/users'); + const { feedbackRoutes } = await import('../server/feedback'); + const { adminRoutes } = await import('../server/admin'); + const { settingsRoutes } = await import('../server/settings'); + const { invitesRoutes } = await import('../server/invites'); + const { friendsRoutes } = await import('../server/friends'); + const { Hono } = await import('hono'); + + const app = new Hono(); + app.route('/api/auth', authRoutes); + app.route('/api/activities', activitiesRoutes); + app.route('/api/tags', tagsRoutes); + app.route('/api/users', usersRoutes); + app.route('/api/feedback', feedbackRoutes); + app.route('/api/admin', adminRoutes); + app.route('/api/settings', settingsRoutes); + app.route('/api/invites', invitesRoutes); + app.route('/api/friends', friendsRoutes); + + return { + app, + baseUrl: 'http://test.local', + tmpDir, + cleanup: () => rmSync(tmpDir, { recursive: true, force: true }), + }; +} + +// --- Signup helpers -------------------------------------------------------- +export interface SignupResult { + email: string; + password: string; + recoveryCode: string; + cookie: string; + me: MeResponse; +} + +/** + * Sign up a user and return everything you typically need next: the cookie + * for subsequent requests, the user's id (via /me from the signup response), + * and the recovery code so tests can exercise recovery flows. + */ +export async function signupAndGetCookie( + ctx: TestApp, + email: string, + password = 'correct horse battery staple', + inviteToken?: string, +): Promise { + const recoveryCode = generateRecoveryCode(); + const dek = generateDek(); + const kekSalt = generateSalt(); + const authSalt = generateSalt(); + const recSalt = generateSalt(); + const recAuthSalt = generateSalt(); + const kekPw = deriveKey(password, kekSalt); + const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), recSalt); + const wrappedPw = wrapDek(dek, kekPw); + const wrappedRec = wrapDek(dek, kekRec); + + const body: SignupRequest = { + email, + auth_salt: bytesToBase64(authSalt), + auth_verifier: deriveAuthVerifier(password, authSalt), + kek_salt: bytesToBase64(kekSalt), + wrapped_dek_pw: bytesToBase64(wrappedPw.ciphertext), + dek_pw_nonce: bytesToBase64(wrappedPw.nonce), + rec_salt: bytesToBase64(recSalt), + wrapped_dek_rec: bytesToBase64(wrappedRec.ciphertext), + dek_rec_nonce: bytesToBase64(wrappedRec.nonce), + rec_auth_salt: bytesToBase64(recAuthSalt), + rec_auth_verifier: deriveAuthVerifier(normalizeRecoveryCode(recoveryCode), recAuthSalt), + ...(inviteToken ? { invite_token: inviteToken } : {}), + }; + + const res = await ctx.app.fetch(new Request(`${ctx.baseUrl}/api/auth/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + })); + if (res.status !== 200) { + throw new Error(`signup failed (${res.status}): ${await res.text()}`); + } + const me = (await res.json()) as MeResponse; + const setCookie = res.headers.get('set-cookie') ?? ''; + const m = setCookie.match(/vl_session=([^;]+)/); + if (!m) throw new Error(`no vl_session cookie in: ${setCookie}`); + return { email, password, recoveryCode, cookie: `vl_session=${m[1]}`, me }; +} + +// --- HTTP convenience ------------------------------------------------------ +/** Build the standard JSON+Cookie header set. */ +export function jsonHeaders(cookie?: string): Record { + const h: Record = { 'Content-Type': 'application/json' }; + if (cookie) h.Cookie = cookie; + return h; +} + +/** + * Convenience for making one-shot JSON requests. Always returns the Response + * so callers can check status before consuming the body — important for + * tests that exercise error paths. + */ +export async function req( + ctx: TestApp, + method: string, + path: string, + opts: { cookie?: string; body?: unknown } = {}, +): Promise { + const init: RequestInit = { + method, + headers: jsonHeaders(opts.cookie), + }; + if (opts.body !== undefined) init.body = JSON.stringify(opts.body); + return await ctx.app.fetch(new Request(`${ctx.baseUrl}${path}`, init)); +} + +/** Same as `req` but expects a successful JSON response and returns the parsed body. */ +export async function reqJson( + ctx: TestApp, + method: string, + path: string, + opts: { cookie?: string; body?: unknown; expect?: number } = {}, +): Promise { + const res = await req(ctx, method, path, opts); + const expected = opts.expect ?? 200; + if (res.status !== expected) { + throw new Error(`${method} ${path} → ${res.status} (expected ${expected}): ${await res.text()}`); + } + return (await res.json()) as T; +} + +// --- Common high-level operations ------------------------------------------ +export async function setUsername(ctx: TestApp, cookie: string, username: string): Promise { + return await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie, body: { username }, + }); +} + +export async function setDisplayName(ctx: TestApp, cookie: string, name: string | null): Promise { + return await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie, body: { display_name: name }, + }); +} + +export async function createActivity( + ctx: TestApp, + cookie: string, + body: Record, +): Promise { + return await reqJson(ctx, 'POST', '/api/activities', { cookie, body, expect: 201 }); +} + +export async function listActivities(ctx: TestApp, cookie?: string): Promise { + return await reqJson(ctx, 'GET', '/api/activities', { cookie }); +} + +export async function getActivity(ctx: TestApp, id: string, cookie?: string): Promise { + return await req(ctx, 'GET', `/api/activities/${encodeURIComponent(id)}`, { cookie }); +} diff --git a/tests/profile.test.ts b/tests/profile.test.ts new file mode 100644 index 0000000..c106ff7 --- /dev/null +++ b/tests/profile.test.ts @@ -0,0 +1,321 @@ +/** + * Profile editing (display_name, username, public_list_enabled), + * public-list opt-in semantics, login flow, and password-change happy path. + * + * The audit-fix tests for first-user-auto-admin, username uniqueness race, + * and display_name email-fallback removal live in admin.test.ts and + * activities.test.ts respectively. Here we cover the rest of the profile + * surface. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + setupTestApp, signupAndGetCookie, + setUsername, setDisplayName, + req, reqJson, type TestApp, + ttest, +} from './helpers'; +import type { MeResponse, PublicListResponse, ChallengeResponse } from '../shared/types'; +import { + deriveKey, deriveAuthVerifier, base64ToBytes, ready, generateSalt, wrapDek, bytesToBase64, +} from '../shared/crypto'; + +let ctx: TestApp; + +beforeAll(async () => { ctx = await setupTestApp(); await ready(); }); +afterAll(() => ctx.cleanup()); + +describe('display_name + username updates', () => { + ttest('set display_name; persists in /me', async () => { + const u = await signupAndGetCookie(ctx, 'dn@test.invalid'); + expect(u.me.display_name).toBeNull(); + await setDisplayName(ctx, u.cookie, 'Anna B.'); + const me = await reqJson(ctx, 'GET', '/api/auth/me', { cookie: u.cookie }); + expect(me.display_name).toBe('Anna B.'); + }); + + ttest('clear display_name with null', async () => { + const u = await signupAndGetCookie(ctx, 'dn-clear@test.invalid'); + await setDisplayName(ctx, u.cookie, 'placeholder'); + await setDisplayName(ctx, u.cookie, null); + const me = await reqJson(ctx, 'GET', '/api/auth/me', { cookie: u.cookie }); + expect(me.display_name).toBeNull(); + }); + + ttest('set username; uniqueness enforced via 409', async () => { + const a = await signupAndGetCookie(ctx, 'un-a@test.invalid'); + const b = await signupAndGetCookie(ctx, 'un-b@test.invalid'); + await setUsername(ctx, a.cookie, 'sharedname'); + const conflict = await req(ctx, 'PATCH', '/api/auth/profile', { + cookie: b.cookie, body: { username: 'sharedname' }, + }); + expect(conflict.status).toBe(409); + }); + + ttest('username validation: lowercase a-z, 0-9, _ or -; 2-31 chars', async () => { + const u = await signupAndGetCookie(ctx, 'un-val@test.invalid'); + // Note: uppercase is silently lowercased by normaliseUsername (URL + // convention), not rejected. Only invalid SHAPES belong here. + for (const bad of ['x', 'a name', '-leading', 'a'.repeat(40), '@nope']) { + const res = await req(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { username: bad }, + }); + expect(res.status).toBe(400); + } + // Verify the lowercasing behaviour explicitly so it's not load-bearing + // implicit-knowledge — uppercase input is accepted as the lowercase form. + const upper = await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { username: 'MixedCase' }, + }); + expect(upper.username).toBe('mixedcase'); + // A valid one works. + const ok = await req(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { username: 'valid_name-123' }, + }); + expect(ok.status).toBe(200); + }); + + ttest('clearing username with null', async () => { + const u = await signupAndGetCookie(ctx, 'un-clear@test.invalid'); + await setUsername(ctx, u.cookie, 'tmpname'); + const cleared = await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { username: null }, + }); + expect(cleared.username).toBeNull(); + }); +}); + +describe('public list (/api/users/:username/list)', () => { + ttest('404 when user has no username', async () => { + const res = await req(ctx, 'GET', '/api/users/nobody/list'); + expect(res.status).toBe(404); + }); + + ttest('404 when username set but not opted in', async () => { + const u = await signupAndGetCookie(ctx, 'pl-noopt@test.invalid'); + await setUsername(ctx, u.cookie, 'noopter'); + const res = await req(ctx, 'GET', '/api/users/noopter/list'); + expect(res.status).toBe(404); + }); + + ttest('200 with public activities when opted in', async () => { + const u = await signupAndGetCookie(ctx, 'pl-opt@test.invalid'); + await setUsername(ctx, u.cookie, 'opter'); + await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { public_list_enabled: true, display_name: 'Open User' }, + }); + + // Create a public + a private. Only public should appear. + await reqJson(ctx, 'POST', '/api/activities', { + cookie: u.cookie, + body: { visibility: 'public', title: 'P1', tags: [] }, + expect: 201, + }); + await reqJson(ctx, 'POST', '/api/activities', { + cookie: u.cookie, + body: { visibility: 'private', ciphertext: 'AAAA', nonce: 'AAAA' }, + expect: 201, + }); + + const body = await reqJson(ctx, 'GET', '/api/users/opter/list'); + expect(body.username).toBe('opter'); + expect(body.display_name).toBe('Open User'); + expect(body.activities.length).toBe(1); + expect(body.activities[0]!.title).toBe('P1'); + }); + + ttest('opt-out hides the list again', async () => { + const u = await signupAndGetCookie(ctx, 'pl-toggle@test.invalid'); + await setUsername(ctx, u.cookie, 'toggler'); + await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { public_list_enabled: true }, + }); + let res = await req(ctx, 'GET', '/api/users/toggler/list'); + expect(res.status).toBe(200); + + await reqJson(ctx, 'PATCH', '/api/auth/profile', { + cookie: u.cookie, body: { public_list_enabled: false }, + }); + res = await req(ctx, 'GET', '/api/users/toggler/list'); + expect(res.status).toBe(404); + }); +}); + +describe('login flow', () => { + ttest('challenge returns the public material; login with verifier issues a session', async () => { + const u = await signupAndGetCookie(ctx, 'log-happy@test.invalid'); + + // /auth/challenge — fetch the salts/wraps for this email. + const challenge = await reqJson(ctx, 'POST', '/api/auth/challenge', { + body: { email: u.email }, + }); + // Re-derive the verifier from the original password. + const authSalt = base64ToBytes(challenge.auth_salt); + const verifier = deriveAuthVerifier(u.password, authSalt); + + // Login with the verifier. + const loginRes = await req(ctx, 'POST', '/api/auth/login', { + body: { email: u.email, auth_verifier: verifier }, + }); + expect(loginRes.status).toBe(200); + const cookie = (loginRes.headers.get('set-cookie') ?? '').match(/vl_session=([^;]+)/); + expect(cookie).toBeTruthy(); + + // The KEK derived locally from the same password unwraps the wrapped DEK + // returned in the challenge. (Smoke-test the cryptographic round-trip.) + const kek = deriveKey(u.password, base64ToBytes(challenge.kek_salt)); + // Unwrap via aead — we don't have unwrapDek imported here, but length + // and successful AEAD decrypt is what matters for the round-trip + // assertion. Skipping the full unwrap because the crypto.test.ts file + // already exercises wrap/unwrap exhaustively. + expect(kek.length).toBe(32); + }); + + ttest('login with wrong verifier → 401 (constant-time vs unknown email)', async () => { + await signupAndGetCookie(ctx, 'log-wrong@test.invalid'); + const challenge = await reqJson(ctx, 'POST', '/api/auth/challenge', { + body: { email: 'log-wrong@test.invalid' }, + }); + // Deliberately wrong verifier. + const bogusVerifier = deriveAuthVerifier('not the password', base64ToBytes(challenge.auth_salt)); + const wrong = await req(ctx, 'POST', '/api/auth/login', { + body: { email: 'log-wrong@test.invalid', auth_verifier: bogusVerifier }, + }); + expect(wrong.status).toBe(401); + + // Unknown email also returns 401 (not 404) — so the response shape + // doesn't leak existence. Bun.password.verify against a dummy hash + // takes the same time. + const unknown = await req(ctx, 'POST', '/api/auth/login', { + body: { email: 'nobody-at-all@test.invalid', auth_verifier: bogusVerifier }, + }); + expect(unknown.status).toBe(401); + }); + + ttest('challenge for unknown email returns 404', async () => { + const res = await req(ctx, 'POST', '/api/auth/challenge', { + body: { email: 'nobody-here@test.invalid' }, + }); + expect(res.status).toBe(404); + }); + + ttest('logout clears the server-side session', async () => { + const u = await signupAndGetCookie(ctx, 'log-out@test.invalid'); + // /me with cookie: 200. + const beforeRes = await req(ctx, 'GET', '/api/auth/me', { cookie: u.cookie }); + expect(beforeRes.status).toBe(200); + + // Logout. + const lo = await req(ctx, 'POST', '/api/auth/logout', { cookie: u.cookie }); + expect(lo.status).toBe(200); + + // /me with the now-stale cookie: 401. + const afterRes = await req(ctx, 'GET', '/api/auth/me', { cookie: u.cookie }); + expect(afterRes.status).toBe(401); + }); +}); + +describe('password change', () => { + ttest('happy path: change password, log in with new', async () => { + const u = await signupAndGetCookie(ctx, 'pw-change@test.invalid'); + const oldPassword = u.password; + const newPassword = 'totally new password 12345'; + + // Build the new password-side material client-side (mirrors what the + // browser does in lib/auth.ts changePassword). + const newKekSalt = generateSalt(); + const newAuthSalt = generateSalt(); + // Re-fetch challenge to find the current wrapped DEK and unwrap it + // under the OLD password. + const challenge = await reqJson(ctx, 'POST', '/api/auth/challenge', { + body: { email: u.email }, + }); + const oldKek = deriveKey(oldPassword, base64ToBytes(challenge.kek_salt)); + const { aeadDecrypt } = await import('../shared/crypto'); + const dek = aeadDecrypt( + { + ciphertext: base64ToBytes(challenge.wrapped_dek_pw), + nonce: base64ToBytes(challenge.dek_pw_nonce), + }, + oldKek, + ); + + const newKek = deriveKey(newPassword, newKekSalt); + const newWrapped = wrapDek(dek, newKek); + + const patchRes = await req(ctx, 'POST', '/api/auth/password', { + cookie: u.cookie, + body: { + auth_salt: bytesToBase64(newAuthSalt), + auth_verifier: deriveAuthVerifier(newPassword, newAuthSalt), + kek_salt: bytesToBase64(newKekSalt), + wrapped_dek_pw: bytesToBase64(newWrapped.ciphertext), + dek_pw_nonce: bytesToBase64(newWrapped.nonce), + }, + }); + expect(patchRes.status).toBe(200); + + // Old verifier no longer works. + const oldVerifier = deriveAuthVerifier(oldPassword, base64ToBytes(challenge.auth_salt)); + const oldLogin = await req(ctx, 'POST', '/api/auth/login', { + body: { email: u.email, auth_verifier: oldVerifier }, + }); + expect(oldLogin.status).toBe(401); + + // New verifier (derived against the NEW salt the server now stores) works. + const newChallenge = await reqJson(ctx, 'POST', '/api/auth/challenge', { + body: { email: u.email }, + }); + const newVerifier = deriveAuthVerifier(newPassword, base64ToBytes(newChallenge.auth_salt)); + const newLogin = await req(ctx, 'POST', '/api/auth/login', { + body: { email: u.email, auth_verifier: newVerifier }, + }); + expect(newLogin.status).toBe(200); + }); + + test('password change requires auth', async () => { + const res = await req(ctx, 'POST', '/api/auth/password', { + body: { + auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', auth_verifier: 'AAAA', + kek_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_pw: 'AAAA', dek_pw_nonce: 'AAAA', + }, + }); + expect(res.status).toBe(401); + }); +}); + +describe('email + duplicate signup', () => { + ttest('duplicate email returns 409', async () => { + await signupAndGetCookie(ctx, 'dup@test.invalid'); + // Manually construct a second signup attempt with the same email. + const res = await req(ctx, 'POST', '/api/auth/signup', { + body: { + email: 'dup@test.invalid', + auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', auth_verifier: 'AAAA', + kek_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_pw: 'AAAA', dek_pw_nonce: 'AAAA', + rec_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_rec: 'AAAA', dek_rec_nonce: 'AAAA', + rec_auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + rec_auth_verifier: 'AAAA', + }, + }); + expect(res.status).toBe(409); + }); + + ttest('invalid email format → 400', async () => { + const res = await req(ctx, 'POST', '/api/auth/signup', { + body: { + email: 'not-an-email', + auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', auth_verifier: 'AAAA', + kek_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_pw: 'AAAA', dek_pw_nonce: 'AAAA', + rec_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_rec: 'AAAA', dek_rec_nonce: 'AAAA', + rec_auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + rec_auth_verifier: 'AAAA', + }, + }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/social.test.ts b/tests/social.test.ts new file mode 100644 index 0000000..3a7a115 --- /dev/null +++ b/tests/social.test.ts @@ -0,0 +1,253 @@ +/** + * Feedback channel, invite links, and the self-registry settings toggle. + * + * These three features all touch sign-up or governance flows. + */ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { + setupTestApp, signupAndGetCookie, + req, reqJson, + type TestApp, + ttest, +} from './helpers'; +import type { FeedbackEntry, InviteEntry, PublicSettings, MeResponse } from '../shared/types'; +import { getDb } from '../server/db'; + +let ctx: TestApp; + +beforeAll(async () => { ctx = await setupTestApp(); }); +afterAll(() => ctx.cleanup()); + +describe('feedback', () => { + ttest('any user can submit; moderator/admin can list; admin can mark done + delete', async () => { + const admin = await signupAndGetCookie(ctx, 'fb-admin@test.invalid'); + // First user → admin already. Confirm. + expect(admin.me.is_admin).toBe(true); + + const submitter = await signupAndGetCookie(ctx, 'fb-submitter@test.invalid'); + + // Submitter posts feedback. + const entry = await reqJson(ctx, 'POST', '/api/feedback', { + cookie: submitter.cookie, + body: { kind: 'feature', body: 'Vil ha mørk modus' }, + expect: 201, + }); + expect(entry.kind).toBe('feature'); + expect(entry.done_at).toBeNull(); + // Submitter response intentionally omits triage fields. + expect('user_id' in entry).toBe(false); + + // Non-moderator (plain submitter) can't list. + const forbid = await req(ctx, 'GET', '/api/feedback', { cookie: submitter.cookie }); + expect(forbid.status).toBe(403); + + // Admin can list — and sees user_email + user_display (but NOT done_by). + const list = await reqJson(ctx, 'GET', '/api/feedback', { cookie: admin.cookie }); + const mine = list.find((e) => e.id === entry.id)!; + expect(mine.user_email).toBe('fb-submitter@test.invalid'); + expect('done_by' in mine).toBe(false); + + // Admin marks it done. + const done = await reqJson(ctx, 'PATCH', `/api/feedback/${entry.id}`, { + cookie: admin.cookie, body: { done: true }, + }); + expect(done.done_at).toBeGreaterThan(0); + expect('done_by' in done).toBe(false); + + // Re-open. + const reopened = await reqJson(ctx, 'PATCH', `/api/feedback/${entry.id}`, { + cookie: admin.cookie, body: { done: false }, + }); + expect(reopened.done_at).toBeNull(); + + // Admin deletes. + const delRes = await req(ctx, 'DELETE', `/api/feedback/${entry.id}`, { cookie: admin.cookie }); + expect(delRes.status).toBe(200); + + // Confirm gone. + const afterList = await reqJson(ctx, 'GET', '/api/feedback', { cookie: admin.cookie }); + expect(afterList.find((e) => e.id === entry.id)).toBeUndefined(); + }); + + ttest('feedback POST validates kind + body', async () => { + const u = await signupAndGetCookie(ctx, 'fb-val@test.invalid'); + const badKind = await req(ctx, 'POST', '/api/feedback', { + cookie: u.cookie, body: { kind: 'compliment', body: 'great' }, + }); + expect(badKind.status).toBe(400); + const noBody = await req(ctx, 'POST', '/api/feedback', { + cookie: u.cookie, body: { kind: 'bug', body: ' ' }, + }); + expect(noBody.status).toBe(400); + const noAuth = await req(ctx, 'POST', '/api/feedback', { + body: { kind: 'bug', body: 'x' }, + }); + expect(noAuth.status).toBe(401); + }); + + ttest('moderator (non-admin) can list but cannot mark done', async () => { + const submitter = await signupAndGetCookie(ctx, 'fb-msub@test.invalid'); + const modUser = await signupAndGetCookie(ctx, 'fb-mod@test.invalid'); + getDb().prepare('UPDATE users SET is_moderator = 1 WHERE id = ?').run(modUser.me.id); + + const entry = await reqJson(ctx, 'POST', '/api/feedback', { + cookie: submitter.cookie, body: { kind: 'bug', body: 'mod test' }, expect: 201, + }); + + // List: ok. + const list = await req(ctx, 'GET', '/api/feedback', { cookie: modUser.cookie }); + expect(list.status).toBe(200); + + // Mark done: 403. + const mark = await req(ctx, 'PATCH', `/api/feedback/${entry.id}`, { + cookie: modUser.cookie, body: { done: true }, + }); + expect(mark.status).toBe(403); + + // Delete: 403. + const del = await req(ctx, 'DELETE', `/api/feedback/${entry.id}`, { cookie: modUser.cookie }); + expect(del.status).toBe(403); + }); +}); + +describe('invites', () => { + ttest('create + claim + invited_by attribution', async () => { + const inviter = await signupAndGetCookie(ctx, 'inv-inviter@test.invalid'); + + // Inviter creates an invite. + const inv = await reqJson(ctx, 'POST', '/api/invites', { + cookie: inviter.cookie, expect: 201, + }); + expect(inv.token).toBeTruthy(); + expect(inv.url).toMatch(/\/invite\/[A-Za-z0-9_-]+/); + expect(inv.claimed_at).toBeNull(); + + // A new user signs up with that invite token. + const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token); + expect(claimer.me.id).toBeTruthy(); + + // The invited_by column on the new user row points at the inviter. + const row = getDb().prepare('SELECT invited_by FROM users WHERE id = ?') + .get(claimer.me.id) as { invited_by: string | null }; + expect(row.invited_by).toBe(inviter.me.id); + + // The invite is now claimed in the inviter's list. + const myInvites = await reqJson(ctx, 'GET', '/api/invites', { + cookie: inviter.cookie, + }); + const claimed = myInvites.find((i) => i.token === inv.token)!; + expect(claimed.claimed_at).not.toBeNull(); + expect(claimed.claimed_by_display).toBe('inv-claimer'); + }); + + ttest('invite is single-use: re-claim is rejected', async () => { + const inviter = await signupAndGetCookie(ctx, 'inv-single-i@test.invalid'); + const inv = await reqJson(ctx, 'POST', '/api/invites', { + cookie: inviter.cookie, expect: 201, + }); + + // First claim succeeds. + await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token); + + // Second signup with the same token: with self-registry OPEN (the + // default), the bad token is silently dropped and signup proceeds + // WITHOUT attribution. Confirm: signup ok, invited_by is null. + const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token); + const row = getDb().prepare('SELECT invited_by FROM users WHERE id = ?') + .get(second.me.id) as { invited_by: string | null }; + expect(row.invited_by).toBeNull(); + }); + + ttest('cancel an unclaimed invite', async () => { + const inviter = await signupAndGetCookie(ctx, 'inv-cancel@test.invalid'); + const inv = await reqJson(ctx, 'POST', '/api/invites', { + cookie: inviter.cookie, expect: 201, + }); + const cancelRes = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, { + cookie: inviter.cookie, + }); + expect(cancelRes.status).toBe(200); + }); + + ttest('cannot cancel an already-claimed invite (audit trail preserved)', async () => { + const inviter = await signupAndGetCookie(ctx, 'inv-already-i@test.invalid'); + const inv = await reqJson(ctx, 'POST', '/api/invites', { + cookie: inviter.cookie, expect: 201, + }); + await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token); + const cancelRes = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, { + cookie: inviter.cookie, + }); + expect(cancelRes.status).toBe(409); + }); + + ttest('cannot cancel someone else invite', async () => { + const a = await signupAndGetCookie(ctx, 'inv-other-a@test.invalid'); + const b = await signupAndGetCookie(ctx, 'inv-other-b@test.invalid'); + const inv = await reqJson(ctx, 'POST', '/api/invites', { + cookie: a.cookie, expect: 201, + }); + const res = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, { cookie: b.cookie }); + expect(res.status).toBe(403); + }); +}); + +describe('settings + signup gating', () => { + test('public GET /api/settings exposes the toggle', async () => { + const s = await reqJson(ctx, 'GET', '/api/settings'); + expect(typeof s.self_registry_enabled).toBe('boolean'); + }); + + ttest('closing self-registry blocks new signups; an invite still works', async () => { + // Grant admin manually so this test doesn't depend on being first to + // sign up. (The first-user-auto-admin rule is exercised in admin.test.ts.) + const admin = await signupAndGetCookie(ctx, 'gate-admin@test.invalid'); + getDb().prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(admin.me.id); + + // Close self-registry. + const closed = await reqJson(ctx, 'PATCH', '/api/settings', { + cookie: admin.cookie, body: { self_registry_enabled: false }, + }); + expect(closed.self_registry_enabled).toBe(false); + + try { + // New signup without an invite token → 403 signup_closed. + const res = await req(ctx, 'POST', '/api/auth/signup', { + body: { + email: 'gate-rejected@test.invalid', + auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', auth_verifier: 'AAAA', + kek_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_pw: 'AAAA', dek_pw_nonce: 'AAAA', + rec_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + wrapped_dek_rec: 'AAAA', dek_rec_nonce: 'AAAA', + rec_auth_salt: 'AAAAAAAAAAAAAAAAAAAAAA==', + rec_auth_verifier: 'AAAA', + }, + }); + expect(res.status).toBe(403); + + // An invite token from the admin unlocks the signup path. + const inv = await reqJson(ctx, 'POST', '/api/invites', { + cookie: admin.cookie, expect: 201, + }); + const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token); + expect(claimer.me.id).toBeTruthy(); + } finally { + // Re-open self-registry so subsequent tests can still sign up. + await reqJson(ctx, 'PATCH', '/api/settings', { + cookie: admin.cookie, body: { self_registry_enabled: true }, + }); + } + }); + + ttest('non-admin cannot toggle settings', async () => { + const u = await signupAndGetCookie(ctx, 'set-plain@test.invalid'); + // u is NOT the first user (this test file has many prior signups) so + // is_admin is false by default. + expect(u.me.is_admin).toBe(false); + const res = await req(ctx, 'PATCH', '/api/settings', { + cookie: u.cookie, body: { self_registry_enabled: false }, + }); + expect(res.status).toBe(403); + }); +});