import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import sharp from 'sharp' import { afterEach, describe, expect, it } from 'vitest' import { buildApp } from '../src/server/app.js' import { AuthService } from '../src/server/auth-service.js' import type { AppConfig } from '../src/server/config.js' import { openDatabase } from '../src/server/database.js' const applications: Awaited>[] = [] const directories: string[] = [] afterEach(async () => { while (applications.length > 0) { await applications.pop()?.close() } await Promise.all( directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }) ) ) }) async function harness( options: { adminBundle?: boolean nodeEnv?: AppConfig['nodeEnv'] publicOrigin?: string } = {} ) { const directory = await mkdtemp(join(tmpdir(), 'product-issue-api-')) directories.push(directory) const config: AppConfig = { nodeEnv: options.nodeEnv ?? 'test', host: '127.0.0.1', port: 3_000, dataDirectory: directory, appSecret: 'test-secret-that-is-at-least-thirty-two-characters', sessionTtlMs: 60 * 60 * 1_000, trustProxy: false, publicRateLimitPerHour: 100, publicInstallationLimitPerHour: 100, maxScreenshotBytes: 5 * 1_024 * 1_024, ...(options.publicOrigin ? { publicOrigin: options.publicOrigin } : {}) } const database = openDatabase(join(directory, 'database.sqlite')) const auth = new AuthService(database, config.appSecret, config.sessionTtlMs) await auth.createAdmin({ email: 'admin@example.com', displayName: 'Administrator', password: 'correct horse battery staple' }) let adminDirectory: string | undefined if (options.adminBundle) { adminDirectory = join(directory, 'admin') await mkdir(join(adminDirectory, 'assets'), { recursive: true }) await writeFile( join(adminDirectory, 'index.html'), 'Feedback admin
' ) await writeFile( join(adminDirectory, 'assets', 'app.js'), 'document.title = "Loaded feedback admin"' ) } const app = await buildApp({ config, database, ...(adminDirectory ? { adminDirectory } : {}) }) applications.push(app) return { app } } function feedbackPayload(clientRequestId: string) { return { schemaVersion: 1, productKey: 'goodbuddy', category: 'bug', title: '魔法笔记页签不可见', description: '平台功能中的魔法笔记页签被布局压缩,无法看到或点击。', contactEmail: 'user@example.com', environment: { appVersion: '0.11.0', platform: 'windows', architecture: 'x64', locale: 'zh-CN' }, installationId: '00000000-0000-4000-8000-000000000101', clientRequestId } } function multipartBody(input: { payload: unknown screenshot: Buffer }): { payload: Buffer; contentType: string } { const boundary = '----product-issue-test-boundary' const start = Buffer.from( `--${boundary}\r\n` + 'Content-Disposition: form-data; name="payload"\r\n\r\n' + `${JSON.stringify(input.payload)}\r\n` + `--${boundary}\r\n` + 'Content-Disposition: form-data; name="screenshot"; filename="capture.jpg"\r\n' + 'Content-Type: image/jpeg\r\n\r\n' ) const end = Buffer.from(`\r\n--${boundary}--\r\n`) return { payload: Buffer.concat([start, input.screenshot, end]), contentType: `multipart/form-data; boundary=${boundary}` } } async function login( app: Awaited> ): Promise<{ cookie: string; csrfToken: string }> { const response = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { email: 'admin@example.com', password: 'correct horse battery staple' } }) expect(response.statusCode).toBe(200) const setCookie = response.headers['set-cookie'] const rawCookie = Array.isArray(setCookie) ? setCookie[0] : setCookie const cookie = rawCookie?.split(';')[0] if (!cookie) { throw new Error('Missing session cookie') } return { cookie, csrfToken: response.json<{ csrfToken: string }>().csrfToken } } describe('feedback platform API', () => { it('uses a hardened production cookie and enforces browser origins', async () => { const { app } = await harness({ nodeEnv: 'production', publicOrigin: 'https://feedback.example.com' }) const loginResponse = await app.inject({ method: 'POST', url: '/api/admin/login', payload: { email: 'admin@example.com', password: 'correct horse battery staple' } }) expect(loginResponse.statusCode).toBe(200) const setCookie = loginResponse.headers['set-cookie'] const rawCookie = Array.isArray(setCookie) ? setCookie[0] : setCookie expect(rawCookie).toMatch( /^__Host-product_issue_session=.*;\s*Max-Age=.*;\s*Path=\/;\s*HttpOnly;\s*Secure;\s*SameSite=Strict$/u ) const rejected = await app.inject({ method: 'POST', url: '/api/v1/feedback', headers: { origin: 'https://untrusted.example' }, payload: feedbackPayload( '00000000-0000-4000-8000-000000000105' ) }) expect(rejected.statusCode).toBe(403) const accepted = await app.inject({ method: 'POST', url: '/api/v1/feedback', headers: { origin: 'https://feedback.example.com' }, payload: feedbackPayload( '00000000-0000-4000-8000-000000000106' ) }) expect(accepted.statusCode).toBe(201) }) it('serves the admin SPA only under the admin path', async () => { const { app } = await harness({ adminBundle: true }) const root = await app.inject({ method: 'GET', url: '/' }) expect(root.statusCode).toBe(302) expect(root.headers.location).toBe('/admin/') const admin = await app.inject({ method: 'GET', url: '/admin/' }) expect(admin.statusCode).toBe(200) expect(admin.body).toContain('Feedback admin') expect(admin.headers['cache-control']).toBe('no-cache') const nested = await app.inject({ method: 'GET', url: '/admin/feedback/example' }) expect(nested.statusCode).toBe(200) expect(nested.body).toContain('Feedback admin') const asset = await app.inject({ method: 'GET', url: '/admin/assets/app.js' }) expect(asset.statusCode).toBe(200) expect(asset.body).toContain('Loaded feedback admin') expect(asset.headers['cache-control']).toBe( 'public, max-age=31536000, immutable' ) expect( ( await app.inject({ method: 'GET', url: '/admin/assets/missing.js' }) ).statusCode ).toBe(404) }) it('accepts idempotent public submissions and exposes them only to admins', async () => { const { app } = await harness() const clientRequestId = '00000000-0000-4000-8000-000000000102' const first = await app.inject({ method: 'POST', url: '/api/v1/feedback', payload: feedbackPayload(clientRequestId) }) expect(first.statusCode).toBe(201) expect(first.headers['cache-control']).toBe('no-store') expect(first.json()).toEqual({ reference: 'GOODBUDDY-000001', duplicate: false }) const duplicate = await app.inject({ method: 'POST', url: '/api/v1/feedback', payload: feedbackPayload(clientRequestId) }) expect(duplicate.statusCode).toBe(200) expect(duplicate.json()).toEqual({ reference: 'GOODBUDDY-000001', duplicate: true }) expect( ( await app.inject({ method: 'GET', url: '/api/admin/feedback' }) ).statusCode ).toBe(401) const session = await login(app) const list = await app.inject({ method: 'GET', url: '/api/admin/feedback?productKey=goodbuddy&search=魔法', headers: { cookie: session.cookie } }) expect(list.statusCode).toBe(200) expect( ( await app.inject({ method: 'GET', url: '/api/admin/summary?productKey=Invalid', headers: { cookie: session.cookie } }) ).statusCode ).toBe(400) const item = list.json<{ items: { id: string; reference: string }[] }>() .items[0] expect(item).toMatchObject({ reference: 'GOODBUDDY-000001' }) if (!item) { throw new Error('Missing feedback item') } const rejectedStatus = await app.inject({ method: 'PATCH', url: `/api/admin/feedback/${item.id}/status`, headers: { cookie: session.cookie }, payload: { status: 'in-progress' } }) expect(rejectedStatus.statusCode).toBe(403) const changedStatus = await app.inject({ method: 'PATCH', url: `/api/admin/feedback/${item.id}/status`, headers: { cookie: session.cookie, 'x-csrf-token': session.csrfToken }, payload: { status: 'in-progress' } }) expect(changedStatus.statusCode).toBe(200) expect(changedStatus.json()).toMatchObject({ status: 'in-progress' }) const note = await app.inject({ method: 'POST', url: `/api/admin/feedback/${item.id}/notes`, headers: { cookie: session.cookie, 'x-csrf-token': session.csrfToken }, payload: { body: '已在开发版本中复现,安排修复。' } }) expect(note.statusCode).toBe(200) expect(note.json()).toMatchObject({ notes: [ { body: '已在开发版本中复现,安排修复。', authorName: 'Administrator' } ] }) const rejectedLogout = await app.inject({ method: 'POST', url: '/api/admin/logout', headers: { cookie: session.cookie } }) expect(rejectedLogout.statusCode).toBe(403) const logout = await app.inject({ method: 'POST', url: '/api/admin/logout', headers: { cookie: session.cookie, 'x-csrf-token': session.csrfToken } }) expect(logout.statusCode).toBe(204) expect( ( await app.inject({ method: 'GET', url: '/api/admin/session', headers: { cookie: session.cookie } }) ).statusCode ).toBe(401) }) it('normalizes screenshots to private PNG assets', async () => { const { app } = await harness() const jpeg = await sharp({ create: { width: 48, height: 32, channels: 3, background: '#4f8fe8' } }) .jpeg() .toBuffer() const body = multipartBody({ payload: { ...feedbackPayload( '00000000-0000-4000-8000-000000000103' ), description: '问'.repeat(5_000) }, screenshot: jpeg }) const submitted = await app.inject({ method: 'POST', url: '/api/v1/feedback', headers: { 'content-type': body.contentType }, payload: body.payload }) expect(submitted.statusCode).toBe(201) const session = await login(app) const list = await app.inject({ method: 'GET', url: '/api/admin/feedback', headers: { cookie: session.cookie } }) const item = list.json<{ items: { id: string; hasScreenshot: boolean }[] }>() .items[0] expect(item?.hasScreenshot).toBe(true) if (!item) { throw new Error('Missing screenshot feedback') } const screenshot = await app.inject({ method: 'GET', url: `/api/admin/feedback/${item.id}/screenshot`, headers: { cookie: session.cookie } }) expect(screenshot.statusCode).toBe(200) expect(screenshot.headers['content-type']).toMatch(/^image\/png/u) expect(screenshot.headers['cache-control']).toBe('private, no-store') const metadata = await sharp(screenshot.rawPayload).metadata() expect(metadata).toMatchObject({ format: 'png', width: 48, height: 32 }) }) it('manages products and rejects submissions after a product is disabled', async () => { const { app } = await harness() const session = await login(app) const created = await app.inject({ method: 'POST', url: '/api/admin/products', headers: { cookie: session.cookie, 'x-csrf-token': session.csrfToken }, payload: { key: 'mesa-writer', name: 'MesaWriter' } }) expect(created.statusCode).toBe(201) const product = created.json<{ id: string }>() expect( ( await app.inject({ method: 'PATCH', url: `/api/admin/products/${product.id}`, headers: { cookie: session.cookie, 'x-csrf-token': session.csrfToken }, payload: { enabled: false } }) ).statusCode ).toBe(200) const submission = feedbackPayload( '00000000-0000-4000-8000-000000000104' ) const rejected = await app.inject({ method: 'POST', url: '/api/v1/feedback', payload: { ...submission, productKey: 'mesa-writer' } }) expect(rejected.statusCode).toBe(404) }) })