diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..a02e569 --- /dev/null +++ b/apps/web/e2e/smoke.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from '@playwright/test' + +/** + * Smoke against local API (+ optional UI). Profile: compose microvps-full. + * + * Env: + * - EVOBGP_E2E_API_URL (default http://127.0.0.1:8080) + * - EVOBGP_E2E_BASE_URL (default http://127.0.0.1:5173) + * - EVOBGP_E2E_TOKEN (default dev) + */ +const apiBase = process.env.EVOBGP_E2E_API_URL ?? 'http://127.0.0.1:8080' +const uiBase = process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173' +const token = process.env.EVOBGP_E2E_TOKEN ?? 'dev' + +test.describe('EvoBGP smoke', () => { + test('API health and modules list', async ({ request }) => { + const health = await request.get(`${apiBase}/v1/health`) + expect(health.ok()).toBeTruthy() + + const mods = await request.get(`${apiBase}/v1/modules?limit=10`, { + headers: { Authorization: `Bearer ${token}` }, + }) + expect([200, 401]).toContain(mods.status()) + }) + + test('UI loads when available', async ({ page }) => { + test.skip(!process.env.EVOBGP_E2E_UI, 'set EVOBGP_E2E_UI=1 to enable UI smoke') + await page.goto(uiBase) + await page.waitForLoadState('domcontentloaded') + await expect(page.locator('body')).toBeVisible() + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 285788a..e2dea18 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,6 +10,7 @@ "typecheck": "tsr generate && tsc --noEmit", "lint": "eslint .", "test": "vitest run", + "test:e2e": "playwright test", "openapi:gen": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.ts", "openapi:check": "openapi-typescript ../../docs/openapi.yaml -o src/types/api.gen.check.ts && diff -q src/types/api.gen.ts src/types/api.gen.check.ts && rm -f src/types/api.gen.check.ts" }, @@ -43,6 +44,7 @@ }, "devDependencies": { "@eslint/js": "^9.0.0", + "@playwright/test": "^1.62.1", "@tailwindcss/vite": "^4.1.0", "@tanstack/router-cli": "^1.130.0", "@tanstack/router-plugin": "^1.130.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..ad2ed4f --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 60_000, + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: process.env.EVOBGP_E2E_BASE_URL ?? 'http://127.0.0.1:5173', + trace: 'on-first-retry', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}) diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 6d274df..5d4539f 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-cols.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-app-switcher.ts","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/vite-env.d.ts","./src/components/app-switcher.tsx","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/counted-line-tabs.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/apps-menu.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/nav-user.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/lookup/lookup-matches-grid.tsx","./src/components/lookup/lookup-search-form.tsx","./src/components/lookup/lookup-summary-kpi.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-discovered-peers-card.tsx","./src/components/network/network-kpi.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/reui-kit/detail-panel.tsx","./src/components/reui-kit/filter-utils.ts","./src/components/reui-kit/frame-data-grid.tsx","./src/components/reui-kit/index.ts","./src/components/reui-kit/kpi-cols.ts","./src/components/reui-kit/kpi-stat-grid.tsx","./src/components/reui-kit/ops-dashboard.tsx","./src/components/reui-kit/quick-action-grid.tsx","./src/components/reui-kit/resource-page.tsx","./src/components/reui-kit/settings-shell.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-app-switcher.ts","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/app-switcher-config.ts","./src/lib/auth.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/app-switcher.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/lookup.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/auth.callback.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/lookup.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index da9084c..2622661 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -35,5 +35,6 @@ export default defineConfig({ test: { environment: 'happy-dom', globals: true, + exclude: ['**/node_modules/**', '**/e2e/**', '**/dist/**'], }, }) diff --git a/cmd/evobgp-api/main.go b/cmd/evobgp-api/main.go index 7637a31..227d34b 100644 --- a/cmd/evobgp-api/main.go +++ b/cmd/evobgp-api/main.go @@ -52,6 +52,8 @@ func main() { observability.SetBuildInfo(version.Version, version.GitSHA) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + otelShutdown := observability.StartOTelIfEnabled(ctx) + defer func() { _ = otelShutdown(context.Background()) }() srv.StartBackground(ctx) startBirdMetricsPoller(ctx) diff --git a/docs/production-checklist.md b/docs/production-checklist.md index 23edd89..8a3bf00 100644 --- a/docs/production-checklist.md +++ b/docs/production-checklist.md @@ -17,8 +17,12 @@ - `EVOBGP_JOB_MAX_CONCURRENT=16`, `EVOBGP_DB_MAX_CONNS=25`, `EVOBGP_COLLECT_CONCURRENCY=16` при росте tenants. - `EVOBGP_NODE_DISPATCH_INSECURE_TLS=0` — только валидный TLS к agent. -- Ограничить `/metrics` сетевой политикой или reverse proxy. -- Профиль `evobgp-all` или HA API + персистентная `job_audit` (PostgreSQL). +- Ограничить `/metrics`: `EVOBGP_METRICS_TOKEN` (Bearer) и/или сетевая политика / reverse proxy. +- `EVOBGP_AUTH_RATE_LIMIT` (default 60/min per IP) на `GET /v1/auth/config`. +- `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` для CDN/DoH в censored сетях. +- Опционально `EVOBGP_OTEL_ENDPOINT` (stub opt-in для будущего OTel export). +- Профиль `evobgp-all` или HA API + персистентная `job_audit` (PostgreSQL SKIP LOCKED reclaim). +- E2E smoke: `pnpm --filter @evobgp/web run test:e2e` против API (`EVOBGP_E2E_API_URL`) / UI (`EVOBGP_E2E_UI=1`). - Мониторинг drift: `evobgp-deploy`, `last_applied_revision_id` vs published. ## Не использовать в prod diff --git a/internal/agentserver/server.go b/internal/agentserver/server.go index e039ff4..e8c7e65 100644 --- a/internal/agentserver/server.go +++ b/internal/agentserver/server.go @@ -2,6 +2,7 @@ package agentserver import ( "context" + "crypto/subtle" "encoding/json" "fmt" "log" @@ -157,7 +158,8 @@ func (s *Server) authorize(r *http.Request) bool { if !strings.HasPrefix(h, prefix) { return false } - return strings.TrimSpace(h[len(prefix):]) == secret + got := strings.TrimSpace(h[len(prefix):]) + return subtle.ConstantTimeCompare([]byte(got), []byte(secret)) == 1 } func writeJSON(w http.ResponseWriter, status int, v any) { diff --git a/internal/httpapi/ratelimit.go b/internal/httpapi/ratelimit.go new file mode 100644 index 0000000..6338048 --- /dev/null +++ b/internal/httpapi/ratelimit.go @@ -0,0 +1,68 @@ +package httpapi + +import ( + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" +) + +// authRateLimiter is a simple per-IP token bucket for public auth-ish endpoints. +type authRateLimiter struct { + mu sync.Mutex + hits map[string][]time.Time + limit int + window time.Duration +} + +func newAuthRateLimiter() *authRateLimiter { + limit := 60 + if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv("EVOBGP_AUTH_RATE_LIMIT"))); err == nil && n > 0 { + limit = n + } + return &authRateLimiter{ + hits: make(map[string][]time.Time), + limit: limit, + window: time.Minute, + } +} + +func (l *authRateLimiter) allow(ip string) bool { + if l == nil { + return true + } + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + cut := now.Add(-l.window) + arr := l.hits[ip] + kept := arr[:0] + for _, t := range arr { + if t.After(cut) { + kept = append(kept, t) + } + } + if len(kept) >= l.limit { + l.hits[ip] = kept + return false + } + kept = append(kept, now) + l.hits[ip] = kept + return true +} + +func (s *Server) withAuthRateLimit(next http.HandlerFunc) http.HandlerFunc { + if s.authLimiter == nil { + s.authLimiter = newAuthRateLimiter() + } + return func(w http.ResponseWriter, r *http.Request) { + ip := clientIP(r) + if !s.authLimiter.allow(ip) { + writeProblem(w, http.StatusTooManyRequests, "Too Many Requests", "auth rate limit exceeded") + return + } + next(w, r) + } +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 3c33ad4..8092882 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -30,12 +30,12 @@ func (s *Server) Handler() http.Handler { s.registerV1(v1) wrappedV1 := http.StripPrefix("/v1", v1) - s.mux.Handle("GET /metrics", observability.MetricsHandler()) + s.mux.Handle("GET /metrics", observability.ProtectMetrics(observability.MetricsHandler())) s.mux.HandleFunc("GET /version", s.handleVersion) s.mux.HandleFunc("GET /v1/health", s.handleHealth) s.mux.HandleFunc("GET /v1/ready", s.handleReady) s.mux.HandleFunc("GET /v1/version", s.handleVersion) - s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic) + s.mux.HandleFunc("GET /v1/auth/config", s.withAuthRateLimit(s.handleAuthConfigPublic)) // Firewall subsystem moved to the standalone EvoFirewall service; see docs/firewall.md. // Registered on the public mux so it wins over the "/v1/" subtree below regardless of auth. s.mux.HandleFunc("/v1/firewall/", s.handleFirewallGone) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 7b7b98b..a724471 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -29,6 +29,7 @@ type Server struct { maintConfig *maintenance.ConfigProvider maintStats *maintenance.DBStatsProvider jobs *jobs.Registry + authLimiter *authRateLimiter bundlePriv ed25519.PrivateKey keyResolver *apiKeyResolver firewallResolver *firewallTokenResolver diff --git a/internal/httpclient/httpclient.go b/internal/httpclient/httpclient.go index 2cb7102..1c1a9fb 100644 --- a/internal/httpclient/httpclient.go +++ b/internal/httpclient/httpclient.go @@ -14,6 +14,7 @@ import ( const DefaultTimeout = 45 * time.Second // New returns an HTTP client with timeout and tuned idle connection pooling. +// Standard proxy env (HTTP_PROXY / HTTPS_PROXY / NO_PROXY) is honored via the cloned DefaultTransport. func New(timeout time.Duration) *http.Client { if timeout <= 0 { timeout = DefaultTimeout diff --git a/internal/observability/metrics_auth.go b/internal/observability/metrics_auth.go new file mode 100644 index 0000000..44aa2a0 --- /dev/null +++ b/internal/observability/metrics_auth.go @@ -0,0 +1,32 @@ +package observability + +import ( + "crypto/subtle" + "net/http" + "os" + "strings" +) + +// ProtectMetrics wraps the Prometheus handler. When EVOBGP_METRICS_TOKEN is set, +// scrapes must send Authorization: Bearer (constant-time compare). +// Empty token keeps /metrics open (dev / private network). +func ProtectMetrics(next http.Handler) http.Handler { + token := strings.TrimSpace(os.Getenv("EVOBGP_METRICS_TOKEN")) + if token == "" { + return next + } + want := []byte(token) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + const p = "Bearer " + got := "" + if strings.HasPrefix(h, p) { + got = strings.TrimSpace(h[len(p):]) + } + if subtle.ConstantTimeCompare([]byte(got), want) != 1 { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/observability/otel.go b/internal/observability/otel.go new file mode 100644 index 0000000..1f1dcd0 --- /dev/null +++ b/internal/observability/otel.go @@ -0,0 +1,21 @@ +package observability + +import ( + "context" + "os" + "strings" + + "evobgp/internal/logging" +) + +// StartOTelIfEnabled is an opt-in stub for OpenTelemetry (EVOBGP_OTEL_ENDPOINT). +// Full exporter wiring lands when the ops stack provides a collector; this logs intent only. +func StartOTelIfEnabled(ctx context.Context) (shutdown func(context.Context) error) { + ep := strings.TrimSpace(os.Getenv("EVOBGP_OTEL_ENDPOINT")) + if ep == "" { + return func(context.Context) error { return nil } + } + logging.Default().Info("otel opt-in enabled (stub; export not wired yet)", "endpoint", ep) + _ = ctx + return func(context.Context) error { return nil } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c36856..9a46be6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@eslint/js': specifier: ^9.0.0 version: 9.39.4 + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 '@tailwindcss/vite': specifier: ^4.1.0 version: 4.3.2(vite@7.3.6(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) @@ -770,6 +773,11 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -2239,6 +2247,11 @@ packages: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3124,6 +3137,16 @@ packages: resolution: {integrity: sha512-C+VUP+8jis7EsQZIhDYmS5qlNtjv2yP4SNtjXK9AP1ZcTRlnSfuumaTnRfYZnYgUUYVIKqL0fRvmUGDV2fmp6g==} engines: {node: '>=4'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -4492,6 +4515,10 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -5996,6 +6023,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -6688,6 +6718,14 @@ snapshots: find-up: 2.1.0 load-json-file: 4.0.0 + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} postcss@8.5.16: