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:
parent
f39fe9ed65
commit
eafc216d9b
1 changed files with 242 additions and 0 deletions
242
tests/friends.test.ts
Normal file
242
tests/friends.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue