Files
2026-08-24 17:27:41 +08:00

436 lines
13 KiB
TypeScript

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<ReturnType<typeof buildApp>>[] = []
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'),
'<!doctype html><title>Feedback admin</title><div id="root"></div>'
)
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<ReturnType<typeof buildApp>>
): 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)
})
})