feat: implement robustness guards

This commit is contained in:
saturn
2026-03-09 02:53:06 +08:00
parent fba480ae6e
commit be1853534a
25 changed files with 1531 additions and 96 deletions

View File

@@ -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)

View 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')
})
})

View File

@@ -0,0 +1,298 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
type PanelRecord = {
id: string
storyboardId: string
panelIndex: number
shotType: string
cameraMove: string
description: string
videoPrompt: string
location: string
characters: string
srtSegment: string
duration: number
}
type StoryboardRecord = {
id: string
episode: {
novelPromotionProject: {
projectId: string
}
}
}
const authMock = vi.hoisted(() => ({
requireProjectAuthLight: vi.fn(async () => ({
session: { user: { id: 'user-1' } },
project: { id: 'project-1', userId: 'user-1', mode: 'novel-promotion' },
})),
isErrorResponse: vi.fn((value: unknown) => value instanceof Response),
}))
const submitTaskMock = vi.hoisted(() => vi.fn<typeof import('@/lib/task/submitter').submitTask>(async () => ({
success: true,
async: true,
taskId: 'task-panel-variant',
runId: null,
status: 'queued',
deduped: false,
})))
const configServiceMock = vi.hoisted(() => ({
getProjectModelConfig: vi.fn(async () => ({
storyboardModel: 'img::storyboard',
})),
buildImageBillingPayload: vi.fn(async (input: { basePayload: Record<string, unknown> }) => ({
...input.basePayload,
generationOptions: { resolution: '1024x1024' },
})),
}))
const rollbackSpy = vi.hoisted(() => ({
delete: vi.fn(async () => ({})),
findFirst: vi.fn(async () => ({ panelIndex: 4 })),
updateMany: vi.fn(async () => ({ count: 2 })),
count: vi.fn(async () => 3),
storyboardUpdate: vi.fn(async () => ({})),
}))
const createTxSpy = vi.hoisted(() => ({
findMany: vi.fn(async () => [
{ id: 'panel-after-1', panelIndex: 2 },
{ id: 'panel-after-2', panelIndex: 3 },
]),
update: vi.fn(async () => ({})),
create: vi.fn(async (args: { data: PanelRecord }) => ({
id: args.data.id,
panelIndex: args.data.panelIndex,
})),
count: vi.fn(async () => 4),
storyboardUpdate: vi.fn(async () => ({})),
}))
const routeState = vi.hoisted(() => ({
storyboard: {
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
} satisfies StoryboardRecord,
panels: new Map<string, PanelRecord>(),
}))
const prismaMock = vi.hoisted(() => ({
novelPromotionStoryboard: {
findUnique: vi.fn(async () => routeState.storyboard),
},
novelPromotionPanel: {
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => routeState.panels.get(where.id) ?? null),
},
$transaction: vi.fn(async (
fn: (tx: {
novelPromotionPanel: {
findMany: typeof createTxSpy.findMany
update: typeof createTxSpy.update
create: typeof createTxSpy.create
delete: typeof rollbackSpy.delete
findFirst: typeof rollbackSpy.findFirst
updateMany: typeof rollbackSpy.updateMany
count: typeof rollbackSpy.count
}
novelPromotionStoryboard: {
update: typeof createTxSpy.storyboardUpdate
}
}) => Promise<unknown>,
) => {
const invocation = prismaMock.$transaction.mock.calls.length
if (invocation > 1) {
return await fn({
novelPromotionPanel: {
findMany: createTxSpy.findMany,
update: createTxSpy.update,
create: createTxSpy.create,
delete: rollbackSpy.delete,
findFirst: rollbackSpy.findFirst,
updateMany: rollbackSpy.updateMany,
count: rollbackSpy.count,
},
novelPromotionStoryboard: {
update: rollbackSpy.storyboardUpdate,
},
})
}
return await fn({
novelPromotionPanel: {
findMany: createTxSpy.findMany,
update: createTxSpy.update,
create: createTxSpy.create,
delete: rollbackSpy.delete,
findFirst: rollbackSpy.findFirst,
updateMany: rollbackSpy.updateMany,
count: rollbackSpy.count,
},
novelPromotionStoryboard: {
update: createTxSpy.storyboardUpdate,
},
})
}),
}))
vi.mock('@/lib/api-auth', () => authMock)
vi.mock('@/lib/task/submitter', () => ({ submitTask: submitTaskMock }))
vi.mock('@/lib/config-service', () => configServiceMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/billing', () => ({
buildDefaultTaskBillingInfo: vi.fn(() => ({ mode: 'default' })),
}))
vi.mock('@/lib/task/resolve-locale', () => ({
resolveRequiredTaskLocale: vi.fn(() => 'zh'),
}))
function buildPanel(id: string, storyboardId: string, panelIndex: number): PanelRecord {
return {
id,
storyboardId,
panelIndex,
shotType: 'medium',
cameraMove: 'static',
description: `description-${id}`,
videoPrompt: `prompt-${id}`,
location: 'Old Town',
characters: '[]',
srtSegment: '',
duration: 3,
}
}
async function invokeRoute(body: Record<string, unknown>): Promise<Response> {
const mod = await import('@/app/api/novel-promotion/[projectId]/panel-variant/route')
const req = buildMockRequest({
path: '/api/novel-promotion/project-1/panel-variant',
method: 'POST',
body,
})
return await mod.POST(req, { params: Promise.resolve({ projectId: 'project-1' }) })
}
describe('api specific - panel variant route', () => {
beforeEach(() => {
vi.clearAllMocks()
routeState.storyboard = {
id: 'storyboard-1',
episode: {
novelPromotionProject: {
projectId: 'project-1',
},
},
}
routeState.panels = new Map<string, PanelRecord>([
['panel-src', buildPanel('panel-src', 'storyboard-1', 1)],
['panel-ins', buildPanel('panel-ins', 'storyboard-1', 2)],
])
})
it('returns INVALID_PARAMS when sourcePanelId does not belong to storyboardId', async () => {
routeState.panels.set('panel-src', buildPanel('panel-src', 'storyboard-other', 1))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('returns INVALID_PARAMS when insertAfterPanelId does not belong to storyboardId', async () => {
routeState.panels.set('panel-ins', buildPanel('panel-ins', 'storyboard-other', 2))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('does not create panel when image billing payload validation fails', async () => {
configServiceMock.buildImageBillingPayload.mockRejectedValueOnce(new Error('missing capability'))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string; message: string } }
expect(res.status).toBe(400)
expect(json.error.code).toBe('INVALID_PARAMS')
expect(json.error.message).toBe('missing capability')
expect(createTxSpy.create).not.toHaveBeenCalled()
expect(submitTaskMock).not.toHaveBeenCalled()
})
it('rolls back the created panel when submitTask fails after insertion', async () => {
submitTaskMock.mockRejectedValueOnce(new Error('queue unavailable'))
const res = await invokeRoute({
storyboardId: 'storyboard-1',
insertAfterPanelId: 'panel-ins',
sourcePanelId: 'panel-src',
variant: { video_prompt: 'variant prompt', description: 'variant desc' },
})
const json = await res.json() as { error: { code: string } }
expect(res.status).toBe(502)
expect(json.error.code).toBe('EXTERNAL_ERROR')
expect(createTxSpy.create).toHaveBeenCalledTimes(1)
const createdPanelId = createTxSpy.create.mock.calls[0]?.[0].data.id
expect(createdPanelId).toEqual(expect.any(String))
expect(rollbackSpy.delete).toHaveBeenCalledWith({
where: { id: createdPanelId },
})
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(1, {
where: {
storyboardId: 'storyboard-1',
panelIndex: { gt: 3 },
},
data: {
panelIndex: { increment: 1004 },
panelNumber: { increment: 1004 },
},
})
expect(rollbackSpy.updateMany).toHaveBeenNthCalledWith(2, {
where: {
storyboardId: 'storyboard-1',
panelIndex: { gt: 1007 },
},
data: {
panelIndex: { decrement: 1005 },
panelNumber: { decrement: 1005 },
},
})
expect(rollbackSpy.storyboardUpdate).toHaveBeenCalledWith({
where: { id: 'storyboard-1' },
data: { panelCount: 3 },
})
})
})