diff --git a/packages/backend/src/services/lessons.ts b/packages/backend/src/services/lessons.ts index 67bda0b..ca634b6 100644 --- a/packages/backend/src/services/lessons.ts +++ b/packages/backend/src/services/lessons.ts @@ -217,6 +217,9 @@ export async function setLessonVisibility( }; if (visibility === 'private') patch.isCurated = false; const [row] = db.update(lessons).set(patch).where(eq(lessons.id, lessonId)).returning().all(); + if (visibility === 'private') { + db.delete(lessonSubscriptions).where(eq(lessonSubscriptions.lessonId, lessonId)).run(); + } return rowToLesson(row!); } diff --git a/packages/backend/src/tests/ownership.integration.test.ts b/packages/backend/src/tests/ownership.integration.test.ts new file mode 100644 index 0000000..bf7ca55 --- /dev/null +++ b/packages/backend/src/tests/ownership.integration.test.ts @@ -0,0 +1,179 @@ +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(); + }); +});