diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 1d57219d3..24c62ad0c 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -8,6 +8,7 @@ const LibraryItemController = require('../../../server/controllers/LibraryItemCo const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') +const zipHelpers = require('../../../server/utils/zipHelpers') describe('LibraryItemController', () => { /** @type {ApiRouter} */ @@ -299,4 +300,95 @@ describe('LibraryItemController', () => { expect(fakeRes.sendStatus.calledWith(403)).to.be.true }) }) + + describe('download', () => { + let req + let res + + beforeEach(() => { + req = { + user: { username: 'testUser', canDownload: true }, + libraryItem: { + isFile: true, + path: '/audiobooks/hitchhikers-guide.mp3', + relPath: 'hitchhikers-guide.mp3', + media: { title: "The Hitchhiker's Guide To The Galaxy" } + } + } + res = { + setHeader: sinon.spy(), + sendFile: sinon.stub(), + sendStatus: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + }) + + it('should return 403 when user does not have canDownload permission', async () => { + req.user.canDownload = false + await LibraryItemController.download(req, res) + expect(res.sendStatus.calledWith(403)).to.be.true + expect(res.sendFile.called).to.be.false + }) + + it('should call res.sendFile for single-file library items', async () => { + res.sendFile.callsFake((filePath, opts, cb) => cb(null)) + await LibraryItemController.download(req, res) + expect(res.sendFile.calledOnce).to.be.true + }) + + it('should pass { dotfiles: "allow" } option to res.sendFile', async () => { + res.sendFile.callsFake((filePath, opts, cb) => cb(null)) + await LibraryItemController.download(req, res) + const opts = res.sendFile.firstCall.args[1] + expect(opts).to.deep.equal({ dotfiles: 'allow' }) + }) + + it('should pass the correct file path to res.sendFile', async () => { + res.sendFile.callsFake((filePath, opts, cb) => cb(null)) + await LibraryItemController.download(req, res) + expect(res.sendFile.firstCall.args[0]).to.equal('/audiobooks/hitchhikers-guide.mp3') + }) + + it('should set Content-Disposition attachment header with the filename', async () => { + res.sendFile.callsFake((filePath, opts, cb) => cb(null)) + await LibraryItemController.download(req, res) + const dispositionCall = res.setHeader.args.find(([header]) => header === 'Content-Disposition') + expect(dispositionCall).to.exist + expect(dispositionCall[1]).to.include('attachment') + expect(dispositionCall[1]).to.include('hitchhikers-guide.mp3') + }) + + it('should URL-encode special characters in the Content-Disposition filename', async () => { + req.libraryItem.relPath = 'Book With Spaces & Symbols!.mp3' + res.sendFile.callsFake((filePath, opts, cb) => cb(null)) + await LibraryItemController.download(req, res) + const dispositionCall = res.setHeader.args.find(([header]) => header === 'Content-Disposition') + expect(dispositionCall[1]).to.include(encodeURIComponent('Book With Spaces & Symbols!.mp3')) + }) + + it('should return 500 when res.sendFile calls back with an error', async () => { + res.sendFile.callsFake((filePath, opts, cb) => cb(new Error('File not found'))) + await LibraryItemController.download(req, res) + expect(res.status.calledWith(500)).to.be.true + expect(res.send.called).to.be.true + }) + + it('should call zipDirectoryPipe for multi-file directory items', async () => { + req.libraryItem.isFile = false + const zipStub = sinon.stub(zipHelpers, 'zipDirectoryPipe').resolves() + await LibraryItemController.download(req, res) + expect(zipStub.calledOnce).to.be.true + expect(zipStub.firstCall.args[0]).to.equal(req.libraryItem.path) + expect(zipStub.firstCall.args[1]).to.equal("The Hitchhiker's Guide To The Galaxy.zip") + expect(zipStub.firstCall.args[2]).to.equal(res) + }) + + it('should pass the response object to zipDirectoryPipe', async () => { + req.libraryItem.isFile = false + const zipStub = sinon.stub(zipHelpers, 'zipDirectoryPipe').resolves() + await LibraryItemController.download(req, res) + expect(zipStub.firstCall.args[2]).to.equal(res) + }) + }) }) diff --git a/test/server/utils/fileUtils.test.js b/test/server/utils/fileUtils.test.js index b57a6fb86..81ebb708e 100644 --- a/test/server/utils/fileUtils.test.js +++ b/test/server/utils/fileUtils.test.js @@ -3,6 +3,9 @@ const expect = chai.expect const sinon = require('sinon') const fileUtils = require('../../../server/utils/fileUtils') const fs = require('fs') +const http = require('http') +const path = require('path') +const os = require('os') const Logger = require('../../../server/Logger') describe('fileUtils', () => { @@ -134,4 +137,85 @@ describe('fileUtils', () => { }) }) }) + + describe('downloadFile', () => { + let server + let serverPort + let tmpDir + let serveContent + let serveContentType + let serveStatusCode + + before(async () => { + // Disable the SSRF filter so we can hit 127.0.0.1 in tests + global.DisableSsrfRequestFilter = () => true + + server = http.createServer((req, res) => { + res.writeHead(serveStatusCode, { + 'Content-Type': serveContentType, + 'Content-Length': serveContent.length.toString() + }) + res.end(serveContent) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + serverPort = server.address().port + }) + + after(async () => { + delete global.DisableSsrfRequestFilter + await new Promise((resolve) => server.close(resolve)) + }) + + beforeEach(() => { + serveContent = Buffer.from('fake audio content for testing purposes') + serveContentType = 'audio/mpeg' + serveStatusCode = 200 + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-fileutils-test-')) + sinon.stub(Logger, 'debug') + sinon.stub(Logger, 'error') + }) + + afterEach(() => { + sinon.restore() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('should download file and write it to the specified path', async () => { + const destPath = path.join(tmpDir, 'output.mp3') + await fileUtils.downloadFile(`http://127.0.0.1:${serverPort}/test.mp3`, destPath) + expect(fs.existsSync(destPath)).to.be.true + expect(fs.readFileSync(destPath).toString()).to.equal(serveContent.toString()) + }) + + it('should write the exact number of bytes served', async () => { + serveContent = Buffer.alloc(8192, 0xff) + const destPath = path.join(tmpDir, 'exact.mp3') + await fileUtils.downloadFile(`http://127.0.0.1:${serverPort}/exact.mp3`, destPath) + expect(fs.statSync(destPath).size).to.equal(8192) + }) + + it('should reject when content type filter rejects the response content type', async () => { + const destPath = path.join(tmpDir, 'rejected.mp3') + let didReject = false + try { + await fileUtils.downloadFile(`http://127.0.0.1:${serverPort}/test.mp3`, destPath, (ct) => ct.includes('video/')) + } catch (e) { + didReject = true + expect(e.message).to.include('Invalid content type') + } + expect(didReject).to.be.true + }) + + it('should pass when content type filter accepts the response content type', async () => { + const destPath = path.join(tmpDir, 'accepted.mp3') + await fileUtils.downloadFile(`http://127.0.0.1:${serverPort}/test.mp3`, destPath, (ct) => ct.includes('audio/')) + expect(fs.existsSync(destPath)).to.be.true + }) + + it('should resolve with no content type filter provided', async () => { + const destPath = path.join(tmpDir, 'nofilter.mp3') + await fileUtils.downloadFile(`http://127.0.0.1:${serverPort}/test.mp3`, destPath) + expect(fs.existsSync(destPath)).to.be.true + }) + }) }) diff --git a/test/server/utils/zipHelpers.test.js b/test/server/utils/zipHelpers.test.js new file mode 100644 index 000000000..cdbc0c047 --- /dev/null +++ b/test/server/utils/zipHelpers.test.js @@ -0,0 +1,87 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { PassThrough } = require('stream') +const path = require('path') +const fs = require('fs') +const os = require('os') +const zipHelpers = require('../../../server/utils/zipHelpers') +const Logger = require('../../../server/Logger') + +describe('zipHelpers', () => { + let tmpDir + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'abs-zip-test-')) + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'debug') + }) + + afterEach(() => { + sinon.restore() + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + describe('zipDirectoryPipe', () => { + function makeMockRes() { + const stream = new PassThrough() + stream.attachment = sinon.spy() + // zipDirectoryPipe resolves on 'close'. PassThrough emits 'finish' but not 'close' + // automatically — destroy() triggers 'close', so bridge the two events here. + stream.on('finish', () => stream.destroy()) + return stream + } + + it('should call res.attachment with the provided filename', async () => { + fs.writeFileSync(path.join(tmpDir, 'book.mp3'), 'audio content') + const res = makeMockRes() + await zipHelpers.zipDirectoryPipe(tmpDir, 'my-book.zip', res) + expect(res.attachment.calledWith('my-book.zip')).to.be.true + }) + + it('should resolve and produce data for a non-empty directory', async () => { + fs.writeFileSync(path.join(tmpDir, 'chapter1.mp3'), 'audio data chapter 1') + fs.writeFileSync(path.join(tmpDir, 'chapter2.mp3'), 'audio data chapter 2') + + const res = makeMockRes() + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + + await zipHelpers.zipDirectoryPipe(tmpDir, 'book.zip', res) + const totalBytes = chunks.reduce((sum, c) => sum + c.length, 0) + expect(totalBytes).to.be.greaterThan(0) + }) + + it('should resolve for an empty directory', async () => { + const res = makeMockRes() + // Should not throw + await zipHelpers.zipDirectoryPipe(tmpDir, 'empty.zip', res) + }) + + it('should produce a valid zip containing the expected files', async () => { + fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'Hello audiobookshelf') + const res = makeMockRes() + const chunks = [] + res.on('data', (chunk) => chunks.push(chunk)) + + await zipHelpers.zipDirectoryPipe(tmpDir, 'archive.zip', res) + + // ZIP files start with the PK signature (0x50 0x4B) + const combined = Buffer.concat(chunks) + expect(combined[0]).to.equal(0x50) // 'P' + expect(combined[1]).to.equal(0x4b) // 'K' + }) + + it('should resolve (not throw) when the source directory does not exist', async () => { + // archiver treats a missing directory as a warning (ENOENT), not a fatal error, + // so zipDirectoryPipe resolves with an empty archive rather than rejecting. + const res = makeMockRes() + let threw = false + try { + await zipHelpers.zipDirectoryPipe('/nonexistent/path/that/does/not/exist', 'fail.zip', res) + } catch (e) { + threw = true + } + expect(threw).to.be.false + }) + }) +})