Initial commit
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user