// Auth gating for the book tracker: files, reader, progress and notes are owner-only and // answer 404 to everyone else; the public pages never leak files or notes. import { beforeEach, describe, expect, it, vi } from 'vitest'; import { isHttpError } from '@sveltejs/kit'; vi.mock('#lib/server/db/index.js', () => ({ db: {} })); vi.mock('#lib/server/books/library.js', () => ({ coverSrc: vi.fn(() => null), getWork: vi.fn(), workFiles: vi.fn(async () => []), workNotes: vi.fn(async () => []), workSessions: vi.fn(async () => []), saveProgress: vi.fn(async () => ({ sessionId: 7 })), setStatus: vi.fn(), setRating: vi.fn(), setManualProgress: vi.fn(), logManualSession: vi.fn(), libraryCards: vi.fn(async () => []), libraryStats: vi.fn(async () => ({})) })); vi.mock('#lib/server/books/notes.js', async (original) => ({ ...(await original()), createNote: vi.fn(async () => ({ id: 1 })), updateNote: vi.fn(async () => ({ id: 1 })), deleteNote: vi.fn(async () => true) })); import * as library from '#lib/server/books/library.js'; import * as notes from '#lib/server/books/notes.js'; import { POST as progress } from './[id=integer]/progress/+server'; import { POST as createNote } from './[id=integer]/notes/+server'; import { PATCH as patchNote, DELETE as deleteNote } from './[id=integer]/notes/[noteId=integer]/+server'; import { GET as file } from './files/[id=integer]/+server'; import { load as readerLoad } from './[id=integer]/read/+page.server'; import { actions, load as detailLoad } from './[id=integer]/+page.server'; const OWNER = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' }; const STRANGER = { id: 'u2', email: 'someone@example.com', role: 'admin' }; function event(user: object | null, init: RequestInit = {}, params: Record = {}) { return { locals: { user, session: null }, params: { id: '1', noteId: '1', ...params }, request: new Request('https://site.test/trackers/books/1', init), url: new URL('https://site.test/trackers/books/1'), setHeaders: vi.fn() } as never; } async function status(fn: () => unknown) { try { const result = await fn(); return result instanceof Response ? result.status : 200; } catch (e) { if (isHttpError(e)) return e.status; throw e; } } const json = (body: unknown, method = 'POST') => ({ method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); const work = { id: 1, title: 'Dune', subtitle: null, authors: ['Frank Herbert'], year: 1965, publisher: null, isbn13: null, isbn10: null, language: 'en', pageCount: 412, description: null, tags: [], coverPath: null, coverUrl: null, openlibraryWorkId: null }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(library.getWork).mockResolvedValue({ work, progress: null } as never); }); describe.each([ ['anonymous', null], ['another signed-in user', STRANGER] ])('%s', (_, user) => { it('gets 404 from every owner-only endpoint', async () => { expect(await status(() => progress(event(user, json({ percent: 10 }))))).toBe(404); expect(await status(() => createNote(event(user, json({ note: 'hi' }))))).toBe(404); expect(await status(() => patchNote(event(user, json({ note: 'x' }, 'PATCH'))))).toBe(404); expect(await status(() => deleteNote(event(user, { method: 'DELETE' })))).toBe(404); expect(await status(() => file(event(user)))).toBe(404); expect(await status(() => readerLoad(event(user)))).toBe(404); for (const action of Object.values(actions)) expect( await status(() => action(event(user, { method: 'POST', body: new FormData() }))) ).toBe(404); expect(library.saveProgress).not.toHaveBeenCalled(); expect(notes.createNote).not.toHaveBeenCalled(); expect(library.setStatus).not.toHaveBeenCalled(); }); it('sees the public catalogue page without files, notes or sessions', async () => { const data = (await detailLoad(event(user))) as Record; expect(data.owner).toBe(false); expect(data).not.toHaveProperty('files'); expect(data).not.toHaveProperty('notes'); expect(data).not.toHaveProperty('sessions'); expect(library.workNotes).not.toHaveBeenCalled(); }); }); describe('owner', () => { it('saves progress and gets a session id back', async () => { const res = await progress( event(OWNER, json({ fileId: 2, locator: 'page:5', page: 5, percent: 1.234 })) ); expect(res.status).toBe(200); expect(await res.json()).toEqual({ sessionId: 7 }); expect(library.saveProgress).toHaveBeenCalledWith( 1, expect.objectContaining({ percent: 1.23, page: 5 }) ); }); it('rejects invalid progress', async () => { expect(await status(() => progress(event(OWNER, json({ percent: 140 }))))).toBe(400); expect(await status(() => progress(event(OWNER, { method: 'POST', body: '{' })))).toBe(400); expect(library.saveProgress).not.toHaveBeenCalled(); }); it('404s progress for a book that does not exist', async () => { vi.mocked(library.saveProgress).mockResolvedValueOnce(null); expect(await status(() => progress(event(OWNER, json({ percent: 3 }))))).toBe(404); }); it('creates highlights and refuses empty notes', async () => { expect( await status(() => createNote(event(OWNER, json({ highlight: 'quote', locator: 'page:1' })))) ).toBe(201); expect(await status(() => createNote(event(OWNER, json({ note: ' ' }))))).toBe(400); }); it('sees notes, files and sessions on the detail page', async () => { const data = (await detailLoad(event(OWNER))) as Record; expect(data.owner).toBe(true); expect(data).toHaveProperty('notes'); expect(data).toHaveProperty('files'); }); it('gets 404 from the reader when the book has no readable file', async () => { expect(await status(() => readerLoad(event(OWNER)))).toBe(404); }); });