test: coverage for all major server features

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 = <new 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.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 15:37:53 +02:00
commit 7d9b4a3599
7 changed files with 1541 additions and 0 deletions

View file

@ -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) {

313
tests/activities.test.ts Normal file
View file

@ -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<string, unknown>;
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');
});
});

176
tests/admin.test.ts Normal file
View file

@ -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<AdminUser>(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<AdminUser>(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<AdminUser>(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<MeResponse>(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<AdminUser[]>(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);
});
});

254
tests/engagement.test.ts Normal file
View file

@ -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<Activity>(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<Activity>(ctx, 'POST', `/api/activities/${pub.id}/heart`, {
cookie: viewer.cookie,
});
expect(reHearted.heart_count).toBe(1);
// Unheart.
const unhearted = await reqJson<Activity>(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<Activity>(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<Activity>(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<Activity>(ctx, 'POST', `/api/activities/${pub.id}/bookmark`, {
cookie: viewer.cookie,
});
expect(reBookmarked.viewer_bookmarked).toBe(true);
// Remove.
const removed = await reqJson<Activity>(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<TagSuggestion[]>(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<TagSuggestion[]>(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<TagSuggestion[]>(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<TagSuggestion[]>(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);
});
});

218
tests/helpers.ts Normal file
View file

@ -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): void {
test(name, fn, 30_000);
}
export interface TestApp {
app: { fetch: (req: Request) => Response | Promise<Response> };
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<TestApp> {
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<SignupResult> {
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<string, string> {
const h: Record<string, string> = { '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<Response> {
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<T>(
ctx: TestApp,
method: string,
path: string,
opts: { cookie?: string; body?: unknown; expect?: number } = {},
): Promise<T> {
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<MeResponse> {
return await reqJson<MeResponse>(ctx, 'PATCH', '/api/auth/profile', {
cookie, body: { username },
});
}
export async function setDisplayName(ctx: TestApp, cookie: string, name: string | null): Promise<MeResponse> {
return await reqJson<MeResponse>(ctx, 'PATCH', '/api/auth/profile', {
cookie, body: { display_name: name },
});
}
export async function createActivity(
ctx: TestApp,
cookie: string,
body: Record<string, unknown>,
): Promise<Activity> {
return await reqJson<Activity>(ctx, 'POST', '/api/activities', { cookie, body, expect: 201 });
}
export async function listActivities(ctx: TestApp, cookie?: string): Promise<Activity[]> {
return await reqJson<Activity[]>(ctx, 'GET', '/api/activities', { cookie });
}
export async function getActivity(ctx: TestApp, id: string, cookie?: string): Promise<Response> {
return await req(ctx, 'GET', `/api/activities/${encodeURIComponent(id)}`, { cookie });
}

321
tests/profile.test.ts Normal file
View file

@ -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<MeResponse>(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<MeResponse>(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<MeResponse>(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<MeResponse>(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<PublicListResponse>(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<ChallengeResponse>(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<ChallengeResponse>(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<ChallengeResponse>(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<ChallengeResponse>(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);
});
});

253
tests/social.test.ts Normal file
View file

@ -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<FeedbackEntry>(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<FeedbackEntry[]>(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<FeedbackEntry>(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<FeedbackEntry>(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<FeedbackEntry[]>(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<FeedbackEntry>(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<InviteEntry>(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<InviteEntry[]>(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<InviteEntry>(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<InviteEntry>(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<InviteEntry>(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<InviteEntry>(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<PublicSettings>(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<PublicSettings>(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<InviteEntry>(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);
});
});