/** * Upload tests. * * The feature is the only one that writes *new* bytes into the library, so the * behaviour worth asserting is not "the file arrived" — it is everything around * that: that a name cannot become a path, that a refused mount is refused before * anything is written, that a failed upload costs nothing, and that a zip of * forty volumes lands as forty books rather than as a zip. * * Real bytes throughout. The parts that break (an archive's central directory, a * Chinese filename, a truncated stream) only break against real input. */ import { test, before, after, describe } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, rename, writeFile, readFile, readdir, rm, stat } from 'node:fs/promises'; import { Readable } from 'node:stream'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { deflateRawSync } from 'node:zlib'; import JSZip from 'jszip'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { loadConfig } from '../src/config/index.ts'; import { openDatabase } from '../src/db/index.ts'; import { Scanner } from '../src/indexer/scanner.ts'; import { UserService } from '../src/services/users.ts'; import { ShelfService } from '../src/services/shelf.ts'; import { SyncService } from '../src/services/sync.ts'; import { TtsService } from '../src/services/tts.ts'; import { BrowseService } from '../src/services/browse.ts'; import { UploadService, moveIntoLibrary, sanitizeUploadName } from '../src/services/uploads.ts'; import { buildApp } from '../src/http/app.ts'; import type { AppContext } from '../src/http/context.ts'; let app: FastifyInstance; let ctx: AppContext; let root: string; let booksDir: string; let token: string; let userId: string; const OPF = (title: string, id: string) => ` ${id} ${title}作者zh `; async function makeEpub(title: string, id: string): Promise { const zip = new JSZip(); zip.file('mimetype', 'application/epub+zip'); zip.file( 'META-INF/container.xml', '', ); zip.file('OEBPS/content.opf', OPF(title, id)); zip.file('OEBPS/c.xhtml', `

${title}

`); return zip.generateAsync({ type: 'nodebuffer' }); } /** A real zip, built by hand so the test controls entry names and order. */ function makeZip(entries: Array<[string, Buffer | string]>, deflate = true): Buffer { const CRC_TABLE = (() => { const table = new Int32Array(256); for (let i = 0; i < 256; i += 1) { let c = i; for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[i] = c; } return table; })(); const crc32 = (buf: Buffer): number => { let crc = -1; for (const byte of buf) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ byte) & 0xff]!; return (crc ^ -1) >>> 0; }; const locals: Buffer[] = []; const central: Buffer[] = []; let offset = 0; for (const [name, content] of entries) { const raw = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8'); const nameBuf = Buffer.from(name, 'utf8'); const deflated = deflateRawSync(raw); const useDeflate = deflate && deflated.byteLength < raw.byteLength; const data = useDeflate ? deflated : raw; const method = useDeflate ? 8 : 0; const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x800, 6); local.writeUInt16LE(method, 8); local.writeUInt32LE(crc32(raw), 14); local.writeUInt32LE(data.byteLength, 18); local.writeUInt32LE(raw.byteLength, 22); local.writeUInt16LE(nameBuf.byteLength, 26); locals.push(local, nameBuf, data); const cen = Buffer.alloc(46); cen.writeUInt32LE(0x02014b50, 0); cen.writeUInt16LE(20, 4); cen.writeUInt16LE(20, 6); cen.writeUInt16LE(0x800, 8); cen.writeUInt16LE(method, 10); cen.writeUInt32LE(crc32(raw), 16); cen.writeUInt32LE(data.byteLength, 20); cen.writeUInt32LE(raw.byteLength, 24); cen.writeUInt16LE(nameBuf.byteLength, 28); cen.writeUInt32LE(offset, 42); central.push(cen, nameBuf); offset += local.byteLength + nameBuf.byteLength + data.byteLength; } const centralBuf = Buffer.concat(central); const eocd = Buffer.alloc(22); eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(entries.length, 8); eocd.writeUInt16LE(entries.length, 10); eocd.writeUInt32LE(centralBuf.byteLength, 12); eocd.writeUInt32LE(offset, 16); return Buffer.concat([...locals, centralBuf, eocd]); } function auth() { return { authorization: `Bearer ${token}` }; } /** * Builds a `multipart/form-data` body. Written by hand rather than with a * library: the boundary and the exact bytes are part of what is being tested, and * a helper that quietly normalised a weird filename would hide the case that * matters most. */ function multipart( files: Array<{ field: string; filename: string; content: Buffer }>, fields: Record = {}, ): { body: Buffer; contentType: string } { const boundary = '----reader-test-boundary'; const chunks: Buffer[] = []; for (const [name, value] of Object.entries(fields)) { chunks.push( Buffer.from( `--${boundary}\r\ncontent-disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`, 'utf8', ), ); } for (const file of files) { chunks.push( Buffer.from( `--${boundary}\r\ncontent-disposition: form-data; name="${file.field}"; filename="${file.filename}"\r\n` + `content-type: application/octet-stream\r\n\r\n`, 'utf8', ), file.content, Buffer.from('\r\n', 'utf8'), ); } chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8')); return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` }; } before(async () => { root = await mkdtemp(join(tmpdir(), 'reader-uploads-')); booksDir = join(root, 'books'); await mkdir(booksDir, { recursive: true }); process.env.BOOKS_DIR = booksDir; process.env.DATA_DIR = join(root, 'data'); process.env.SCAN_INTERVAL = '0'; process.env.WATCH_INTERVAL = '0'; process.env.READER_TOKEN_SECRET = 'test-secret-that-is-long-enough'; const config = loadConfig(); const db = openDatabase(config); ctx = { config, db, scanner: undefined as never, users: undefined as never, shelf: undefined as never, sync: undefined as never, tts: undefined as never, browse: undefined as never, uploads: undefined as never, log: undefined as never, }; const silent = Fastify({ logger: false }); ctx.log = silent.log; ctx.scanner = new Scanner(db, config, { info: () => {}, warn: () => {} }); ctx.users = new UserService(db, config); ctx.shelf = new ShelfService(db); ctx.sync = new SyncService(db, ctx.shelf); ctx.tts = new TtsService(config); ctx.browse = new BrowseService(db, config, ctx.shelf); ctx.uploads = new UploadService(db, config, ctx.browse, ctx.scanner); app = buildApp(ctx); await app.ready(); const created = await app.inject({ method: 'POST', url: '/api/v1/auth/register', payload: { username: 'owner', password: 'password123' }, }); assert.equal(created.statusCode, 201, created.body); const body = created.json() as { session: { accessToken: string }; user: { id: string } }; token = body.session.accessToken; userId = body.user.id; await mkdir(join(booksDir, '待整理'), { recursive: true }); }); after(async () => { await app.close(); ctx.db.close(); await rm(root, { recursive: true, force: true }); }); describe('uploading a book', () => { /** * A directory of its own per test. * * The prompt's library is shared between tests by design — that is what makes * the conflict policies testable at all — so the collision cases name their * own folder instead of relying on a particular file being uploaded first. */ let caseIndex = 0; const freshTarget = async (): Promise => { caseIndex += 1; const dir = `用例${caseIndex}`; await mkdir(join(booksDir, dir), { recursive: true }); return dir; }; test('stores the file, indexes it, and reports the book it became', async () => { const target = await freshTarget(); const epub = await makeEpub('三体', 'urn:upload-one'); const { body, contentType } = multipart( [{ field: 'file', filename: '三体.epub', content: epub }], { path: target }, ); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { uploaded: Array<{ path: string; name: string; size: number; bookId?: string; title?: string }>; skipped: unknown[]; scan: { added: number }; }; assert.equal(result.uploaded.length, 1); assert.equal(result.uploaded[0]!.path, `${target}/三体.epub`); assert.equal(result.uploaded[0]!.title, '三体'); // Indexed before the response is written, not at the next scheduled scan: // a file on disk that the shelf cannot see is the exact state this product // exists to avoid. assert.ok(result.uploaded[0]!.bookId, 'the upload should be indexed already'); assert.equal(result.uploaded[0]!.size, epub.byteLength); const onDisk = await readFile(join(booksDir, target, '三体.epub')); assert.equal(onDisk.byteLength, epub.byteLength); }); test('a Chinese name written by a Windows client lands as a name, not a path', async () => { const epub = await makeEpub('斗破苍穹', 'urn:upload-two'); const { body, contentType } = multipart([ { field: 'file', filename: 'C:\\Users\\me\\我的书\\斗破苍穹.epub', content: epub }, ]); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { uploaded: Array<{ path: string }> }; // Only the leaf is meaningful, and the backslashes must not survive as part // of a file name. assert.equal(result.uploaded[0]!.path, '斗破苍穹.epub'); assert.ok((await readdir(booksDir)).includes('斗破苍穹.epub')); }); test('a name that tries to escape the library is refused rather than sanitised into place', () => { for (const name of ['..', '../..', '/', '.', '']) { assert.throws(() => sanitizeUploadName(name)); } // A traversal inside a *path* collapses to the leaf, which is inside. assert.equal(sanitizeUploadName('../../etc/passwd.epub'), 'passwd.epub'); assert.equal(sanitizeUploadName('a\u0000b.epub'), 'ab.epub'); // A leading dot is not cosmetic: the scanner skips dot-prefixed entries by // design, so a book uploaded as `.三体.epub` would be on disk and absent from // the shelf forever. assert.equal(sanitizeUploadName('.三体.epub'), '三体.epub'); assert.equal(sanitizeUploadName('..\\.hidden.epub'), 'hidden.epub'); // A trailing dot or space is invisible on Windows and dropped by SMB; the // name that gets indexed has to be the name the user will see. assert.equal(sanitizeUploadName('三体.epub.'), '三体.epub'); }); test('traversal in the destination directory is refused', async () => { const epub = await makeEpub('逃逸', 'urn:upload-three'); for (const target of ['../外面', '..', '/etc', '待整理/../..']) { const { body, contentType } = multipart( [{ field: 'file', filename: '逃逸.epub', content: epub }], { path: target }, ); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.ok(res.statusCode >= 400, `${target} answered ${res.statusCode}`); } }); test('a name already taken is renamed, skipped or refused according to the policy', async () => { const target = await freshTarget(); const first = await makeEpub('重名', 'urn:dupe-1'); const second = await makeEpub('重名', 'urn:dupe-2'); const upload = async (content: Buffer, onConflict?: string) => { const { body, contentType } = multipart( [{ field: 'file', filename: '重名.epub', content }], { path: target, ...(onConflict ? { onConflict } : {}) }, ); return app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); }; // The first copy takes the name; every upload after it is the conflict the // policy exists for. const initial = await upload(first); assert.equal(initial.statusCode, 200, initial.body); assert.equal((initial.json() as { uploaded: Array<{ name: string }> }).uploaded[0]!.name, '重名.epub'); const renamed = await upload(second); assert.equal(renamed.statusCode, 200, renamed.body); assert.equal((renamed.json() as { uploaded: Array<{ name: string }> }).uploaded[0]!.name, '重名 (2).epub'); const skipped = await upload(first, 'skip'); assert.equal(skipped.statusCode, 200, skipped.body); const skippedBody = skipped.json() as { uploaded: unknown[]; skipped: Array<{ reason: string }> }; assert.equal(skippedBody.uploaded.length, 0); assert.equal(skippedBody.skipped.length, 1); }); test('an empty upload is refused, and a failed upload leaves nothing behind', async () => { const { body, contentType } = multipart([{ field: 'file', filename: '空.epub', content: Buffer.alloc(0) }]); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 400); assert.equal((res.json() as { error: { code: string } }).error.code, 'EMPTY_UPLOAD'); // Every upload copies its bytes into DATA_DIR/uploads before touching the // library, so the scratch area is where a failure could accumulate. It must // not: a library that cannot be uploaded to twice is worse than no feature. // // Asked of a *fresh* scratch root, because node:test runs the cases in this // file concurrently against one library: asserting on the shared directory // would be asserting on whichever other case happens to be mid-flight. const isolated = await mkdtemp(join(root, 'scratch-')); const service = new UploadService(ctx.db, { ...ctx.config, dataDir: isolated }, ctx.browse, ctx.scanner); const empty = await makeEpub('空', 'urn:empty'); await assert.rejects( service.store({ name: '空.epub', stream: Readable.from(Buffer.alloc(0)), target: '' }), (err: unknown) => (err as { code?: string }).code === 'EMPTY_UPLOAD', ); const left = await readdir(join(isolated, 'uploads')).catch(() => []); assert.deepEqual(left, [], 'a failed upload must not leave scratch behind'); void empty; assert.ok(!(await readdir(booksDir)).includes('空.epub')); }); test('a stream that dies mid-transfer is reported as an incomplete upload', async () => { const broken = new Readable({ read() { this.push(Buffer.from('partial')); this.destroy(new Error('connection reset')); }, }); await assert.rejects( ctx.uploads.store({ name: '半本.epub', stream: broken, target: '' }), (err: unknown) => (err as { code?: string }).code === 'UPLOAD_INCOMPLETE', ); assert.ok(!(await readdir(booksDir)).includes('半本.epub')); }); test('a batch is stored together and indexed once', async () => { const target = await freshTarget(); const { body, contentType } = multipart( [ { field: 'file', filename: '合集一.epub', content: await makeEpub('合集一', 'urn:batch-a') }, { field: 'file', filename: '合集二.epub', content: await makeEpub('合集二', 'urn:batch-b') }, ], { path: target }, ); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { uploaded: Array<{ path: string; bookId?: string }>; scan: { startedAt: number; finishedAt: number }; }; assert.equal(result.uploaded.length, 2); assert.ok(result.uploaded.every((item) => item.bookId), 'both files should be indexed'); // One scan, not one per file: a batch is one library change. assert.ok(result.scan.finishedAt >= result.scan.startedAt); }); test('a batch whose later name is taken is still refused whole', async () => { const target = await freshTarget(); const taken = await makeEpub('占用', 'urn:taken'); const seed = multipart([{ field: 'file', filename: '占用.epub', content: taken }], { path: target }); const seeded = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': seed.contentType }, payload: seed.body, }); assert.equal(seeded.statusCode, 200, seeded.body); const { body, contentType } = multipart( [ { field: 'file', filename: '新书.epub', content: await makeEpub('新书', 'urn:new') }, { field: 'file', filename: '占用.epub', content: taken }, ], { path: target, onConflict: 'fail' }, ); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); // `fail` names the one conflict that has no reasonable guess behind it, so // the whole request is refused. Nothing may have been written for the first // file either — a half-applied batch is the shape a user cannot reason about. assert.equal(res.statusCode, 409, res.body); assert.equal((res.json() as { error: { code: string } }).error.code, 'DESTINATION_EXISTS'); const names = await readdir(join(booksDir, target)); assert.deepEqual(names, ['占用.epub']); }); test('a JSON body is refused, so the client gets a usable error', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': 'application/json' }, payload: { file: 'nope' }, }); assert.equal(res.statusCode, 400); assert.equal((res.json() as { error: { code: string } }).error.code, 'NOT_MULTIPART'); }); test('the endpoint requires a token', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload' }); assert.equal(res.statusCode, 401); }); test('the write probe answers before a phone starts sending bytes', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers: auth() }); assert.equal(res.statusCode, 200); assert.equal((res.json() as { writable: boolean }).writable, true); }); test('members cannot probe or upload, and rejected uploads leave the library unchanged', async () => { await ctx.users.createAsAdmin({ username: 'upload-member', password: 'password123', role: 'member' }); const member = await ctx.users.login('upload-member', 'password123'); const headers = { authorization: `Bearer ${member.accessToken}` }; const before = await readdir(booksDir); const probe = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers }); assert.equal(probe.statusCode, 403); const body = multipart([{ field: 'files', filename: 'member-forbidden.txt', content: Buffer.from('not allowed') }]); const upload = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...headers, 'content-type': body.contentType }, payload: body.body }); assert.equal(upload.statusCode, 403); assert.deepEqual(await readdir(booksDir), before); }); }); describe('uploading an archive', () => { let archiveCase = 0; const freshArchiveTarget = async (): Promise => { archiveCase += 1; const dir = `压缩包${archiveCase}`; await mkdir(join(booksDir, dir), { recursive: true }); return dir; }; test('a zip of books is unpacked into the library, one book each', async () => { const target = await freshArchiveTarget(); const archive = makeZip([ ['系列/第01卷.epub', await makeEpub('第一卷', 'urn:series-1')], ['系列/第02卷.epub', await makeEpub('第二卷', 'urn:series-2')], ['系列/说明.txt', '第一卷:开始\n第二卷:结束\n'], ]); const { body, contentType } = multipart( [{ field: 'file', filename: '系列合集.zip', content: archive }], { path: target }, ); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { uploaded: Array<{ path: string; title?: string; kind: string }> }; assert.equal(result.uploaded.length, 3); const paths = result.uploaded.map((item) => item.path).sort(); // The single wrapping folder is flattened, because that is what every // "download this series" zip looks like. assert.deepEqual(paths, [`${target}/第01卷.epub`, `${target}/第02卷.epub`, `${target}/说明.txt`]); assert.ok(result.uploaded.every((item) => item.kind === 'archive-entry')); assert.ok(result.uploaded.some((item) => item.title === '第一卷')); }); test('a nested comic archive keeps its structure, because that is what makes it a book', async () => { const target = await freshArchiveTarget(); const archive = makeZip([ ['套图/第01卷/001.jpg', Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])], ['套图/第01卷/002.jpg', Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])], ['套图/第02卷/001.jpg', Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])], ]); const { body, contentType } = multipart( [{ field: 'file', filename: '漫画.zip', content: archive }], { path: target }, ); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { uploaded: Array<{ path: string; bookId?: string; title?: string }> }; // `漫画/` is one folder, so it is flattened; the volumes inside it are not. // Flattening those would destroy the volume layout the comic handler reads. assert.deepEqual( result.uploaded.map((item) => item.path).sort(), [`${target}/第01卷/001.jpg`, `${target}/第01卷/002.jpg`, `${target}/第02卷/001.jpg`], ); assert.ok(result.uploaded.every((item) => item.bookId), 'the folder as a whole should be a book'); }); test('an entry name cannot escape the target directory', async () => { const archive = makeZip([ ['../../逃逸.txt', 'nope'], ['/绝对路径.txt', 'nope'], ['ok/正常.txt', '这本书在目录里\n'], ]); const { body, contentType } = multipart([ { field: 'file', filename: '恶意.zip', content: archive }, ]); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { uploaded: Array<{ path: string }>; skipped: Array<{ name: string }> }; const paths = result.uploaded.map((item) => item.path); // Nothing outside the library, and nothing that is not simply a name. for (const path of paths) { assert.ok(!path.includes('..'), `${path} should not be a traversal`); assert.ok(!path.startsWith('/'), `${path} should not be absolute`); } // The two hostile names are refused rather than rewritten into place. assert.ok(!paths.some((path) => path.includes('逃逸'))); assert.ok(!paths.some((path) => path.includes('绝对路径'))); await assert.rejects(stat(join(root, '逃逸.txt'))); }); test('a zip that contains nothing is refused', async () => { const { body, contentType } = multipart([ { field: 'file', filename: '空.zip', content: makeZip([]) }, ]); const res = await app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }); assert.ok(res.statusCode >= 400, res.body); }); }); describe('batch management', () => { let batchRoot: string; before(async () => { batchRoot = '批量'; await mkdir(join(booksDir, batchRoot), { recursive: true }); await writeFile(join(booksDir, batchRoot, '卷一.epub'), await makeEpub('合集第一卷', 'urn:batch-1')); await writeFile(join(booksDir, batchRoot, '卷二.epub'), await makeEpub('合集第二卷', 'urn:batch-2')); await ctx.scanner.scan(); }); test('one patch rewrites the metadata of a whole folder', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/library/browse/metadata', headers: auth(), payload: { paths: [batchRoot], fields: { author: '批量作者', series: '合集' } }, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { applied: number; books: string[]; failed: Array<{ path: string }> }; assert.equal(result.applied, 2); assert.equal(result.books.length, 2); assert.deepEqual(result.failed, []); // Read through the shelf, not the base column: a manual override is applied // when the book is read, so a test that inspected `books.author` would see // the embedded value and conclude the edit had been lost. const books = ctx.db.all<{ book_id: string }>( 'SELECT book_id FROM book_files WHERE rel_path LIKE ?', `${batchRoot}/%`, ); assert.equal(books.length, 2); const authors = books.map((row) => ctx.shelf.get(userId, row.book_id).author); assert.deepEqual(authors, ['批量作者', '批量作者']); }); test('a path that is not a book is reported rather than failing the batch', async () => { await mkdir(join(booksDir, '空目录'), { recursive: true }); const res = await app.inject({ method: 'POST', url: '/api/v1/library/browse/metadata', headers: auth(), payload: { paths: [`${batchRoot}/卷一.epub`, '空目录'], fields: { publisher: '某社' } }, }); assert.equal(res.statusCode, 200, res.body); const result = res.json() as { applied: number; failed: Array<{ path: string; reason: string }> }; assert.equal(result.applied, 1); assert.deepEqual(result.failed, [{ path: '空目录', reason: 'NOT_A_BOOK' }]); }); test('books can be taken off the shelf and put back without touching the disk', async () => { const before = ctx.db.get<{ n: number }>( 'SELECT COUNT(*) AS n FROM user_books WHERE user_id = ? AND hidden = 0', userId, ); const removed = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [batchRoot], action: 'remove' }, }); assert.equal(removed.statusCode, 200, removed.body); assert.equal((removed.json() as { applied: number }).applied, 2); const after = ctx.db.get<{ n: number }>( 'SELECT COUNT(*) AS n FROM user_books WHERE user_id = ? AND hidden = 0', userId, ); assert.equal(after!.n, before!.n - 2); // The files are still there. This is a shelf operation, not a delete, and // conflating the two is the mistake this endpoint is shaped to prevent. await stat(join(booksDir, batchRoot, '卷一.epub')); const restored = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [batchRoot], action: 'unhide' }, }); assert.equal(restored.statusCode, 200, restored.body); const back = ctx.db.get<{ n: number }>( 'SELECT COUNT(*) AS n FROM user_books WHERE user_id = ? AND hidden = 0', userId, ); assert.equal(back!.n, before!.n); }); test('a row reports whether its book is on the caller\'s shelf', async () => { /* * The file manager could describe the disk and not the shelf, and the two * diverge the moment a reader takes a book off their shelf. Without this flag * the screen offers "从书架拿掉" for every book — including the ones already off * it — so a reader looking for the reason a book is missing from their shelf * found the one screen that could explain it unable to say anything. */ const listing = async (): Promise> => (await app.inject({ method: 'GET', url: `/api/v1/library/browse?path=${encodeURIComponent(batchRoot)}`, headers: auth(), })).json<{ entries: Array<{ name: string; shelfState: string | null }> }>().entries; // A fresh scan puts every book on every user's shelf, so both start `on`. assert.deepEqual((await listing()).map((entry) => entry.shelfState), ['on', 'on']); const removed = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [`${batchRoot}/卷一.epub`], action: 'remove' }, }); assert.equal(removed.statusCode, 200, removed.body); const after = new Map((await listing()).map((entry) => [entry.name, entry.shelfState])); assert.equal(after.get('卷一.epub'), 'off', 'a book taken off the shelf must say so'); assert.equal(after.get('卷二.epub'), 'on'); }); test('"加入书架" puts back a book that was taken off the shelf', async () => { /* * The reported defect, at the layer that caused it. * * `add` was a bare `INSERT OR IGNORE`, and a book taken off the shelf already * *had* a `user_books` row — the one `remove` writes with `hidden = 1`, which is * exactly what makes a removal reversible. So `OR IGNORE` skipped the insert and * the book stayed hidden: the reader selected it, chose "加入书架", and nothing * happened. Reversibility that exists in the schema but not in the endpoint is * not reversibility. */ const total = async (search: string): Promise => (await app.inject({ method: 'GET', url: `/api/v1/books?search=${encodeURIComponent(search)}`, headers: auth() })) .json<{ total: number }>().total; assert.equal(await total('合集第一卷'), 0, 'precondition: the book is off the shelf'); const added = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [`${batchRoot}/卷一.epub`], action: 'add' }, }); assert.equal(added.statusCode, 200, added.body); assert.equal((added.json() as { applied: number }).applied, 1); assert.equal(await total('合集第一卷'), 1, '"add" must clear the hidden flag, not only create a missing row'); /* * And a folder is one action, not forty. * * "Hide these forty scans from my shelf" is the batch the endpoint exists for, * and its inverse has to be the same shape — otherwise putting a series back is * a per-volume chore, which is the manual work the auto-add rule exists to * avoid in the first place. */ const off = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [batchRoot], action: 'remove' }, }); assert.equal(off.statusCode, 200, off.body); assert.equal((off.json() as { applied: number }).applied, 2); assert.equal(await total('合集第二卷'), 0); const restored = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [batchRoot], action: 'unhide' }, }); assert.equal(restored.statusCode, 200, restored.body); assert.equal(await total('合集第二卷'), 1, 'the alias must be the same write as `add`'); }); test('an unknown shelf action is refused', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/library/browse/shelf', headers: auth(), payload: { paths: [batchRoot], action: 'delete' }, }); assert.equal(res.statusCode, 400); assert.equal((res.json() as { error: { code: string } }).error.code, 'BAD_ACTION'); }); test('a batch with no editable field is refused', async () => { const res = await app.inject({ method: 'POST', url: '/api/v1/library/browse/metadata', headers: auth(), payload: { paths: [batchRoot], fields: { id: 'nope' } }, }); assert.equal(res.statusCode, 400); }); }); describe('a read-only mount', () => { /** * Stands in for a read-only bind mount, which looks writable by mode bits and * refuses every write with `EPERM`. * * Simulated on the probe because the alternative is remounting the test's own * filesystem, which CI runs as root and would let straight through — the whole * reason the writability check probes by writing rather than reading mode bits. */ const withReadOnly = async (fn: () => Promise): Promise => { const original = ctx.browse.writable.bind(ctx.browse); ctx.browse.writable = async () => false; try { return await fn(); } finally { ctx.browse.writable = original; } }; test('the probes agree that the mount cannot be written to', async () => { await withReadOnly(async () => { const probe = await app.inject({ method: 'GET', url: '/api/v1/library/upload', headers: auth() }); assert.equal(probe.statusCode, 200); assert.equal((probe.json() as { writable: boolean }).writable, false); }); }); test('refuses an upload with 403 before storing a byte', async () => { const epub = await makeEpub('只读', 'urn:ro'); const { body, contentType } = multipart([{ field: 'file', filename: '只读.epub', content: epub }], { path: '待整理', }); const res = await withReadOnly(() => app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }), ); // A 403 with a code the client can act on, rather than an `EROFS` stack // trace: the mount is the deployment's, and the answer has to say so. assert.equal(res.statusCode, 403, res.body); assert.equal((res.json() as { error: { code: string } }).error.code, 'READ_ONLY_MOUNT'); await assert.rejects(stat(join(booksDir, '待整理', '只读.epub'))); }); test('leaves no scratch behind when the mount refuses the write', async () => { const epub = await makeEpub('只读二', 'urn:ro-2'); const { body, contentType } = multipart([{ field: 'file', filename: '只读二.epub', content: epub }]); const res = await withReadOnly(() => app.inject({ method: 'POST', url: '/api/v1/library/upload', headers: { ...auth(), 'content-type': contentType }, payload: body, }), ); assert.equal(res.statusCode, 403); // Asked of a *fresh* scratch root, because node:test runs the cases in this // file concurrently against one library: asserting on the shared directory // would be asserting on whichever other case happens to be mid-flight. const isolated = await mkdtemp(join(root, 'ro-scratch-')); const service = new UploadService(ctx.db, { ...ctx.config, dataDir: isolated }, ctx.browse, ctx.scanner); await withReadOnly(() => assert.rejects( service.store({ name: '只读三.epub', stream: Readable.from(epub), target: '待整理' }), (err: unknown) => (err as { code?: string }).code === 'READ_ONLY_MOUNT', ), ); // The bytes were received before the mount was consulted, so the scratch copy // is exactly what a refusal has to clean up. const left = await readdir(join(isolated, 'uploads')).catch(() => []); assert.deepEqual(left, [], 'a refused upload must not leave scratch behind'); }); }); describe('moving a staged file into the library', () => { /** * The scratch directory and the library are different mounts in every real * deployment — `DATA_DIR` is the container's writable layer, `BOOKS_DIR` is a * bind mount — so `rename` across them fails with `EXDEV`. The move has to * fall back to copying instead of surfacing a raw errno to the user. * * The cross-device rename is injected rather than staged on two real mounts: * ownership of the branch under test is the `EXDEV` handling, not the kernel's. */ const exdev = (): never => { const err = new Error('EXDEV: cross-device link not permitted, rename') as NodeJS.ErrnoException; err.code = 'EXDEV'; throw err; }; test('copies when the rename reports EXDEV, and removes the scratch file', async () => { const dir = await mkdtemp(join(root, 'cross-')); const source = join(dir, 'part'); const destination = join(booksDir, '跨设备.epub'); await writeFile(source, 'the bytes'); await moveIntoLibrary(source, destination, exdev as unknown as typeof rename); assert.equal(await readFile(destination, 'utf8'), 'the bytes'); await assert.rejects(stat(source), 'the scratch copy must not be left behind'); }); test('relies on rename when the filesystems match', async () => { const dir = await mkdtemp(join(root, 'same-')); const source = join(dir, 'part'); const destination = join(booksDir, '同设备.epub'); await writeFile(source, 'the bytes'); await moveIntoLibrary(source, destination); assert.equal(await readFile(destination, 'utf8'), 'the bytes'); await assert.rejects(stat(source)); }); test('does not swallow any other errno', async () => { const dir = await mkdtemp(join(root, 'enosys-')); const source = join(dir, 'part'); await writeFile(source, 'the bytes'); const enoent = (): never => { const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException; err.code = 'ENOENT'; throw err; }; await assert.rejects( moveIntoLibrary(source, join(booksDir, '不会存在.epub'), enoent as unknown as typeof rename), (err: unknown) => (err as { code?: string }).code === 'ENOENT', ); // A failed move must not have copied anything either. await assert.rejects(stat(join(booksDir, '不会存在.epub'))); }); });