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:
parent
12c808d9af
commit
7d9b4a3599
7 changed files with 1541 additions and 0 deletions
|
|
@ -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
313
tests/activities.test.ts
Normal 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
176
tests/admin.test.ts
Normal 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
254
tests/engagement.test.ts
Normal 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
218
tests/helpers.ts
Normal 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
321
tests/profile.test.ts
Normal 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
253
tests/social.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue