test(ownership): multi-user integration coverage

This commit is contained in:
2026-05-21 00:31:23 +02:00
parent 5822dbc8ae
commit 4d4001e202
2 changed files with 182 additions and 0 deletions

View File

@@ -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!);
}

View File

@@ -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<typeof createApp>, 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<typeof makeTestDb>, 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<typeof makeTestDb>;
let app: ReturnType<typeof createApp>;
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();
});
});