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
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
apps/web/src/routeTree.gen.ts
|
apps/web/src/routeTree.gen.ts
|
||||||
apps/web/playwright-report
|
|
||||||
apps/web/test-results
|
apps/web/test-results
|
||||||
|
|
||||||
*.md
|
*.md
|
||||||
@@ -30,7 +29,6 @@ CONTRIBUTING.md
|
|||||||
LICENSE
|
LICENSE
|
||||||
docs
|
docs
|
||||||
|
|
||||||
.pre-commit-config.yaml
|
|
||||||
.releaserc.json
|
.releaserc.json
|
||||||
.commitlintrc.*
|
.commitlintrc.*
|
||||||
commitlint.config.cjs
|
commitlint.config.cjs
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ jobs:
|
|||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
sh scripts/ci/pnpm-ci.sh
|
sh scripts/ci/pnpm-ci.sh
|
||||||
pnpm --filter @evofw/web run typecheck
|
pnpm --filter @evofw/web run typecheck
|
||||||
|
pnpm --filter @evofw/web run test
|
||||||
pnpm --filter @evofw/web run build
|
pnpm --filter @evofw/web run build
|
||||||
|
|
||||||
api:
|
api:
|
||||||
@@ -251,13 +252,16 @@ jobs:
|
|||||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
pnpm-${{ runner.os }}-
|
pnpm-${{ runner.os }}-
|
||||||
- name: pnpm install, test, build
|
- name: pnpm install, typecheck, test, build
|
||||||
env:
|
env:
|
||||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
sh scripts/ci/pnpm-ci.sh
|
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
|
pnpm exec turbo run build --filter=@evofw/api
|
||||||
|
|
||||||
commitlint:
|
commitlint:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
22
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"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)\"",
|
"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",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
|
|||||||
@@ -33,8 +33,14 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: { level: config.logLevel },
|
logger: { level: config.logLevel },
|
||||||
|
genReqId: () => crypto.randomUUID(),
|
||||||
}).withTypeProvider<ZodTypeProvider>()
|
}).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.setValidatorCompiler(validatorCompiler)
|
||||||
app.setSerializerCompiler(serializerCompiler)
|
app.setSerializerCompiler(serializerCompiler)
|
||||||
|
|
||||||
|
|||||||
@@ -14,17 +14,25 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function errorHandlerPlugin(app: FastifyInstance) {
|
async function errorHandlerPlugin(app: FastifyInstance) {
|
||||||
app.setErrorHandler((err, _req, reply) => {
|
app.setErrorHandler((err, req, reply) => {
|
||||||
if (err instanceof AppError) {
|
if (err instanceof AppError) {
|
||||||
return reply.code(err.statusCode).send({
|
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) {
|
if (err instanceof ZodError) {
|
||||||
const message =
|
const message =
|
||||||
err.issues.map((i) => i.message).join('; ') || 'Validation error'
|
err.issues.map((i) => i.message).join('; ') || 'Validation error'
|
||||||
return reply.code(400).send({
|
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 }
|
const e = err as { statusCode?: number; message?: string }
|
||||||
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
|
|||||||
error: {
|
error: {
|
||||||
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
|
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
|
||||||
message,
|
message,
|
||||||
|
request_id: String(req.id),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const testConfig: AppConfig = {
|
|||||||
publicBaseUrl: 'https://fw.example.com',
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
enrollSeed: 'test-seed',
|
enrollSeed: 'test-seed',
|
||||||
corsOrigins: [],
|
corsOrigins: [],
|
||||||
|
authAuditIngestSecret: null,
|
||||||
secretKey: null,
|
secretKey: null,
|
||||||
statsRetentionDays: 30,
|
statsRetentionDays: 30,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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({
|
export default defineConfig({
|
||||||
schema: './src/schema.ts',
|
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',
|
dialect: 'sqlite',
|
||||||
dbCredentials: { url: 'data/app.db' },
|
dbCredentials: { url: 'data/app.db' },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsup src/index.ts --format esm --dts",
|
"build": "tsup src/index.ts --format esm --dts",
|
||||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsup src/index.ts --format esm --dts",
|
"build": "tsup src/index.ts --format esm --dts",
|
||||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||||
"test": "vitest run --passWithNoTests"
|
"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', () => {
|
it('maps integrations to lists read / settings admin', () => {
|
||||||
expect(
|
expect(
|
||||||
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
|
permissionForRequest('GET', '/api/v1/integrations/evobgp/communities'),
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export function permissionForRequest(
|
|||||||
const m = method.toUpperCase()
|
const m = method.toUpperCase()
|
||||||
const write = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS'
|
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')) {
|
if (path.startsWith('/api/v1/agents') || path.startsWith('/api/v1/install-links')) {
|
||||||
return write ? 'fw:agents:write' : 'fw:agents:read'
|
return write ? 'fw:agents:write' : 'fw:agents:read'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user