fix(mcp): подготовить доступ и отзыв разрешений для приёмки
This commit is contained in:
commit
ef7bb79d4e
311 files changed
+73119
No files matched your search
@@ -0,0 +1,105 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } 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 {
|
||||
backupDatabase,
|
||||
checkIntegrity,
|
||||
closeDb,
|
||||
createDb,
|
||||
createUser,
|
||||
getUserByEmail,
|
||||
listBackups,
|
||||
migrateSchema,
|
||||
} from '../src/index.js'
|
||||
|
||||
const dirs: string[] = []
|
||||
const dbs: Array<ReturnType<typeof createDb>['sqlite']> = []
|
||||
afterEach(() => {
|
||||
for (const s of dbs.splice(0)) closeDb(s)
|
||||
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fileDb() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-bk-'))
|
||||
dirs.push(dir)
|
||||
const r = createDb(join(dir, 'app.db'))
|
||||
migrateSchema(r.sqlite)
|
||||
dbs.push(r.sqlite)
|
||||
return { dir, ...r }
|
||||
}
|
||||
|
||||
describe('backupDatabase', () => {
|
||||
it('создаёт консистентную копию с данными из WAL', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
createUser(db, { email: 'a@x.io', name: 'A', passwordHash: 'h', isAdmin: false })
|
||||
const res = await backupDatabase(sqlite, { dir: join(dir, 'backups'), keep: 3 })
|
||||
expect(existsSync(res.file)).toBe(true)
|
||||
expect(checkIntegrity(res.file)).toBe(true)
|
||||
|
||||
const copy = new Database(res.file, { readonly: true })
|
||||
const row = copy.prepare('SELECT email FROM users').get()
|
||||
copy.close()
|
||||
expect(row).toEqual({ email: 'a@x.io' })
|
||||
})
|
||||
|
||||
it('бэкап во время записи остаётся валидным', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
const writer = setInterval(() => {
|
||||
try {
|
||||
createUser(db, {
|
||||
email: `u${Math.random()}@x.io`,
|
||||
name: 'U',
|
||||
passwordHash: 'h',
|
||||
isAdmin: false,
|
||||
})
|
||||
} catch {
|
||||
/* БД могла закрыться */
|
||||
}
|
||||
}, 1)
|
||||
const res = await backupDatabase(sqlite, { dir, keep: 3 })
|
||||
clearInterval(writer)
|
||||
expect(checkIntegrity(res.file)).toBe(true)
|
||||
})
|
||||
|
||||
it('ротация оставляет keep последних', async () => {
|
||||
const { dir, sqlite } = fileDb()
|
||||
const bdir = join(dir, 'b')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await backupDatabase(sqlite, {
|
||||
dir: bdir,
|
||||
keep: 2,
|
||||
now: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
|
||||
})
|
||||
}
|
||||
const left = listBackups(bdir)
|
||||
expect(left).toHaveLength(2)
|
||||
expect(left[0]).toContain('000004')
|
||||
})
|
||||
|
||||
it('восстановленная копия открывается и содержит данные', async () => {
|
||||
const { dir, db, sqlite } = fileDb()
|
||||
createUser(db, { email: 'r@x.io', name: 'R', passwordHash: 'h', isAdmin: false })
|
||||
const res = await backupDatabase(sqlite, { dir, keep: 1 })
|
||||
const restored = createDb(res.file)
|
||||
dbs.push(restored.sqlite)
|
||||
migrateSchema(restored.sqlite)
|
||||
expect(getUserByEmail(restored.db, 'r@x.io')?.name).toBe('R')
|
||||
})
|
||||
|
||||
it('битый файл не проходит integrity_check', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ap-bad-'))
|
||||
dirs.push(dir)
|
||||
const p = join(dir, 'bad.db')
|
||||
writeFileSync(p, 'not a sqlite file'.repeat(100))
|
||||
expect(() => checkIntegrity(p)).toThrow()
|
||||
})
|
||||
|
||||
it('ошибка бэкапа не оставляет .partial', async () => {
|
||||
const { dir, sqlite } = fileDb()
|
||||
closeDb(sqlite)
|
||||
await expect(backupDatabase(sqlite, { dir: join(dir, 'x'), keep: 1 })).rejects.toThrow()
|
||||
expect(listBackups(join(dir, 'x'))).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,426 @@
|
||||
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,
|
||||
getUserById,
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createMemoryDb, migrateSchema, closeDb, createUser, getUserById,
|
||||
getUserPermissions, setUserAccess, updateUser, deleteUser,
|
||||
createRefreshSession, revokeRefreshSession, revokeSessionById, revokeAllSessionsForUser,
|
||||
enablePortalRevocations,
|
||||
} from '../src/index.js'
|
||||
|
||||
const target = 'https://mm.example.invalid/_internal/mcp/portal/revocations'
|
||||
describe('integrated portal revocation outbox', () => {
|
||||
it('opts in explicitly and retains deletion event independently of user FK', () => {
|
||||
const { db, sqlite } = createMemoryDb(); migrateSchema(sqlite)
|
||||
try {
|
||||
const user = createUser(db, {email:'owned@example.invalid',name:'owned',passwordHash:'synthetic',isAdmin:false})
|
||||
setUserAccess(db,user.id,['mm'],['old'])
|
||||
expect(sqlite.prepare('SELECT count(*) AS n FROM portal_revocation_outbox').get()).toEqual({n:0})
|
||||
enablePortalRevocations(db,target)
|
||||
setUserAccess(db,user.id,['mm'],['new']); deleteUser(db,user.id)
|
||||
const rows = sqlite.prepare('SELECT body FROM portal_revocation_outbox ORDER BY sequence').all() as {body:string}[]
|
||||
expect(rows.map(row=>JSON.parse(row.body).type)).toEqual(['permissions.changed','user.disabled'])
|
||||
for (const row of rows) expect(JSON.parse(row.body)).toMatchObject({version:2,userId:user.id})
|
||||
expect(getUserById(db,user.id)).toBeUndefined()
|
||||
} finally { closeDb(sqlite) }
|
||||
})
|
||||
it('enqueue failure prevents success of every genuine access/disable/delete/session mutation', () => {
|
||||
const { db, sqlite } = createMemoryDb(); migrateSchema(sqlite)
|
||||
try {
|
||||
const user = createUser(db,{email:'atomic@example.invalid',name:'owned',passwordHash:'synthetic',isAdmin:false})
|
||||
setUserAccess(db,user.id,['mm'],['old'])
|
||||
const session = createRefreshSession(db,user.id,'owned-refresh',new Date(Date.now()+60000))
|
||||
enablePortalRevocations(db,target)
|
||||
sqlite.exec("CREATE TRIGGER deny_revocation BEFORE INSERT ON portal_revocation_outbox BEGIN SELECT RAISE(ABORT,'OWNED_ENQUEUE_FAILURE'); END")
|
||||
for (const mutation of [
|
||||
()=>setUserAccess(db,user.id,[],[]), ()=>updateUser(db,user.id,{isAdmin:true}),
|
||||
()=>updateUser(db,user.id,{disabled:true}), ()=>deleteUser(db,user.id),
|
||||
()=>revokeSessionById(db,session), ()=>revokeRefreshSession(db,'owned-refresh'),
|
||||
()=>revokeAllSessionsForUser(db,user.id),
|
||||
]) {
|
||||
expect(mutation).toThrow('OWNED_ENQUEUE_FAILURE')
|
||||
expect(getUserById(db,user.id)).toMatchObject({isAdmin:false,disabled:false})
|
||||
expect(getUserPermissions(db,user.id)).toEqual(['old'])
|
||||
expect(sqlite.prepare('SELECT revoked_at FROM refresh_sessions WHERE id=?').get(session)).toEqual({revoked_at:null})
|
||||
expect(sqlite.prepare('SELECT count(*) AS n FROM portal_revocation_outbox').get()).toEqual({n:0})
|
||||
}
|
||||
} finally { closeDb(sqlite) }
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user