mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
* feat: add /api/ihost/images CRUD API with two-stage and stream-proxy upload Implements the full image-hosting CRUD at /api/ihost/images: - POST (JSON): two-stage upload — creates draft row + returns presigned URL - POST (multipart): stream-proxy to S3 via PicGo-compatible tool response - GET /: cursor-based list with optional pathPrefix filter - GET /🆔 detail with org isolation enforced - PATCH /:id action=confirm: transitions draft → active, increments quota - DELETE /🆔 hard-deletes S3 object + DB row, decrements quota Auth: session (all verbs) or apiKey with image-hosting:upload (POST only). Path validation: no .., no leading/trailing /, max depth 5, max 256 chars. Collision: auto-appends 4-hex suffix on (orgId, path) conflict. MIME gate: allows png/jpeg/gif/webp; rejects svg+xml with 415. Size gate: max 20 MB enforced at both JSON and multipart paths. Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * fix(ihost): resolve PR #317 blockers — API key auth, status codes, test coverage - Blocker 1: replace raw SQL key lookup with auth.api.verifyApiKey() so the SHA-256-hashed better-auth API keys are verified correctly - Blocker 2: add explicit pre-checks in JSON branch returning 413 for size > 20 MB, 415 for SVG/unsupported MIME before falling through to zod (which was returning 400 for all of these); also guard non-JSON content type → 415 - Blocker 3: replace raw insertApiKey() SQL helper with createTestApiKey() that calls auth.api.createApiKey() server-side so tests use properly hashed keys; fix expected status codes (401 for missing permission, 415/413); add quota-refund assertion in S3 failure test; add quota exceeded confirm test - Additional: handle selectStorage failure → 503, use Number.isFinite guard for Content-Length, add null guards to getOrgId/getUserId test helpers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ihost): correct rebase conflicts — merge T5 schema + remove duplicate tables - Merge T5's image_hosting_configs/image_hostings FK constraints with CRUD service (resolveActiveImageByToken + incrementAccessCount) and CRUD schemas - Remove duplicate table definitions in test/setup.ts left by rebase conflict resolution (keep T5's FK-constrained versions, add apikey table once) - Fix org-isolation test: insert a real organization row to satisfy the image_hostings.org_id FK constraint added by T5 Agent-Profile: https://agent-kanban.dev/agents/$AK_AGENT_ID * chore: trigger CI on rebased PR #317 Agent-Profile: https://agent-kanban.dev/agents/$AK_AGENT_ID * chore(ihost): add export comment to trigger CI sync event Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ihost): add targeted tests to meet 95% patch coverage gate Cover previously uncovered patch lines: - s3.ts: add putObject unit test (was 0% — 3 missing lines) - ihost.ts: add tests for 503 no-storage, 413 Content-Length header, 415 unsupported content-type, 400 zod parse failure, 400 missing file field, 415 non-image MIME in multipart, 422 quota exceeded in multipart, nanoid fallback after collision retries, validatePath edge cases (starts-with-/, ends-with-/, invalid chars, path too long) - image-hosting.ts: add direct service tests for deleteImageHosting null guard and confirmImageHosting with size=0; add validatePath tests via multipart path (bypasses zod max-256 guard) - ihost.ts: remove dead code (unreachable 'Unknown action' branch — patchIhostImageSchema discriminated union only allows 'confirm') Agent-Profile: https://agent-kanban.dev/agents/$AK_AGENT_ID --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
271 lines
8.7 KiB
TypeScript
271 lines
8.7 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import type { Storage } from '../../shared/types'
|
|
import { S3Service } from './s3.js'
|
|
|
|
const mockSend = vi.fn()
|
|
|
|
vi.mock('@aws-sdk/client-s3', () => {
|
|
class MockS3Client {
|
|
config: unknown
|
|
constructor(config: unknown) {
|
|
this.config = config
|
|
}
|
|
send = mockSend
|
|
}
|
|
class MockPutObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockGetObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockHeadObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockCopyObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockDeleteObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockDeleteObjectsCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
return {
|
|
S3Client: MockS3Client,
|
|
PutObjectCommand: MockPutObjectCommand,
|
|
GetObjectCommand: MockGetObjectCommand,
|
|
HeadObjectCommand: MockHeadObjectCommand,
|
|
CopyObjectCommand: MockCopyObjectCommand,
|
|
DeleteObjectCommand: MockDeleteObjectCommand,
|
|
DeleteObjectsCommand: MockDeleteObjectsCommand,
|
|
}
|
|
})
|
|
|
|
vi.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
getSignedUrl: vi.fn().mockResolvedValue('https://signed-url.example.com'),
|
|
}))
|
|
|
|
function makeStorage(overrides: Partial<Storage> = {}): Storage {
|
|
return {
|
|
id: 's1',
|
|
uid: 'u1',
|
|
title: 'Test',
|
|
mode: 'private',
|
|
bucket: 'my-bucket',
|
|
endpoint: 'https://s3.example.com',
|
|
region: 'us-east-1',
|
|
accessKey: 'AKID',
|
|
secretKey: 'SECRET',
|
|
customHost: '',
|
|
capacity: 0,
|
|
used: 0,
|
|
status: 'active',
|
|
createdAt: '2026-01-01',
|
|
updatedAt: '2026-01-01',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('S3Service', () => {
|
|
const service = new S3Service()
|
|
const storage = makeStorage()
|
|
|
|
describe('createClient', () => {
|
|
it('creates a client with correct config', () => {
|
|
const client = service.createClient(storage)
|
|
expect(client.config).toMatchObject({
|
|
region: 'us-east-1',
|
|
endpoint: 'https://s3.example.com',
|
|
forcePathStyle: true,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('presignUpload', () => {
|
|
it('returns a signed URL', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const url = await service.presignUpload(storage, 'test.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://signed-url.example.com')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.jpg', ContentType: 'image/jpeg' } }),
|
|
{ expiresIn: 3600 },
|
|
)
|
|
})
|
|
|
|
it('respects custom expiresIn', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
await service.presignUpload(storage, 'test.jpg', 'image/jpeg', 600)
|
|
expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 600 })
|
|
})
|
|
})
|
|
|
|
describe('presignDownload', () => {
|
|
it('returns a signed URL with Content-Disposition', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const url = await service.presignDownload(storage, 'test.jpg', 'my photo.jpg')
|
|
expect(url).toBe('https://signed-url.example.com')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
ResponseContentDisposition: 'attachment; filename="my%20photo.jpg"',
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('presignInline', () => {
|
|
it('returns a signed URL with inline Content-Disposition and ResponseContentType', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const url = await service.presignInline(storage, 'images/photo.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://signed-url.example.com')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
ResponseContentDisposition: 'inline',
|
|
ResponseContentType: 'image/jpeg',
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
)
|
|
})
|
|
|
|
it('respects custom expiresIn', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
await service.presignInline(storage, 'img.png', 'image/png', 600)
|
|
expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 600 })
|
|
})
|
|
})
|
|
|
|
describe('getPublicUrl', () => {
|
|
it('uses customHost when available', () => {
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com' })
|
|
expect(service.getPublicUrl(s, 'a/b.jpg')).toBe('https://cdn.example.com/a/b.jpg')
|
|
})
|
|
|
|
it('strips trailing slash from customHost', () => {
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com/' })
|
|
expect(service.getPublicUrl(s, 'a/b.jpg')).toBe('https://cdn.example.com/a/b.jpg')
|
|
})
|
|
|
|
it('falls back to endpoint/bucket when no customHost', () => {
|
|
expect(service.getPublicUrl(storage, 'a/b.jpg')).toBe('https://s3.example.com/my-bucket/a/b.jpg')
|
|
})
|
|
|
|
it('strips trailing slash from endpoint', () => {
|
|
const s = makeStorage({ endpoint: 'https://s3.example.com/' })
|
|
expect(service.getPublicUrl(s, 'a/b.jpg')).toBe('https://s3.example.com/my-bucket/a/b.jpg')
|
|
})
|
|
})
|
|
|
|
describe('headObject', () => {
|
|
it('returns size and contentType', async () => {
|
|
mockSend.mockResolvedValueOnce({
|
|
ContentLength: 1024,
|
|
ContentType: 'image/png',
|
|
$metadata: {},
|
|
})
|
|
const result = await service.headObject(storage, 'test.png')
|
|
expect(result).toEqual({ size: 1024, contentType: 'image/png' })
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.png' } }),
|
|
)
|
|
})
|
|
|
|
it('defaults size to 0 and contentType to application/octet-stream', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const result = await service.headObject(storage, 'test.bin')
|
|
expect(result).toEqual({ size: 0, contentType: 'application/octet-stream' })
|
|
})
|
|
})
|
|
|
|
describe('copyObject', () => {
|
|
it('sends CopyObjectCommand with correct CopySource', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const dst = makeStorage({ bucket: 'dst-bucket' })
|
|
await service.copyObject(storage, 'src.jpg', dst, 'dst.jpg')
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: {
|
|
CopySource: 'my-bucket/src.jpg',
|
|
Bucket: 'dst-bucket',
|
|
Key: 'dst.jpg',
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('deleteObject', () => {
|
|
it('sends DeleteObjectCommand', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
await service.deleteObject(storage, 'test.jpg')
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.jpg' } }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('deleteObjects', () => {
|
|
it('sends individual DeleteObjectCommand for each key', async () => {
|
|
mockSend.mockResolvedValue({ $metadata: {} })
|
|
await service.deleteObjects(storage, ['a.jpg', 'b.jpg'])
|
|
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'a.jpg' } }))
|
|
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'b.jpg' } }))
|
|
})
|
|
|
|
it('skips API call for empty keys array', async () => {
|
|
mockSend.mockClear()
|
|
await service.deleteObjects(storage, [])
|
|
expect(mockSend).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('putObject', () => {
|
|
it('sends PutObjectCommand with correct params for Uint8Array body', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const body = new Uint8Array([1, 2, 3])
|
|
await service.putObject(storage, 'images/test.png', body, 'image/png')
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: {
|
|
Bucket: 'my-bucket',
|
|
Key: 'images/test.png',
|
|
Body: body,
|
|
ContentType: 'image/png',
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('propagates S3 errors', async () => {
|
|
mockSend.mockRejectedValueOnce(new Error('S3 put failed'))
|
|
await expect(service.putObject(storage, 'fail.png', new Uint8Array(), 'image/png')).rejects.toThrow(
|
|
'S3 put failed',
|
|
)
|
|
})
|
|
})
|
|
})
|