141 lines
5.9 KiB
TypeScript
141 lines
5.9 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
// The action runs against fakes: a Blob Storage client that records what it was
|
|
// given, and a database whose note and attachment rows are set per test.
|
|
const NOTE = '00000000-0000-4000-8000-0000000000aa'
|
|
const mocks = vi.hoisted(() => ({
|
|
put: vi.fn(async (pathname: string) => ({ url: `https://blob.example.com/${pathname}` })),
|
|
log: vi.fn(async () => {}),
|
|
recordUsage: vi.fn(async () => ({ accepted: 1, duplicates: 0, rejected: [] })),
|
|
consumeUploadQuota: vi.fn(async () => null as { allowed: boolean; remaining: number } | null),
|
|
revalidatePath: vi.fn(),
|
|
loggerInfo: vi.fn(),
|
|
loggerRecordException: vi.fn(),
|
|
note: undefined as { id: string; authorId: string } | undefined,
|
|
inserted: [] as Record<string, unknown>[],
|
|
}))
|
|
|
|
const fakeDb = {
|
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => (mocks.note ? [mocks.note] : []) }) }) }),
|
|
insert: () => ({
|
|
values: (row: Record<string, unknown>) => ({
|
|
returning: async () => {
|
|
mocks.inserted.push(row)
|
|
return [{ id: '00000000-0000-4000-8000-0000000000ff', ...row, createdAt: new Date() }]
|
|
},
|
|
}),
|
|
}),
|
|
}
|
|
|
|
vi.mock('next/cache', () => ({ revalidatePath: mocks.revalidatePath }))
|
|
vi.mock('@/shared/api', () => ({
|
|
db: () => fakeDb,
|
|
audit: () => ({ log: mocks.log }),
|
|
blob: () => ({ put: mocks.put }),
|
|
recordUsage: mocks.recordUsage,
|
|
serverErrors: () => ({ captureException: vi.fn() }),
|
|
logger: () => ({ info: mocks.loggerInfo, recordException: mocks.loggerRecordException }),
|
|
}))
|
|
vi.mock('@/shared/config', () => ({
|
|
getEnv: () => ({ CONUSAI_API_URL: 'https://console.example.com', CONUSAI_API_TOKEN: 'dt_fixture' }),
|
|
}))
|
|
vi.mock('@/shared/lib/visitor-session', () => ({ ensureVisitor: async () => 'visitor-1' }))
|
|
vi.mock('@/shared/lib/optional', () => ({ consumeUploadQuota: mocks.consumeUploadQuota }))
|
|
|
|
import { uploadFile } from '@/features/upload-file/api/actions'
|
|
|
|
function attach(file: File): FormData {
|
|
const data = new FormData()
|
|
data.set('file', file)
|
|
return data
|
|
}
|
|
|
|
describe('uploadFile', () => {
|
|
beforeEach(() => {
|
|
mocks.put.mockClear()
|
|
mocks.log.mockClear()
|
|
mocks.revalidatePath.mockClear()
|
|
mocks.consumeUploadQuota.mockReset()
|
|
mocks.consumeUploadQuota.mockResolvedValue(null)
|
|
mocks.inserted.length = 0
|
|
mocks.note = { id: NOTE, authorId: 'visitor-1' }
|
|
mocks.loggerInfo.mockClear()
|
|
mocks.loggerRecordException.mockClear()
|
|
})
|
|
|
|
it('refuses a file over the size limit before any upload, and logs the failure', async () => {
|
|
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.png', { type: 'image/png' })
|
|
await expect(uploadFile(NOTE, attach(big))).rejects.toThrow(/5 MiB/)
|
|
expect(mocks.put).not.toHaveBeenCalled()
|
|
expect(mocks.log).not.toHaveBeenCalled()
|
|
expect(mocks.loggerInfo).not.toHaveBeenCalled()
|
|
expect(mocks.loggerRecordException).toHaveBeenCalledTimes(1)
|
|
expect(mocks.loggerRecordException).toHaveBeenCalledWith(expect.any(Error), { 'conusai.action': 'file.uploaded', 'note.id': NOTE })
|
|
})
|
|
|
|
it('refuses a disallowed type before any upload', async () => {
|
|
const exe = new File(['MZ'], 'run.exe', { type: 'application/x-msdownload' })
|
|
await expect(uploadFile(NOTE, attach(exe))).rejects.toThrow(/not accepted/)
|
|
expect(mocks.put).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refuses a note the visitor did not write, without uploading or spending quota', async () => {
|
|
mocks.note = { id: NOTE, authorId: 'visitor-2' }
|
|
const png = new File(['png'], 'a.png', { type: 'image/png' })
|
|
await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/not yours/)
|
|
expect(mocks.put).not.toHaveBeenCalled()
|
|
expect(mocks.inserted).toHaveLength(0)
|
|
expect(mocks.consumeUploadQuota).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('stores the bytes, records the attachment, and audits file.uploaded once', async () => {
|
|
const png = new File(['png'], 'a.png', { type: 'image/png' })
|
|
await uploadFile(NOTE, attach(png))
|
|
|
|
expect(mocks.put).toHaveBeenCalledTimes(1)
|
|
expect(mocks.put.mock.calls[0]?.[0]).toBe(`notes/${NOTE}/a.png`)
|
|
expect(mocks.inserted[0]).toMatchObject({
|
|
noteId: NOTE,
|
|
authorId: 'visitor-1',
|
|
name: 'a.png',
|
|
contentType: 'image/png',
|
|
size: 3,
|
|
url: `https://blob.example.com/notes/${NOTE}/a.png`,
|
|
})
|
|
expect(mocks.log).toHaveBeenCalledTimes(1)
|
|
expect(mocks.log).toHaveBeenCalledWith('file.uploaded', {
|
|
actor: { kind: 'human', id: 'visitor-1' },
|
|
target: { id: '00000000-0000-4000-8000-0000000000ff' },
|
|
})
|
|
expect(mocks.loggerInfo).toHaveBeenCalledTimes(1)
|
|
expect(mocks.loggerInfo).toHaveBeenCalledWith('file.uploaded', {
|
|
'file.id': '00000000-0000-4000-8000-0000000000ff',
|
|
'note.id': NOTE,
|
|
outcome: 'ok',
|
|
})
|
|
expect(mocks.loggerRecordException).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends exactly one usage event, keyed by the attachment, for one upload', async () => {
|
|
const png = new File(['png'], 'a.png', { type: 'image/png' })
|
|
await uploadFile(NOTE, attach(png))
|
|
|
|
expect(mocks.recordUsage).toHaveBeenCalledTimes(1)
|
|
const [config, events] = mocks.recordUsage.mock.calls[0] as unknown as [
|
|
{ baseUrl: string; token: string },
|
|
Array<{ idempotency_key: string; meter: string; quantity: string }>,
|
|
]
|
|
expect(config).toEqual({ baseUrl: 'https://console.example.com', token: 'dt_fixture' })
|
|
expect(events).toHaveLength(1)
|
|
expect(events[0]).toMatchObject({ idempotency_key: 'upload:00000000-0000-4000-8000-0000000000ff', meter: 'uploads', quantity: '1' })
|
|
})
|
|
|
|
it('refuses an upload over the minute allowance before any write', async () => {
|
|
mocks.consumeUploadQuota.mockResolvedValue({ allowed: false, remaining: 0 })
|
|
const png = new File(['png'], 'a.png', { type: 'image/png' })
|
|
await expect(uploadFile(NOTE, attach(png))).rejects.toThrow(/limit/)
|
|
expect(mocks.put).not.toHaveBeenCalled()
|
|
expect(mocks.inserted).toHaveLength(0)
|
|
})
|
|
})
|