feat: implement robustness guards
This commit is contained in:
@@ -76,6 +76,17 @@ const prismaMock = vi.hoisted(() => ({
|
||||
userPreference: {
|
||||
findUnique: vi.fn(async () => ({ lipSyncModel: 'fal::lipsync-model' })),
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
id: 'storyboard-1',
|
||||
episode: {
|
||||
novelPromotionProject: {
|
||||
projectId: 'project-1',
|
||||
},
|
||||
},
|
||||
})),
|
||||
update: vi.fn(async () => ({})),
|
||||
},
|
||||
novelPromotionPanel: {
|
||||
findFirst: vi.fn(async () => ({ id: 'panel-1' })),
|
||||
findMany: vi.fn(async () => []),
|
||||
@@ -84,6 +95,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
if (id === 'panel-src') {
|
||||
return {
|
||||
id,
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 1,
|
||||
shotType: 'wide',
|
||||
cameraMove: 'static',
|
||||
@@ -98,6 +110,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
if (id === 'panel-ins') {
|
||||
return {
|
||||
id,
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 2,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'push',
|
||||
@@ -111,6 +124,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
}
|
||||
return {
|
||||
id,
|
||||
storyboardId: 'storyboard-1',
|
||||
panelIndex: 0,
|
||||
shotType: 'medium',
|
||||
cameraMove: 'static',
|
||||
@@ -124,6 +138,10 @@ const prismaMock = vi.hoisted(() => ({
|
||||
}),
|
||||
update: vi.fn(async () => ({})),
|
||||
create: vi.fn(async () => ({ id: 'panel-created', panelIndex: 3 })),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
delete: vi.fn(async () => ({})),
|
||||
count: vi.fn(async () => 3),
|
||||
updateMany: vi.fn(async () => ({ count: 0 })),
|
||||
},
|
||||
novelPromotionProject: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
@@ -153,14 +171,31 @@ const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionPanel: {
|
||||
findMany: (args: unknown) => Promise<Array<{ id: string; panelIndex: number }>>
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
create: (args: unknown) => Promise<{ id: string; panelIndex: number }>
|
||||
create: (args: { data?: { id?: string; panelIndex?: number } }) => Promise<{ id: string; panelIndex: number }>
|
||||
findFirst: (args: unknown) => Promise<{ panelIndex: number } | null>
|
||||
delete: (args: unknown) => Promise<unknown>
|
||||
count: (args: unknown) => Promise<number>
|
||||
updateMany: (args: unknown) => Promise<{ count: number }>
|
||||
}
|
||||
novelPromotionStoryboard: {
|
||||
update: (args: unknown) => Promise<unknown>
|
||||
}
|
||||
}) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
novelPromotionPanel: {
|
||||
findMany: async () => [],
|
||||
update: async () => ({}),
|
||||
create: async () => ({ id: 'panel-created', panelIndex: 3 }),
|
||||
create: async (args: { data?: { id?: string; panelIndex?: number } }) => ({
|
||||
id: args.data?.id || 'panel-created',
|
||||
panelIndex: args.data?.panelIndex ?? 3,
|
||||
}),
|
||||
findFirst: async () => ({ panelIndex: 3 }),
|
||||
delete: async () => ({}),
|
||||
count: async () => 3,
|
||||
updateMany: async () => ({ count: 0 }),
|
||||
},
|
||||
novelPromotionStoryboard: {
|
||||
update: async () => ({}),
|
||||
},
|
||||
}
|
||||
return await fn(tx)
|
||||
|
||||
207
tests/integration/api/contract/infra-routes.test.ts
Normal file
207
tests/integration/api/contract/infra-routes.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ROUTE_CATALOG } from '../../../contracts/route-catalog'
|
||||
import { buildMockRequest } from '../../../helpers/request'
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
authenticated: false,
|
||||
}))
|
||||
|
||||
const loggingMock = vi.hoisted(() => ({
|
||||
readAllLogs: vi.fn(async () => 'worker log line 1\nworker log line 2'),
|
||||
}))
|
||||
|
||||
const storageMock = vi.hoisted(() => ({
|
||||
getSignedObjectUrl: vi.fn(async (key: string, ttl: number) => `https://signed.example/${key}?expires=${ttl}`),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => {
|
||||
const unauthorized = () => new Response(
|
||||
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
|
||||
return {
|
||||
isErrorResponse: (value: unknown) => value instanceof Response,
|
||||
requireUserAuth: async () => {
|
||||
if (!authState.authenticated) return unauthorized()
|
||||
return { session: { user: { id: 'user-1' } } }
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/logging/file-writer', () => loggingMock)
|
||||
vi.mock('@/lib/storage', () => storageMock)
|
||||
|
||||
describe('api contract - infra routes (behavior)', () => {
|
||||
const routes = ROUTE_CATALOG.filter((entry) => entry.contractGroup === 'infra-routes')
|
||||
const originalUploadDir = process.env.UPLOAD_DIR
|
||||
const tempState = {
|
||||
uploadDirAbs: '',
|
||||
uploadDirRel: '',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
authState.authenticated = false
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.resetModules()
|
||||
if (tempState.uploadDirAbs) {
|
||||
await fs.rm(tempState.uploadDirAbs, { recursive: true, force: true })
|
||||
tempState.uploadDirAbs = ''
|
||||
tempState.uploadDirRel = ''
|
||||
}
|
||||
if (originalUploadDir === undefined) {
|
||||
delete process.env.UPLOAD_DIR
|
||||
} else {
|
||||
process.env.UPLOAD_DIR = originalUploadDir
|
||||
}
|
||||
})
|
||||
|
||||
async function prepareUploadDir(): Promise<void> {
|
||||
const unique = `test-uploads-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
tempState.uploadDirRel = path.join('.tmp', unique)
|
||||
tempState.uploadDirAbs = path.join(process.cwd(), tempState.uploadDirRel)
|
||||
process.env.UPLOAD_DIR = tempState.uploadDirRel
|
||||
await fs.mkdir(tempState.uploadDirAbs, { recursive: true })
|
||||
}
|
||||
|
||||
it('infra route group exists', () => {
|
||||
expect(routes.map((entry) => entry.routeFile)).toEqual(expect.arrayContaining([
|
||||
'src/app/api/admin/download-logs/route.ts',
|
||||
'src/app/api/cos/image/route.ts',
|
||||
'src/app/api/files/[...path]/route.ts',
|
||||
'src/app/api/storage/sign/route.ts',
|
||||
'src/app/api/system/boot-id/route.ts',
|
||||
]))
|
||||
})
|
||||
|
||||
it('GET /api/admin/download-logs rejects unauthenticated requests', async () => {
|
||||
const mod = await import('@/app/api/admin/download-logs/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/admin/download-logs',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
expect(res.status).toBe(401)
|
||||
expect(loggingMock.readAllLogs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('GET /api/admin/download-logs returns attachment headers when authenticated', async () => {
|
||||
authState.authenticated = true
|
||||
const mod = await import('@/app/api/admin/download-logs/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/admin/download-logs',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
const text = await res.text()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toContain('worker log line 1')
|
||||
expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8')
|
||||
expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="waoowaoo-logs-/)
|
||||
})
|
||||
|
||||
it('GET /api/cos/image redirects to signed storage route with normalized query', async () => {
|
||||
const mod = await import('@/app/api/cos/image/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/cos/image?key=folder/a.png&expires=7200',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
|
||||
expect(res.status).toBe(307)
|
||||
expect(res.headers.get('location')).toBe('http://localhost:3000/api/storage/sign?key=folder%2Fa.png&expires=7200')
|
||||
})
|
||||
|
||||
it('GET /api/storage/sign redirects to signed object url with default ttl', async () => {
|
||||
const mod = await import('@/app/api/storage/sign/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/storage/sign?key=folder/a.png',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, { params: Promise.resolve({}) })
|
||||
|
||||
expect(storageMock.getSignedObjectUrl).toHaveBeenCalledWith('folder/a.png', 3600)
|
||||
expect(res.status).toBe(307)
|
||||
expect(res.headers.get('location')).toBe('https://signed.example/folder/a.png?expires=3600')
|
||||
})
|
||||
|
||||
it('GET /api/system/boot-id returns the current server boot id', async () => {
|
||||
const mod = await import('@/app/api/system/boot-id/route')
|
||||
const serverBoot = await import('@/lib/server-boot')
|
||||
const res = await mod.GET()
|
||||
const json = await res.json() as { bootId: string }
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(json.bootId).toBe(serverBoot.SERVER_BOOT_ID)
|
||||
expect(typeof json.bootId).toBe('string')
|
||||
expect(json.bootId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('GET /api/files/[...path] rejects path traversal attempts', async () => {
|
||||
await prepareUploadDir()
|
||||
const mod = await import('@/app/api/files/[...path]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/files/%2E%2E/secret.txt',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, {
|
||||
params: Promise.resolve({ path: ['..', 'secret.txt'] }),
|
||||
})
|
||||
const json = await res.json() as { error: string }
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
expect(json.error).toBe('Access denied')
|
||||
})
|
||||
|
||||
it('GET /api/files/[...path] returns 404 when the file is missing', async () => {
|
||||
await prepareUploadDir()
|
||||
const mod = await import('@/app/api/files/[...path]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/files/missing.txt',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, {
|
||||
params: Promise.resolve({ path: ['missing.txt'] }),
|
||||
})
|
||||
const json = await res.json() as { error: string }
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(json.error).toBe('File not found')
|
||||
})
|
||||
|
||||
it('GET /api/files/[...path] serves local files from the configured upload dir', async () => {
|
||||
await prepareUploadDir()
|
||||
const nestedDir = path.join(tempState.uploadDirAbs, 'folder')
|
||||
await fs.mkdir(nestedDir, { recursive: true })
|
||||
await fs.writeFile(path.join(nestedDir, 'hello.txt'), 'hello local file', 'utf8')
|
||||
|
||||
const mod = await import('@/app/api/files/[...path]/route')
|
||||
const req = buildMockRequest({
|
||||
path: '/api/files/folder/hello.txt',
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
const res = await mod.GET(req, {
|
||||
params: Promise.resolve({ path: ['folder', 'hello.txt'] }),
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(text).toBe('hello local file')
|
||||
expect(res.headers.get('content-type')).toBe('text/plain')
|
||||
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user