Initial commit

This commit is contained in:
lofyer
2026-08-24 17:27:41 +08:00
commit 5ff80caf7e
45 changed files with 11485 additions and 0 deletions
+435
View File
@@ -0,0 +1,435 @@
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)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { AuthService } from '../src/server/auth-service.js'
import { openDatabase, type AppDatabase } from '../src/server/database.js'
const directories: string[] = []
const databases: AppDatabase[] = []
afterEach(async () => {
while (databases.length > 0) {
databases.pop()?.close()
}
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('AuthService', () => {
it('invalidates existing sessions when an administrator is reset', async () => {
const directory = await mkdtemp(join(tmpdir(), 'product-issue-auth-'))
directories.push(directory)
const database = openDatabase(join(directory, 'database.sqlite'))
databases.push(database)
const auth = new AuthService(
database,
'test-secret-that-is-at-least-thirty-two-characters',
60 * 60 * 1_000
)
await auth.createAdmin({
email: 'admin@example.com',
displayName: 'First name',
password: 'correct horse battery staple'
})
await expect(
auth.login('unknown@example.com', 'an incorrect password')
).resolves.toBeUndefined()
const session = await auth.login(
'admin@example.com',
'correct horse battery staple'
)
expect(session).toBeDefined()
await auth.createAdmin({
email: 'admin@example.com',
displayName: 'Updated name',
password: 'a different secure password'
})
expect(auth.authenticate(session?.token)).toBeUndefined()
await expect(
auth.login('admin@example.com', 'correct horse battery staple')
).resolves.toBeUndefined()
await expect(
auth.login('admin@example.com', 'a different secure password')
).resolves.toMatchObject({ displayName: 'Updated name' })
})
})
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { loadConfig } from '../src/server/config.js'
const secret = 'test-secret-that-is-at-least-thirty-two-characters'
describe('loadConfig', () => {
it('normalizes a valid production origin', () => {
expect(
loadConfig({
NODE_ENV: 'production',
APP_SECRET: secret,
PUBLIC_ORIGIN: 'https://feedback.example.com/'
})
).toMatchObject({
nodeEnv: 'production',
publicOrigin: 'https://feedback.example.com'
})
})
it('requires a production HTTPS origin without paths or credentials', () => {
expect(() =>
loadConfig({
NODE_ENV: 'production',
APP_SECRET: secret
})
).toThrow()
expect(() =>
loadConfig({
NODE_ENV: 'production',
APP_SECRET: secret,
PUBLIC_ORIGIN: 'http://feedback.example.com'
})
).toThrow()
expect(() =>
loadConfig({
NODE_ENV: 'production',
APP_SECRET: secret,
PUBLIC_ORIGIN: 'https://feedback.example.com/admin'
})
).toThrow()
})
it('rejects the example placeholder secret', () => {
expect(() =>
loadConfig({
APP_SECRET: 'replace-with-at-least-32-random-characters'
})
).toThrow()
})
})
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import {
productKeySchema,
publicFeedbackPayloadSchema,
publicFeedbackResponseSchema
} from '../src/shared/contracts.js'
const validFeedback = {
schemaVersion: 1 as const,
productKey: 'goodbuddy',
category: 'bug' as const,
title: '平台功能页签不可见',
description: '打开设置中心的平台功能后,无法看到魔法笔记页签。',
contactEmail: 'user@example.com',
environment: {
appVersion: '0.11.0',
platform: 'windows' as const,
architecture: 'x64' as const,
locale: 'zh-CN'
},
installationId: '00000000-0000-4000-8000-000000000001',
clientRequestId: '00000000-0000-4000-8000-000000000002'
}
describe('feedback contracts', () => {
it('accepts a bounded versioned GoodBuddy submission', () => {
expect(publicFeedbackPayloadSchema.parse(validFeedback)).toEqual(
validFeedback
)
})
it('rejects unknown fields and oversized or malformed input', () => {
expect(
publicFeedbackPayloadSchema.safeParse({
...validFeedback,
secret: 'must not be accepted'
}).success
).toBe(false)
expect(
publicFeedbackPayloadSchema.safeParse({
...validFeedback,
description: 'too short'
}).success
).toBe(false)
expect(
publicFeedbackPayloadSchema.safeParse({
...validFeedback,
contactEmail: 'not-an-email'
}).success
).toBe(false)
})
it('uses stable lowercase product keys', () => {
expect(productKeySchema.parse('mesa-writer')).toBe('mesa-writer')
for (const invalid of ['GoodBuddy', '-goodbuddy', 'goodbuddy-', 'a', 'x_y']) {
expect(productKeySchema.safeParse(invalid).success).toBe(false)
}
})
it('accepts references after the sequence exceeds six digits', () => {
expect(
publicFeedbackResponseSchema.parse({
reference: 'GOODBUDDY-1000000',
duplicate: false
})
).toEqual({
reference: 'GOODBUDDY-1000000',
duplicate: false
})
})
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { InstallationLimiter } from '../src/server/installation-limiter.js'
describe('InstallationLimiter', () => {
it('enforces a bounded window independently per installation', () => {
const limiter = new InstallationLimiter(2, 1_000)
expect(limiter.consume('first', 0)).toBe(true)
expect(limiter.consume('first', 1)).toBe(true)
expect(limiter.consume('first', 2)).toBe(false)
expect(limiter.consume('second', 2)).toBe(true)
expect(limiter.consume('first', 1_000)).toBe(true)
})
it('fails closed when too many active installation counters exist', () => {
const limiter = new InstallationLimiter(2, 1_000)
for (let index = 0; index < 10_000; index += 1) {
expect(limiter.consume(`installation-${index}`, 0)).toBe(true)
}
expect(limiter.consume('overflow', 1)).toBe(false)
expect(limiter.consume('installation-0', 1)).toBe(true)
expect(limiter.consume('overflow', 1_000)).toBe(true)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { openDatabase, type AppDatabase } from '../src/server/database.js'
import {
FeedbackRepository,
ProductNotFoundError
} from '../src/server/feedback-repository.js'
const directories: string[] = []
const databases: AppDatabase[] = []
afterEach(async () => {
while (databases.length > 0) {
databases.pop()?.close()
}
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
async function repository(): Promise<FeedbackRepository> {
const directory = await mkdtemp(join(tmpdir(), 'product-issue-repository-'))
directories.push(directory)
const database = openDatabase(join(directory, 'database.sqlite'))
databases.push(database)
return new FeedbackRepository(database)
}
describe('FeedbackRepository', () => {
it('seeds GoodBuddy and stores searchable feedback with stable references', async () => {
const store = await repository()
expect(store.listProducts()).toMatchObject([
{ key: 'goodbuddy', name: 'GoodBuddy', enabled: true }
])
const result = store.createFeedback({
id: '00000000-0000-4000-8000-000000000010',
payload: {
schemaVersion: 1,
productKey: 'goodbuddy',
category: 'feature',
title: '增加应用内反馈',
description: '希望可以在关于页面直接提交产品建议和截图。',
installationId: '00000000-0000-4000-8000-000000000011',
clientRequestId: '00000000-0000-4000-8000-000000000012'
},
installationHash: 'installation',
sourceIpHash: 'ip'
})
expect(result.reference).toBe('GOODBUDDY-000001')
expect(
store.getDuplicate(
'goodbuddy',
'00000000-0000-4000-8000-000000000012'
)
).toMatchObject({ reference: result.reference })
expect(
store.listFeedback({
search: '应用内',
page: 1,
limit: 30
})
).toMatchObject({
total: 1,
items: [{ reference: result.reference, category: 'feature' }]
})
})
it('fails closed for unknown or disabled products', async () => {
const store = await repository()
const product = store.createProduct('another-product', 'Another Product')
store.updateProduct(product.id, { enabled: false })
expect(() =>
store.createFeedback({
id: '00000000-0000-4000-8000-000000000020',
payload: {
schemaVersion: 1,
productKey: 'another-product',
category: 'other',
title: 'A valid feedback title',
description: 'A sufficiently descriptive feedback body.',
installationId: '00000000-0000-4000-8000-000000000021',
clientRequestId: '00000000-0000-4000-8000-000000000022'
},
installationHash: 'installation',
sourceIpHash: 'ip'
})
).toThrow(ProductNotFoundError)
})
})
+63
View File
@@ -0,0 +1,63 @@
import { randomBytes } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import sharp from 'sharp'
import { afterEach, describe, expect, it } from 'vitest'
import {
ScreenshotStorage,
ScreenshotValidationError
} from '../src/server/screenshot-storage.js'
const directories: string[] = []
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) =>
rm(directory, { recursive: true, force: true })
)
)
})
describe('ScreenshotStorage', () => {
it('rejects decodable formats outside PNG, JPEG, and WebP', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'product-issue-screenshot-')
)
directories.push(directory)
const storage = new ScreenshotStorage(directory, 64 * 1_024)
await expect(
storage.store(
'00000000-0000-4000-8000-000000000000',
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>'
)
)
).rejects.toBeInstanceOf(ScreenshotValidationError)
})
it('rejects images whose normalized private asset exceeds the limit', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'product-issue-screenshot-')
)
directories.push(directory)
const jpeg = await sharp(randomBytes(512 * 512 * 3), {
raw: {
width: 512,
height: 512,
channels: 3
}
})
.jpeg({ quality: 1 })
.toBuffer()
expect(jpeg.byteLength).toBeLessThan(64 * 1_024)
const storage = new ScreenshotStorage(directory, 64 * 1_024)
await expect(
storage.store(
'00000000-0000-4000-8000-000000000001',
jpeg
)
).rejects.toBeInstanceOf(ScreenshotValidationError)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import {
hashPassword,
keyedDigest,
randomToken,
safeTokenEquals,
verifyPassword
} from '../src/server/security.js'
describe('security helpers', () => {
it('round-trips scrypt password hashes without storing plaintext', async () => {
const password = 'correct horse battery staple'
const encoded = await hashPassword(password)
expect(encoded).toMatch(/^scrypt\$/u)
expect(encoded).not.toContain(password)
await expect(verifyPassword(password, encoded)).resolves.toBe(true)
await expect(verifyPassword('wrong password', encoded)).resolves.toBe(false)
await expect(verifyPassword(password, 'invalid')).resolves.toBe(false)
await expect(
verifyPassword(password, encoded.replace('$16384$', '$1048576$'))
).resolves.toBe(false)
})
it('creates random tokens and stable keyed digests', () => {
const first = randomToken()
const second = randomToken()
expect(first).not.toBe(second)
expect(keyedDigest('secret', 'value')).toBe(
keyedDigest('secret', 'value')
)
expect(keyedDigest('secret', 'value')).not.toBe(
keyedDigest('other', 'value')
)
expect(safeTokenEquals('same', 'same')).toBe(true)
expect(safeTokenEquals('same', 'different')).toBe(false)
})
})