perf: prepared statements, CSP, digest pin, вырезание design-preview, нагрузочные бюджеты, jsdom-тесты web, composite CI, SBOM/Trivy

This commit is contained in:
Denozordec committed 2026-10-23 17:22:23 +07:00
1 parent 08be310e6b
commit 1a68597d48
31 files changed
+1313 -158

No files matched your search

+5 -4
View File
@@ -21,6 +21,7 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage",
"test:perf": "vitest run --config vitest.perf.config.ts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
@@ -31,15 +32,15 @@
"drizzle-orm": "^0.44.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@types/better-sqlite3": "^7.6.13",
"@vitest/coverage-v8": "4.1.10",
"drizzle-kit": "^0.31.1",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+48 -16
View File
@@ -29,8 +29,48 @@ export function listAdmins(db: AppDb): UserRow[] {
return db.select().from(users).where(eq(users.isAdmin, true)).all()
}
/**
* Подготовленные запросы горячего пути: компиляция SQL один раз на соединение
* вместо prepare на каждый вызов.
*/
function buildPrepared(db: AppDb) {
return {
userById: db
.select()
.from(users)
.where(eq(users.id, sql.placeholder('id')))
.prepare(),
apps: db
.select({ appId: userApps.appId })
.from(userApps)
.where(eq(userApps.userId, sql.placeholder('userId')))
.prepare(),
permissions: db
.select({ permission: userPermissions.permission })
.from(userPermissions)
.where(eq(userPermissions.userId, sql.placeholder('userId')))
.prepare(),
sessionByHash: db
.select()
.from(refreshSessions)
.where(eq(refreshSessions.tokenHash, sql.placeholder('hash')))
.prepare(),
}
}
const preparedByDb = new WeakMap<AppDb, ReturnType<typeof buildPrepared>>()
function prepared(db: AppDb) {
let p = preparedByDb.get(db)
if (!p) {
p = buildPrepared(db)
preparedByDb.set(db, p)
}
return p
}
export function getUserById(db: AppDb, id: string): UserRow | undefined {
return db.select().from(users).where(eq(users.id, id)).get()
return prepared(db).userById.get({ id })
}
export function getUserByEmail(
@@ -45,20 +85,14 @@ export function getUserByEmail(
}
export function getUserApps(db: AppDb, userId: string): string[] {
return db
.select()
.from(userApps)
.where(eq(userApps.userId, userId))
.all()
return prepared(db)
.apps.all({ userId })
.map((r) => r.appId)
}
export function getUserPermissions(db: AppDb, userId: string): string[] {
return db
.select()
.from(userPermissions)
.where(eq(userPermissions.userId, userId))
.all()
return prepared(db)
.permissions.all({ userId })
.map((r) => r.permission)
}
@@ -258,11 +292,9 @@ export function getUserByRefreshToken(
rawToken: string,
): UserRow | undefined {
const now = new Date().toISOString()
const session = db
.select()
.from(refreshSessions)
.where(eq(refreshSessions.tokenHash, hashToken(rawToken)))
.get()
const session = prepared(db).sessionByHash.get({
hash: hashToken(rawToken),
})
if (!session || session.revokedAt) return undefined
if (session.expiresAt < now) return undefined
const user = getUserById(db, session.userId)
+36
View File
@@ -17,6 +17,7 @@ import {
createWebauthnCredential,
getActiveOidcSigningKey,
getUserApps,
getUserById,
getUserPermissions,
insertOidcSigningKey,
listActiveSessions,
@@ -388,3 +389,38 @@ describe('runHousekeeping', () => {
expect(runHousekeeping(db, { retentionDays: 1 }).sessions).toBe(2500)
})
})
describe('подготовленные запросы', () => {
it('горячие запросы не вызывают prepare повторно и дают прежние результаты', () => {
const { db, sqlite } = memDb()
const u = mkUser(db, 'p@x.io')
setUserAccess(db, u.id, ['dns', 'vps'], ['dns:dns:read'])
// прогрев: первая подготовка
getUserById(db, u.id)
getUserApps(db, u.id)
getUserPermissions(db, u.id)
const calls: string[] = []
const orig = sqlite.prepare.bind(sqlite)
sqlite.prepare = ((sql: string) => {
calls.push(sql)
return orig(sql)
}) as typeof sqlite.prepare
for (let i = 0; i < 20; i++) {
expect(getUserById(db, u.id)?.email).toBe('p@x.io')
expect(getUserApps(db, u.id).sort()).toEqual(['dns', 'vps'])
expect(getUserPermissions(db, u.id)).toEqual(['dns:dns:read'])
}
expect(getUserById(db, 'missing')).toBeUndefined()
expect(calls).toHaveLength(0)
})
it('независимые соединения не делят подготовленные запросы', () => {
const a = memDb()
const b = memDb()
const ua = mkUser(a.db, 'a@x.io')
expect(getUserById(a.db, ua.id)).toBeDefined()
expect(getUserById(b.db, ua.id)).toBeUndefined()
})
})
+100
View File
@@ -0,0 +1,100 @@
import { afterAll, describe, expect, it } from 'vitest'
import {
appendAuditBatch,
closeDb,
createDb,
createUser,
getUserApps,
getUserById,
listActiveSessions,
listAudit,
migrateSchema,
runHousekeeping,
searchDirectoryUsers,
} from '../../src/index.js'
const { db, sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
afterAll(() => closeDb(sqlite))
function timed<T>(fn: () => T): { ms: number; value: T } {
const t0 = performance.now()
const value = fn()
return { ms: performance.now() - t0, value }
}
// Бюджеты щедрые (CI-раннеры шумные): ловим деградацию на порядок, а не на проценты.
describe('бюджеты производительности БД', () => {
const users = Array.from({ length: 200 }, (_, i) =>
createUser(db, {
email: `u${i}@x.io`,
name: `User ${i}`,
passwordHash: 'h',
isAdmin: false,
}),
)
it('вставка 100k сессий и фильтр активных по пользователю < 150 мс', () => {
const ins = sqlite.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES (?,?,?,?,?)`,
)
const future = new Date(Date.now() + 86_400_000).toISOString()
const past = '2000-01-01T00:00:00.000Z'
sqlite.transaction(() => {
for (let i = 0; i < 100_000; i++) {
ins.run(`s${i}`, users[i % users.length]!.id, `h${i}`, i % 10 === 0 ? future : past, `t${i}`)
}
})()
listActiveSessions(db, { userId: users[0]!.id }) // прогрев
const { ms, value } = timed(() => listActiveSessions(db, { userId: users[0]!.id }))
console.log(`listActiveSessions(user) на 100k: ${ms.toFixed(1)} мс, ${value.length} строк`)
expect(value.length).toBeGreaterThan(0)
expect(ms).toBeLessThan(150)
})
it('housekeeping 100k истёкших сессий < 3 с', () => {
const { ms, value } = timed(() => runHousekeeping(db, { retentionDays: 1 }))
console.log(`runHousekeeping: ${ms.toFixed(0)} мс, удалено ${value.sessions}`)
expect(value.sessions).toBeGreaterThan(80_000)
expect(ms).toBeLessThan(3000)
})
it('пакетный ingest 10k событий одной транзакцией < 1.5 с', () => {
const events = Array.from({ length: 10_000 }, (_, i) => ({
eventId: `e${i}`,
action: i % 2 ? 'auth.login' : 'user.update',
summary: 'bench',
}))
const { ms, value } = timed(() => appendAuditBatch(db, events))
console.log(`appendAuditBatch(10k): ${ms.toFixed(0)} мс`)
expect(value.accepted).toBe(10_000)
expect(ms).toBeLessThan(1500)
})
it('listAudit по kind использует индекс: < 50 мс на 10k', () => {
listAudit(db, { kind: 'logins', limit: 100 })
const { ms, value } = timed(() => listAudit(db, { kind: 'logins', limit: 100 }))
console.log(`listAudit(kind=logins): ${ms.toFixed(1)} мс`)
expect(value.length).toBe(100)
expect(ms).toBeLessThan(50)
})
it('10k горячих запросов профиля (prepared) < 400 мс', () => {
const id = users[0]!.id
const { ms } = timed(() => {
for (let i = 0; i < 10_000; i++) {
getUserById(db, id)
getUserApps(db, id)
}
})
console.log(`20k prepared-запросов: ${ms.toFixed(0)} мс`)
expect(ms).toBeLessThan(400)
})
it('поиск каталога по 200 пользователям < 20 мс', () => {
searchDirectoryUsers(db, 'user 1')
const { ms } = timed(() => searchDirectoryUsers(db, 'user 1'))
expect(ms).toBeLessThan(20)
})
})
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vitest/config'
// Бюджеты производительности БД (не входят в обычный test): pnpm --filter @authportal/db test:perf
export default defineConfig({
test: { include: ['test/perf/**/*.perf.ts'], testTimeout: 60_000 },
})
+4 -4
View File
@@ -26,13 +26,13 @@
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/js": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "^10.7.0",
"globals": "^17.7.0",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"typescript": "catalog:",
"typescript-eslint": "^8.64.0",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
"react-dom": "^19.0.0"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@base-ui/react": "catalog:",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "catalog:",