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
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user