perf(db): pragma, транзакции, индексы, версия схемы, SQL-фильтры и очистка
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
140ffdb7b1
commit
107eddba9e
18 files changed
+871
-139
No files matched your search
@@ -19,6 +19,7 @@
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push"
|
||||
@@ -36,6 +37,7 @@
|
||||
"globals": "^17.7.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "^8.64.0"
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, like, lt, notLike, or, sql } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, lt, or, sql } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
DEFAULT_AUDIT_RETENTION_DAYS,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@authportal/shared'
|
||||
import type { AppDb } from './index.js'
|
||||
import { auditLog, portalSettings } from './schema/index.js'
|
||||
import { withTransaction } from './tx.js'
|
||||
|
||||
const SETTINGS_ID = 'main'
|
||||
|
||||
@@ -64,16 +65,9 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
|
||||
const now = input.createdAt ?? new Date().toISOString()
|
||||
const eventId = input.eventId ?? null
|
||||
|
||||
if (eventId) {
|
||||
const existing = db
|
||||
.select({ id: auditLog.id })
|
||||
.from(auditLog)
|
||||
.where(eq(auditLog.eventId, eventId))
|
||||
.get()
|
||||
if (existing) return false
|
||||
}
|
||||
|
||||
db.insert(auditLog)
|
||||
// Дубликат event_id отсекается уникальным индексом: без SELECT и гонок.
|
||||
const res = db
|
||||
.insert(auditLog)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
eventId,
|
||||
@@ -90,8 +84,32 @@ export function appendAudit(db: AppDb, input: AppendAuditInput): boolean {
|
||||
ip: input.ip ?? null,
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
return true
|
||||
return res.changes > 0
|
||||
}
|
||||
|
||||
/** Пачка событий в одной транзакции — один коммит вместо N. */
|
||||
export function appendAuditBatch(
|
||||
db: AppDb,
|
||||
inputs: AppendAuditInput[],
|
||||
): { accepted: number; duplicates: number; failed: number } {
|
||||
let accepted = 0
|
||||
let duplicates = 0
|
||||
let failed = 0
|
||||
withTransaction(db, () => {
|
||||
for (const input of inputs) {
|
||||
try {
|
||||
// SAVEPOINT: сбой одного события не откатывает остальные.
|
||||
const ok = withTransaction(db, () => appendAudit(db, input))
|
||||
if (ok) accepted += 1
|
||||
else duplicates += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
})
|
||||
return { accepted, duplicates, failed }
|
||||
}
|
||||
|
||||
export function listAudit(
|
||||
@@ -112,9 +130,12 @@ export function listAudit(
|
||||
if (opts.severity) conditions.push(eq(auditLog.severity, opts.severity))
|
||||
if (opts.sourceApp) conditions.push(eq(auditLog.sourceApp, opts.sourceApp))
|
||||
if (opts.kind === 'logins') {
|
||||
conditions.push(like(auditLog.action, 'auth.%'))
|
||||
// Диапазон по индексу action: 'auth.' <= action < 'auth/' ('/' идёт сразу после '.').
|
||||
conditions.push(gte(auditLog.action, 'auth.'), lt(auditLog.action, 'auth/'))
|
||||
} else if (opts.kind === 'changes') {
|
||||
conditions.push(notLike(auditLog.action, 'auth.%'))
|
||||
conditions.push(
|
||||
or(lt(auditLog.action, 'auth.'), gte(auditLog.action, 'auth/'))!,
|
||||
)
|
||||
}
|
||||
if (opts.actorEmail && opts.userId) {
|
||||
conditions.push(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import type { AppDb } from './index.js'
|
||||
|
||||
const BATCH = 1000
|
||||
|
||||
/** Удаляет пачками по BATCH строк, чтобы не держать писательскую блокировку надолго. */
|
||||
function deleteInBatches(
|
||||
db: AppDb,
|
||||
table: string,
|
||||
where: ReturnType<typeof sql>,
|
||||
): number {
|
||||
let total = 0
|
||||
for (;;) {
|
||||
const res = db.run(
|
||||
sql`DELETE FROM ${sql.raw(table)} WHERE rowid IN (
|
||||
SELECT rowid FROM ${sql.raw(table)} WHERE ${where} LIMIT ${BATCH}
|
||||
)`,
|
||||
)
|
||||
total += res.changes
|
||||
if (res.changes < BATCH) return total
|
||||
}
|
||||
}
|
||||
|
||||
/** Сессии, истёкшие или отозванные раньше cutoff (ISO). */
|
||||
export function purgeStaleRefreshSessions(db: AppDb, cutoffIso: string): number {
|
||||
return deleteInBatches(
|
||||
db,
|
||||
'refresh_sessions',
|
||||
sql`expires_at < ${cutoffIso} OR (revoked_at IS NOT NULL AND revoked_at < ${cutoffIso})`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Коды авторизации: истёкшие раньше cutoff, либо использованные раньше cutoff. */
|
||||
export function purgeStaleOidcAuthCodes(db: AppDb, cutoffIso: string): number {
|
||||
return deleteInBatches(
|
||||
db,
|
||||
'oidc_auth_codes',
|
||||
sql`expires_at < ${cutoffIso} OR (used_at IS NOT NULL AND used_at < ${cutoffIso})`,
|
||||
)
|
||||
}
|
||||
|
||||
export function purgeExpiredChallenges(db: AppDb, nowIso: string): number {
|
||||
return deleteInBatches(db, 'webauthn_challenges', sql`expires_at < ${nowIso}`)
|
||||
}
|
||||
|
||||
export type HousekeepingResult = {
|
||||
sessions: number
|
||||
authCodes: number
|
||||
challenges: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна проходка очистки. Записи хранятся ещё retentionDays после
|
||||
* истечения/отзыва — для разбора инцидентов в списке сессий.
|
||||
*/
|
||||
export function runHousekeeping(
|
||||
db: AppDb,
|
||||
opts: { retentionDays: number; now?: Date } = { retentionDays: 7 },
|
||||
): HousekeepingResult {
|
||||
const now = opts.now ?? new Date()
|
||||
const cutoff = new Date(
|
||||
now.getTime() - opts.retentionDays * 24 * 60 * 60 * 1000,
|
||||
).toISOString()
|
||||
return {
|
||||
sessions: purgeStaleRefreshSessions(db, cutoff),
|
||||
authCodes: purgeStaleOidcAuthCodes(db, cutoff),
|
||||
challenges: purgeExpiredChallenges(db, now.toISOString()),
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ export function createDb(databaseUrl: string): { db: AppDb; sqlite: Sqlite } {
|
||||
sqlite.pragma('journal_mode = WAL')
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
sqlite.pragma('synchronous = NORMAL')
|
||||
// Без busy_timeout любая конкуренция за блокировку сразу даёт SQLITE_BUSY.
|
||||
sqlite.pragma('busy_timeout = 5000')
|
||||
sqlite.pragma('cache_size = -16000') // ~16 МиБ
|
||||
sqlite.pragma('temp_store = MEMORY')
|
||||
sqlite.pragma('mmap_size = 67108864') // 64 МиБ
|
||||
sqlite.pragma('wal_autocheckpoint = 1000')
|
||||
sqlite.pragma('journal_size_limit = 67108864')
|
||||
const db = drizzle(sqlite, { schema })
|
||||
return { db, sqlite }
|
||||
}
|
||||
@@ -28,7 +35,38 @@ export function createMemoryDb(): { db: AppDb; sqlite: Sqlite } {
|
||||
return { db, sqlite }
|
||||
}
|
||||
|
||||
/** Версия схемы в PRAGMA user_version; при совпадении migrateSchema ничего не делает. */
|
||||
export const SCHEMA_VERSION = 1
|
||||
|
||||
export function migrateSchema(sqlite: Sqlite): void {
|
||||
const current = sqlite.pragma('user_version', { simple: true }) as number
|
||||
if (current >= SCHEMA_VERSION) return
|
||||
sqlite.transaction(() => {
|
||||
baseMigrate(sqlite)
|
||||
migrateV1(sqlite)
|
||||
sqlite.pragma(`user_version = ${SCHEMA_VERSION}`)
|
||||
})()
|
||||
}
|
||||
|
||||
/** v1: дедупликация доступа, уникальные индексы и индексы под очистку/фильтры. */
|
||||
function migrateV1(sqlite: Sqlite): void {
|
||||
sqlite.exec(`
|
||||
DELETE FROM user_apps WHERE rowid NOT IN (
|
||||
SELECT MIN(rowid) FROM user_apps GROUP BY user_id, app_id
|
||||
);
|
||||
DELETE FROM user_permissions WHERE rowid NOT IN (
|
||||
SELECT MIN(rowid) FROM user_permissions GROUP BY user_id, permission
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_apps ON user_apps(user_id, app_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_user_permissions ON user_permissions(user_id, permission);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires ON refresh_sessions(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_severity ON audit_log(severity, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_actor_email ON audit_log(actor_email);
|
||||
`)
|
||||
}
|
||||
|
||||
function baseMigrate(sqlite: Sqlite): void {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -231,6 +269,8 @@ export function healthCheck(sqlite: Sqlite): void {
|
||||
}
|
||||
|
||||
export * from './schema/index.js'
|
||||
export * from './tx.js'
|
||||
export * from './housekeeping.js'
|
||||
export * from './users.js'
|
||||
export * from './settings.js'
|
||||
export * from './audit-log.js'
|
||||
|
||||
+32
-34
@@ -1,6 +1,7 @@
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import { and, desc, eq, gte, isNull } from 'drizzle-orm'
|
||||
import { randomBytes, randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { withTransaction } from './tx.js'
|
||||
import { oidcAuthCodes, oidcClients, oidcSigningKeys } from './schema/index.js'
|
||||
import { hashToken } from './users.js'
|
||||
|
||||
@@ -153,22 +154,20 @@ export function consumeOidcAuthCode(
|
||||
db: AppDb,
|
||||
rawCode: string,
|
||||
): OidcAuthCodeRow | undefined {
|
||||
const codeHash = hashToken(rawCode)
|
||||
const row = db
|
||||
.select()
|
||||
.from(oidcAuthCodes)
|
||||
.where(
|
||||
and(eq(oidcAuthCodes.codeHash, codeHash), isNull(oidcAuthCodes.usedAt)),
|
||||
)
|
||||
.get()
|
||||
if (!row) return undefined
|
||||
const now = new Date().toISOString()
|
||||
if (row.expiresAt < now) return undefined
|
||||
db.update(oidcAuthCodes)
|
||||
// Атомарно: UPDATE … RETURNING исключает двойное использование кода.
|
||||
return db
|
||||
.update(oidcAuthCodes)
|
||||
.set({ usedAt: now })
|
||||
.where(eq(oidcAuthCodes.id, row.id))
|
||||
.run()
|
||||
return row
|
||||
.where(
|
||||
and(
|
||||
eq(oidcAuthCodes.codeHash, hashToken(rawCode)),
|
||||
isNull(oidcAuthCodes.usedAt),
|
||||
gte(oidcAuthCodes.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getActiveOidcSigningKey(
|
||||
@@ -178,8 +177,9 @@ export function getActiveOidcSigningKey(
|
||||
.select()
|
||||
.from(oidcSigningKeys)
|
||||
.where(eq(oidcSigningKeys.active, true))
|
||||
.all()
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0]
|
||||
.orderBy(desc(oidcSigningKeys.createdAt))
|
||||
.limit(1)
|
||||
.get()
|
||||
}
|
||||
|
||||
export function listOidcSigningKeys(db: AppDb): OidcSigningKeyRow[] {
|
||||
@@ -194,23 +194,21 @@ export function insertOidcSigningKey(
|
||||
publicJwkJson: string
|
||||
},
|
||||
): OidcSigningKeyRow {
|
||||
for (const key of listOidcSigningKeys(db)) {
|
||||
if (key.active) {
|
||||
db.update(oidcSigningKeys)
|
||||
.set({ active: false })
|
||||
.where(eq(oidcSigningKeys.kid, key.kid))
|
||||
.run()
|
||||
}
|
||||
}
|
||||
db.insert(oidcSigningKeys)
|
||||
.values({
|
||||
kid: input.kid,
|
||||
privatePem: input.privatePem,
|
||||
publicJwkJson: input.publicJwkJson,
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
withTransaction(db, () => {
|
||||
db.update(oidcSigningKeys)
|
||||
.set({ active: false })
|
||||
.where(eq(oidcSigningKeys.active, true))
|
||||
.run()
|
||||
db.insert(oidcSigningKeys)
|
||||
.values({
|
||||
kid: input.kid,
|
||||
privatePem: input.privatePem,
|
||||
publicJwkJson: input.publicJwkJson,
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
.run()
|
||||
})
|
||||
return db
|
||||
.select()
|
||||
.from(oidcSigningKeys)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AppDb } from './index.js'
|
||||
|
||||
/**
|
||||
* Выполняет fn в транзакции better-sqlite3. Вложенные вызовы превращаются
|
||||
* в SAVEPOINT, поэтому безопасно вызывать из функций, уже работающих в транзакции.
|
||||
*/
|
||||
export function withTransaction<T>(db: AppDb, fn: () => T): T {
|
||||
return db.$client.transaction(fn)()
|
||||
}
|
||||
+59
-34
@@ -1,6 +1,7 @@
|
||||
import { and, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { and, desc, eq, gt, gte, isNull, or, sql } from 'drizzle-orm'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { withTransaction } from './tx.js'
|
||||
import {
|
||||
refreshSessions,
|
||||
userApps,
|
||||
@@ -140,18 +141,54 @@ export function setUserAccess(
|
||||
apps: string[],
|
||||
permissions: string[],
|
||||
): void {
|
||||
db.delete(userApps).where(eq(userApps.userId, userId)).run()
|
||||
db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run()
|
||||
for (const appId of apps) {
|
||||
db.insert(userApps).values({ userId, appId }).run()
|
||||
}
|
||||
for (const permission of permissions) {
|
||||
db.insert(userPermissions).values({ userId, permission }).run()
|
||||
}
|
||||
db.update(users)
|
||||
.set({ updatedAt: new Date().toISOString() })
|
||||
.where(eq(users.id, userId))
|
||||
.run()
|
||||
const uniqApps = [...new Set(apps)]
|
||||
const uniqPerms = [...new Set(permissions)]
|
||||
withTransaction(db, () => {
|
||||
db.delete(userApps).where(eq(userApps.userId, userId)).run()
|
||||
db.delete(userPermissions).where(eq(userPermissions.userId, userId)).run()
|
||||
if (uniqApps.length > 0) {
|
||||
db.insert(userApps)
|
||||
.values(uniqApps.map((appId) => ({ userId, appId })))
|
||||
.run()
|
||||
}
|
||||
if (uniqPerms.length > 0) {
|
||||
db.insert(userPermissions)
|
||||
.values(uniqPerms.map((permission) => ({ userId, permission })))
|
||||
.run()
|
||||
}
|
||||
db.update(users)
|
||||
.set({ updatedAt: new Date().toISOString() })
|
||||
.where(eq(users.id, userId))
|
||||
.run()
|
||||
})
|
||||
}
|
||||
|
||||
/** Поиск для каталога пользователей: только нужные колонки, LIKE и LIMIT в SQL. */
|
||||
export function searchDirectoryUsers(
|
||||
db: AppDb,
|
||||
query: string,
|
||||
limit = 50,
|
||||
): Array<{ id: string; email: string; name: string }> {
|
||||
const q = query.trim().toLowerCase().replace(/[\\%_]/g, (c) => `\\${c}`)
|
||||
const pattern = `%${q}%`
|
||||
return db
|
||||
.select({ id: users.id, email: users.email, name: users.name })
|
||||
.from(users)
|
||||
.where(
|
||||
and(
|
||||
eq(users.disabled, false),
|
||||
q
|
||||
? or(
|
||||
sql`lower(${users.email}) LIKE ${pattern} ESCAPE '\\'`,
|
||||
sql`lower(${users.name}) LIKE ${pattern} ESCAPE '\\'`,
|
||||
sql`lower(${users.id}) LIKE ${pattern} ESCAPE '\\'`,
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(users.email)
|
||||
.limit(limit)
|
||||
.all()
|
||||
}
|
||||
|
||||
export function touchLastLogin(
|
||||
@@ -235,7 +272,7 @@ export function listActiveSessions(
|
||||
opts: { userId?: string } = {},
|
||||
): ActiveSessionRow[] {
|
||||
const now = new Date().toISOString()
|
||||
const rows = db
|
||||
return db
|
||||
.select({
|
||||
id: refreshSessions.id,
|
||||
userId: refreshSessions.userId,
|
||||
@@ -245,30 +282,18 @@ export function listActiveSessions(
|
||||
userAgent: refreshSessions.userAgent,
|
||||
createdAt: refreshSessions.createdAt,
|
||||
expiresAt: refreshSessions.expiresAt,
|
||||
revokedAt: refreshSessions.revokedAt,
|
||||
})
|
||||
.from(refreshSessions)
|
||||
.innerJoin(users, eq(refreshSessions.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
isNull(refreshSessions.revokedAt),
|
||||
gte(refreshSessions.expiresAt, now),
|
||||
opts.userId ? eq(refreshSessions.userId, opts.userId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(refreshSessions.createdAt))
|
||||
.all()
|
||||
|
||||
return rows
|
||||
.filter((r) => {
|
||||
if (r.revokedAt) return false
|
||||
if (r.expiresAt < now) return false
|
||||
if (opts.userId && r.userId !== opts.userId) return false
|
||||
return true
|
||||
})
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
email: r.email,
|
||||
name: r.name,
|
||||
ip: r.ip,
|
||||
userAgent: r.userAgent,
|
||||
createdAt: r.createdAt,
|
||||
expiresAt: r.expiresAt,
|
||||
}))
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
}
|
||||
|
||||
export function revokeSessionById(db: AppDb, sessionId: string): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq, lt, sql } from 'drizzle-orm'
|
||||
import { and, desc, eq, lt, sql } from 'drizzle-orm'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AppDb } from './index.js'
|
||||
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
|
||||
@@ -77,12 +77,17 @@ export function listWebauthnCredentials(
|
||||
.select()
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.orderBy(desc(webauthnCredentials.createdAt))
|
||||
.all()
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))
|
||||
}
|
||||
|
||||
export function countWebauthnCredentials(db: AppDb, userId: string): number {
|
||||
return listWebauthnCredentials(db, userId).length
|
||||
const row = db
|
||||
.select({ total: sql<number>`count(*)` })
|
||||
.from(webauthnCredentials)
|
||||
.where(eq(webauthnCredentials.userId, userId))
|
||||
.get()
|
||||
return Number(row?.total ?? 0)
|
||||
}
|
||||
|
||||
/** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SCHEMA_VERSION,
|
||||
appendAudit,
|
||||
appendAuditBatch,
|
||||
closeDb,
|
||||
consumeOidcAuthCode,
|
||||
countWebauthnCredentials,
|
||||
createDb,
|
||||
createOidcAuthCode,
|
||||
createRefreshSession,
|
||||
createUser,
|
||||
createWebauthnCredential,
|
||||
getActiveOidcSigningKey,
|
||||
getUserApps,
|
||||
getUserPermissions,
|
||||
insertOidcSigningKey,
|
||||
listActiveSessions,
|
||||
listAudit,
|
||||
migrateSchema,
|
||||
revokeSessionById,
|
||||
runHousekeeping,
|
||||
searchDirectoryUsers,
|
||||
setUserAccess,
|
||||
withTransaction,
|
||||
type AppDb,
|
||||
type Sqlite,
|
||||
} from '../src/index.js'
|
||||
|
||||
const open: Sqlite[] = []
|
||||
const dirs: string[] = []
|
||||
|
||||
function memDb(): { db: AppDb; sqlite: Sqlite } {
|
||||
const r = createDb('sqlite::memory:')
|
||||
migrateSchema(r.sqlite)
|
||||
open.push(r.sqlite)
|
||||
return r
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of open.splice(0)) closeDb(s)
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function mkUser(db: AppDb, email: string, extra: { name?: string } = {}) {
|
||||
return createUser(db, {
|
||||
email,
|
||||
name: extra.name ?? email.split('@')[0]!,
|
||||
passwordHash: 'x',
|
||||
isAdmin: false,
|
||||
})
|
||||
}
|
||||
|
||||
describe('createDb / closeDb', () => {
|
||||
it('выставляет pragma на файловой БД', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-db-'))
|
||||
dirs.push(dir)
|
||||
const { sqlite } = createDb(join(dir, 'app.db'))
|
||||
open.push(sqlite)
|
||||
expect(sqlite.pragma('journal_mode', { simple: true })).toBe('wal')
|
||||
expect(sqlite.pragma('busy_timeout', { simple: true })).toBe(5000)
|
||||
expect(sqlite.pragma('synchronous', { simple: true })).toBe(1)
|
||||
expect(sqlite.pragma('temp_store', { simple: true })).toBe(2)
|
||||
expect(sqlite.pragma('cache_size', { simple: true })).toBe(-16000)
|
||||
expect(sqlite.pragma('foreign_keys', { simple: true })).toBe(1)
|
||||
})
|
||||
|
||||
it('closeDb идемпотентен', () => {
|
||||
const { sqlite } = memDb()
|
||||
closeDb(sqlite)
|
||||
expect(sqlite.open).toBe(false)
|
||||
expect(() => closeDb(sqlite)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateSchema', () => {
|
||||
it('ставит user_version и повторный запуск — no-op', () => {
|
||||
const { sqlite } = memDb()
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION)
|
||||
expect(() => migrateSchema(sqlite)).not.toThrow()
|
||||
})
|
||||
|
||||
it('создаёт новые индексы', () => {
|
||||
const { sqlite } = memDb()
|
||||
const names = (
|
||||
sqlite
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='index'`)
|
||||
.all() as Array<{ name: string }>
|
||||
).map((r) => r.name)
|
||||
for (const n of [
|
||||
'uq_user_apps',
|
||||
'uq_user_permissions',
|
||||
'idx_refresh_sessions_expires',
|
||||
'idx_oidc_auth_codes_expires',
|
||||
'idx_audit_log_severity',
|
||||
'idx_audit_log_actor_email',
|
||||
]) {
|
||||
expect(names).toContain(n)
|
||||
}
|
||||
})
|
||||
|
||||
it('мигрирует «старую» схему: дедупликация и недостающие колонки', () => {
|
||||
const sqlite = new Database(':memory:')
|
||||
open.push(sqlite)
|
||||
sqlite.pragma('foreign_keys = ON')
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY NOT NULL, email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL, password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE user_apps (user_id TEXT NOT NULL, app_id TEXT NOT NULL);
|
||||
CREATE TABLE user_permissions (user_id TEXT NOT NULL, permission TEXT NOT NULL);
|
||||
INSERT INTO users VALUES ('u1','a@b.c','A','h',0,0,'t','t');
|
||||
INSERT INTO user_apps VALUES ('u1','dns'),('u1','dns'),('u1','vps');
|
||||
INSERT INTO user_permissions VALUES ('u1','p'),('u1','p');
|
||||
`)
|
||||
migrateSchema(sqlite)
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) c FROM user_apps').get(),
|
||||
).toEqual({ c: 2 })
|
||||
expect(
|
||||
sqlite.prepare('SELECT COUNT(*) c FROM user_permissions').get(),
|
||||
).toEqual({ c: 1 })
|
||||
const cols = (
|
||||
sqlite.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }>
|
||||
).map((c) => c.name)
|
||||
expect(cols).toContain('last_login_at')
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION)
|
||||
expect(() =>
|
||||
sqlite.prepare(`INSERT INTO user_apps VALUES ('u1','dns')`).run(),
|
||||
).toThrow(/UNIQUE/)
|
||||
})
|
||||
|
||||
it('откатывается целиком при ошибке', () => {
|
||||
const sqlite = new Database(':memory:')
|
||||
open.push(sqlite)
|
||||
// users — это view-подобная помеха: таблица с несовместимой схемой user_apps без user_id
|
||||
sqlite.exec(`CREATE TABLE user_apps (foo TEXT)`)
|
||||
expect(() => migrateSchema(sqlite)).toThrow()
|
||||
expect(sqlite.pragma('user_version', { simple: true })).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withTransaction / setUserAccess', () => {
|
||||
it('откатывает все изменения при исключении', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
setUserAccess(db, u.id, ['dns'], ['p1'])
|
||||
expect(() =>
|
||||
withTransaction(db, () => {
|
||||
setUserAccess(db, u.id, ['vps', 'bgp'], ['p2'])
|
||||
throw new Error('boom')
|
||||
}),
|
||||
).toThrow('boom')
|
||||
expect(getUserApps(db, u.id)).toEqual(['dns'])
|
||||
expect(getUserPermissions(db, u.id)).toEqual(['p1'])
|
||||
})
|
||||
|
||||
it('схлопывает дубликаты во входе', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
setUserAccess(db, u.id, ['dns', 'dns'], ['p', 'p'])
|
||||
expect(getUserApps(db, u.id)).toEqual(['dns'])
|
||||
expect(getUserPermissions(db, u.id)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('вложенная транзакция откатывается как savepoint', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
withTransaction(db, () => {
|
||||
setUserAccess(db, u.id, ['dns'], [])
|
||||
try {
|
||||
withTransaction(db, () => {
|
||||
setUserAccess(db, u.id, ['vps'], [])
|
||||
throw new Error('inner')
|
||||
})
|
||||
} catch {
|
||||
/* ожидаемо */
|
||||
}
|
||||
})
|
||||
expect(getUserApps(db, u.id)).toEqual(['dns'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('сессии и каталог', () => {
|
||||
it('listActiveSessions: только активные, фильтр по пользователю, сортировка', () => {
|
||||
const { db } = memDb()
|
||||
const a = mkUser(db, 'a@x.io')
|
||||
const b = mkUser(db, 'b@x.io')
|
||||
const future = new Date(Date.now() + 3600_000)
|
||||
const past = new Date(Date.now() - 3600_000)
|
||||
const s1 = createRefreshSession(db, a.id, 't1', future)
|
||||
createRefreshSession(db, a.id, 't2', past)
|
||||
const s3 = createRefreshSession(db, b.id, 't3', future)
|
||||
const s4 = createRefreshSession(db, b.id, 't4', future)
|
||||
revokeSessionById(db, s4)
|
||||
|
||||
const all = listActiveSessions(db).map((s) => s.id)
|
||||
expect(new Set(all)).toEqual(new Set([s1, s3]))
|
||||
expect(listActiveSessions(db, { userId: a.id }).map((s) => s.id)).toEqual([
|
||||
s1,
|
||||
])
|
||||
})
|
||||
|
||||
it('searchDirectoryUsers: без passwordHash, без disabled, LIMIT, экранирование', () => {
|
||||
const { db } = memDb()
|
||||
for (let i = 0; i < 60; i++) mkUser(db, `user${i}@x.io`)
|
||||
const weird = mkUser(db, 'pct@x.io', { name: '100%' })
|
||||
const dis = mkUser(db, 'off@x.io')
|
||||
db.$client.prepare('UPDATE users SET disabled=1 WHERE id=?').run(dis.id)
|
||||
|
||||
const page = searchDirectoryUsers(db, '', 50)
|
||||
expect(page).toHaveLength(50)
|
||||
expect(Object.keys(page[0]!).sort()).toEqual(['email', 'id', 'name'])
|
||||
expect(searchDirectoryUsers(db, 'off@x.io')).toEqual([])
|
||||
expect(searchDirectoryUsers(db, 'USER7@').map((u) => u.email)).toEqual([
|
||||
'user7@x.io',
|
||||
])
|
||||
expect(searchDirectoryUsers(db, '%').map((u) => u.id)).toEqual([weird.id])
|
||||
expect(searchDirectoryUsers(db, weird.id)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('countWebauthnCredentials считает COUNT(*)', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
expect(countWebauthnCredentials(db, u.id)).toBe(0)
|
||||
for (const id of ['c1', 'c2']) {
|
||||
createWebauthnCredential(db, {
|
||||
userId: u.id,
|
||||
credentialId: id,
|
||||
publicKey: 'k',
|
||||
counter: 0,
|
||||
name: id,
|
||||
})
|
||||
}
|
||||
expect(countWebauthnCredentials(db, u.id)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OIDC', () => {
|
||||
function code(db: AppDb, userId: string, raw: string, expiresAt: Date) {
|
||||
createOidcAuthCode(db, {
|
||||
rawCode: raw,
|
||||
clientId: 'c',
|
||||
userId,
|
||||
redirectUri: 'https://x/cb',
|
||||
scope: 'openid',
|
||||
expiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
it('consumeOidcAuthCode: одноразовый, просроченный отклоняется', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
code(db, u.id, 'ok', new Date(Date.now() + 60_000))
|
||||
code(db, u.id, 'old', new Date(Date.now() - 60_000))
|
||||
expect(consumeOidcAuthCode(db, 'ok')?.userId).toBe(u.id)
|
||||
expect(consumeOidcAuthCode(db, 'ok')).toBeUndefined()
|
||||
expect(consumeOidcAuthCode(db, 'old')).toBeUndefined()
|
||||
expect(consumeOidcAuthCode(db, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('insertOidcSigningKey оставляет один активный ключ — новый', () => {
|
||||
const { db } = memDb()
|
||||
insertOidcSigningKey(db, { kid: 'k1', privatePem: 'p', publicJwkJson: '{}' })
|
||||
insertOidcSigningKey(db, { kid: 'k2', privatePem: 'p', publicJwkJson: '{}' })
|
||||
expect(getActiveOidcSigningKey(db)?.kid).toBe('k2')
|
||||
const active = db.$client
|
||||
.prepare('SELECT COUNT(*) c FROM oidc_signing_keys WHERE active=1')
|
||||
.get()
|
||||
expect(active).toEqual({ c: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('audit', () => {
|
||||
it('appendAudit: дубликат event_id → false', () => {
|
||||
const { db } = memDb()
|
||||
expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe(
|
||||
true,
|
||||
)
|
||||
expect(appendAudit(db, { eventId: 'e1', action: 'a', summary: 's' })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true)
|
||||
expect(appendAudit(db, { action: 'a', summary: 's' })).toBe(true)
|
||||
})
|
||||
|
||||
it('appendAuditBatch: принимает, пропускает дубли, не теряет остальные', () => {
|
||||
const { db } = memDb()
|
||||
const events = Array.from({ length: 500 }, (_, i) => ({
|
||||
eventId: `e${i % 400}`,
|
||||
action: 'x.y',
|
||||
summary: 's',
|
||||
}))
|
||||
const res = appendAuditBatch(db, events)
|
||||
expect(res).toEqual({ accepted: 400, duplicates: 100, failed: 0 })
|
||||
expect(listAudit(db, { limit: 1000 })).toHaveLength(400)
|
||||
})
|
||||
|
||||
it('listAudit kind=logins/changes делит по префиксу auth.', () => {
|
||||
const { db } = memDb()
|
||||
appendAudit(db, { action: 'auth.login', summary: 's' })
|
||||
appendAudit(db, { action: 'auth.login_failed', summary: 's' })
|
||||
appendAudit(db, { action: 'user.create', summary: 's' })
|
||||
appendAudit(db, { action: 'audit.purge', summary: 's' })
|
||||
appendAudit(db, { action: 'authz.other', summary: 's' })
|
||||
const logins = listAudit(db, { kind: 'logins' }).map((e) => e.action)
|
||||
const changes = listAudit(db, { kind: 'changes' }).map((e) => e.action)
|
||||
expect(logins.sort()).toEqual(['auth.login', 'auth.login_failed'])
|
||||
expect(changes.sort()).toEqual(['audit.purge', 'authz.other', 'user.create'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('runHousekeeping', () => {
|
||||
it('удаляет старые истёкшие/отозванные, оставляя активные и свежие', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
const day = 86_400_000
|
||||
const active = createRefreshSession(db, u.id, 'a', new Date(Date.now() + day))
|
||||
createRefreshSession(db, u.id, 'oldexp', new Date(Date.now() - 10 * day))
|
||||
createRefreshSession(db, u.id, 'freshexp', new Date(Date.now() - day))
|
||||
const oldRevoked = createRefreshSession(
|
||||
db,
|
||||
u.id,
|
||||
'oldrev',
|
||||
new Date(Date.now() + day),
|
||||
)
|
||||
db.$client
|
||||
.prepare('UPDATE refresh_sessions SET revoked_at=? WHERE id=?')
|
||||
.run(new Date(Date.now() - 10 * day).toISOString(), oldRevoked)
|
||||
|
||||
createOidcAuthCode(db, {
|
||||
rawCode: 'stale',
|
||||
clientId: 'c',
|
||||
userId: u.id,
|
||||
redirectUri: 'r',
|
||||
scope: 's',
|
||||
expiresAt: new Date(Date.now() - 10 * day),
|
||||
})
|
||||
createOidcAuthCode(db, {
|
||||
rawCode: 'live',
|
||||
clientId: 'c',
|
||||
userId: u.id,
|
||||
redirectUri: 'r',
|
||||
scope: 's',
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
})
|
||||
db.$client
|
||||
.prepare(
|
||||
`INSERT INTO webauthn_challenges (id,purpose,challenge,expires_at,created_at)
|
||||
VALUES ('c1','register','x',?,?)`,
|
||||
)
|
||||
.run(new Date(Date.now() - 1000).toISOString(), new Date().toISOString())
|
||||
|
||||
const res = runHousekeeping(db, { retentionDays: 7 })
|
||||
expect(res).toEqual({ sessions: 2, authCodes: 1, challenges: 1 })
|
||||
const left = db.$client
|
||||
.prepare('SELECT id FROM refresh_sessions')
|
||||
.all() as Array<{ id: string }>
|
||||
expect(left).toHaveLength(2)
|
||||
expect(left.map((r) => r.id)).toContain(active)
|
||||
|
||||
expect(runHousekeeping(db, { retentionDays: 7 })).toEqual({
|
||||
sessions: 0,
|
||||
authCodes: 0,
|
||||
challenges: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('удаляет пачками > 1000 строк', () => {
|
||||
const { db } = memDb()
|
||||
const u = mkUser(db, 'a@x.io')
|
||||
const ins = db.$client.prepare(
|
||||
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
|
||||
VALUES (?,?,?,?,?)`,
|
||||
)
|
||||
db.$client.transaction(() => {
|
||||
for (let i = 0; i < 2500; i++) {
|
||||
ins.run(`s${i}`, u.id, `h${i}`, '2000-01-01T00:00:00.000Z', 't')
|
||||
}
|
||||
})()
|
||||
expect(runHousekeeping(db, { retentionDays: 1 }).sessions).toBe(2500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: { passWithNoTests: true, include: ['test/**/*.test.ts'] },
|
||||
})
|
||||
Reference in new issue
Block a user