test(e2e): add real staging image upload smoke #1790

Merged
hongming merged 1 commits from fix/real-user-upload-staging-e2e into main 2026-05-24 10:20:52 +00:00
49 changed files with 394 additions and 686 deletions
+48 -50
View File
@@ -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
@@ -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 (T1T9, 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)
+16 -3
View File
@@ -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"
@@ -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
+3 -2
View File
@@ -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"
+4 -4
View File
@@ -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
+12 -6
View File
@@ -49,7 +49,7 @@ export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
};
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<void> {
* Mint a workspace auth token so the canvas can make authenticated API
* calls (WorkspaceAuth middleware).
*/
export async function mintTestToken(workspaceId: string): Promise<string> {
const res = await fetch(
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
);
export async function mintWorkspaceToken(workspaceId: string): Promise<string> {
const headers: Record<string, string> = {};
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;
@@ -40,7 +40,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -41,7 +41,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -42,7 +42,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -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.
@@ -44,7 +44,7 @@ vi.mock("../uploads", () => ({
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
platformAuthHeaders: () => ({ Authorization: "Bearer fixture-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -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 ──────────────────────────────────────────────────────────────────
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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 |
+3 -2
View File
@@ -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/<id>/test-token
curl -X POST http://localhost:8080/admin/workspaces/<id>/tokens \
-H "Authorization: Bearer <ADMIN_TOKEN>"
# Response: {"auth_token": "...", "workspace_id": "..."}
```
+6 -41
View File
@@ -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 <ADMIN_TOKEN>` |
| `GET /admin/liveness` | `Authorization: Bearer <ADMIN_TOKEN>` |
| `POST /org/import` | `Authorization: Bearer <ADMIN_TOKEN>` |
| `GET /admin/workspaces/:id/test-token` | `Authorization: Bearer <ADMIN_TOKEN>` (enabled only when `MOLECULE_ENV != "production"`) |
| `POST /admin/workspaces/:id/tokens` | `Authorization: Bearer <ADMIN_TOKEN>`; 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).
+2 -2
View File
@@ -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)
+7 -10
View File
@@ -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'])"
+18 -18
View File
@@ -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" \
+7 -4
View File
@@ -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 ..."
+5 -2
View File
@@ -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"
+8 -6
View File
@@ -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 ""
+16 -7
View File
@@ -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
+78
View File
@@ -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
+20 -8
View File
@@ -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
+19 -12
View File
@@ -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"
+5 -4
View File
@@ -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}" \
+21 -5
View File
@@ -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 ""
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
-3
View File
@@ -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)
}
@@ -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
}
@@ -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,
})
}
@@ -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())
}
}
@@ -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 =====================================
@@ -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.
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// devModeAllowsLoopback reports whether the SSRF defence should permit
// http://127.0.0.1:<port> 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:<ephemeral> and writes that URL
@@ -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 {
@@ -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.
@@ -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() {
@@ -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()
@@ -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=<uuid>
//
// instance=91854...;region=ord;t=1700000000000;state=<uuid>
//
// 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)
}
}
@@ -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)
}
}
@@ -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)
}
}
+1 -13
View File
@@ -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
@@ -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
}