436 lines
13 KiB
TypeScript
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)
|
|
})
|
|
})
|