158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
// 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<typeof import('#lib/server/books/notes.js')>()),
|
|
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<string, string> = {}) {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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);
|
|
});
|
|
});
|