test(friends): lock in directional visibility semantics

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).
This commit is contained in:
Ole-Morten Duesund 2026-05-25 15:02:57 +02:00
commit eafc216d9b

242
tests/friends.test.ts Normal file
View file

@ -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<ReturnType<typeof loadApp>>;
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<string> {
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<void> {
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<void> {
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<Activity> {
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<Activity[]> {
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<Response> {
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);
});
});