chore(ci): request-id, RBAC stats, web-тесты и гигиена CI
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
CD / quality (push) Failing after 9s
quality / docker-check (push) Skipped
CD / publish (push) Skipped

- genReqId (uuid) + x-request-id в каждом ответе и request_id в error
  envelope — корреляция ошибок между клиентом и логами
- RBAC: /agents/:id/(stats|blocked-ips|blocked-ports) классифицируются
  как fw:stats:read (reset остаётся под fw:agents:write)
- web: test-скрипт + 10 unit-тестов (filter-utils, fleet-kpis, parseClaims, nav)
- typecheck-скрипты для api/shared/db; CI: тесты shared и web, typecheck
  всех пакетов
- гигиена: .node-version (22), актуальный .dockerignore, drizzle out →
  ./migrations, удалены 12 лишних .gitkeep и пустой apps/api/test
This commit is contained in:
Denozordec
2026-09-20 20:08:14 +07:00
parent 3dc8e6d5e2
commit 15b5cce8cc
36 changed files with 285 additions and 8 deletions
+1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"start": "node dist/server.js",
"test": "vitest run"
+6
View File
@@ -33,8 +33,14 @@ export async function buildApp(opts: BuildAppOptions = {}) {
const app = Fastify({
logger: { level: config.logLevel },
genReqId: () => crypto.randomUUID(),
}).withTypeProvider<ZodTypeProvider>()
// Correlation id in every response and in the error envelope.
app.addHook('onSend', async (req, reply) => {
reply.header('x-request-id', req.id)
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
View File
+12 -3
View File
@@ -14,17 +14,25 @@ export class AppError extends Error {
}
async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _req, reply) => {
app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) {
return reply.code(err.statusCode).send({
error: { code: err.code, message: err.message },
error: {
code: err.code,
message: err.message,
request_id: String(req.id),
},
})
}
if (err instanceof ZodError) {
const message =
err.issues.map((i) => i.message).join('; ') || 'Validation error'
return reply.code(400).send({
error: { code: 'VALIDATION_ERROR', message },
error: {
code: 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
})
}
const e = err as { statusCode?: number; message?: string }
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
error: {
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
})
})
View File
View File
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
+1
View File
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
+1
View File
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
@@ -16,6 +16,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
@@ -16,6 +16,7 @@ const testConfig: AppConfig = {
publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
View File
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { isAgentStale, computeFleetCounts } from './agents-fleet-kpis'
import type { Agent } from '@evofw/shared'
function agent(patch: Partial<Agent>): Agent {
return {
id: 'a1',
name: 'a',
hostname: null,
platform: 'linux',
token_prefix: 'x',
status: 'approved',
default_action: 'accept',
policy_mode: 'blacklist',
policy_generation: 1,
last_seen_at: null,
last_seen_ip: null,
last_apply_at: null,
last_apply_status: null,
last_apply_error: null,
last_apply_prefix_count: null,
last_apply_packets_dropped: null,
last_apply_packets_accepted: null,
total_packets_dropped: 0,
total_packets_accepted: 0,
last_apply_kernel_method: null,
client_version: null,
created_at: '2026-01-01T00:00:00Z',
approved_at: null,
revoked_at: null,
install_curl: null,
install_link_id: null,
...patch,
} as Agent
}
describe('agents-fleet-kpis', () => {
it('stale = approved + unseen or seen >24h ago; never for pending', () => {
const now = Date.parse('2026-09-20T12:00:00Z')
expect(isAgentStale(agent({ last_seen_at: null }), now)).toBe(true)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-20T11:00:00Z' }), now),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-19T11:00:00Z' }), now),
).toBe(true)
expect(
isAgentStale(
agent({ status: 'pending', last_seen_at: null }),
now,
),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: 'garbage' }), now),
).toBe(true)
})
it('computeFleetCounts tallies statuses, stale and apply errors', () => {
const now = Date.now()
const counts = computeFleetCounts([
agent({ id: '1', status: 'pending' }),
agent({ id: '2', status: 'invited' }),
agent({ id: '3', status: 'revoked' }),
agent({ id: '4', last_seen_at: new Date(now).toISOString() }),
agent({
id: '5',
last_seen_at: new Date(now - 25 * 3600_000).toISOString(),
}),
agent({
id: '6',
last_seen_at: new Date(now).toISOString(),
last_apply_error: 'boom',
}),
])
expect(counts.pending).toBe(1)
expect(counts.invited).toBe(1)
expect(counts.revoked).toBe(1)
expect(counts.approved).toBe(3)
expect(counts.stale).toBe(1)
expect(counts.applyErrors).toBe(1)
})
})
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import {
getActiveFilters,
applyFiltersToData,
} from '@/components/reui-kit/filter-utils'
type Row = { name: string; status: string }
const rows: Row[] = [
{ name: 'web-01', status: 'approved' },
{ name: 'db-01', status: 'pending' },
{ name: 'mt-01', status: 'revoked' },
]
const field = (item: Row, f: string) =>
f === 'name' ? item.name : f === 'status' ? item.status : undefined
describe('filter-utils', () => {
it('getActiveFilters drops empty filters', () => {
const active = getActiveFilters([
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
{ id: '2', field: 'name', operator: 'contains', values: [''] },
{ id: '3', field: 'x', operator: 'is', values: [] },
{ id: '4', field: 'x', operator: 'is', values: [null, undefined] },
])
expect(active.map((f) => f.id)).toEqual(['1'])
})
it('applies is / is_not / is_any_of operators', () => {
expect(
applyFiltersToData(
rows,
[{ id: '1', field: 'status', operator: 'is', values: ['pending'] }],
field,
),
).toEqual([rows[1]])
expect(
applyFiltersToData(
rows,
[
{
id: '1',
field: 'status',
operator: 'is_any_of',
values: ['pending', 'revoked'],
},
],
field,
),
).toEqual([rows[1], rows[2]])
expect(
applyFiltersToData(
rows,
[
{
id: '1',
field: 'status',
operator: 'is_not',
values: ['revoked'],
},
],
field,
),
).toEqual([rows[0], rows[1]])
})
it('combines multiple filters with AND', () => {
const out = applyFiltersToData(
rows,
[
{ id: '1', field: 'status', operator: 'is', values: ['approved'] },
{ id: '2', field: 'name', operator: 'contains', values: ['web'] },
],
field,
)
expect(out).toEqual([rows[0]])
})
})
View File
View File
+36
View File
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest'
import { parseClaims } from './auth'
function makeToken(payload: object): string {
const b64 = (obj: object) =>
btoa(JSON.stringify(obj))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `${b64({ alg: 'none' })}.${b64(payload)}.sig`
}
describe('parseClaims', () => {
it('parses a portal JWT payload', () => {
const claims = parseClaims(
makeToken({
sub: 'u1',
email: '[email protected]',
name: 'Admin',
apps: ['fw'],
permissions: ['fw:agents:write'],
is_admin: true,
exp: 1893456000,
}),
)
expect(claims?.sub).toBe('u1')
expect(claims?.apps).toEqual(['fw'])
expect(claims?.is_admin).toBe(true)
})
it('returns null for garbage / wrong shapes', () => {
expect(parseClaims('not-a-jwt')).toBeNull()
expect(parseClaims('a.b')).toBeNull()
expect(parseClaims('%%%')).toBeNull()
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { NAV_ITEMS, NAV_SECTIONS, navLabel, navParentForDetail } from './nav'
describe('nav config', () => {
it('every nav item has label, icon, keywords and a known section', () => {
const sectionIds = new Set(NAV_SECTIONS.map((s) => s.id))
for (const item of NAV_ITEMS) {
expect(item.label.length).toBeGreaterThan(0)
expect(item.keywords.length).toBeGreaterThan(0)
expect(sectionIds.has(item.section)).toBe(true)
}
})
it('navLabel resolves known routes', () => {
expect(navLabel('/')).toBe('Панель управления')
expect(navLabel('/agents')).toBe('Агенты')
expect(navLabel('/nope')).toBeUndefined()
})
it('navParentForDetail maps detail routes to parents', () => {
expect(navParentForDetail('/agents/abc')).toBe('/agents')
expect(navParentForDetail('/lists/abc')).toBe('/lists')
expect(navParentForDetail('/rules/set-1')).toBe('/rules')
expect(navParentForDetail('/settings')).toBeUndefined()
expect(navParentForDetail('/agents/abc/preview')).toBeUndefined()
})
})
View File