mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-03-10 20:39:50 +01:00
198 lines
6.8 KiB
TypeScript
198 lines
6.8 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
|
import path from 'path'
|
|
|
|
const mockReadFile = mock()
|
|
const mockRealpath = mock()
|
|
const mockLstat = mock()
|
|
|
|
mock.module('fs/promises', () => ({
|
|
default: {
|
|
readFile: mockReadFile,
|
|
realpath: mockRealpath,
|
|
lstat: mockLstat,
|
|
},
|
|
readFile: mockReadFile,
|
|
realpath: mockRealpath,
|
|
lstat: mockLstat,
|
|
}))
|
|
|
|
let GET: typeof import('./route').GET
|
|
|
|
beforeAll(async () => {
|
|
;({ GET } = await import('./route'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
mock.restore()
|
|
})
|
|
|
|
describe('GET /api/avatars/[...path]', () => {
|
|
beforeEach(() => {
|
|
mockReadFile.mockReset()
|
|
mockRealpath.mockReset()
|
|
mockLstat.mockReset()
|
|
|
|
spyOn(process, 'cwd').mockReturnValue('/app')
|
|
|
|
mockRealpath.mockImplementation(async (value: string) => value)
|
|
mockLstat.mockResolvedValue({ isSymbolicLink: () => false })
|
|
})
|
|
|
|
test('returns avatar image for valid file path', async () => {
|
|
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('image/png')
|
|
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
|
|
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'avatar.png'))
|
|
})
|
|
|
|
test('allows nested valid avatar paths', async () => {
|
|
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/user-1/avatar.png'), {
|
|
params: Promise.resolve({ path: ['user-1', 'avatar.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(mockReadFile).toHaveBeenCalledWith(path.resolve('/app', 'data', 'avatars', 'user-1', 'avatar.png'))
|
|
})
|
|
|
|
test('supports uppercase extensions', async () => {
|
|
mockReadFile.mockResolvedValue(Buffer.from('avatar-binary'))
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.PNG'), {
|
|
params: Promise.resolve({ path: ['avatar.PNG'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(response.headers.get('Content-Type')).toBe('image/png')
|
|
})
|
|
|
|
test('rejects traversal segments', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/../auth.json'), {
|
|
params: Promise.resolve({ path: ['..', 'auth.json'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects encoded traversal payloads', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/%2e%2e%2fauth.json'), {
|
|
params: Promise.resolve({ path: ['%2e%2e%2fauth.json'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects encoded backslash traversal payloads', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/..%5cauth.png'), {
|
|
params: Promise.resolve({ path: ['..%5cauth.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects null byte payloads', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png%00'), {
|
|
params: Promise.resolve({ path: ['avatar.png%00'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects dot-only segments', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/./avatar.png'), {
|
|
params: Promise.resolve({ path: ['.', 'avatar.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects malformed encoded segments', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/%E0%A4%A'), {
|
|
params: Promise.resolve({ path: ['%E0%A4%A'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects unsupported file extensions', async () => {
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/config.json'), {
|
|
params: Promise.resolve({ path: ['config.json'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Unsupported file type' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects symlinked avatar files', async () => {
|
|
mockLstat.mockResolvedValue({ isSymbolicLink: () => true })
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('rejects files whose real path escapes avatars directory', async () => {
|
|
mockRealpath.mockImplementation(async (value: string) => {
|
|
if (value === path.resolve('/app', 'data', 'avatars')) {
|
|
return value
|
|
}
|
|
|
|
return path.resolve('/app', 'data', 'auth.png')
|
|
})
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(await response.json()).toEqual({ error: 'Invalid avatar path' })
|
|
expect(mockReadFile).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('returns 404 when file is missing', async () => {
|
|
mockLstat.mockRejectedValue({ code: 'ENOENT' })
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/missing.png'), {
|
|
params: Promise.resolve({ path: ['missing.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(await response.json()).toEqual({ error: 'File not found' })
|
|
})
|
|
|
|
test('returns 500 for non-ENOENT read errors', async () => {
|
|
mockReadFile.mockRejectedValue({ code: 'EACCES' })
|
|
|
|
const response = await GET(new Request('http://localhost:3000/api/avatars/avatar.png'), {
|
|
params: Promise.resolve({ path: ['avatar.png'] }),
|
|
})
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(await response.json()).toEqual({ error: 'Internal server error' })
|
|
})
|
|
})
|