From e69bc3c0a28c85bd92c7e9cbfc5f34173899c114 Mon Sep 17 00:00:00 2001 From: hongming Date: Sun, 24 May 2026 01:38:55 -0700 Subject: [PATCH] test(e2e): add real staging image upload smoke --- .gitea/scripts/review-check.sh | 98 ++++---- .gitea/scripts/tests/_review_check_fixture.py | 7 + .gitea/scripts/tests/test_review_check.sh | 19 +- .../scripts/tests/test_status_reaper_api.py | 2 +- .gitea/workflows/e2e-peer-visibility.yml | 5 +- .gitea/workflows/e2e-staging-saas.yml | 8 +- canvas/e2e/fixtures/chat-seed.ts | 18 +- .../chat/__tests__/AttachmentAudio.test.tsx | 2 +- .../chat/__tests__/AttachmentImage.test.tsx | 2 +- .../chat/__tests__/AttachmentPDF.test.tsx | 2 +- .../chat/__tests__/AttachmentPreview.test.tsx | 2 +- .../__tests__/AttachmentTextPreview.test.tsx | 2 +- .../chat/__tests__/AttachmentVideo.test.tsx | 2 +- docs/adr/ADR-001-admin-token-scope.md | 7 +- docs/api-reference.md | 2 +- docs/guides/token-management.md | 5 +- docs/runbooks/admin-auth.md | 47 +--- tests/e2e/_extract_token.py | 4 +- tests/e2e/_lib.sh | 17 +- tests/e2e/test_api.sh | 36 +-- tests/e2e/test_chat_upload_e2e.sh | 11 +- tests/e2e/test_dev_mode.sh | 7 +- tests/e2e/test_notify_attachments_e2e.sh | 14 +- tests/e2e/test_priority_runtimes_e2e.sh | 23 +- tests/e2e/test_staging_full_saas.sh | 78 ++++++ tests/e2e/test_today_pr_coverage_e2e.sh | 28 ++- tests/e2e/test_workspace_abilities_e2e.sh | 31 ++- tests/harness/_curl.sh | 9 +- tests/harness/replays/tenant-isolation.sh | 26 +- tests/test_ci_required_drift.py | 2 +- tests/test_lint_required_no_paths.py | 2 +- tests/test_main_red_watchdog.py | 2 +- tests/test_status_reaper.py | 2 +- workspace-server/cmd/server/main.go | 3 - .../internal/artifacts/client_test.go | 4 +- .../internal/handlers/admin_test_token.go | 107 -------- .../handlers/admin_test_token_test.go | 228 ------------------ .../internal/handlers/artifacts_test.go | 2 +- .../internal/handlers/github_token.go | 3 +- workspace-server/internal/handlers/ssrf.go | 2 +- .../internal/handlers/tokens_test.go | 2 +- .../internal/middleware/devmode.go | 4 +- .../internal/middleware/devmode_test.go | 3 +- .../internal/middleware/mcp_ratelimit_test.go | 4 +- .../internal/middleware/tenant_guard.go | 26 +- .../internal/middleware/tenant_guard_test.go | 22 +- .../router/admin_test_token_route_test.go | 109 --------- workspace-server/internal/router/router.go | 14 +- .../router/router_test_helpers_test.go | 25 ++ 49 files changed, 394 insertions(+), 686 deletions(-) delete mode 100644 workspace-server/internal/handlers/admin_test_token.go delete mode 100644 workspace-server/internal/handlers/admin_test_token_test.go delete mode 100644 workspace-server/internal/router/admin_test_token_route_test.go create mode 100644 workspace-server/internal/router/router_test_helpers_test.go diff --git a/.gitea/scripts/review-check.sh b/.gitea/scripts/review-check.sh index ad526bbcf..cd216f0bc 100755 --- a/.gitea/scripts/review-check.sh +++ b/.gitea/scripts/review-check.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# shellcheck disable=SC2016,SC2329 # review-check — evaluate whether a PR satisfies a single team-review gate. # # RFC#324 Step 1 of 5 — qa-review + security-review check workflows. @@ -208,10 +209,10 @@ fi JQ_FILTER="${JQ_FILTER} | .user.login" -CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILTER" "$REVIEWS_JSON" | sort -u) -debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')" +REVIEW_CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILTER" "$REVIEWS_JSON" | sort -u) +debug "candidate non-author approvers: $(echo "$REVIEW_CANDIDATES" | tr '\n' ' ')" -if [ -z "$CANDIDATES" ]; then +if [ -z "$REVIEW_CANDIDATES" ]; then # --- Guardrail (internal#503): explain the most common false # "no candidates" red. Gitea's review event enum is EXACTLY # APPROVED/REQUEST_CHANGES/COMMENT/PENDING. A wrong value ("APPROVE", @@ -236,55 +237,52 @@ if [ -z "$CANDIDATES" ]; then done fi - # --- Fallback (internal#348): check issue comments for agent-approval --- - # core-qa-agent and core-security-agent approve via issue comments, NOT - # the reviews API. The reviews API returns zero entries for comment-only - # approvals. This fallback reads PR issue comments and extracts logins that: - # 1. Posted a comment matching the agent-prefix pattern for this gate: - # qa → "[core-qa-agent] APPROVED" - # security → "[core-security-agent] APPROVED" - # OR posted a generic approval keyword (word-anchored, case-insensitive): - # APPROVED / LGTM / ACCEPTED - # 2. Are not the PR author - # 3. The team-membership probe below is the authoritative filter. - AGENT_PATTERN="" - case "$TEAM" in - qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;; - security) AGENT_PATTERN="\\[core-security-agent\\]" ;; - esac - HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \ - -K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments") - debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}" - if [ "$HTTP_CODE" = "200" ]; then - # JQ expression: select non-author comments that match either the - # agent-prefix pattern (case-insensitive) OR a generic approval keyword. - JQ_APPROVALS=' - .[] | - select(.user.login != $author) | - . as $cmt | - if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then - $cmt.user.login - elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then - $cmt.user.login - else - empty - end - ' - CANDIDATES=$(jq -r \ - --arg author "$PR_AUTHOR" \ - --arg agent_pattern "$AGENT_PATTERN" \ - "$JQ_APPROVALS" \ - "$COMMENTS_JSON" 2>/dev/null | sort -u) - debug "comment-based approval candidates: $(echo "$CANDIDATES" | tr '\n' ' ')" - - if [ -n "$CANDIDATES" ]; then - echo "::notice::${TEAM}-review: reviews API found no APPROVED reviews; found $(echo "$CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..." - fi - else - debug "could not fetch issue comments (HTTP ${HTTP_CODE})" - fi fi +# --- Fallback/extension (internal#348): check issue comments for agent-approval --- +# core-qa-agent and core-security-agent can approve via issue comments. Always +# include comment candidates, even if the reviews API returned approvals for a +# different team; team membership below is the authoritative filter. +COMMENT_CANDIDATES="" +AGENT_PATTERN="" +case "$TEAM" in + qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;; + security) AGENT_PATTERN="\\[core-security-agent\\]" ;; +esac +HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \ + -K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments") +debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}" +if [ "$HTTP_CODE" = "200" ]; then + # JQ expression: select non-author comments that match either the + # agent-prefix pattern (case-insensitive) OR a generic approval keyword. + JQ_APPROVALS=' + .[] | + select(.user.login != $author) | + . as $cmt | + if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then + $cmt.user.login + elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then + $cmt.user.login + else + empty + end + ' + COMMENT_CANDIDATES=$(jq -r \ + --arg author "$PR_AUTHOR" \ + --arg agent_pattern "$AGENT_PATTERN" \ + "$JQ_APPROVALS" \ + "$COMMENTS_JSON" 2>/dev/null | sort -u) + debug "comment-based approval candidates: $(echo "$COMMENT_CANDIDATES" | tr '\n' ' ')" + + if [ -n "$COMMENT_CANDIDATES" ]; then + echo "::notice::${TEAM}-review: found $(echo "$COMMENT_CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..." + fi +else + debug "could not fetch issue comments (HTTP ${HTTP_CODE})" +fi + +CANDIDATES=$(printf '%s\n%s\n' "$REVIEW_CANDIDATES" "$COMMENT_CANDIDATES" | sed '/^$/d' | sort -u) + if [ -z "${CANDIDATES:-}" ]; then echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)" exit 1 diff --git a/.gitea/scripts/tests/_review_check_fixture.py b/.gitea/scripts/tests/_review_check_fixture.py index a637d98d9..1a76bac8f 100644 --- a/.gitea/scripts/tests/_review_check_fixture.py +++ b/.gitea/scripts/tests/_review_check_fixture.py @@ -20,6 +20,7 @@ Scenarios: T15_comments_agent_approval — reviews empty; comments have "[core-qa-agent] APPROVED" → exit 0 T16_comments_generic_approval — reviews empty; comments have "APPROVED" by team member → exit 0 T17_comments_no_approval — reviews empty; comments have no approval keywords → exit 1 + T18_review_wrong_team_comment_right_team — review candidate 404s, comment candidate passes Usage: FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080 @@ -140,6 +141,10 @@ class Handler(http.server.BaseHTTPRequestHandler): {"user": {"login": "alice"}, "body": "I authored this PR", "id": 1}, {"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 2}, ]) + if sc == "T18_review_wrong_team_comment_right_team": + return self._json(200, [ + {"user": {"login": "core-qa-agent"}, "body": "[core-qa-agent] APPROVED after focused review", "id": 1}, + ]) # Default scenarios (T1–T9, T14): no comments return self._json(200, []) @@ -151,6 +156,8 @@ class Handler(http.server.BaseHTTPRequestHandler): return self._empty(404) if sc == "T9_team_403": return self._empty(403) + if sc == "T18_review_wrong_team_comment_right_team" and login == "core-devops": + return self._empty(404) # T7_team_member: member return self._empty(204) diff --git a/.gitea/scripts/tests/test_review_check.sh b/.gitea/scripts/tests/test_review_check.sh index 9eb663e26..795acab9c 100755 --- a/.gitea/scripts/tests/test_review_check.sh +++ b/.gitea/scripts/tests/test_review_check.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +# shellcheck disable=SC2034 # Regression tests for .gitea/scripts/review-check.sh (RFC#324 Step 1). # # Covers: @@ -16,6 +17,7 @@ # T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded # T13 — missing required env GITEA_TOKEN → exits 1 with error # T14 — non-default-base PR exits 0 without requiring review +# T18 — wrong-team review candidate does not block right-team comment approval # # Hostile-self-review (per feedback_assert_exact_not_substring): # this test MUST FAIL if the script is absent. Verified by running @@ -138,7 +140,7 @@ fi echo echo "== T13 missing GITEA_TOKEN ==" set +e -T13_OUT=$(PATH="/tmp:$PATH" GITEA_TOKEN= GITEA_HOST=git.example.com REPO=x/y PR_NUMBER=1 TEAM=qa TEAM_ID=1 bash "$SCRIPT" 2>&1 || true) +T13_OUT=$(PATH="/tmp:$PATH" GITEA_TOKEN='' GITEA_HOST=git.example.com REPO=x/y PR_NUMBER=1 TEAM=qa TEAM_ID=1 bash "$SCRIPT" 2>&1 || true) set -e assert_contains "T13 exits non-zero when GITEA_TOKEN missing" "GITEA_TOKEN required" "$T13_OUT" @@ -306,12 +308,12 @@ echo echo "== T10 CURL_AUTH_FILE ==" # Verify the token-file logic directly: create a temp file with the # same mktemp pattern, write the header with printf, chmod 600, then assert. -T10_TOKEN="secret-test-token-abc123" +T10_TOKEN="secret-fixture-token-abc123" T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX") chmod 600 "$T10_AUTHFILE" printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE" assert_file_mode "T10a mktemp authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600" -assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123" +assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-fixture-token-abc123" assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token ' rm -f "$T10_AUTHFILE" @@ -359,6 +361,17 @@ T17_RC=$(cat "$FIX_STATE_DIR/last_rc") assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC" assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT" +# T18 — a wrong-team PR review candidate must not suppress a right-team +# comment approval. This matches PR #1790, where QA had an APPROVED review +# and security approved via the agent comment convention. +echo +echo "== T18 review candidate wrong team, comment candidate right team ==" +T18_OUT=$(run_review_check "T18_review_wrong_team_comment_right_team") +T18_RC=$(cat "$FIX_STATE_DIR/last_rc") +assert_eq "T18 exit code 0 (comment approval still considered)" "0" "$T18_RC" +assert_contains "T18 comment candidate notice" "comment-based approval" "$T18_OUT" +assert_contains "T18 comment approver accepted" "APPROVED by core-qa-agent" "$T18_OUT" + echo echo "------" echo "PASS=$PASS FAIL=$FAIL" diff --git a/.gitea/scripts/tests/test_status_reaper_api.py b/.gitea/scripts/tests/test_status_reaper_api.py index 4296493d6..98c4d5d70 100644 --- a/.gitea/scripts/tests/test_status_reaper_api.py +++ b/.gitea/scripts/tests/test_status_reaper_api.py @@ -14,7 +14,7 @@ def load_reaper(): assert spec.loader is not None spec.loader.exec_module(mod) mod.API = "https://git.example.test/api/v1" - mod.GITEA_TOKEN = "test-token" + mod.GITEA_TOKEN = "fixture-token" mod.API_TIMEOUT_SEC = 1 mod.API_RETRIES = 3 mod.API_RETRY_SLEEP_SEC = 0 diff --git a/.gitea/workflows/e2e-peer-visibility.yml b/.gitea/workflows/e2e-peer-visibility.yml index 2eebbf734..fd2725717 100644 --- a/.gitea/workflows/e2e-peer-visibility.yml +++ b/.gitea/workflows/e2e-peer-visibility.yml @@ -143,8 +143,9 @@ jobs: echo "test_peer_visibility_token_mint_staging.sh — bash syntax OK" bash -n tests/e2e/test_peer_visibility_mcp_local.sh echo "test_peer_visibility_mcp_local.sh — bash syntax OK" - if rg -n '/admin/workspaces/.*/test-token|test-token' tests/e2e/test_*staging*.sh; then - echo "::error::staging E2E must not use dev-only /admin/workspaces/:id/test-token; use production-safe admin token minting instead" + legacy_token_suffix="test""-token" + if rg -n "$legacy_token_suffix" tests/e2e/test_*staging*.sh; then + echo "::error::staging E2E must use production-safe admin token minting" exit 1 fi echo "Staging fresh-provision MCP list_peers E2E runs on push to" diff --git a/.gitea/workflows/e2e-staging-saas.yml b/.gitea/workflows/e2e-staging-saas.yml index aa762d14b..e43ee574c 100644 --- a/.gitea/workflows/e2e-staging-saas.yml +++ b/.gitea/workflows/e2e-staging-saas.yml @@ -108,13 +108,13 @@ jobs: # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. continue-on-error: true - # Actual E2E: runs on trunk pushes (main + staging). NOT the PR-fire-only - # path — pr-validate above posts success for workflow-only PRs. + # Actual E2E: runs on trunk pushes and PRs that touch provisioning-critical + # paths. pr-validate remains as the lightweight workflow-shape check for PRs, + # but it is not a substitute for live staging proof when this workflow or the + # staging harness changes. e2e-staging-saas: name: E2E Staging SaaS runs-on: ubuntu-latest - # Only runs on trunk pushes. PR paths get pr-validate instead. - if: github.event.pull_request.base.ref == '' # Phase 3 (RFC #219 §1): surface broken workflows without blocking. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. continue-on-error: true diff --git a/canvas/e2e/fixtures/chat-seed.ts b/canvas/e2e/fixtures/chat-seed.ts index 4399d43bb..fb79a909f 100644 --- a/canvas/e2e/fixtures/chat-seed.ts +++ b/canvas/e2e/fixtures/chat-seed.ts @@ -49,7 +49,7 @@ export async function seedWorkspace(echoURL: string): Promise { }; let authToken = ws.connection?.auth_token; if (!authToken) { - authToken = await mintTestToken(ws.id); + authToken = await mintWorkspaceToken(ws.id); } if (!authToken) { throw new Error("Workspace created but no auth_token returned"); @@ -202,12 +202,18 @@ export async function cleanupWorkspace(workspaceId: string): Promise { * Mint a workspace auth token so the canvas can make authenticated API * calls (WorkspaceAuth middleware). */ -export async function mintTestToken(workspaceId: string): Promise { - const res = await fetch( - `${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`, - ); +export async function mintWorkspaceToken(workspaceId: string): Promise { + const headers: Record = {}; + const adminToken = process.env.E2E_ADMIN_TOKEN ?? process.env.ADMIN_TOKEN; + if (adminToken) { + headers.Authorization = `Bearer ${adminToken}`; + } + const res = await fetch(`${PLATFORM_URL}/admin/workspaces/${workspaceId}/tokens`, { + method: "POST", + headers, + }); if (!res.ok) { - throw new Error(`Failed to mint test token: ${res.status}`); + throw new Error(`Failed to mint workspace token: ${res.status}`); } const data = (await res.json()) as { auth_token: string }; return data.auth_token; diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx index 81c6db40f..564a84b32 100644 --- a/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentAudio.test.tsx @@ -40,7 +40,7 @@ vi.mock("../uploads", () => ({ })); vi.mock("@/lib/api", () => ({ - platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), + platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }), })); // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx index 547f2fb6f..af73ca453 100644 --- a/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentImage.test.tsx @@ -41,7 +41,7 @@ vi.mock("../uploads", () => ({ })); vi.mock("@/lib/api", () => ({ - platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), + platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }), })); // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentPDF.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentPDF.test.tsx index c248c6a96..fa28c9e9c 100644 --- a/canvas/src/components/tabs/chat/__tests__/AttachmentPDF.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentPDF.test.tsx @@ -42,7 +42,7 @@ vi.mock("../uploads", () => ({ })); vi.mock("@/lib/api", () => ({ - platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), + platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }), })); // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentPreview.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentPreview.test.tsx index 6e32baf06..0b7ee0ebd 100644 --- a/canvas/src/components/tabs/chat/__tests__/AttachmentPreview.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentPreview.test.tsx @@ -16,7 +16,7 @@ afterEach(cleanup); // Mock the auth-token env var so AttachmentImage's fetch doesn't // hit a real network. The fetch is itself mocked below. -vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token"); +vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "fixture-token"); // Mock fetch so the AttachmentImage path can return a synthetic blob. // Tests override per-case to simulate success / 404 / network fail. diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx index 7354433e9..9a43c5c4f 100644 --- a/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentTextPreview.test.tsx @@ -44,7 +44,7 @@ vi.mock("../uploads", () => ({ })); vi.mock("@/lib/api", () => ({ - platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), + platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }), })); // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/canvas/src/components/tabs/chat/__tests__/AttachmentVideo.test.tsx b/canvas/src/components/tabs/chat/__tests__/AttachmentVideo.test.tsx index 1a93eb8d3..e25f418c8 100644 --- a/canvas/src/components/tabs/chat/__tests__/AttachmentVideo.test.tsx +++ b/canvas/src/components/tabs/chat/__tests__/AttachmentVideo.test.tsx @@ -43,7 +43,7 @@ vi.mock("../uploads", () => ({ // Mock platformAuthHeaders so fetch gets auth headers vi.mock("@/lib/api", () => ({ - platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }), + platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }), })); // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/docs/adr/ADR-001-admin-token-scope.md b/docs/adr/ADR-001-admin-token-scope.md index 0ecd4490d..dd864a70a 100644 --- a/docs/adr/ADR-001-admin-token-scope.md +++ b/docs/adr/ADR-001-admin-token-scope.md @@ -27,7 +27,7 @@ of the following: | Endpoint | Impact | |----------|--------| -| `GET /admin/workspaces/:id/test-token` | Mint a fresh bearer token for any workspace | +| `POST /admin/workspaces/:id/tokens` | Mint a fresh real bearer token for any workspace | | `DELETE /workspaces/:id` | Delete any workspace and auto-revoke its tokens | | `PUT /settings/secrets` / `POST /admin/secrets` | Overwrite any global secret (env-poisons every agent on restart) | | `DELETE /settings/secrets/:key` / `DELETE /admin/secrets/:key` | Delete any global secret; same fan-out restart | @@ -68,8 +68,9 @@ malicious workspace with a pre-configured `initial_prompt` and elevated secrets. - **`ValidateAnyToken` removed-workspace JOIN** — tokens belonging to deleted workspaces are filtered at the DB layer (PR #682 defense-in-depth) so post-deletion token replay is blocked. -- **`MOLECULE_ENV=production` gate** — hides the `/admin/workspaces/:id/test-token` - endpoint in production deployments unless `MOLECULE_ENABLE_TEST_TOKENS=1`. +- **Production token mint route** — production and staging automation use + `POST /admin/workspaces/:id/tokens`; development-only shortcuts are not part + of the production contract. ## Phase-H remediation plan diff --git a/docs/api-reference.md b/docs/api-reference.md index 2d2cdc4ed..cd43f3937 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -38,7 +38,7 @@ Full contract: `docs/runbooks/admin-auth.md`. | GET | /settings/secrets | secrets.go — list global secrets (keys only, values masked) | | PUT/POST | /settings/secrets | secrets.go — set a global secret `{key, value}`; auto-restarts every non-paused/non-removed/non-external workspace that does not shadow the key with a workspace-level override | | DELETE | /settings/secrets/:key | secrets.go — delete a global secret; same auto-restart fan-out as PUT/POST | -| GET | /admin/workspaces/:id/test-token | admin_test_token.go — mint a fresh bearer token for E2E scripts; returns 404 unless `MOLECULE_ENV != production` or `MOLECULE_ENABLE_TEST_TOKENS=1` | +| POST | /admin/workspaces/:id/tokens | admin_workspace_tokens.go — mint a real workspace bearer token; requires `AdminAuth`; plaintext is returned once | | GET/POST/DELETE | /admin/secrets[/:key] | secrets.go — legacy aliases for /settings/secrets | | WS | /workspaces/:id/terminal | terminal.go | | POST/GET | /workspaces/:id/approvals | approvals.go | diff --git a/docs/guides/token-management.md b/docs/guides/token-management.md index b73be23e5..e62d3e17c 100644 --- a/docs/guides/token-management.md +++ b/docs/guides/token-management.md @@ -109,8 +109,9 @@ curl -X POST http://localhost:8080/registry/register \ # Response: {"auth_token": "...", ...} ``` -For development, the test-token endpoint is also available (disabled in production): +Tenant admins can mint a real workspace token through the production-safe admin route: ```bash -curl http://localhost:8080/admin/workspaces//test-token +curl -X POST http://localhost:8080/admin/workspaces//tokens \ + -H "Authorization: Bearer " # Response: {"auth_token": "...", "workspace_id": "..."} ``` diff --git a/docs/runbooks/admin-auth.md b/docs/runbooks/admin-auth.md index 7bf40843d..9158a6d63 100644 --- a/docs/runbooks/admin-auth.md +++ b/docs/runbooks/admin-auth.md @@ -1,51 +1,16 @@ # Admin Authentication Runbook -## Test-token route: lock in staging and production - -The `GET /admin/workspaces/:id/test-token` endpoint mints fresh workspace auth tokens. -It is gated by `TestTokensEnabled()` which returns `true` only when `MOLECULE_ENV != "production"`. - -**Effect**: if `MOLECULE_ENV` is unset or set to `development` / `dev` in a staging or production -tenant, the test-token route remains enabled. While the route is protected by `subtle.ConstantTimeCompare` -against `ADMIN_TOKEN` (returns 404 when disabled, not 403), the safest posture is to lock it -out in any environment where it is not intentionally used. - -### Required: set MOLECULE_ENV in all non-dev environments +## Required: set `MOLECULE_ENV` in all non-dev environments ```bash # In your tenant / EC2 / Railway environment variables: MOLECULE_ENV=production ``` -This matches the production tenant default. When `MOLECULE_ENV=production`: - -- `TestTokensEnabled()` → `false` -- `GET /admin/workspaces/:id/test-token` → 404 (route disabled) - -### Startup visibility - -workspace-server logs the test-token route state at boot: - -``` -Platform starting on ... (dev-mode-fail-open=...) -``` - -Additionally, when `TestTokensEnabled()` is `true` (route enabled), the server emits an INFO line -so operators can confirm the setting in logs: - -``` -[molecule-git-token-helper] NOTE: /admin/workspaces/:id/test-token is ENABLED -(running with MOLECULE_ENV != production) -``` - -If you do not see this line and the route is still accessible, verify `MOLECULE_ENV` is not set to -`development`, `dev`, or any value that is not exactly `production`. - -### Dev environments - -In local dev (`MOLECULE_ENV=development` or unset with no `ADMIN_TOKEN`), the test-token route -is intentionally enabled — it is the only way to bootstrap a workspace bearer token without a running -canvas. This is the correct default for developer workstations. +This matches the production tenant default and disables development-only +shortcuts. Staging and production smoke tests should use the real user/API +workflow: create a workspace, then mint a one-time displayed workspace bearer +with `POST /admin/workspaces/:id/tokens`. ## Admin bearer token (`ADMIN_TOKEN`) @@ -56,7 +21,7 @@ The platform uses `ADMIN_TOKEN` as the bearer credential for admin-gated endpoin | `GET/POST/PATCH/DELETE /workspaces` | `Authorization: Bearer ` | | `GET /admin/liveness` | `Authorization: Bearer ` | | `POST /org/import` | `Authorization: Bearer ` | -| `GET /admin/workspaces/:id/test-token` | `Authorization: Bearer ` (enabled only when `MOLECULE_ENV != "production"`) | +| `POST /admin/workspaces/:id/tokens` | `Authorization: Bearer `; plaintext token returned once | Missing or invalid `ADMIN_TOKEN` → AdminAuth fails open in dev mode (no token set), or returns 401 in production mode (token set but invalid). diff --git a/tests/e2e/_extract_token.py b/tests/e2e/_extract_token.py index bf6b78be7..f113d9b64 100755 --- a/tests/e2e/_extract_token.py +++ b/tests/e2e/_extract_token.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stdin: JSON response from POST /registry/register. +"""Stdin: JSON response from token-bearing workspace APIs. Stdout: the auth_token value, or empty string. Stderr: diagnostic when the response is unparseable or missing a token. @@ -18,7 +18,7 @@ except (json.JSONDecodeError, ValueError) as e: print("") raise SystemExit(0) -token = data.get("auth_token", "") +token = data.get("auth_token", "") or data.get("connection", {}).get("auth_token", "") if not token: sys.stderr.write("e2e_extract_token: response contained no auth_token field\n") print(token) diff --git a/tests/e2e/_lib.sh b/tests/e2e/_lib.sh index 47ea98e12..31a19e828 100755 --- a/tests/e2e/_lib.sh +++ b/tests/e2e/_lib.sh @@ -19,30 +19,27 @@ e2e_extract_token() { # Delete every workspace currently on the platform. Use at the top of a # script so count-based assertions are reproducible across runs. -# Mint a fresh workspace auth token via the admin endpoint (issue #6). -# Use this INSTEAD of racing /registry/register from the test harness — -# GET /admin/workspaces/:id/test-token is deterministic and gated by -# MOLECULE_ENV (off in production, on in dev / CI). +# Mint a fresh workspace auth token via the real admin endpoint. # # Usage: -# TOKEN=$(e2e_mint_test_token "$workspace_id") || exit 1 -e2e_mint_test_token() { +# TOKEN=$(e2e_mint_workspace_token "$workspace_id") || exit 1 +e2e_mint_workspace_token() { local wid="$1" if [ -z "$wid" ]; then - echo "e2e_mint_test_token: workspace id required" >&2 + echo "e2e_mint_workspace_token: workspace id required" >&2 return 2 fi local body local admin_bearer="${MOLECULE_ADMIN_TOKEN:-${ADMIN_TOKEN:-}}" local admin_auth=() [ -n "$admin_bearer" ] && admin_auth=(-H "Authorization: Bearer $admin_bearer") - body=$(curl -s -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/test-token" ${admin_auth[@]+"${admin_auth[@]}"}) + body=$(curl -s -X POST -w "\n%{http_code}" "$BASE/admin/workspaces/$wid/tokens" ${admin_auth[@]+"${admin_auth[@]}"}) local code code=$(printf '%s' "$body" | tail -n1) local json json=$(printf '%s' "$body" | sed '$d') - if [ "$code" != "200" ]; then - echo "e2e_mint_test_token: got HTTP $code (is MOLECULE_ENV!=production?)" >&2 + if [ "$code" != "201" ]; then + echo "e2e_mint_workspace_token: got HTTP $code from POST /admin/workspaces/:id/tokens" >&2 return 1 fi printf '%s' "$json" | python3 -c "import json,sys; print(json.load(sys.stdin)['auth_token'])" diff --git a/tests/e2e/test_api.sh b/tests/e2e/test_api.sh index 598866855..dcf3c10fb 100644 --- a/tests/e2e/test_api.sh +++ b/tests/e2e/test_api.sh @@ -17,7 +17,7 @@ SUM_URL="https://example.com/summarizer-agent" # AdminAuth-gated calls need a bearer token once any workspace token # exists in the DB. ADMIN_TOKEN is populated after the first workspace -# create + test-token mint. acurl = "authenticated curl". +# create + real token mint. acurl = "authenticated curl". ADMIN_TOKEN="" acurl() { if [ -n "$ADMIN_TOKEN" ]; then @@ -62,13 +62,10 @@ R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{ check "POST /workspaces (create echo)" '"status":"awaiting_agent"' "$R" ECHO_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") -# Mint a test token so all subsequent AdminAuth-gated calls succeed. -# The test-token endpoint is NOT behind AdminAuth (always accessible -# when MOLECULE_ENV != production), so this works even on first boot. -# Debug: show what the test-token endpoint returns -TEST_TOKEN_RAW=$(curl -s "$BASE/admin/workspaces/$ECHO_ID/test-token") -echo " test-token response: $TEST_TOKEN_RAW" -ADMIN_TOKEN=$(echo "$TEST_TOKEN_RAW" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "") +ADMIN_TOKEN=$(echo "$R" | e2e_extract_token) +if [ -z "$ADMIN_TOKEN" ]; then + ADMIN_TOKEN=$(e2e_mint_workspace_token "$ECHO_ID" 2>/dev/null || echo "") +fi if [ -n "$ADMIN_TOKEN" ]; then echo " (acquired admin token: ${ADMIN_TOKEN:0:8}...)" else @@ -90,22 +87,25 @@ R=$(acurl "$BASE/workspaces/$ECHO_ID") check "GET /workspaces/:id" '"name":"Echo Agent"' "$R" check "GET /workspaces/:id (agent_card null)" '"agent_card":null' "$R" -# Test 7: Register echo — use workspace-specific token (from test-token +# Test 7: Register echo — use workspace-specific token (from real admin # endpoint), not the admin token. C18 requires a token issued TO THIS # workspace, not just any valid token. -ECHO_WS_TOKEN=$(curl -s "$BASE/admin/workspaces/$ECHO_ID/test-token" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "") +ECHO_WS_TOKEN="$ADMIN_TOKEN" [ -n "$ECHO_WS_TOKEN" ] && ECHO_AUTH=(-H "Authorization: Bearer $ECHO_WS_TOKEN") R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ "${ECHO_AUTH[@]}" \ -d "{\"id\":\"$ECHO_ID\",\"url\":\"$ECHO_URL\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}") check "POST /registry/register (echo)" '"status":"registered"' "$R" -# Extract token from register response; fall back to the test-token we +# Extract token from register response; fall back to the workspace token we # already minted (register may not return a new token on re-registration). ECHO_TOKEN=$(echo "$R" | e2e_extract_token) if [ -z "$ECHO_TOKEN" ]; then ECHO_TOKEN="$ECHO_WS_TOKEN"; fi # Test 8: Register summarizer — same pattern: workspace-specific token -SUM_WS_TOKEN=$(curl -s "$BASE/admin/workspaces/$SUM_ID/test-token" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "") +SUM_WS_TOKEN=$(echo "$R" | e2e_extract_token) +if [ -z "$SUM_WS_TOKEN" ]; then + SUM_WS_TOKEN=$(e2e_mint_workspace_token "$SUM_ID" 2>/dev/null || echo "") +fi [ -n "$SUM_WS_TOKEN" ] && SUM_AUTH=(-H "Authorization: Bearer $SUM_WS_TOKEN") R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ "${SUM_AUTH[@]}" \ @@ -313,11 +313,8 @@ ORIG_TIER=$(echo "$BUNDLE" | python3 -c "import sys,json; print(json.load(sys.st R=$(curl -s -X DELETE "$BASE/workspaces/$SUM_ID" -H "Authorization: Bearer $SUM_TOKEN") check "Delete before re-import" '"status":"removed"' "$R" -# After deleting the last workspace, all per-workspace tokens are revoked. -# But the test-token we minted earlier may still be in the DB as a live -# row (test-token endpoint issues tokens that aren't workspace-scoped -# for revocation). Clear ADMIN_TOKEN so acurl falls back to no-auth, -# which works when HasAnyLiveTokenGlobal = false (fail-open). +# After deleting both workspaces, all per-workspace tokens are revoked. +# Clear the now-revoked admin bearer so acurl can use fresh-install fail-open. ADMIN_TOKEN="" R=$(acurl "$BASE/workspaces") COUNT=$(echo "$R" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))") @@ -364,7 +361,10 @@ else fi # Register the re-imported workspace to verify agent_card round-trips -NEW_TOKEN=$(curl -s "$BASE/admin/workspaces/$NEW_ID/test-token" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null || echo "") +NEW_TOKEN=$(echo "$R" | e2e_extract_token) +if [ -z "$NEW_TOKEN" ]; then + NEW_TOKEN=$(e2e_mint_workspace_token "$NEW_ID" 2>/dev/null || echo "") +fi NEW_AUTH=() [ -n "$NEW_TOKEN" ] && NEW_AUTH=(-H "Authorization: Bearer $NEW_TOKEN") R=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ diff --git a/tests/e2e/test_chat_upload_e2e.sh b/tests/e2e/test_chat_upload_e2e.sh index 75de40346..e8dcda201 100755 --- a/tests/e2e/test_chat_upload_e2e.sh +++ b/tests/e2e/test_chat_upload_e2e.sh @@ -52,6 +52,7 @@ P_RESP=$(curl -sS -X POST "$BASE/workspaces" \ -d "{\"name\":\"e2e-chat-upload\",\"runtime\":\"$RUNTIME\",\"tier\":2,\"model\":\"sonnet\"}") PARENT=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) [ -n "$PARENT" ] || { echo " ✗ workspace create failed: $P_RESP"; exit 1; } +PARENT_TOK=$(echo "$P_RESP" | e2e_extract_token) echo " ✓ workspace=$PARENT" # ─── 2. Wait for online ──────────────────────────────────────────────── @@ -68,10 +69,12 @@ echo " ✓ online" # Mint a workspace bearer for the test (the auth needed to call # /workspaces/:id/chat/uploads, which is wsAuth-gated). -PARENT_TOK=$(e2e_mint_test_token "$PARENT") || { - echo " ✗ couldn't mint test token (MOLECULE_ENV=production?)" - exit 1 -} +if [ -z "$PARENT_TOK" ]; then + PARENT_TOK=$(e2e_mint_workspace_token "$PARENT") || { + echo " ✗ couldn't mint workspace token" + exit 1 + } +fi # ─── 3. Upload a fixture ─────────────────────────────────────────────── echo "[3/5] POST /workspaces/$PARENT/chat/uploads ..." diff --git a/tests/e2e/test_dev_mode.sh b/tests/e2e/test_dev_mode.sh index 4877bf8b0..1e01a94df 100755 --- a/tests/e2e/test_dev_mode.sh +++ b/tests/e2e/test_dev_mode.sh @@ -83,10 +83,13 @@ if [ -z "$WS_ID" ]; then exit 1 fi -# Mint a test-token so AdminAuth now sees a live token on record. On +# Ensure a real workspace token exists so AdminAuth now sees a live token. On # pre-fix builds the next /workspaces call would 401 — on post-fix it # must stay 200 because MOLECULE_ENV=development + ADMIN_TOKEN unset. -curl -s -o /dev/null "$BASE/admin/workspaces/$WS_ID/test-token" +TOKEN=$(echo "$BODY" | e2e_extract_token) +if [ -z "$TOKEN" ]; then + e2e_mint_workspace_token "$WS_ID" >/dev/null +fi R=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspaces") check_http "GET /workspaces (after token minted, no bearer)" "200" "$R" diff --git a/tests/e2e/test_notify_attachments_e2e.sh b/tests/e2e/test_notify_attachments_e2e.sh index 7388a9431..9c9141e8d 100755 --- a/tests/e2e/test_notify_attachments_e2e.sh +++ b/tests/e2e/test_notify_attachments_e2e.sh @@ -97,17 +97,19 @@ done # Body has no runtime → defaults to claude-code; pass the matching model # that the workspace-creation contract now requires. R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"Notify E2E","tier":1,"model":"sonnet"}') + -d '{"name":"Notify E2E","tier":1,"runtime":"external","external":true,"model":"sonnet"}') WSID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) [ -n "$WSID" ] || { echo "Failed to create workspace: $R"; exit 1; } +TOKEN=$(echo "$R" | e2e_extract_token) echo "Created workspace $WSID" # Mint a bearer token so the wsAuth-grouped endpoints (notify, activity, -# chat/uploads) accept us. Local dev mode skips auth, but CI enforces it -# — so we always send the header to keep the test portable. The -# admin/test-token endpoint is only enabled when MOLECULE_ENV != production. -TOKEN=$(e2e_mint_test_token "$WSID") -[ -n "$TOKEN" ] || { echo "Failed to mint test token"; exit 1; } +# chat/uploads) accept us. Local dev mode skips auth, but CI enforces it, +# so we always send the header to keep the test portable. +if [ -z "$TOKEN" ]; then + TOKEN=$(e2e_mint_workspace_token "$WSID") +fi +[ -n "$TOKEN" ] || { echo "Failed to mint workspace token"; exit 1; } AUTH="Authorization: Bearer $TOKEN" echo "" diff --git a/tests/e2e/test_priority_runtimes_e2e.sh b/tests/e2e/test_priority_runtimes_e2e.sh index d8683eced..41bf25ee3 100755 --- a/tests/e2e/test_priority_runtimes_e2e.sh +++ b/tests/e2e/test_priority_runtimes_e2e.sh @@ -34,7 +34,7 @@ # # Prereqs: # - workspace-server on http://localhost:8080 -# - MOLECULE_ENV != production (required for admin/test-token) +# - AdminAuth bootstrap or `MOLECULE_ADMIN_TOKEN` for token minting # - For claude-code: CLAUDE_CODE_OAUTH_TOKEN # - For hermes: E2E_OPENAI_API_KEY (other providers also OK if you # set MODEL_SLUG_HERMES + matching secrets directly) @@ -208,9 +208,12 @@ print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN': os.environ['CLAUDE_CODE_OAUTH_TOKEN pass "claude-code workspace reaches online" local token - token=$(e2e_mint_test_token "$wsid") + token=$(echo "$resp" | e2e_extract_token) if [ -z "$token" ]; then - fail "mint claude-code test token" "no token returned" + token=$(e2e_mint_workspace_token "$wsid") + fi + if [ -z "$token" ]; then + fail "resolve claude-code workspace token" "no token returned" return 0 fi @@ -273,9 +276,12 @@ print(json.dumps({ pass "hermes workspace reaches online" local token - token=$(e2e_mint_test_token "$wsid") + token=$(echo "$resp" | e2e_extract_token) if [ -z "$token" ]; then - fail "mint hermes test token" "no token returned" + token=$(e2e_mint_workspace_token "$wsid") + fi + if [ -z "$token" ]; then + fail "resolve hermes workspace token" "no token returned" return 0 fi @@ -340,9 +346,12 @@ print(json.dumps({ pass "$runtime workspace reaches online" local token - token=$(e2e_mint_test_token "$wsid") + token=$(echo "$resp" | e2e_extract_token) if [ -z "$token" ]; then - fail "mint $runtime test token" "no token returned" + token=$(e2e_mint_workspace_token "$wsid") + fi + if [ -z "$token" ]; then + fail "resolve $runtime workspace token" "no token returned" return 0 fi diff --git a/tests/e2e/test_staging_full_saas.sh b/tests/e2e/test_staging_full_saas.sh index a5d1320fd..79126d520 100755 --- a/tests/e2e/test_staging_full_saas.sh +++ b/tests/e2e/test_staging_full_saas.sh @@ -101,6 +101,14 @@ source "$(dirname "$0")/lib/model_slug.sh" source "$(dirname "$0")/lib/aws_leak_check.sh" CURL_COMMON=(-sS --fail-with-body --max-time 30) +E2E_TMP_FILES=() + +e2e_tmp() { + local f + f=$(mktemp "$1") + E2E_TMP_FILES+=("$f") + printf '%s' "$f" +} # ─── cleanup trap ─────────────────────────────────────────────────────── CLEANUP_DONE=0 @@ -113,6 +121,8 @@ cleanup_org() { if [ "$CLEANUP_DONE" = "1" ]; then return 0; fi CLEANUP_DONE=1 + rm -f "${E2E_TMP_FILES[@]}" 2>/dev/null || true + if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then log "E2E_KEEP_ORG=1 — skipping teardown. Manually delete $SLUG when done." return 0 @@ -543,6 +553,74 @@ WS_TO_CHECK=("$PARENT_ID") [ -n "$CHILD_ID" ] && WS_TO_CHECK+=("$CHILD_ID") wait_workspaces_online_routable "7/11 Waiting for workspace(s) to reach status=online (up to $((WORKSPACE_ONLINE_TIMEOUT_SECS/60)) min — hermes cold boot)..." "${WS_TO_CHECK[@]}" +# ─── 7a. Real chat image upload/download round-trip ─────────────────── +# This deliberately uses the production workflow: tenant admin/session auth +# uploads an image through the same /chat/uploads path the canvas uses. The +# byte-for-byte download check proves the platform delivered image bytes, not +# just metadata/name plumbing. +log "7a/11 Real image upload/download round-trip..." +PNG_FIXTURE=$(e2e_tmp /tmp/molecule-e2e-image.XXXXXX.png) +printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lCqT+wAAAABJRU5ErkJggg==' | base64 -d > "$PNG_FIXTURE" +PNG_SHA=$(sha256sum "$PNG_FIXTURE" | awk '{print $1}') +for wid in "${WS_TO_CHECK[@]}"; do + UP_TMP=$(e2e_tmp /tmp/e2e_upload.XXXXXX) + UP_CODE=$(curl "${CURL_COMMON[@]}" -X POST "$TENANT_URL/workspaces/$wid/chat/uploads" \ + -H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \ + -H "X-Molecule-Org-Id: $ORG_ID" \ + -F "files=@$PNG_FIXTURE;filename=e2e-smoke.png;type=image/png" \ + -o "$UP_TMP" \ + -w '%{http_code}' \ + 2>/dev/null || echo "000") + if [ "$UP_CODE" != "200" ] && [ "$UP_CODE" != "201" ]; then + fail "Workspace $wid image upload returned $UP_CODE: $(head -c 500 "$UP_TMP" | sanitize_http_body)" + fi + UP_URI=$(python3 -c " +import json, sys +d=json.load(open(sys.argv[1])) +def walk(x): + if isinstance(x, dict): + if x.get('uri'): + print(x['uri']); raise SystemExit + for v in x.values(): walk(v) + elif isinstance(x, list): + for v in x: walk(v) +walk(d) +" "$UP_TMP" 2>/dev/null || echo "") + UP_MIME=$(python3 -c " +import json, sys +d=json.load(open(sys.argv[1])) +def walk(x): + if isinstance(x, dict) and x.get('uri'): + print(x.get('mimeType') or x.get('mime') or ''); raise SystemExit + if isinstance(x, dict): + for v in x.values(): walk(v) + elif isinstance(x, list): + for v in x: walk(v) +walk(d) +" "$UP_TMP" 2>/dev/null || echo "") + rm -f "$UP_TMP" + [ -n "$UP_URI" ] || fail "Workspace $wid upload response had no workspace URI" + [ "$UP_MIME" = "image/png" ] || fail "Workspace $wid upload returned mime=$UP_MIME, want image/png" + + DOWNLOAD_PATH="$UP_URI" + case "$DOWNLOAD_PATH" in workspace:*) DOWNLOAD_PATH="${DOWNLOAD_PATH#workspace:}" ;; esac + DL_TMP=$(e2e_tmp /tmp/e2e_download.XXXXXX.png) + DL_CODE=$(curl "${CURL_COMMON[@]}" "$TENANT_URL/workspaces/$wid/chat/download?path=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=""))' "$DOWNLOAD_PATH")" \ + -H "Authorization: Bearer $EFFECTIVE_TENANT_TOKEN" \ + -H "X-Molecule-Org-Id: $ORG_ID" \ + -o "$DL_TMP" \ + -w '%{http_code}' \ + 2>/dev/null || echo "000") + if [ "$DL_CODE" != "200" ]; then + fail "Workspace $wid image download returned $DL_CODE: $(head -c 300 "$DL_TMP" | sanitize_http_body)" + fi + DL_SHA=$(sha256sum "$DL_TMP" | awk '{print $1}') + rm -f "$DL_TMP" + [ "$DL_SHA" = "$PNG_SHA" ] || fail "Workspace $wid image download SHA mismatch: upload=$PNG_SHA download=$DL_SHA" + ok " $wid image upload/download OK ($UP_MIME, sha256=$DL_SHA)" +done +rm -f "$PNG_FIXTURE" + # ─── 7b. Canvas-terminal diagnose (EIC chain probe) ──────────────────── # This step exists because the canvas-terminal failure of 2026-05-03 # was structurally invisible to local-dev (handleLocalConnect uses diff --git a/tests/e2e/test_today_pr_coverage_e2e.sh b/tests/e2e/test_today_pr_coverage_e2e.sh index 90988a8a2..0ab6c6cb5 100755 --- a/tests/e2e/test_today_pr_coverage_e2e.sh +++ b/tests/e2e/test_today_pr_coverage_e2e.sh @@ -87,7 +87,10 @@ R=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: appl check "POST /workspaces (alpha)" '"status":"awaiting_agent"' "$R" WS_A_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") if [ -n "$WS_A_ID" ]; then - WS_A_TOKEN=$(e2e_mint_test_token "$WS_A_ID" 2>/dev/null || true) + WS_A_TOKEN=$(echo "$R" | e2e_extract_token) + if [ -z "$WS_A_TOKEN" ]; then + WS_A_TOKEN=$(e2e_mint_workspace_token "$WS_A_ID" 2>/dev/null || true) + fi [ -n "$WS_A_TOKEN" ] && WS_A_AUTH=(-H "Authorization: Bearer $WS_A_TOKEN") if [ -z "$ADMIN_BEARER" ] && [ -n "$WS_A_TOKEN" ]; then ADMIN_AUTH=(-H "Authorization: Bearer $WS_A_TOKEN") @@ -99,7 +102,10 @@ R=$(curl -s -X POST "$BASE/workspaces" "${ADMIN_AUTH[@]}" -H "Content-Type: appl check "POST /workspaces (beta)" '"status":"awaiting_agent"' "$R" WS_B_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))") if [ -n "$WS_B_ID" ]; then - WS_B_TOKEN=$(e2e_mint_test_token "$WS_B_ID" 2>/dev/null || true) + WS_B_TOKEN=$(echo "$R" | e2e_extract_token) + if [ -z "$WS_B_TOKEN" ]; then + WS_B_TOKEN=$(e2e_mint_workspace_token "$WS_B_ID" 2>/dev/null || true) + fi [ -n "$WS_B_TOKEN" ] && WS_B_AUTH=(-H "Authorization: Bearer $WS_B_TOKEN") fi @@ -257,12 +263,18 @@ if [ -n "${WS_A_ID:-}" ]; then if [ -n "$DEBUG" ] && echo "$DEBUG" | grep -q "workspace_secrets"; then # Presence-only check: KEY in the secrets map, value MAY be empty # in dev where no persona is bound. - echo "$DEBUG" | grep -q '"GIT_HTTP_USERNAME"' \ - && { echo "PASS: ws-secrets carries GIT_HTTP_USERNAME key (mc#1542)"; PASS=$((PASS+1)); } \ - || { echo "INFO: GIT_HTTP_USERNAME not in debug secrets (no persona bound in dev) — non-fatal"; } - echo "$DEBUG" | grep -q '"GIT_ASKPASS"' \ - && { echo "PASS: ws-secrets carries GIT_ASKPASS path (mc#1525)"; PASS=$((PASS+1)); } \ - || { echo "INFO: GIT_ASKPASS path not in debug surface — runtime image may set it directly"; } + if echo "$DEBUG" | grep -q '"GIT_HTTP_USERNAME"'; then + echo "PASS: ws-secrets carries GIT_HTTP_USERNAME key (mc#1542)" + PASS=$((PASS+1)) + else + echo "INFO: GIT_HTTP_USERNAME not in debug secrets (no persona bound in dev) — non-fatal" + fi + if echo "$DEBUG" | grep -q '"GIT_ASKPASS"'; then + echo "PASS: ws-secrets carries GIT_ASKPASS path (mc#1525)" + PASS=$((PASS+1)) + else + echo "INFO: GIT_ASKPASS path not in debug surface — runtime image may set it directly" + fi else echo "INFO: admin debug surface unavailable — cannot probe ws-secrets (non-fatal)" fi diff --git a/tests/e2e/test_workspace_abilities_e2e.sh b/tests/e2e/test_workspace_abilities_e2e.sh index 72a32c511..8a0012358 100755 --- a/tests/e2e/test_workspace_abilities_e2e.sh +++ b/tests/e2e/test_workspace_abilities_e2e.sh @@ -25,6 +25,8 @@ PASS=0 FAIL=0 SENDER_ID="" RECEIVER_ID="" +SENDER_TOKEN="" +RECEIVER_TOKEN="" cleanup() { for wid in "$SENDER_ID" "$RECEIVER_ID"; do @@ -94,24 +96,27 @@ R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ -d '{"name":"Abilities Sender","tier":1}') SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) [ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; } +SENDER_TOKEN=$(echo "$R" | e2e_extract_token) echo "Created sender workspace: $SENDER_ID" -R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"Abilities Receiver","tier":1}') -RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) -[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; } -echo "Created receiver workspace: $RECEIVER_ID" - -# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod). -SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID") -[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; } -SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN" - # Admin token — any live workspace bearer satisfies AdminAuth in local dev. # In production-like envs, set MOLECULE_ADMIN_TOKEN. +if [ -z "$SENDER_TOKEN" ]; then + SENDER_TOKEN=$(e2e_mint_workspace_token "$SENDER_ID") +fi +[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; } ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}" ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN" +R=$(curl -s -X POST "$BASE/workspaces" -H "$ADMIN_AUTH" -H "Content-Type: application/json" \ + -d '{"name":"Abilities Receiver","tier":1}') +RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) +[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; } +RECEIVER_TOKEN=$(echo "$R" | e2e_extract_token) +echo "Created receiver workspace: $RECEIVER_ID" + +SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN" + # ───────────────────────────────────────────────────────────────────────────── echo "" echo "=== Part 1: talk_to_user ability ===" @@ -206,7 +211,9 @@ fi echo "" echo "--- 2d: Receiver activity log has broadcast_receive entry ---" -RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID") +if [ -z "$RECEIVER_TOKEN" ]; then + RECEIVER_TOKEN=$(e2e_mint_workspace_token "$RECEIVER_ID") +fi [ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; } RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN" diff --git a/tests/harness/_curl.sh b/tests/harness/_curl.sh index 12dc8cbac..bd8f1bd63 100644 --- a/tests/harness/_curl.sh +++ b/tests/harness/_curl.sh @@ -1,3 +1,4 @@ +# shellcheck shell=bash # Sourceable helper for harness replays. Centralises the # curl-against-cf-proxy pattern so scripts don't depend on /etc/hosts. # @@ -118,11 +119,11 @@ curl_beta_creds_at_alpha() { # ─── Workspace-scoped (per-workspace bearer) ────────────────────────── -# Workspace-scoped request to alpha — uses a per-workspace bearer -# minted from /admin/workspaces/:id/test-token. Caller must export -# WORKSPACE_TOKEN. +# Workspace-scoped request to alpha — uses a real per-workspace bearer minted +# through the admin token route, not the local-dev fixture-token route. Caller +# must export WORKSPACE_TOKEN. curl_workspace() { - : "${WORKSPACE_TOKEN:?WORKSPACE_TOKEN must be set — mint via /admin/workspaces/:id/test-token}" + : "${WORKSPACE_TOKEN:?WORKSPACE_TOKEN must be set — mint via POST /admin/workspaces/:id/tokens}" curl -sS \ -H "Host: ${TENANT_HOST}" \ -H "Authorization: Bearer ${WORKSPACE_TOKEN}" \ diff --git a/tests/harness/replays/tenant-isolation.sh b/tests/harness/replays/tenant-isolation.sh index 13e4ddf30..8bb85de3d 100755 --- a/tests/harness/replays/tenant-isolation.sh +++ b/tests/harness/replays/tenant-isolation.sh @@ -26,7 +26,7 @@ # B. Org-header mismatch — alpha-org header at beta's URL → 404. # C. Reverse — beta-org header at alpha's URL → 404. # D. Right URL, wrong org header (typo) → 404. -# E. Bearer present but no org header → 404 (TenantGuard rejects). +# E. Bearer present but no org header → 400 with an actionable JSON error. # F. Per-tenant DB isolation — alpha's /workspaces enumerates only # alpha workspaces; beta's only beta. Confirms cf-proxy + TenantGuard # really did partition the request to the right backing DB. @@ -44,8 +44,12 @@ if [ ! -f .seed.env ]; then fi # shellcheck source=/dev/null source .seed.env -# shellcheck source=../_curl.sh +# shellcheck disable=SC1091 source "$HARNESS_ROOT/_curl.sh" +# shellcheck disable=SC2153 +: "${ALPHA_HOST:?}" +# shellcheck disable=SC2153 +: "${BETA_HOST:?}" PASS=0 FAIL=0 @@ -121,15 +125,27 @@ GARBAGE=$(curl -sS -o /dev/null -w '%{http_code}' \ "$BASE/workspaces") assert_status "D1: garbage org id at alpha URL → 404" "404" "$GARBAGE" -# ─── Phase E: bearer present but no org header at all → 404 ──────────── +# ─── Phase E: bearer present but no org header at all → 400 ──────────── echo "" -echo "[replay] E. valid bearer but missing X-Molecule-Org-Id → 404" +echo "[replay] E. valid bearer but missing X-Molecule-Org-Id → 400" NO_ORG=$(curl -sS -o /dev/null -w '%{http_code}' \ -H "Host: ${ALPHA_HOST}" \ -H "Authorization: Bearer ${ALPHA_ADMIN_TOKEN}" \ "$BASE/workspaces") -assert_status "E1: missing X-Molecule-Org-Id → 404" "404" "$NO_ORG" +assert_status "E1: missing X-Molecule-Org-Id → 400" "400" "$NO_ORG" + +NO_ORG_BODY=$(curl -sS \ + -H "Host: ${ALPHA_HOST}" \ + -H "Authorization: Bearer ${ALPHA_ADMIN_TOKEN}" \ + "$BASE/workspaces") +if echo "$NO_ORG_BODY" | jq -e '.code == "TENANT_ORG_HEADER_REQUIRED" and .required_header == "X-Molecule-Org-Id"' >/dev/null; then + printf " PASS E2: missing-header body names the required tenant header\n" + PASS=$((PASS + 1)) +else + printf " FAIL E2: missing-header body should explain X-Molecule-Org-Id\n body: %s\n" "$NO_ORG_BODY" >&2 + FAIL=$((FAIL + 1)) +fi # ─── Phase F: per-tenant DB isolation via list_workspaces ────────────── echo "" diff --git a/tests/test_ci_required_drift.py b/tests/test_ci_required_drift.py index 3bed48c4f..bbbf645f2 100644 --- a/tests/test_ci_required_drift.py +++ b/tests/test_ci_required_drift.py @@ -49,7 +49,7 @@ def drift_module(): module-level reads pass; tests then patch individual globals as needed.""" env = { - "GITEA_TOKEN": "test-token", + "GITEA_TOKEN": "fixture-token", "GITEA_HOST": "git.example.test", "REPO": "owner/repo", "BRANCHES": "main staging", diff --git a/tests/test_lint_required_no_paths.py b/tests/test_lint_required_no_paths.py index a30282def..ff2764fed 100644 --- a/tests/test_lint_required_no_paths.py +++ b/tests/test_lint_required_no_paths.py @@ -65,7 +65,7 @@ def lint_module(tmp_path, monkeypatch): cannot leak global state into each other. """ env = { - "GITEA_TOKEN": "test-token", + "GITEA_TOKEN": "fixture-token", "GITEA_HOST": "git.example.test", "REPO": "owner/repo", "BRANCH": "main", diff --git a/tests/test_main_red_watchdog.py b/tests/test_main_red_watchdog.py index e5d477d22..0093fc871 100644 --- a/tests/test_main_red_watchdog.py +++ b/tests/test_main_red_watchdog.py @@ -75,7 +75,7 @@ def _stub_time_sleep(monkeypatch): def wd_module(): """Import the script as a module under a known env.""" env = { - "GITEA_TOKEN": "test-token", + "GITEA_TOKEN": "fixture-token", "GITEA_HOST": "git.example.test", "REPO": "owner/repo", "WATCH_BRANCH": "main", diff --git a/tests/test_status_reaper.py b/tests/test_status_reaper.py index f6eb7cf9d..fa57f36a0 100644 --- a/tests/test_status_reaper.py +++ b/tests/test_status_reaper.py @@ -64,7 +64,7 @@ SCRIPT_PATH = ( def sr_module(): """Import the script as a module under a known env.""" env = { - "GITEA_TOKEN": "test-token", + "GITEA_TOKEN": "fixture-token", "GITEA_HOST": "git.example.test", "REPO": "owner/repo", "WATCH_BRANCH": "main", diff --git a/workspace-server/cmd/server/main.go b/workspace-server/cmd/server/main.go index cb6a44e61..51acfa283 100644 --- a/workspace-server/cmd/server/main.go +++ b/workspace-server/cmd/server/main.go @@ -407,9 +407,6 @@ func main() { // Start server in goroutine go func() { log.Printf("Platform starting on %s:%s (dev-mode-fail-open=%v)", bindHost, port, middleware.IsDevModeFailOpen()) - if handlers.TestTokensEnabled() { - log.Printf("NOTE: /admin/workspaces/:id/test-token is ENABLED (MOLECULE_ENV=%q — set MOLECULE_ENV=production in staging/prod to lock this route)", os.Getenv("MOLECULE_ENV")) - } if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed: %v", err) } diff --git a/workspace-server/internal/artifacts/client_test.go b/workspace-server/internal/artifacts/client_test.go index 1be795252..f5d94b62f 100644 --- a/workspace-server/internal/artifacts/client_test.go +++ b/workspace-server/internal/artifacts/client_test.go @@ -48,7 +48,7 @@ func newTestClient(t *testing.T, mux *http.ServeMux) *artifacts.Client { t.Helper() srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - return artifacts.NewWithBaseURL("test-token", "test-ns", srv.URL) + return artifacts.NewWithBaseURL("fixture-token", "test-ns", srv.URL) } // ---- CreateRepo ---------------------------------------------------------- @@ -61,7 +61,7 @@ func TestCreateRepo_Success(t *testing.T) { return } // Verify auth header - if r.Header.Get("Authorization") != "Bearer test-token" { + if r.Header.Get("Authorization") != "Bearer fixture-token" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/workspace-server/internal/handlers/admin_test_token.go b/workspace-server/internal/handlers/admin_test_token.go deleted file mode 100644 index ea0056887..000000000 --- a/workspace-server/internal/handlers/admin_test_token.go +++ /dev/null @@ -1,107 +0,0 @@ -// Package handlers — admin test-token endpoint (follow-up to PR #5, issue #6). -// -// GET /admin/workspaces/:id/test-token mints a fresh workspace auth token for -// E2E scripts, eliminating the register-race in test_comprehensive_e2e.sh. -// The endpoint is DELIBERATELY hidden in production: it returns 404 rather -// than 403 when disabled, so an attacker scanning for admin surfaces can't -// distinguish "route exists, forbidden" from "route doesn't exist." -// -// Enablement contract: -// -// - If MOLECULE_ENABLE_TEST_TOKENS=1 → enabled. -// - Else if MOLECULE_ENV is set and != "production" → enabled. -// - Else → disabled (404). -// -// The fallback to MOLECULE_ENV keeps local dev and CI "just work" without -// requiring every operator to set the enable flag, while forcing production -// deployments (which should set MOLECULE_ENV=production) to stay locked. -package handlers - -import ( - "crypto/subtle" - "database/sql" - "log" - "net/http" - "os" - "strings" - - "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" - "github.com/gin-gonic/gin" -) - -// TestTokensEnabled reports whether the /admin/workspaces/:id/test-token -// route should respond with tokens. Exported so tests (and operator health -// checks) can share the exact same gating logic. -func TestTokensEnabled() bool { - if os.Getenv("MOLECULE_ENABLE_TEST_TOKENS") == "1" { - return true - } - // Empty MOLECULE_ENV defaults to enabled — local dev runs don't set it. - // Production deployments MUST set MOLECULE_ENV=production to lock this. - return os.Getenv("MOLECULE_ENV") != "production" -} - -// AdminTestTokenHandler mints a fresh token for an existing workspace. -type AdminTestTokenHandler struct{} - -func NewAdminTestTokenHandler() *AdminTestTokenHandler { - return &AdminTestTokenHandler{} -} - -// GetTestToken handles GET /admin/workspaces/:id/test-token. -func (h *AdminTestTokenHandler) GetTestToken(c *gin.Context) { - if !TestTokensEnabled() { - // 404 (not 403) — hide the route's existence entirely in prod. - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - - // IDOR fix (#112, CRITICAL): when ADMIN_TOKEN is set, require it - // explicitly. Org-scoped tokens and session cookies must not grant - // access — the original gap was that AdminAuth accepted any bearer - // that matched a live org token, allowing cross-org token minting. - adminSecret := os.Getenv("ADMIN_TOKEN") - if adminSecret != "" { - tok := c.GetHeader("Authorization") - tok = strings.TrimPrefix(tok, "Bearer ") - if tok == "" || subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) != 1 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "admin auth required"}) - return - } - } - - workspaceID := c.Param("id") - if workspaceID == "" { - c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) - return - } - - // Confirm the workspace exists — a missing workspace also 404s so we - // can't be used to probe for arbitrary IDs. - var exists string - err := db.DB.QueryRowContext(c.Request.Context(), - `SELECT id FROM workspaces WHERE id = $1`, workspaceID).Scan(&exists) - if err != nil { - if err == sql.ErrNoRows { - c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"}) - return - } - - token, err := wsauth.IssueToken(c.Request.Context(), db.DB, workspaceID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "token issue failed"}) - return - } - - // INFO log — never include the token itself. - log.Printf("admin: issued test token for workspace %s", workspaceID) - - c.JSON(http.StatusOK, gin.H{ - "auth_token": token, - "workspace_id": workspaceID, - }) -} diff --git a/workspace-server/internal/handlers/admin_test_token_test.go b/workspace-server/internal/handlers/admin_test_token_test.go deleted file mode 100644 index 3097aea79..000000000 --- a/workspace-server/internal/handlers/admin_test_token_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package handlers - -import ( - "database/sql" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/wsauth" - "github.com/gin-gonic/gin" -) - -func newTestTokenRequest(workspaceID string) (*httptest.ResponseRecorder, *gin.Context) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Params = gin.Params{{Key: "id", Value: workspaceID}} - c.Request = httptest.NewRequest("GET", "/admin/workspaces/"+workspaceID+"/test-token", nil) - return w, c -} - -func TestAdminTestToken_HiddenInProduction(t *testing.T) { - setupTestDB(t) - t.Setenv("MOLECULE_ENV", "production") - t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "") - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - h.GetTestToken(c) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404 in production, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestAdminTestToken_EnabledViaFlagEvenInProd(t *testing.T) { - mock := setupTestDB(t) - t.Setenv("MOLECULE_ENV", "production") - t.Setenv("MOLECULE_ENABLE_TEST_TOKENS", "1") - t.Setenv("ADMIN_TOKEN", "") - - mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). - WithArgs("ws-1"). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) - mock.ExpectExec("INSERT INTO workspace_auth_tokens"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - h.GetTestToken(c) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestAdminTestToken_WorkspaceNotFound(t *testing.T) { - mock := setupTestDB(t) - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "") - - mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). - WithArgs("missing"). - WillReturnError(sqlErrNoRows()) - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("missing") - h.GetTestToken(c) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404 for missing workspace, got %d: %s", w.Code, w.Body.String()) - } -} - -func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) { - mock := setupTestDB(t) - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "") - - mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). - WithArgs("ws-1"). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) - - // Capture the hash inserted by IssueToken so we can replay it on Validate. - var capturedHash []byte - mock.ExpectExec("INSERT INTO workspace_auth_tokens"). - WithArgs("ws-1", sqlmock.AnyArg(), sqlmock.AnyArg()). - WillReturnResult(sqlmock.NewResult(0, 1)) - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - h.GetTestToken(c) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - - var resp struct { - AuthToken string `json:"auth_token"` - WorkspaceID string `json:"workspace_id"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("bad json: %v", err) - } - if resp.AuthToken == "" { - t.Fatal("expected non-empty auth_token") - } - if resp.WorkspaceID != "ws-1" { - t.Errorf("expected workspace_id ws-1, got %q", resp.WorkspaceID) - } - if len(resp.AuthToken) < 32 { - t.Errorf("token looks too short: %d chars", len(resp.AuthToken)) - } - - // Now simulate ValidateToken lookup using the same DB — prove the token - // can be validated by feeding its sha256 back through ExpectedArgs. - // (We stub the SELECT rather than re-reading capturedHash since sqlmock - // doesn't capture live args; the important invariant is that the issued - // token passes ValidateToken given a matching hash row exists.) - _ = capturedHash - mock.ExpectQuery("SELECT t\\.id, t\\.workspace_id.*FROM workspace_auth_tokens t.*JOIN workspaces"). - WithArgs(sqlmock.AnyArg()). - WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id"}).AddRow("tok-1", "ws-1")) - mock.ExpectExec("UPDATE workspace_auth_tokens SET last_used_at"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - if err := wsauth.ValidateToken(c.Request.Context(), db.DB, "ws-1", resp.AuthToken); err != nil { - t.Errorf("issued token failed to validate: %v", err) - } -} - -func sqlErrNoRows() error { return sql.ErrNoRows } - -// TestAdminTestToken_AdminTokenRequired_NoHeader pins the IDOR-fix (#112): -// when ADMIN_TOKEN is set, calls without an Authorization header MUST 401. -// Pre-fix, the route accepted any bearer that matched a live org token, -// allowing cross-org test-token minting. The current code uses -// subtle.ConstantTimeCompare against ADMIN_TOKEN explicitly. This test -// pins that no-header == 401 so a regression that re-enabled the AdminAuth -// fallback would fail loudly. -func TestAdminTestToken_AdminTokenRequired_NoHeader(t *testing.T) { - setupTestDB(t) - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "the-admin-secret") - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - h.GetTestToken(c) - - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 with ADMIN_TOKEN set + no Authorization, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestAdminTestToken_AdminTokenRequired_WrongHeader pins that a non-matching -// bearer is rejected. Critical for #112 — an attacker presenting any other -// org's token must NOT pass. -func TestAdminTestToken_AdminTokenRequired_WrongHeader(t *testing.T) { - setupTestDB(t) - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "the-admin-secret") - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - c.Request.Header.Set("Authorization", "Bearer wrong-token") - h.GetTestToken(c) - - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 with wrong Authorization, got %d: %s", w.Code, w.Body.String()) - } -} - -// TestAdminTestToken_AdminTokenRequired_CorrectHeader pins the success -// path through the ADMIN_TOKEN gate. Together with the no-header + wrong- -// header pair, this proves the gate distinguishes correct from incorrect -// rather than (e.g.) erroring on every request. -func TestAdminTestToken_AdminTokenRequired_CorrectHeader(t *testing.T) { - mock := setupTestDB(t) - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "the-admin-secret") - - mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). - WithArgs("ws-1"). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) - mock.ExpectExec("INSERT INTO workspace_auth_tokens"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - c.Request.Header.Set("Authorization", "Bearer the-admin-secret") - h.GetTestToken(c) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 with correct ADMIN_TOKEN, got %d: %s", w.Code, w.Body.String()) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("sqlmock expectations not met — INSERT into workspace_auth_tokens did not run, suggesting the gate short-circuited the success path: %v", err) - } -} - -// TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely pins that when -// ADMIN_TOKEN is unset (typical local-dev setup), the explicit gate is -// bypassed and the route works without an Authorization header. This is -// the same code path the existing TestAdminTestToken_EnabledViaFlagEvenInProd -// exercises, but pinned explicitly so a future refactor that conflates -// "ADMIN_TOKEN unset" with "always 401" gets caught immediately. -func TestAdminTestToken_AdminTokenEmpty_GateBypassedSafely(t *testing.T) { - mock := setupTestDB(t) - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "") - - mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). - WithArgs("ws-1"). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-1")) - mock.ExpectExec("INSERT INTO workspace_auth_tokens"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - h := NewAdminTestTokenHandler() - w, c := newTestTokenRequest("ws-1") - // Note: NO Authorization header — the gate is unset, so this MUST work. - h.GetTestToken(c) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200 with ADMIN_TOKEN empty + no Authorization, got %d: %s", w.Code, w.Body.String()) - } -} diff --git a/workspace-server/internal/handlers/artifacts_test.go b/workspace-server/internal/handlers/artifacts_test.go index 283dea0b3..e0b34f85b 100644 --- a/workspace-server/internal/handlers/artifacts_test.go +++ b/workspace-server/internal/handlers/artifacts_test.go @@ -56,7 +56,7 @@ func newArtifactsMockCFServer(t *testing.T, suffix string, handler http.HandlerF mux.HandleFunc("/namespaces/test-ns"+suffix, handler) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - return artifacts.NewWithBaseURL("cf-test-token", "test-ns", srv.URL) + return artifacts.NewWithBaseURL("cf-fixture-token", "test-ns", srv.URL) } // ============================= Create ===================================== diff --git a/workspace-server/internal/handlers/github_token.go b/workspace-server/internal/handlers/github_token.go index f00714a96..cd1d1d1fb 100644 --- a/workspace-server/internal/handlers/github_token.go +++ b/workspace-server/internal/handlers/github_token.go @@ -78,8 +78,7 @@ func NewGitHubTokenHandler(reg *provisionhook.Registry) *GitHubTokenHandler { // 500 {"error": "token refresh failed"} — provider returned error // // The 404 vs 403 distinction is intentional: a 404 means the feature is -// simply not configured, not that the caller is forbidden. This matches -// the pattern used by GET /admin/workspaces/:id/test-token. +// simply not configured, not that the caller is forbidden. // // Callers must retry with exponential back-off on 500 — a transient // upstream GitHub API error should not permanently block git operations. diff --git a/workspace-server/internal/handlers/ssrf.go b/workspace-server/internal/handlers/ssrf.go index 2e795e900..c4182aa0e 100644 --- a/workspace-server/internal/handlers/ssrf.go +++ b/workspace-server/internal/handlers/ssrf.go @@ -12,7 +12,7 @@ import ( // devModeAllowsLoopback reports whether the SSRF defence should permit // http://127.0.0.1: workspace URLs. True only when MOLECULE_ENV is // a dev value — this is the same convention the middleware dev-mode -// escape hatch uses (handlers/admin_test_token.go, middleware/devmode.go). +// escape hatch uses. // // Why: on a self-hosted Docker setup the provisioner publishes each // container's A2A port on 127.0.0.1: and writes that URL diff --git a/workspace-server/internal/handlers/tokens_test.go b/workspace-server/internal/handlers/tokens_test.go index bf6d66030..e087604cd 100644 --- a/workspace-server/internal/handlers/tokens_test.go +++ b/workspace-server/internal/handlers/tokens_test.go @@ -188,7 +188,7 @@ func createTestWorkspace(t *testing.T) string { t.Helper() var id string err := db.DB.QueryRow(` - INSERT INTO workspaces (name, status, tier) VALUES ('test-token-ws', 'online', 2) + INSERT INTO workspaces (name, status, tier) VALUES ('fixture-token-ws', 'online', 2) RETURNING id `).Scan(&id) if err != nil { diff --git a/workspace-server/internal/middleware/devmode.go b/workspace-server/internal/middleware/devmode.go index a751da129..51782e829 100644 --- a/workspace-server/internal/middleware/devmode.go +++ b/workspace-server/internal/middleware/devmode.go @@ -24,8 +24,8 @@ import ( // `ADMIN_TOKEN` (a random secret, checked by Tier-2 above) and // `MOLECULE_ENV=production`. Either one being set makes this helper // return false, so the fail-open branch is unreachable in production. -// The convention matches `handlers/admin_test_token.go`, which gates -// the e2e test-token mint on `MOLECULE_ENV != "production"`. +// Real token minting goes through AdminAuth, so local development keeps a +// narrow fail-open mode for browser/API smoke tests without an admin secret. // devModeEnvValues is the set of MOLECULE_ENV values that count as // "explicit dev mode". Production callers don't set any of these. diff --git a/workspace-server/internal/middleware/devmode_test.go b/workspace-server/internal/middleware/devmode_test.go index 17685efa7..5be40acb6 100644 --- a/workspace-server/internal/middleware/devmode_test.go +++ b/workspace-server/internal/middleware/devmode_test.go @@ -18,8 +18,7 @@ func TestIsDevModeFailOpen_DevModeNoAdminToken_True(t *testing.T) { } func TestIsDevModeFailOpen_DevModeShortAlias_True(t *testing.T) { - // "dev" is a valid alias for "development" — matches the convention - // in handlers/admin_test_token.go. + // "dev" is a valid alias for "development". t.Setenv("MOLECULE_ENV", "dev") t.Setenv("ADMIN_TOKEN", "") if !isDevModeFailOpen() { diff --git a/workspace-server/internal/middleware/mcp_ratelimit_test.go b/workspace-server/internal/middleware/mcp_ratelimit_test.go index 244256906..c88d8668b 100644 --- a/workspace-server/internal/middleware/mcp_ratelimit_test.go +++ b/workspace-server/internal/middleware/mcp_ratelimit_test.go @@ -95,7 +95,7 @@ func TestMCPRateLimiter_NoToken_Returns401(t *testing.T) { func TestMCPRateLimiter_SetsRateLimitHeaders(t *testing.T) { r := newMCPTestRouter(t, 10, time.Minute) w := httptest.NewRecorder() - r.ServeHTTP(w, mcpReq("header-test-token")) + r.ServeHTTP(w, mcpReq("header-fixture-token")) if w.Header().Get("X-RateLimit-Limit") != "10" { t.Errorf("X-RateLimit-Limit: got %q, want 10", w.Header().Get("X-RateLimit-Limit")) @@ -110,7 +110,7 @@ func TestMCPRateLimiter_SetsRateLimitHeaders(t *testing.T) { func TestMCPRateLimiter_ResetsAfterInterval(t *testing.T) { r := newMCPTestRouter(t, 1, 50*time.Millisecond) - token := "reset-test-token" + token := "reset-fixture-token" // Exhaust the bucket. w1 := httptest.NewRecorder() diff --git a/workspace-server/internal/middleware/tenant_guard.go b/workspace-server/internal/middleware/tenant_guard.go index 1363060df..964bc19a5 100644 --- a/workspace-server/internal/middleware/tenant_guard.go +++ b/workspace-server/internal/middleware/tenant_guard.go @@ -1,6 +1,7 @@ package middleware import ( + "net/http" "os" "strings" @@ -10,7 +11,9 @@ import ( // flyReplaySrcHeader is the header Fly injects on requests it replays via // the `fly-replay: ...;state=...` mechanism. Format is a semicolon- // separated list of k=v pairs, e.g. -// instance=91854...;region=ord;t=1700000000000;state= +// +// instance=91854...;region=ord;t=1700000000000;state= +// // Control plane puts the bare UUID in state (no prefix) because Fly's // proxy returns 502 "replay malformed" on any second `=` in the value. // We read the whole state= segment as the org id. @@ -31,7 +34,10 @@ const flyReplaySrcHeader = "Fly-Replay-Src" // // The guard intentionally knows nothing about orgs, signup, billing, or // provisioning. Those live in the private control-plane repo. All this code -// does is: "am I the tenant for this request? if not, 404." +// does is: "am I the tenant for this request? if not, reject it." +// Missing tenant identity is an actionable client error. Wrong tenant identity +// still returns 404 so cross-tenant probes cannot distinguish "wrong tenant" +// from "no such route". // tenantOrgIDHeader is the HTTP header the control-plane router sets when it // uses fly-replay to route a request to a tenant machine. Case-insensitive at @@ -119,8 +125,20 @@ func TenantGuardWithOrgID(configuredOrgID string) gin.HandlerFunc { c.Next() return } - // 404 not 403 — existence of this tenant must not be inferable by - // probing other orgs' machines. + // Missing identity is an actionable API client error. This is the + // common operator/molecli failure mode: a valid bearer reaches the right + // hostname but omits the required SaaS routing header. + if c.GetHeader(tenantOrgIDHeader) == "" && c.GetHeader(flyReplaySrcHeader) == "" { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "missing tenant routing header", + "code": "TENANT_ORG_HEADER_REQUIRED", + "required_header": tenantOrgIDHeader, + "detail": "SaaS tenant API requests must include X-Molecule-Org-Id matching the organization UUID.", + }) + return + } + // Wrong identity remains 404, not 403 — existence of this tenant must + // not be inferable by probing other orgs' machines. c.AbortWithStatus(404) } } diff --git a/workspace-server/internal/middleware/tenant_guard_test.go b/workspace-server/internal/middleware/tenant_guard_test.go index c2ab57920..a35da1c09 100644 --- a/workspace-server/internal/middleware/tenant_guard_test.go +++ b/workspace-server/internal/middleware/tenant_guard_test.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -64,11 +65,16 @@ func TestTenantGuard_MismatchedHeaderIs404(t *testing.T) { } } -// Set + missing header → 404. -func TestTenantGuard_MissingHeaderIs404(t *testing.T) { +// Set + missing header → actionable 400. This is a client integration error, +// not a cross-tenant probe, so the API should tell operators what to fix. +func TestTenantGuard_MissingHeaderIs400(t *testing.T) { r := newGuardedRouter("org-abc") - if w := doRequest(r, "/workspaces", ""); w.Code != 404 { - t.Errorf("missing header: expected 404, got %d", w.Code) + w := doRequest(r, "/workspaces", "") + if w.Code != http.StatusBadRequest { + t.Fatalf("missing header: expected 400, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "TENANT_ORG_HEADER_REQUIRED") { + t.Errorf("missing header body should explain the required header, got %q", w.Body.String()) } } @@ -219,8 +225,8 @@ func TestTenantGuard_SameOriginCanvasInactiveWithoutEnv(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - if w.Code != 404 { - t.Errorf("same-origin canvas without CANVAS_PROXY_URL: expected 404, got %d", w.Code) + if w.Code != http.StatusBadRequest { + t.Errorf("same-origin canvas without CANVAS_PROXY_URL: expected 400 missing-header response, got %d", w.Code) } } @@ -235,7 +241,7 @@ func TestTenantGuard_AllowlistIsExactMatch(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("expected /health/debug to be guarded (404), got %d", w.Code) + if w.Code != http.StatusBadRequest { + t.Errorf("expected /health/debug to be guarded with missing-header 400, got %d", w.Code) } } diff --git a/workspace-server/internal/router/admin_test_token_route_test.go b/workspace-server/internal/router/admin_test_token_route_test.go deleted file mode 100644 index 6a6fc51ab..000000000 --- a/workspace-server/internal/router/admin_test_token_route_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package router - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/handlers" - "github.com/Molecule-AI/molecule-monorepo/platform/internal/middleware" - "github.com/gin-gonic/gin" -) - -// buildTestTokenEngine builds a minimal Gin engine containing only the -// test-token route with AdminAuth middleware — the same registration that -// router.go now uses. Allows us to verify the auth gate is enforced at the -// HTTP layer without spinning up the full Setup() dependency graph. -func buildTestTokenEngine(t *testing.T) gin.IRouter { - t.Helper() - gin.SetMode(gin.TestMode) - r := gin.New() - tokh := handlers.NewAdminTestTokenHandler() - r.GET("/admin/workspaces/:id/test-token", middleware.AdminAuth(db.DB), tokh.GetTestToken) - return r -} - -// setupRouterTestDB initialises db.DB with a sqlmock connection and returns -// the mock controller. Restores db.DB on test cleanup. -func setupRouterTestDB(t *testing.T) sqlmock.Sqlmock { - t.Helper() - mockDB, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("sqlmock.New: %v", err) - } - prev := db.DB - db.DB = mockDB - t.Cleanup(func() { - db.DB = prev - mockDB.Close() - }) - return mock -} - -// TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist verifies that once the -// platform has at least one live token, the test-token endpoint returns 401 -// for callers that provide no Authorization header. This is the core security -// property added by the fix — without AdminAuth in the router the request -// would reach the handler and mint a new bearer for any workspace UUID. -func TestTestTokenRoute_RequiresAdminAuth_WhenTokensExist(t *testing.T) { - t.Setenv("MOLECULE_ENV", "development") // enable the handler itself - // Explicit ADMIN_TOKEN so AdminAuth's dev-mode fail-open branch - // (middleware/devmode.go::isDevModeFailOpen) does NOT fire — we're - // testing the production-like security property that once any - // workspace token exists, an unauthenticated request is rejected. - // Setting ADMIN_TOKEN is the operator's opt-in to #684 closure and - // is what hosted SaaS tenants always have set. - t.Setenv("ADMIN_TOKEN", "test-admin-secret-not-presented-by-caller") - mock := setupRouterTestDB(t) - - // HasAnyLiveTokenGlobal: platform has one enrolled workspace. - mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) - - r := buildTestTokenEngine(t) - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/admin/workspaces/ws-target/test-token", nil) - // No Authorization header — should be rejected by AdminAuth. - r.(http.Handler).ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("expected 401 when tokens exist and no auth header, got %d: %s", w.Code, w.Body.String()) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("sqlmock expectations not met: %v", err) - } -} - -// TestTestTokenRoute_FailOpenOnFreshInstall verifies that AdminAuth is -// fail-open on a fresh install (HasAnyLiveTokenGlobal == 0), so the test-token -// bootstrap path still works before the first workspace has registered. -func TestTestTokenRoute_FailOpenOnFreshInstall(t *testing.T) { - t.Setenv("MOLECULE_ENV", "development") - t.Setenv("ADMIN_TOKEN", "") - mock := setupRouterTestDB(t) - - // HasAnyLiveTokenGlobal: no tokens yet — fresh install. - mock.ExpectQuery("SELECT COUNT.*FROM workspace_auth_tokens"). - WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) - - // Handler's own DB queries: workspace existence check + token insert. - mock.ExpectQuery("SELECT id FROM workspaces WHERE id ="). - WithArgs("ws-bootstrap"). - WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-bootstrap")) - mock.ExpectExec("INSERT INTO workspace_auth_tokens"). - WillReturnResult(sqlmock.NewResult(0, 1)) - - r := buildTestTokenEngine(t) - w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/admin/workspaces/ws-bootstrap/test-token", nil) - r.(http.Handler).ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("expected 200 on fresh install (fail-open), got %d: %s", w.Code, w.Body.String()) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("sqlmock expectations not met: %v", err) - } -} diff --git a/workspace-server/internal/router/router.go b/workspace-server/internal/router/router.go index ec3754d9a..f2ba08f12 100644 --- a/workspace-server/internal/router/router.go +++ b/workspace-server/internal/router/router.go @@ -55,7 +55,7 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi r.Use(cors.New(cors.Config{ AllowOrigins: corsOrigins, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "X-Workspace-ID", "Authorization"}, + AllowHeaders: []string{"Origin", "Content-Type", "X-Workspace-ID", "X-Molecule-Org-Id", "X-Molecule-Org-Slug", "Authorization"}, AllowCredentials: true, })) @@ -620,18 +620,6 @@ func Setup(hub *ws.Hub, broadcaster *events.Broadcaster, prov *provisioner.Provi adminAuth.POST("/admin/plugin-updates/:id/apply", driftH.Apply) } - // Admin — test token minting (issue #6). Hidden in production via TestTokensEnabled(). - // NOT behind AdminAuth — this is the bootstrap endpoint E2E tests and - // fresh installs use to obtain their first admin bearer. Adding AdminAuth - // (#612) broke the chicken-and-egg: after first workspace provision creates - // a live token in the DB, AdminAuth requires auth for ALL requests, but the - // client has no token yet because it needs this endpoint to get one. - // The handler itself rejects calls when MOLECULE_ENV=prod (TestTokensEnabled). - { - tokh := handlers.NewAdminTestTokenHandler() - r.GET("/admin/workspaces/:id/test-token", tokh.GetTestToken) - } - // Admin — GitHub App installation token refresh (issue #547). // Long-running workspaces (>60 min) use this endpoint to refresh // GH_TOKEN without restarting. Returns the current installation token diff --git a/workspace-server/internal/router/router_test_helpers_test.go b/workspace-server/internal/router/router_test_helpers_test.go new file mode 100644 index 000000000..8df394b68 --- /dev/null +++ b/workspace-server/internal/router/router_test_helpers_test.go @@ -0,0 +1,25 @@ +package router + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" +) + +// setupRouterTestDB initialises db.DB with a sqlmock connection and returns +// the mock controller. Restores db.DB on test cleanup. +func setupRouterTestDB(t *testing.T) sqlmock.Sqlmock { + t.Helper() + mockDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + prev := db.DB + db.DB = mockDB + t.Cleanup(func() { + db.DB = prev + mockDB.Close() + }) + return mock +} -- 2.52.0