import { describe, it, expect, beforeEach } from 'vitest'; import request from 'supertest'; import { createApp } from '../app.js'; import { makeTestDb, createUserDirect } from './dbHelper.js'; import { hashPassword } from '../services/auth/passwords.js'; import { setMailerForTests, type Mailer } from '../services/auth/email.js'; class StubMailer implements Mailer { async send() {} } async function login(app: ReturnType, email: string, password = 'secretpass') { const r = await request(app).post('/api/auth/login').send({ email, password }); if (r.status !== 200) throw new Error(`login failed: ${r.status} ${JSON.stringify(r.body)}`); const cookies = r.headers['set-cookie'] as unknown as string[]; const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!; return { cookies, csrf }; } async function makeActiveUser(env: ReturnType, email: string, role: 'user'|'sysadmin' = 'user') { return createUserDirect(env.db, { email, role, isActive: true, passwordHash: await hashPassword('secretpass'), emailVerifiedAt: Math.floor(Date.now() / 1000), }); } let env: ReturnType; let app: ReturnType; beforeEach(async () => { env = makeTestDb(); setMailerForTests(new StubMailer()); app = createApp(env.db); }); describe('ownership & sharing integration', () => { it('user B cannot read user A private lesson', async () => { await makeActiveUser(env, 'a@example.com'); await makeActiveUser(env, 'b@example.com'); const aAuth = await login(app, 'a@example.com'); const newL = await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ name: 'Privé' }); expect(newL.status).toBe(201); const bAuth = await login(app, 'b@example.com'); const tree = await request(app).get('/api/lessons/tree').set('Cookie', bAuth.cookies); expect(tree.status).toBe(200); expect(tree.body).toHaveLength(0); }); it('B finds A shared lesson in marketplace and subscribes', async () => { await makeActiveUser(env, 'a@example.com'); await makeActiveUser(env, 'b@example.com'); const aAuth = await login(app, 'a@example.com'); const created = await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ name: 'Spaans' }); const aLesson = created.body; await request(app).patch(`/api/lessons/${aLesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ visibility: 'shared' }); const bAuth = await login(app, 'b@example.com'); const mk = await request(app).get('/api/marketplace/lessons').set('Cookie', bAuth.cookies); expect(mk.status).toBe(200); expect(mk.body.rows.find((r: { name: string }) => r.name === 'Spaans')).toBeTruthy(); const sub = await request(app).post(`/api/lessons/${aLesson.id}/subscribe`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf); expect(sub.status).toBe(201); const tree = await request(app).get('/api/lessons/tree').set('Cookie', bAuth.cookies); expect(tree.body.find((n: { id: number }) => n.id === aLesson.id)).toBeTruthy(); }); it('B forks an A shared lesson and edits independently', async () => { await makeActiveUser(env, 'a@example.com'); await makeActiveUser(env, 'b@example.com'); const aAuth = await login(app, 'a@example.com'); const aLesson = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ name: 'Spaans' })).body; await request(app).patch(`/api/lessons/${aLesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ visibility: 'shared' }); await request(app).post(`/api/lessons/${aLesson.id}/cards`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ question: 'hola', answer: 'hello' }); const bAuth = await login(app, 'b@example.com'); const fork = await request(app).post(`/api/lessons/${aLesson.id}/fork`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf); expect(fork.status).toBe(201); expect(fork.body.ownerId).not.toBeNull(); expect(fork.body.sourceLessonId).toBe(aLesson.id); expect(fork.body.visibility).toBe('private'); const bCards = (await request(app).get(`/api/lessons/${fork.body.id}/cards`).set('Cookie', bAuth.cookies)).body; expect(bCards).toHaveLength(1); const editR = await request(app).patch(`/api/cards/${bCards[0].id}`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf) .send({ answer: 'BUENOS' }); expect(editR.status).toBe(200); // A's original is unaffected const aCards = (await request(app).get(`/api/lessons/${aLesson.id}/cards`).set('Cookie', aAuth.cookies)).body; expect(aCards[0].answer).toBe('hello'); }); it('sysadmin can mark lesson as curated; all users see it without subscribing', async () => { await makeActiveUser(env, 'admin@example.com', 'sysadmin'); await makeActiveUser(env, 'user@example.com'); const adminAuth = await login(app, 'admin@example.com'); const lesson = (await request(app).post('/api/lessons').set('Cookie', adminAuth.cookies).set('x-csrf-token', adminAuth.csrf) .send({ name: 'Officieel' })).body; await request(app).patch(`/api/admin/lessons/${lesson.id}/curated`).set('Cookie', adminAuth.cookies).set('x-csrf-token', adminAuth.csrf) .send({ isCurated: true }); const uAuth = await login(app, 'user@example.com'); const tree = await request(app).get('/api/lessons/tree').set('Cookie', uAuth.cookies); expect(tree.body.find((n: { id: number }) => n.id === lesson.id)).toBeTruthy(); }); it('regular user cannot mark lesson as curated', async () => { await makeActiveUser(env, 'u@example.com'); const uAuth = await login(app, 'u@example.com'); const lesson = (await request(app).post('/api/lessons').set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf) .send({ name: 'X' })).body; const r = await request(app).patch(`/api/admin/lessons/${lesson.id}/curated`).set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf) .send({ isCurated: true }); expect(r.status).toBe(403); }); it('subscribers cannot edit but can practice; per-user progress is isolated', async () => { await makeActiveUser(env, 'a@example.com'); await makeActiveUser(env, 'b@example.com'); const aAuth = await login(app, 'a@example.com'); const lesson = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ name: 'L' })).body; await request(app).patch(`/api/lessons/${lesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ visibility: 'shared' }); const card = (await request(app).post(`/api/lessons/${lesson.id}/cards`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ question: 'q', answer: 'a' })).body; const bAuth = await login(app, 'b@example.com'); await request(app).post(`/api/lessons/${lesson.id}/subscribe`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf); // B cannot edit const bad = await request(app).patch(`/api/cards/${card.id}`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf) .send({ answer: 'X' }); expect(bad.status).toBe(403); // B starts session and records an attempt — stats are scoped to B const sess = (await request(app).post('/api/sessions').set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf) .send({ lessonId: lesson.id, shuffle: false })).body; const next = (await request(app).get(`/api/sessions/${sess.session.id}/next`).set('Cookie', bAuth.cookies)).body; await request(app).post(`/api/sessions/${sess.session.id}/attempts`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf) .send({ cardId: next.item.cardId, direction: 'forward', result: 'correct' }); await request(app).post(`/api/sessions/${sess.session.id}/end`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf); // A's overview has 0 sessions; B's has 1 const aOv = (await request(app).get('/api/stats/overview').set('Cookie', aAuth.cookies)).body; const bOv = (await request(app).get('/api/stats/overview').set('Cookie', bAuth.cookies)).body; expect(aOv.totalSessions).toBe(0); expect(bOv.totalSessions).toBe(1); }); it('flipping visibility back to private clears is_curated and revokes subscriber read', async () => { await makeActiveUser(env, 'a@example.com', 'sysadmin'); await makeActiveUser(env, 'b@example.com'); const aAuth = await login(app, 'a@example.com'); const lesson = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ name: 'L' })).body; await request(app).patch(`/api/admin/lessons/${lesson.id}/curated`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ isCurated: true }); const bAuth = await login(app, 'b@example.com'); await request(app).post(`/api/lessons/${lesson.id}/subscribe`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf); // flip back to private const flip = await request(app).patch(`/api/lessons/${lesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf) .send({ visibility: 'private' }); expect(flip.body.isCurated).toBe(false); // B can no longer see it in their tree const tree = await request(app).get('/api/lessons/tree').set('Cookie', bAuth.cookies); expect(tree.body.find((n: { id: number }) => n.id === lesson.id)).toBeUndefined(); }); });