test(ownership): multi-user integration coverage
This commit is contained in:
@@ -217,6 +217,9 @@ export async function setLessonVisibility(
|
|||||||
};
|
};
|
||||||
if (visibility === 'private') patch.isCurated = false;
|
if (visibility === 'private') patch.isCurated = false;
|
||||||
const [row] = db.update(lessons).set(patch).where(eq(lessons.id, lessonId)).returning().all();
|
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!);
|
return rowToLesson(row!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
179
packages/backend/src/tests/ownership.integration.test.ts
Normal file
179
packages/backend/src/tests/ownership.integration.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user