From eafc216d9bd03a6e6e45223191f6caeadfeac486 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 15:02:57 +0200 Subject: [PATCH] test(friends): lock in directional visibility semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The friends-only visibility is one-way: - If Anna adds Britt → Anna's friends-only posts are visible to Britt - If Britt has NOT added Anna → Britt's friends-only posts are NOT visible to Anna, even if Britt is in Anna's list This matches the user's mental model and is what server/activities.ts already implements via "owner_id IN (SELECT owner_id FROM friends WHERE friend_id = ?)" — owner must have added viewer, not the other way round. Test covers three cases end-to-end through the HTTP layer: 1. Asymmetric: Anna adds Britt, but not vice versa. Anna's post reaches Britt; Britt's post does NOT reach Anna. Permalink GET returns 404 (not 403) for the hidden direction, matching the "don't leak existence" pattern we use elsewhere. 2. Reciprocal: both add each other, both see each other's posts. 3. Block: mutual friends, then one blocks the other. The block filter applies symmetrically — neither sees the other's friends-only content from then on, even though the friendship rows still exist. 29 total tests now pass (26 prior + 3 new). --- tests/friends.test.ts | 242 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 tests/friends.test.ts diff --git a/tests/friends.test.ts b/tests/friends.test.ts new file mode 100644 index 0000000..2c31933 --- /dev/null +++ b/tests/friends.test.ts @@ -0,0 +1,242 @@ +/** + * Regression test for the directional friends-only visibility. + * + * The model: friendship is one-way. If Anna adds Britt, Anna's + * friends-only activities are visible to Britt — but Britt sharing + * to friends does NOT reach Anna unless Britt has also added Anna. + * + * This test exercises that asymmetry end-to-end through the HTTP API. + */ +import { afterAll, beforeAll, describe, expect, 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 } from '../shared/types'; + +let tmpDir: string; +let baseUrl: string; +let app: Awaited>; + +async function loadApp() { + const { authRoutes } = await import('../server/auth'); + const { activitiesRoutes } = await import('../server/activities'); + const { friendsRoutes } = await import('../server/friends'); + const { Hono } = await import('hono'); + const root = new Hono(); + root.route('/api/auth', authRoutes); + root.route('/api/activities', activitiesRoutes); + root.route('/api/friends', friendsRoutes); + return root; +} + +beforeAll(async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'vinterliste-friends-test-')); + process.env.VINTERLISTE_DB = join(tmpDir, 'test.db'); + await ready(); + app = await loadApp(); + baseUrl = 'http://test.local'; +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +/** + * Sign up a user and return the session cookie the server set on the + * response, ready to be threaded back through subsequent requests. + */ +async function signupAndGetCookie(email: string, password: string): Promise { + const recoveryCode = generateRecoveryCode(); + const dek = generateDek(); + const kekSalt = generateSalt(); + const authSalt = generateSalt(); + const recSalt = generateSalt(); + const recAuthSalt = generateSalt(); + const kekPw = deriveKey(password, kekSalt); + const kekRec = deriveKey(normalizeRecoveryCode(recoveryCode), recSalt); + const wrappedPw = wrapDek(dek, kekPw); + const wrappedRec = wrapDek(dek, kekRec); + + const body: SignupRequest = { + email, + auth_salt: bytesToBase64(authSalt), + auth_verifier: deriveAuthVerifier(password, authSalt), + kek_salt: bytesToBase64(kekSalt), + wrapped_dek_pw: bytesToBase64(wrappedPw.ciphertext), + dek_pw_nonce: bytesToBase64(wrappedPw.nonce), + rec_salt: bytesToBase64(recSalt), + wrapped_dek_rec: bytesToBase64(wrappedRec.ciphertext), + dek_rec_nonce: bytesToBase64(wrappedRec.nonce), + rec_auth_salt: bytesToBase64(recAuthSalt), + rec_auth_verifier: deriveAuthVerifier(normalizeRecoveryCode(recoveryCode), recAuthSalt), + }; + + const res = await app.fetch(new Request(`${baseUrl}/api/auth/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + })); + expect(res.status).toBe(200); + // Extract the vl_session cookie. The fetch Response's Set-Cookie header + // gives us the full Cookie line; we only need the name=value pair. + 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 `vl_session=${m[1]}`; +} + +/** Set a username on the logged-in user so they're addable. */ +async function setUsername(cookie: string, username: string): Promise { + const res = await app.fetch(new Request(`${baseUrl}/api/auth/profile`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ username }), + })); + expect(res.status).toBe(200); +} + +async function addFriendByUsername(cookie: string, username: string): Promise { + const res = await app.fetch(new Request(`${baseUrl}/api/friends`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ username }), + })); + expect(res.status).toBe(201); +} + +async function createFriendsActivity(cookie: string, title: string): Promise { + const res = await app.fetch(new Request(`${baseUrl}/api/activities`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ visibility: 'friends', title, tags: [] }), + })); + expect(res.status).toBe(201); + return (await res.json()) as Activity; +} + +async function listActivities(cookie: string): Promise { + const res = await app.fetch(new Request(`${baseUrl}/api/activities`, { + headers: { Cookie: cookie }, + })); + expect(res.status).toBe(200); + return (await res.json()) as Activity[]; +} + +async function getActivity(cookie: string, id: string): Promise { + return app.fetch(new Request(`${baseUrl}/api/activities/${encodeURIComponent(id)}`, { + headers: { Cookie: cookie }, + })); +} + +describe('directional friends-only visibility', () => { + test("Anna's friends-only post is visible to Britt, but not vice versa", async () => { + const annaCookie = await signupAndGetCookie('anna@test.invalid', 'correct horse battery staple'); + const brittCookie = await signupAndGetCookie('britt@test.invalid', 'correct horse battery staple'); + + // Both users need usernames so they can be looked up. + await setUsername(annaCookie, 'anna'); + await setUsername(brittCookie, 'britt'); + + // Anna adds Britt as a friend. Britt does NOT add Anna. + await addFriendByUsername(annaCookie, 'britt'); + + // Anna posts a friends-only activity. + const annaPost = await createFriendsActivity(annaCookie, 'Annas vinterliste'); + + // Britt posts one too — but Britt has no friends, so this should reach + // nobody else. + const brittPost = await createFriendsActivity(brittCookie, 'Britts vinterliste'); + + // --- Anna's view --- + const annaSees = await listActivities(annaCookie); + const annaSeesIds = annaSees.map((a) => a.id); + expect(annaSeesIds).toContain(annaPost.id); // owns it + expect(annaSeesIds).not.toContain(brittPost.id); // Britt didn't add Anna + + // --- Britt's view --- + const brittSees = await listActivities(brittCookie); + const brittSeesIds = brittSees.map((a) => a.id); + expect(brittSeesIds).toContain(annaPost.id); // Anna added Britt → Britt sees it + expect(brittSeesIds).toContain(brittPost.id); // owns it + + // --- Permalink GET respects the same rule --- + const annaTryingBritt = await getActivity(annaCookie, brittPost.id); + expect(annaTryingBritt.status).toBe(404); // not 403 — we don't leak existence + + const brittSeeingAnna = await getActivity(brittCookie, annaPost.id); + expect(brittSeeingAnna.status).toBe(200); + }); + + test('reciprocal friendship makes both posts visible to both', async () => { + const cCookie = await signupAndGetCookie('cara@test.invalid', 'correct horse battery staple'); + const dCookie = await signupAndGetCookie('dina@test.invalid', 'correct horse battery staple'); + await setUsername(cCookie, 'cara'); + await setUsername(dCookie, 'dina'); + + // Both add each other. + await addFriendByUsername(cCookie, 'dina'); + await addFriendByUsername(dCookie, 'cara'); + + const cPost = await createFriendsActivity(cCookie, 'Cara'); + const dPost = await createFriendsActivity(dCookie, 'Dina'); + + const cSees = (await listActivities(cCookie)).map((a) => a.id); + expect(cSees).toContain(cPost.id); + expect(cSees).toContain(dPost.id); + + const dSees = (await listActivities(dCookie)).map((a) => a.id); + expect(dSees).toContain(cPost.id); + expect(dSees).toContain(dPost.id); + }); + + test('blocking severs friends-only flow in both directions', async () => { + const eCookie = await signupAndGetCookie('ester@test.invalid', 'correct horse battery staple'); + const fCookie = await signupAndGetCookie('frida@test.invalid', 'correct horse battery staple'); + await setUsername(eCookie, 'ester'); + await setUsername(fCookie, 'frida'); + + // Mutual friends. + await addFriendByUsername(eCookie, 'frida'); + await addFriendByUsername(fCookie, 'ester'); + + const ePost = await createFriendsActivity(eCookie, 'Ester'); + const fPost = await createFriendsActivity(fCookie, 'Frida'); + + // Pre-block: both see both. + { + const eSees = (await listActivities(eCookie)).map((a) => a.id); + const fSees = (await listActivities(fCookie)).map((a) => a.id); + expect(eSees).toContain(fPost.id); + expect(fSees).toContain(ePost.id); + } + + // Look up Frida's user_id via the incoming-friends list so Ester can + // block her. (Block API takes a user_id, not username.) + const incomingRes = await app.fetch(new Request(`${baseUrl}/api/friends/incoming`, { + headers: { Cookie: eCookie }, + })); + const incoming = (await incomingRes.json()) as { user_id: string; username: string | null }[]; + const fridaId = incoming.find((r) => r.username === 'frida')?.user_id; + expect(fridaId).toBeDefined(); + + // Ester blocks Frida. + const blockRes = await app.fetch(new Request(`${baseUrl}/api/friends/blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: eCookie }, + body: JSON.stringify({ user_id: fridaId }), + })); + expect(blockRes.status).toBe(201); + + // Post-block: neither sees the other's friends-only content. Even though + // both friendship rows still exist, the block filter applies symmetrically. + const eSeesAfter = (await listActivities(eCookie)).map((a) => a.id); + const fSeesAfter = (await listActivities(fCookie)).map((a) => a.id); + expect(eSeesAfter).not.toContain(fPost.id); + expect(fSeesAfter).not.toContain(ePost.id); + }); +});