diff --git a/workspace-server/internal/handlers/admin_test_token_test.go b/workspace-server/internal/handlers/admin_test_token_test.go index 3ac72923..62d3f2b6 100644 --- a/workspace-server/internal/handlers/admin_test_token_test.go +++ b/workspace-server/internal/handlers/admin_test_token_test.go @@ -129,3 +129,97 @@ func TestAdminTestToken_HappyPath_TokenValidates(t *testing.T) { } 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()) + } +}