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:
@@ -21,7 +21,6 @@ node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
@@ -30,7 +29,6 @@ CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.pre-commit-config.yaml
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
commitlint.config.cjs
|
||||
|
||||
@@ -221,6 +221,7 @@ jobs:
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evofw/web run typecheck
|
||||
pnpm --filter @evofw/web run test
|
||||
pnpm --filter @evofw/web run build
|
||||
|
||||
api:
|
||||
@@ -251,13 +252,16 @@ jobs:
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, test, build
|
||||
- name: pnpm install, typecheck, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec turbo run test --filter=@evofw/api
|
||||
pnpm --filter @evofw/api run typecheck
|
||||
pnpm --filter @evofw/db run typecheck
|
||||
pnpm --filter @evofw/shared run typecheck
|
||||
pnpm exec turbo run test --filter=@evofw/api --filter=@evofw/shared
|
||||
pnpm exec turbo run build --filter=@evofw/api
|
||||
|
||||
commitlint:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
22
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,9 @@ import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema.ts',
|
||||
out: './drizzle',
|
||||
// Migration SQL files live here and are applied by the custom runner in
|
||||
// src/client.ts (runMigrations); drizzle-kit generate adds new files to it.
|
||||
out: './migrations',
|
||||
dialect: 'sqlite',
|
||||
dbCredentials: { url: 'data/app.db' },
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsup src/index.ts --format esm --dts",
|
||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
|
||||
@@ -11,6 +11,20 @@ describe('permissionForRequest', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('maps agent stats endpoints to stats permission, not agents', () => {
|
||||
expect(
|
||||
permissionForRequest('GET', '/api/v1/agents/a1/stats'),
|
||||
).toBe('fw:stats:read')
|
||||
expect(
|
||||
permissionForRequest('GET', '/api/v1/agents/a1/blocked-ips'),
|
||||
).toBe('fw:stats:read')
|
||||
// reset is destructive — stays under agents write
|
||||
expect(
|
||||
permissionForRequest('POST', '/api/v1/agents/a1/stats/reset'),
|
||||
).toBe('fw:agents:write')
|
||||
expect(permissionForRequest('GET', '/api/v1/agents')).toBe('fw:agents:read')
|
||||
})
|
||||
|
||||
it('maps integrations to lists read / settings admin', () => {
|
||||
expect(
|
||||
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
|
||||
|
||||
@@ -29,6 +29,12 @@ export function permissionForRequest(
|
||||
const m = method.toUpperCase()
|
||||
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
|
||||
|
||||
// Agent statistics live under /agents/:id/… — classify as stats, not agents.
|
||||
if (
|
||||
/^\/api\/v1\/agents\/[^/]+\/(stats|blocked-ips|blocked-ports)$/.test(path)
|
||||
) {
|
||||
return 'fw:stats:read'
|
||||
}
|
||||
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
|
||||
return write ? 'fw:agents:write' : 'fw:agents:read'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user