From f8ec1864effc09509d8a6603c27e9e4510e0fa58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yuniel=20Acosta=20P=C3=A9rez?= <33158051+yacosta738@users.noreply.github.com> Date: Sun, 31 May 2026 18:07:33 +0200 Subject: [PATCH 1/2] feat(api-keys): add API key CRUD management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full API key lifecycle management: - Backend: CRUD endpoints, DTOs, use case wiring, auth middleware - Frontend: API keys view, useApiKeys composable, types - Tests: Integration tests and Playwright E2E tests - Docs: API docs and configuration guide - CLI: seed-admin subcommand for admin bootstrap - Fix: axum 0.8 path syntax (':id' → '{id}') NOTE: Changed example key prefix from 'rk_live_' to 'rook_fake_' to avoid GitHub secret scanner false positive. --- .atl/skill-registry.md | 94 ++++ Cargo.lock | 122 +++++ Dockerfile.dev | 4 +- apps/rook/Cargo.toml | 1 + apps/rook/dashboard/e2e/api-keys.spec.ts | 339 ++++++++++++ .../rook/dashboard/src/components/NavMain.vue | 4 +- .../dashboard/src/components/NavSecondary.vue | 5 +- .../ui/carousel/CarouselContent.vue | 2 +- .../ui/locale-switcher/LocaleSwitcher.vue | 7 +- .../dashboard/src/composables/useApiKeys.ts | 117 +++++ apps/rook/dashboard/src/config/endpoints.ts | 2 +- apps/rook/dashboard/src/i18n/index.ts | 3 +- apps/rook/dashboard/src/lib/api.ts | 83 ++- .../dashboard/src/types/lucide-icons.d.ts | 9 + apps/rook/dashboard/src/types/unovis.d.ts | 23 + apps/rook/dashboard/src/views/ApiKeysView.vue | 489 +++++++++++++++--- .../dashboard/src/views/EndpointsView.vue | 2 - .../dashboard/src/views/ProvidersView.vue | 2 +- .../dashboard/src/views/sidebar/index.vue | 6 - apps/rook/dashboard/tsconfig.json | 1 + apps/rook/src/di.rs | 21 +- apps/rook/src/main.rs | 74 ++- crates/application/rook-usecases/Cargo.toml | 3 +- .../src/authenticate_client_api.rs | 49 ++ crates/application/rook-usecases/src/lib.rs | 5 + .../rook-usecases/src/manage_api_keys.rs | 441 ++++++++++++++++ crates/domain/rook-core/src/ports.rs | 28 +- crates/infrastructure/auth-sqlite/src/lib.rs | 330 +++++++++++- .../transport-axum/src/api_key_dto.rs | 29 ++ .../transport-axum/src/authz.rs | 49 ++ .../transport-axum/src/handlers/api_key.rs | 288 +++++++++++ .../transport-axum/src/handlers/mod.rs | 1 + .../infrastructure/transport-axum/src/lib.rs | 1 + .../transport-axum/src/provider_routes.rs | 8 +- .../transport-axum/src/routes.rs | 19 +- .../transport-axum/tests/api_key_routes.rs | 91 ++++ dev/e2e/run-api-keys-e2e.sh | 134 +++++ dev/test-configs/rook-api-keys-test.toml | 23 + docs/api.md | 191 +++++++ docs/configuration.md | 72 +++ .../archive/2026-05-31-api-key-crud/design.md | 344 ++++++++++++ .../2026-05-31-api-key-crud/proposal.md | 269 ++++++++++ .../specs/api-key-dashboard.md | 334 ++++++++++++ .../specs/api-key-domain.md | 236 +++++++++ .../specs/api-key-repository.md | 239 +++++++++ .../specs/api-key-transport.md | 326 ++++++++++++ .../specs/api-key-usecases.md | 276 ++++++++++ .../2026-05-31-api-key-crud/state.yaml | 201 +++++++ .../archive/2026-05-31-api-key-crud/tasks.md | 369 +++++++++++++ .../2026-05-31-api-key-crud/verify-report.md | 237 +++++++++ openspec/specs/api-key-dashboard/spec.md | 334 ++++++++++++ openspec/specs/api-key-domain/spec.md | 236 +++++++++ openspec/specs/api-key-repository/spec.md | 239 +++++++++ openspec/specs/api-key-transport/spec.md | 326 ++++++++++++ openspec/specs/api-key-usecases/spec.md | 276 ++++++++++ 55 files changed, 7310 insertions(+), 104 deletions(-) create mode 100644 .atl/skill-registry.md create mode 100644 apps/rook/dashboard/e2e/api-keys.spec.ts create mode 100644 apps/rook/dashboard/src/composables/useApiKeys.ts create mode 100644 apps/rook/dashboard/src/types/lucide-icons.d.ts create mode 100644 apps/rook/dashboard/src/types/unovis.d.ts create mode 100644 crates/application/rook-usecases/src/manage_api_keys.rs create mode 100644 crates/infrastructure/transport-axum/src/api_key_dto.rs create mode 100644 crates/infrastructure/transport-axum/src/handlers/api_key.rs create mode 100644 crates/infrastructure/transport-axum/tests/api_key_routes.rs create mode 100755 dev/e2e/run-api-keys-e2e.sh create mode 100644 dev/test-configs/rook-api-keys-test.toml create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/design.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/proposal.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/specs/api-key-dashboard.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/specs/api-key-domain.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/specs/api-key-repository.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/specs/api-key-transport.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/specs/api-key-usecases.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/state.yaml create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/tasks.md create mode 100644 openspec/changes/archive/2026-05-31-api-key-crud/verify-report.md create mode 100644 openspec/specs/api-key-dashboard/spec.md create mode 100644 openspec/specs/api-key-domain/spec.md create mode 100644 openspec/specs/api-key-repository/spec.md create mode 100644 openspec/specs/api-key-transport/spec.md create mode 100644 openspec/specs/api-key-usecases/spec.md diff --git a/.atl/skill-registry.md b/.atl/skill-registry.md new file mode 100644 index 00000000..8fcf692b --- /dev/null +++ b/.atl/skill-registry.md @@ -0,0 +1,94 @@ +# Skill Registry — cortex + +> Auto-generated by sdd-init. DO NOT edit manually. Re-generate with `sdd-init`. + +## Project: cortex + +- **Type**: Rust workspace monorepo (Cargo workspace) +- **Language**: Rust 1.81+ (toolchain 1.89) +- **Artifact store**: openspec (`openspec/`) +- **Strict TDD**: false (no inline `#[cfg(test)]` modules; tests are separate test targets) + +## Compact Rules + +### SDD Workflow + +- SDD artifacts live in `openspec/changes/{change-name}/` +- Phase order: `init → explore → propose → spec → design → tasks → apply → verify → archive` +- State tracked in `openspec/changes/{change-name}/state.yaml` +- Resume via `sdd-continue` reading `state.yaml` to determine next phase + +### Quality Gates (CI Order) + +``` +fmt --check → clippy → check → test → doc → audit +``` + +Run locally: `just ci-local` + +### Architecture Conventions + +- Clean Architecture: `apps/rook` (binary, DI) → `transport-axum` (HTTP) → `rook-usecases` (app) → `rook-core` (domain) → `shared-kernel` (no deps) +- All port traits: `#[async_trait]` +- Error handling: `thiserror` enums per domain +- No inline `#[cfg(test)]` modules — tests live in `tests/` dirs or `#[cfg(test)]` at crate root +- Newtypes for domain IDs: `SmolStr` wrapper (e.g., `ApiKeyId`, `UserId`, `ProviderId`) +- SQLite schema: migration on open in `new()` (no separate migration binary) + +### Key Patterns + +| Pattern | Location | Notes | +|---------|----------|-------| +| Use case struct with DI | `rook-usecases/src/manage_connections.rs` → `ManageConnections` | Fields: repo, key_manager, provider builders | +| Repository trait | `rook-core/src/ports.rs` | `Port = Send + Sync`, async_trait | +| Repository impl | `auth-sqlite/src/lib.rs` | `SqliteXxxRepository { conn: Mutex }` | +| Migration on open | `auth-sqlite/src/lib.rs` → `run_migration()` | Called from `new()` | +| Error enum | `rook-core/src/api_key.rs` → `ApiKeyRepositoryError` | Variants: NotFound, DuplicateHash, Database | +| Rate limiting | `transport-axum/src/middleware/api_key_rate_limiter.rs` | Token bucket, per-tier limits | +| Encrypted credentials | `encryption-inmemory` | AES-256-GCM + Argon2id key derivation | + +### Tooling + +- Task runner: `just` (see `justfile`) +- Git hooks: `lefthook` (`lefthook.yml`) +- Test: `cargo test --workspace --all-features` +- Coverage: `cargo llvm-cov --workspace --html` +- Format: `cargo fmt --all` +- Clippy (deny warnings): `cargo clippy --workspace --all-targets -- -D warnings` + +### Test Files Location + +``` +apps/rook/tests/*.rs +crates/infrastructure/*/tests/*.rs +``` + +### Dashboard + +- Vue.js at `apps/rook/dashboard/` +- Dev: `just dev` (watch mode with check + test + clippy) + +## Skills (available) + +| Skill | Trigger | Purpose | +|-------|---------|---------| +| `brainstorming` | Ideas, spikes, isolated behavior | Collaborative thinking without full SDD | +| `systematic-debugging` | Bug reports, flaky tests | Systematic elimination process | +| `writing-plans` | Implementation plan from approved design | Temporary, not SDD | +| `verification-before-completion` | Before claiming work done | Run verification commands | +| `sdd-*` phases | SDD workflow | See phase command routing | + +## Testing Capabilities Detected + +| Capability | Status | Command / Evidence | +|-----------|--------|-------------------| +| Unit test runner | ✅ | `cargo test --workspace --all-features` | +| Integration tests | ✅ | `tests/*.rs` in crate dirs | +| Coverage report | ✅ | `cargo llvm-cov --workspace --html` | +| Doc tests | ✅ | `cargo test --workspace --doc` | +| Format check | ✅ | `cargo fmt --all -- --check` | +| Clippy (deny) | ✅ | `cargo clippy --workspace --all-targets -- -D warnings` | +| Security audit | ✅ | `cargo audit` | +| Watch mode | ✅ | `just dev` (cargo watch) | + +**No TTD enforcement** — `strict_tdd: false` because inline `#[cfg(test)]` modules are not the primary pattern. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index b01ddbdf..1bb89d0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -459,6 +509,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.58" @@ -468,6 +558,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cookie" version = "0.18.1" @@ -1271,6 +1367,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.12.1" @@ -1563,6 +1665,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.3.1" @@ -2266,6 +2374,7 @@ dependencies = [ "axum 0.8.9", "bytes", "cache-memory", + "clap", "dirs", "encryption-inmemory", "figment", @@ -2331,6 +2440,7 @@ dependencies = [ "time", "tokio", "tracing", + "uuid", ] [[package]] @@ -2701,6 +2811,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -3207,6 +3323,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.2" diff --git a/Dockerfile.dev b/Dockerfile.dev index a618d79f..14c4fd8c 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -54,7 +54,9 @@ COPY dev/test-configs/rook-minimal.toml /app/rook.toml # Expose default port EXPOSE 8080 +# Set config path via env var +ENV ROOK_CONFIG=/app/rook.toml + USER non-root ENTRYPOINT ["/usr/local/bin/rook"] -CMD ["--config", "/app/rook.toml"] diff --git a/apps/rook/Cargo.toml b/apps/rook/Cargo.toml index 47a31164..72a7d056 100644 --- a/apps/rook/Cargo.toml +++ b/apps/rook/Cargo.toml @@ -27,6 +27,7 @@ provider-sqlite = { path = "../../crates/infrastructure/provider-sqlite" } auth-sqlite = { path = "../../crates/infrastructure/auth-sqlite" } tokio = { version = "1", features = ["full"] } anyhow = "1" +clap = { version = "4", features = ["derive"] } tracing = "0.1" axum = { version = "0.8", features = ["macros"] } tower = "0.5" diff --git a/apps/rook/dashboard/e2e/api-keys.spec.ts b/apps/rook/dashboard/e2e/api-keys.spec.ts new file mode 100644 index 00000000..8a153f5d --- /dev/null +++ b/apps/rook/dashboard/e2e/api-keys.spec.ts @@ -0,0 +1,339 @@ +import { test, expect, Page } from '@playwright/test' + +// ============================================================================= +// Test Configuration +// ============================================================================= + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080' +const DASHBOARD_URL = process.env.DASHBOARD_URL || 'http://localhost:5173' + +// ============================================================================= +// Helper: Get CSRF token via API +// ============================================================================= + +async function getCsrfToken(page: Page): Promise { + const response = await page.request.get(`${API_BASE_URL}/login`) + const body = await response.json() + return body.csrf_token as string +} + +// ============================================================================= +// Helper: Login via API and set cookies in browser +// ============================================================================= + +async function loginAsAdmin(page: Page, password: string = 'admin123'): Promise { + // Get CSRF token + const csrfToken = await getCsrfToken(page) + + // Get the CSRF cookie from the response + const cookies = await page.request.getCookies(`${API_BASE_URL}`) + const csrfCookie = cookies.find(c => c.name === 'csrf_token') + + // Login via API with CSRF token and cookie + const loginResponse = await page.request.post(`${API_BASE_URL}/login`, { + data: { + username: 'admin', + password: password + }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + 'Cookie': `csrf_token=${csrfCookie?.value || csrfToken}` + } + }) + + if (!loginResponse.ok()) { + throw new Error(`Login failed: ${loginResponse.status()} ${await loginResponse.text()}`) + } + + // The auth_token cookie should now be set in the browser + // Give time for cookie to be set + await page.waitForTimeout(500) +} + +// ============================================================================= +// Helper: Create API key via API +// ============================================================================= + +async function createApiKeyViaApi( + page: Page, + label: string, + scopes: string[] = ['read'], + tier: string = 'free' +): Promise<{ id: string; plaintextKey: string }> { + const csrfToken = await getCsrfToken(page) + const cookies = await page.request.getCookies(`${API_BASE_URL}`) + const csrfCookie = cookies.find(c => c.name === 'csrf_token')?.value || csrfToken + const authCookie = cookies.find(c => c.name === 'auth_token')?.value || '' + + const response = await page.request.post(`${API_BASE_URL}/api/api-keys`, { + data: { label, scopes, tier, expiresAt: null }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + 'Cookie': `csrf_token=${csrfCookie}; auth_token=${authCookie}` + } + }) + + if (!response.ok()) { + throw new Error(`Failed to create API key: ${response.status()} ${await response.text()}`) + } + + const data = await response.json() + return { id: data.key.id, plaintextKey: data.plaintextKey } +} + +// ============================================================================= +// Helper: Get auth cookies for API requests +// ============================================================================= + +async function getAuthCookies(page: Page): Promise { + const cookies = await page.request.getCookies(`${API_BASE_URL}`) + const csrfCookie = cookies.find(c => c.name === 'csrf_token')?.value || '' + const authCookie = cookies.find(c => c.name === 'auth_token')?.value || '' + return `csrf_token=${csrfCookie}; auth_token=${authCookie}` +} + +// ============================================================================= +// Test: Dashboard loads without crashing +// ============================================================================= + +test.describe('Dashboard', () => { + test('loads the home page', async ({ page }) => { + // Set API base URL for the frontend + await page.goto(DASHBOARD_URL) + await page.waitForLoadState('networkidle') + + // Should see the home page title + await expect(page.getByRole('heading', { name: /AI Router/i })).toBeVisible({ timeout: 10000 }) + }) +}) + +// ============================================================================= +// Test: API Keys Page - Loading and Empty State +// ============================================================================= + +test.describe('API Keys - List View', () => { + test.beforeEach(async ({ page }) => { + // Login first + await page.goto(DASHBOARD_URL) + await page.waitForLoadState('networkidle') + + // Login via API + await loginAsAdmin(page) + + // Navigate to API keys page + await page.goto(`${DASHBOARD_URL}/api-keys`) + await page.waitForLoadState('networkidle') + }) + + test('shows page title', async ({ page }) => { + await expect(page.getByRole('heading', { name: /api keys/i })).toBeVisible() + }) + + test('shows empty state when no keys exist', async ({ page }) => { + await expect(page.getByText(/no api keys yet/i)).toBeVisible() + }) + + test('shows Create API Key button', async ({ page }) => { + await expect(page.getByRole('button', { name: /create api key/i })).toBeVisible() + }) +}) + +// ============================================================================= +// Test: API Keys - Create Flow +// ============================================================================= + +test.describe('API Keys - Create Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DASHBOARD_URL) + await page.waitForLoadState('networkidle') + await loginAsAdmin(page) + await page.goto(`${DASHBOARD_URL}/api-keys`) + await page.waitForLoadState('networkidle') + }) + + test('opens create modal when clicking Create API Key button', async ({ page }) => { + await page.getByRole('button', { name: /create api key/i }).click() + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByText(/create api key/i).first()).toBeVisible() + }) + + test('shows validation errors when submitting empty form', async ({ page }) => { + await page.getByRole('button', { name: /create api key/i }).click() + await page.getByRole('button', { name: /create key/i }).click() + await expect(page.getByText(/label is required/i)).toBeVisible() + await expect(page.getByText(/at least one scope is required/i)).toBeVisible() + }) + + test('creates API key with valid form data', async ({ page }) => { + await page.getByRole('button', { name: /create api key/i }).click() + + // Fill in the form + await page.getByLabel(/label/i).fill('test-agent-key') + await page.getByText(/read/i).click() + + // Submit + await page.getByRole('button', { name: /create key/i }).click() + + // Should show the warning about saving the key + await expect( + page.getByText(/save this key now — it will not be shown again/i) + ).toBeVisible() + + // Should show the plaintext key + const keyDisplay = page.locator('code') + await expect(keyDisplay).toBeVisible() + + // Copy button should be visible + await expect(page.getByRole('button', { name: /copy/i })).toBeVisible() + + // Click Done to close + await page.getByRole('button', { name: /done/i }).click() + + // Modal should close + await expect(page.getByRole('dialog')).not.toBeVisible() + + // The key should now appear in the list + await expect(page.getByText(/test-agent-key/i)).toBeVisible() + }) +}) + +// ============================================================================= +// Test: API Keys - Edit Flow +// ============================================================================= + +test.describe('API Keys - Edit Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DASHBOARD_URL) + await page.waitForLoadState('networkidle') + await loginAsAdmin(page) + await page.goto(`${DASHBOARD_URL}/api-keys`) + await page.waitForLoadState('networkidle') + + // Create a key to edit + await createApiKeyViaApi(page, 'key-to-edit', ['read'], 'free') + }) + + test('opens edit modal when clicking edit button', async ({ page }) => { + // Reload to see the created key + await page.reload() + await page.waitForLoadState('networkidle') + + // Find the key row + const row = page.locator('tbody tr').filter({ hasText: 'key-to-edit' }) + + // Click edit button (pencil icon) + await row.locator('button').first().click() + + // Modal should be visible + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByText(/edit api key/i)).toBeVisible() + }) + + test('pre-fills form with existing key data', async ({ page }) => { + await page.reload() + await page.waitForLoadState('networkidle') + + const row = page.locator('tbody tr').filter({ hasText: 'key-to-edit' }) + await row.locator('button').first().click() + + // Label should be pre-filled + await expect(page.getByLabel(/label/i)).toHaveValue('key-to-edit') + }) + + test('updates key successfully', async ({ page }) => { + await page.reload() + await page.waitForLoadState('networkidle') + + const row = page.locator('tbody tr').filter({ hasText: 'key-to-edit' }) + await row.locator('button').first().click() + + // Change the label + await page.getByLabel(/label/i).fill('updated-key-label') + + // Save changes + await page.getByRole('button', { name: /save changes/i }).click() + + // Modal should close + await expect(page.getByRole('dialog')).not.toBeVisible() + + // Key should show updated label + await expect(page.getByText(/updated-key-label/i)).toBeVisible() + }) +}) + +// ============================================================================= +// Test: API Keys - Revoke Flow +// ============================================================================= + +test.describe('API Keys - Revoke Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto(DASHBOARD_URL) + await page.waitForLoadState('networkidle') + await loginAsAdmin(page) + await page.goto(`${DASHBOARD_URL}/api-keys`) + await page.waitForLoadState('networkidle') + + // Create a key to revoke + await createApiKeyViaApi(page, 'key-to-revoke', ['read'], 'free') + }) + + test('shows confirmation dialog when revoking', async ({ page }) => { + await page.reload() + await page.waitForLoadState('networkidle') + + const row = page.locator('tbody tr').filter({ hasText: 'key-to-revoke' }) + + // Click revoke button (trash icon) + await row.locator('button').nth(1).click() + + // Confirmation dialog should appear + await expect(page.getByRole('dialog')).toBeVisible() + await expect(page.getByText(/revoke api key/i)).toBeVisible() + }) + + test('revokes key successfully and shows Revoked status', async ({ page }) => { + await page.reload() + await page.waitForLoadState('networkidle') + + const row = page.locator('tbody tr').filter({ hasText: 'key-to-revoke' }) + + // Click revoke button + await row.locator('button').nth(1).click() + + // Confirm revocation + await page.getByRole('button', { name: /revoke key/i }).click() + + // Wait for dialog to close and status to update + await page.waitForTimeout(500) + + // Key should now show Revoked status + await page.reload() + await page.waitForLoadState('networkidle') + + const revokedRow = page.locator('tbody tr').filter({ hasText: 'key-to-revoke' }) + await expect(revokedRow.locator('text=Revoked')).toBeVisible() + }) +}) + +// ============================================================================= +// Test: API Keys - Pagination (if more than 20 keys exist) +// ============================================================================= + +test.describe('API Keys - Pagination', () => { + test('pagination controls exist', async ({ page }) => { + await page.goto(DASHBOARD_URL) + await page.waitForLoadState('networkidle') + await loginAsAdmin(page) + await page.goto(`${DASHBOARD_URL}/api-keys`) + await page.waitForLoadState('networkidle') + + // If there are keys, pagination should be visible + const hasKeys = await page.locator('tbody tr').count() > 0 + if (hasKeys) { + await expect(page.getByRole('button', { name: /previous/i })).toBeVisible() + await expect(page.getByRole('button', { name: /next/i })).toBeVisible() + } + }) +}) diff --git a/apps/rook/dashboard/src/components/NavMain.vue b/apps/rook/dashboard/src/components/NavMain.vue index 5cd3311f..4c706117 100644 --- a/apps/rook/dashboard/src/components/NavMain.vue +++ b/apps/rook/dashboard/src/components/NavMain.vue @@ -1,6 +1,6 @@ diff --git a/apps/rook/dashboard/src/components/ui/carousel/CarouselContent.vue b/apps/rook/dashboard/src/components/ui/carousel/CarouselContent.vue index bd3db7e7..e6448337 100644 --- a/apps/rook/dashboard/src/components/ui/carousel/CarouselContent.vue +++ b/apps/rook/dashboard/src/components/ui/carousel/CarouselContent.vue @@ -9,7 +9,7 @@ defineOptions({ const props = defineProps() -const { carouselRef, orientation } = useCarousel() +const { carouselRef: _carouselRef, orientation } = useCarousel() \ No newline at end of file diff --git a/apps/rook/dashboard/src/views/EndpointsView.vue b/apps/rook/dashboard/src/views/EndpointsView.vue index 20ad757a..50632b3f 100644 --- a/apps/rook/dashboard/src/views/EndpointsView.vue +++ b/apps/rook/dashboard/src/views/EndpointsView.vue @@ -108,8 +108,6 @@ const categories = [ :class="{ 'bg-green-500': endpoint.method === 'GET', 'bg-blue-500': endpoint.method === 'POST', - 'bg-yellow-500': endpoint.method === 'PUT', - 'bg-red-500': endpoint.method === 'DELETE', }" > {{ endpoint.method }} diff --git a/apps/rook/dashboard/src/views/ProvidersView.vue b/apps/rook/dashboard/src/views/ProvidersView.vue index f4655ff1..13dc34f0 100644 --- a/apps/rook/dashboard/src/views/ProvidersView.vue +++ b/apps/rook/dashboard/src/views/ProvidersView.vue @@ -8,7 +8,7 @@ import { useProviders } from '@/composables/useProviders' const { t } = useI18n() const activeTab = ref<'list' | 'quotes'>('list') -const { providers, loading, error, fetch, remove, test } = useProviders() +const { providers, loading, error, fetch, test } = useProviders() onMounted(() => { fetch() diff --git a/apps/rook/dashboard/src/views/sidebar/index.vue b/apps/rook/dashboard/src/views/sidebar/index.vue index fc16ccde..183f5f84 100644 --- a/apps/rook/dashboard/src/views/sidebar/index.vue +++ b/apps/rook/dashboard/src/views/sidebar/index.vue @@ -17,14 +17,8 @@ import { SidebarTrigger, } from '@/components/ui/sidebar' import { useRoute } from 'vue-router' -import { computed } from 'vue' const route = useRoute() - -const breadcrumbs = computed(() => { - const name = route.name as string - return [{ label: name || 'Dashboard', href: '#' }] -})