Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
469 lines
17 KiB
TypeScript
469 lines
17 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||
import os from 'os'
|
||
import path from 'path'
|
||
import type { Session } from 'next-auth'
|
||
import {
|
||
PerformanceSurveyApiError,
|
||
createPerformanceSurveyWorkItemFromRequest,
|
||
} from '@/lib/performance-survey-service'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync'
|
||
import { deleteFromS3, uploadFileToS3 } from '@/lib/s3'
|
||
import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
|
||
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
|
||
import {
|
||
deleteOwnedUploadSession,
|
||
getOwnedUploadedFile,
|
||
getOwnedUploadSession,
|
||
} from '@/lib/upload-sessions'
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
user: { findUnique: jest.fn(), findMany: jest.fn() },
|
||
performanceSurvey: { findUnique: jest.fn(), count: jest.fn() },
|
||
performanceSurveyWorkItem: { findUnique: jest.fn() },
|
||
$transaction: jest.fn(),
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/lib/performance-survey-report-sync', () => ({
|
||
ensurePerformanceSurveyDailyEmployeeSyncStarted: jest.fn(),
|
||
ensurePerformanceSurveyEmployeeProfileForUser: jest.fn(),
|
||
getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({ id: { not: '__hidden__' } })),
|
||
}))
|
||
|
||
jest.mock('@/lib/performance-survey-notifications', () => ({
|
||
ensurePerformanceSurveyNotificationWorkerStarted: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/dingtalk-client', () => ({
|
||
getDingtalkPerformanceSurveyNotificationChannel: jest.fn(() => 'work_notice'),
|
||
getDingtalkRobotOtoConfigStatus: jest.fn(() => ({ configured: true })),
|
||
getDingtalkRobotOtoTestUserIds: jest.fn(() => []),
|
||
getDingtalkWorkNoticeConfigStatus: jest.fn(() => ({ configured: true })),
|
||
}))
|
||
|
||
jest.mock('@/lib/s3', () => ({
|
||
deleteFromS3: jest.fn(),
|
||
uploadFileToS3: jest.fn(),
|
||
uploadToS3: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/material-upload-cross-process-lock', () => ({
|
||
acquireTaskMaterialUploadSessionLocks: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/temp-cleanup', () => ({
|
||
cleanupConsumedUploadSession: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/upload-sessions', () => ({
|
||
deleteOwnedUploadSession: jest.fn(),
|
||
getOwnedUploadSession: jest.fn(),
|
||
getOwnedUploadedFile: jest.fn(),
|
||
}))
|
||
|
||
function vp8xHeader(width: number, height: number) {
|
||
const buffer = Buffer.alloc(30)
|
||
buffer.write('RIFF', 0, 'ascii')
|
||
buffer.write('WEBP', 8, 'ascii')
|
||
buffer.write('VP8X', 12, 'ascii')
|
||
buffer.writeUIntLE(width - 1, 24, 3)
|
||
buffer.writeUIntLE(height - 1, 27, 3)
|
||
return buffer
|
||
}
|
||
|
||
const session = { user: { id: 'user-1' } } as Session
|
||
const employee = {
|
||
id: 'employee-1',
|
||
displayName: '测试人员',
|
||
departmentName: '综合支持部',
|
||
departmentPath: '集团/品牌中心/综合支持部',
|
||
levelType: 'P',
|
||
positionLevel: 'P4',
|
||
isLeadership: false,
|
||
}
|
||
|
||
function makeReleaseLock() {
|
||
return Object.assign(jest.fn(async () => undefined), {
|
||
assertHeld: jest.fn(),
|
||
})
|
||
}
|
||
|
||
describe('performance survey processed work item upload', () => {
|
||
let tempRoot = ''
|
||
let webpPath = ''
|
||
let transactionClient: any
|
||
let releaseUploadSessionLocks: ReturnType<typeof makeReleaseLock>
|
||
|
||
beforeEach(async () => {
|
||
jest.clearAllMocks()
|
||
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'survey-processed-'))
|
||
process.env.UPLOAD_DIR = tempRoot
|
||
webpPath = path.join(tempRoot, 'slice.webp')
|
||
await writeFile(webpPath, vp8xHeader(1600, 900))
|
||
|
||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||
id: 'user-1',
|
||
username: 'tester',
|
||
email: 'tester@example.com',
|
||
role: 'USER',
|
||
department: '综合支持部',
|
||
managedDepartment: null,
|
||
isSuperAdmin: false,
|
||
dingtalkUserId: 'ding-user-1',
|
||
realName: '测试人员',
|
||
avatar: null,
|
||
})
|
||
;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(employee)
|
||
;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({
|
||
id: 'survey-1',
|
||
title: '2026年7月 · 视觉评分',
|
||
templateCode: 'visual_brand_tone',
|
||
templateSnapshot: {
|
||
code: 'visual_brand_tone',
|
||
surveyType: 'visual_case',
|
||
requiredWorkItemCount: 3,
|
||
},
|
||
status: 'draft',
|
||
creatorUserId: 'user-1',
|
||
creatorEmployeeId: 'employee-1',
|
||
departmentName: '综合支持部',
|
||
targets: [{ id: 'target-1', employeeProfileId: 'employee-1' }],
|
||
workItems: [],
|
||
})
|
||
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
|
||
fileName: 'slice.webp',
|
||
fileSize: 30,
|
||
userId: 'user-1',
|
||
})
|
||
;(getOwnedUploadedFile as jest.Mock).mockResolvedValue(webpPath)
|
||
;(uploadFileToS3 as jest.Mock).mockImplementation(async (_filePath, fileName) => `https://s3.example/${fileName}`)
|
||
;(deleteOwnedUploadSession as jest.Mock).mockResolvedValue(true)
|
||
;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true })
|
||
;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue(null)
|
||
releaseUploadSessionLocks = makeReleaseLock()
|
||
;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLocks)
|
||
|
||
transactionClient = {
|
||
$queryRawUnsafe: jest.fn().mockResolvedValue([{ status: 'draft', dueAt: null }]),
|
||
performanceSurveyWorkItem: {
|
||
count: jest.fn().mockResolvedValue(0),
|
||
create: jest.fn(async ({ data }) => ({
|
||
...data,
|
||
id: 'work-item-1',
|
||
})),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(transactionClient))
|
||
})
|
||
|
||
afterEach(async () => {
|
||
await rm(tempRoot, { recursive: true, force: true })
|
||
})
|
||
|
||
test('streams validated WebP slices to S3 and uses the first slice as optional preview fallback', async () => {
|
||
const request = new Request('https://example.test/api/performance-survey/surveys/survey-1/work-items', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
targetId: 'target-1',
|
||
title: '案例 1',
|
||
processedImages: [{
|
||
uploadId: 'upload-1',
|
||
mimeType: 'image/webp',
|
||
width: 1600,
|
||
height: 900,
|
||
kind: 'slice',
|
||
sourceName: '1.webp',
|
||
sourceIndex: 0,
|
||
sliceIndex: 0,
|
||
sliceCount: 1,
|
||
}],
|
||
}),
|
||
})
|
||
|
||
const result = await createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request)
|
||
|
||
expect(uploadFileToS3).toHaveBeenCalledTimes(1)
|
||
expect(result.imageGroups).toEqual([
|
||
expect.objectContaining({
|
||
sourceName: '1.webp',
|
||
previewUrl: expect.stringContaining('.webp'),
|
||
previewWidth: 1600,
|
||
previewHeight: 900,
|
||
slices: [expect.objectContaining({ width: 1600, height: 900 })],
|
||
}),
|
||
])
|
||
expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1'])
|
||
expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1')
|
||
expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
|
||
expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1)
|
||
expect((acquireTaskMaterialUploadSessionLocks as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
|
||
(getOwnedUploadSession as jest.Mock).mock.invocationCallOrder[0],
|
||
)
|
||
expect((cleanupConsumedUploadSession as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan(
|
||
releaseUploadSessionLocks.mock.invocationCallOrder[0],
|
||
)
|
||
})
|
||
|
||
test('treats a committed transaction with a lost acknowledgement as success and preserves S3', async () => {
|
||
let committedData: any
|
||
transactionClient.performanceSurveyWorkItem.create.mockImplementation(async ({ data }: any) => {
|
||
committedData = data
|
||
;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue({
|
||
id: data.id,
|
||
surveyId: data.surveyId,
|
||
targetId: data.targetId,
|
||
submitterUserId: data.submitterUserId,
|
||
title: data.title,
|
||
description: data.description,
|
||
imageUrls: data.imageUrls,
|
||
status: data.status,
|
||
submittedAt: data.submittedAt,
|
||
})
|
||
throw new Error('transaction response lost')
|
||
})
|
||
|
||
const result = await createPerformanceSurveyWorkItemFromRequest(
|
||
session,
|
||
'survey-1',
|
||
makeProcessedImageRequest('upload-1'),
|
||
)
|
||
|
||
expect(result).toEqual(expect.objectContaining({
|
||
id: committedData.id,
|
||
imageGroups: committedData.imageUrls,
|
||
}))
|
||
expect(prisma.performanceSurveyWorkItem.findUnique).toHaveBeenCalledWith(expect.objectContaining({
|
||
where: { id: committedData.id },
|
||
}))
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1')
|
||
})
|
||
|
||
test('fails closed and preserves S3 and source session when database outcome is unknown', async () => {
|
||
transactionClient.performanceSurveyWorkItem.create.mockRejectedValue(new Error('connection lost'))
|
||
;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable'))
|
||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
|
||
await expect(createPerformanceSurveyWorkItemFromRequest(
|
||
session,
|
||
'survey-1',
|
||
makeProcessedImageRequest('upload-1'),
|
||
)).rejects.toMatchObject({
|
||
status: 503,
|
||
message: expect.stringContaining('服务器已保留图片和上传会话'),
|
||
})
|
||
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
|
||
expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
|
||
consoleError.mockRestore()
|
||
})
|
||
|
||
test('requires the persisted work item to contain the complete expected image groups', async () => {
|
||
let committedId = ''
|
||
transactionClient.performanceSurveyWorkItem.create.mockImplementation(async ({ data }: any) => {
|
||
committedId = data.id
|
||
;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue({
|
||
id: data.id,
|
||
surveyId: data.surveyId,
|
||
targetId: data.targetId,
|
||
submitterUserId: data.submitterUserId,
|
||
title: data.title,
|
||
description: data.description,
|
||
imageUrls: [],
|
||
status: data.status,
|
||
submittedAt: data.submittedAt,
|
||
})
|
||
throw new Error('transaction response lost')
|
||
})
|
||
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
|
||
await expect(createPerformanceSurveyWorkItemFromRequest(
|
||
session,
|
||
'survey-1',
|
||
makeProcessedImageRequest('upload-1'),
|
||
)).rejects.toMatchObject({ status: 503 })
|
||
|
||
expect(committedId).not.toBe('')
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
|
||
consoleError.mockRestore()
|
||
})
|
||
|
||
test('deletes only this attempt S3 after the database explicitly confirms no commit and preserves source', async () => {
|
||
transactionClient.performanceSurveyWorkItem.create.mockRejectedValue(new Error('transaction rolled back'))
|
||
;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue(null)
|
||
|
||
await expect(createPerformanceSurveyWorkItemFromRequest(
|
||
session,
|
||
'survey-1',
|
||
makeProcessedImageRequest('upload-1'),
|
||
)).rejects.toThrow('transaction rolled back')
|
||
|
||
expect(deleteFromS3).toHaveBeenCalledTimes(1)
|
||
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
|
||
expect(deleteOwnedUploadSession).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('sorts and locks all uploadIds before resolve, database commit, and successful cleanup', async () => {
|
||
const request = new Request('https://example.test/upload', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
targetId: 'target-1',
|
||
processedImages: [
|
||
makeProcessedImageDeclaration('upload-b', { sourceName: '2.webp', sourceIndex: 0 }),
|
||
makeProcessedImageDeclaration('upload-a', { sourceName: '1.webp', sourceIndex: 1 }),
|
||
],
|
||
}),
|
||
})
|
||
|
||
await createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request)
|
||
|
||
expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-a', 'upload-b'])
|
||
expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(2)
|
||
expect((cleanupConsumedUploadSession as jest.Mock).mock.calls.map(([uploadId]) => uploadId)).toEqual([
|
||
'upload-a',
|
||
'upload-b',
|
||
])
|
||
})
|
||
|
||
test('serializes concurrent reuse of one uploadId through cleanup before the next resolve', async () => {
|
||
let tail = Promise.resolve()
|
||
;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockImplementation(async () => {
|
||
const predecessor = tail
|
||
let releaseNext!: () => void
|
||
tail = new Promise<void>((resolve) => {
|
||
releaseNext = resolve
|
||
})
|
||
await predecessor
|
||
let active = true
|
||
return Object.assign(jest.fn(async () => {
|
||
active = false
|
||
releaseNext()
|
||
}), {
|
||
assertHeld: jest.fn(() => {
|
||
if (!active) throw new Error('lock released')
|
||
}),
|
||
})
|
||
})
|
||
|
||
let notifyCleanupStarted!: () => void
|
||
const cleanupStarted = new Promise<void>((resolve) => {
|
||
notifyCleanupStarted = resolve
|
||
})
|
||
let finishCleanup!: () => void
|
||
;(cleanupConsumedUploadSession as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => {
|
||
notifyCleanupStarted()
|
||
finishCleanup = () => resolve({ success: true })
|
||
}))
|
||
;(getOwnedUploadSession as jest.Mock)
|
||
.mockResolvedValueOnce({ fileName: 'slice.webp', fileSize: 30, userId: 'user-1' })
|
||
.mockResolvedValueOnce(null)
|
||
|
||
const first = createPerformanceSurveyWorkItemFromRequest(
|
||
session,
|
||
'survey-1',
|
||
makeProcessedImageRequest('upload-1'),
|
||
)
|
||
await cleanupStarted
|
||
|
||
const second = createPerformanceSurveyWorkItemFromRequest(
|
||
session,
|
||
'survey-1',
|
||
makeProcessedImageRequest('upload-1'),
|
||
)
|
||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||
expect(getOwnedUploadSession).toHaveBeenCalledTimes(1)
|
||
|
||
finishCleanup()
|
||
await expect(first).resolves.toEqual(expect.objectContaining({ id: 'work-item-1' }))
|
||
await expect(second).rejects.toThrow('图片上传已过期')
|
||
expect(uploadFileToS3).toHaveBeenCalledTimes(1)
|
||
expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('rejects client dimensions that do not match the actual WebP header', async () => {
|
||
const request = new Request('https://example.test/upload', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
targetId: 'target-1',
|
||
processedImages: [{
|
||
uploadId: 'upload-1',
|
||
mimeType: 'image/webp',
|
||
width: 1600,
|
||
height: 800,
|
||
kind: 'slice',
|
||
sourceName: '1.webp',
|
||
sourceIndex: 0,
|
||
sliceIndex: 0,
|
||
sliceCount: 1,
|
||
}],
|
||
}),
|
||
})
|
||
|
||
await expect(createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request)).rejects.toEqual(
|
||
new PerformanceSurveyApiError('图片「1.webp」尺寸校验失败,请重新上传'),
|
||
)
|
||
expect(uploadFileToS3).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('explicitly rejects raw JSON and legacy multipart uploads', async () => {
|
||
const rawJsonRequest = new Request('https://example.test/upload', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ images: [{ uploadId: 'old-upload', mimeType: 'image/png' }] }),
|
||
})
|
||
await expect(createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', rawJsonRequest)).rejects.toEqual(
|
||
new PerformanceSurveyApiError('原始图片上传协议已停用,请刷新页面后重新上传', 409),
|
||
)
|
||
|
||
const multipartRequest = new Request('https://example.test/upload', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'multipart/form-data; boundary=legacy' },
|
||
body: '--legacy--',
|
||
})
|
||
await expect(createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', multipartRequest)).rejects.toEqual(
|
||
new PerformanceSurveyApiError('旧版图片上传协议已停用,请刷新页面并使用最新版 Chrome 或 Edge 重新上传', 409),
|
||
)
|
||
})
|
||
})
|
||
|
||
function makeProcessedImageDeclaration(
|
||
uploadId: string,
|
||
overrides: Partial<{
|
||
sourceName: string
|
||
sourceIndex: number
|
||
}> = {},
|
||
) {
|
||
return {
|
||
uploadId,
|
||
mimeType: 'image/webp',
|
||
width: 1600,
|
||
height: 900,
|
||
kind: 'slice',
|
||
sourceName: overrides.sourceName || '1.webp',
|
||
sourceIndex: overrides.sourceIndex ?? 0,
|
||
sliceIndex: 0,
|
||
sliceCount: 1,
|
||
}
|
||
}
|
||
|
||
function makeProcessedImageRequest(uploadId: string) {
|
||
return new Request('https://example.test/upload', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
targetId: 'target-1',
|
||
title: '案例 1',
|
||
processedImages: [makeProcessedImageDeclaration(uploadId)],
|
||
}),
|
||
})
|
||
}
|