package router import ( "encoding/base64" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "vctp/internal/auth" "vctp/internal/secrets" "vctp/internal/settings" ) func TestProtectedRoutesRequireAuthentication(t *testing.T) { cfg := testRouterSettings(t, false) app := testRouter(t, cfg) tests := []struct { name string method string path string body string }{ { name: "auth me", method: http.MethodGet, path: "/api/auth/me", }, { name: "viewer route", method: http.MethodGet, path: "/api/report/snapshot", }, { name: "admin route", method: http.MethodPost, path: "/api/encrypt", body: `{"plaintext":"hello"}`, }, { name: "legacy route", method: http.MethodPost, path: "/api/import/vm", body: `{`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) if tc.body != "" { req.Header.Set("Content-Type", "application/json") } rr := httptest.NewRecorder() app.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("expected status %d, got %d for %s %s", http.StatusUnauthorized, rr.Code, tc.method, tc.path) } }) } } func TestViewerCanReadButCannotMutate(t *testing.T) { cfg := testRouterSettings(t, false) app := testRouter(t, cfg) viewerToken := mustIssueToken(t, cfg, "viewer-user", []string{"viewer"}) readReq := httptest.NewRequest(http.MethodGet, "/api/report/snapshot", nil) readReq.Header.Set("Authorization", "Bearer "+viewerToken) readRR := httptest.NewRecorder() app.ServeHTTP(readRR, readReq) if readRR.Code != http.StatusBadRequest { t.Fatalf("expected viewer read to reach handler and return %d, got %d", http.StatusBadRequest, readRR.Code) } mutateReq := httptest.NewRequest(http.MethodPost, "/api/encrypt", strings.NewReader(`{"plaintext":"hello"}`)) mutateReq.Header.Set("Authorization", "Bearer "+viewerToken) mutateReq.Header.Set("Content-Type", "application/json") mutateRR := httptest.NewRecorder() app.ServeHTTP(mutateRR, mutateReq) if mutateRR.Code != http.StatusForbidden { t.Fatalf("expected viewer mutate to return %d, got %d", http.StatusForbidden, mutateRR.Code) } } func TestAdminCanMutate(t *testing.T) { cfg := testRouterSettings(t, false) app := testRouter(t, cfg) adminToken := mustIssueToken(t, cfg, "admin-user", []string{"admin"}) req := httptest.NewRequest(http.MethodPost, "/api/encrypt", strings.NewReader(`{"plaintext":"hello"}`)) req.Header.Set("Authorization", "Bearer "+adminToken) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() app.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected admin mutate to return %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String()) } var payload map[string]string if err := json.Unmarshal(rr.Body.Bytes(), &payload); err != nil { t.Fatalf("failed to decode response: %v", err) } if payload["status"] != "OK" { t.Fatalf("expected status OK, got %q", payload["status"]) } if strings.TrimSpace(payload["ciphertext"]) == "" { t.Fatal("expected ciphertext in response") } } func TestLegacyEndpointGatingStillAppliesWithAuthEnabled(t *testing.T) { disabledCfg := testRouterSettings(t, false) disabledApp := testRouter(t, disabledCfg) adminToken := mustIssueToken(t, disabledCfg, "admin-user", []string{"admin"}) disabledReq := httptest.NewRequest(http.MethodPost, "/api/import/vm", strings.NewReader(`{`)) disabledReq.Header.Set("Authorization", "Bearer "+adminToken) disabledReq.Header.Set("Content-Type", "application/json") disabledRR := httptest.NewRecorder() disabledApp.ServeHTTP(disabledRR, disabledReq) if disabledRR.Code != http.StatusGone { t.Fatalf("expected legacy-disabled route to return %d, got %d", http.StatusGone, disabledRR.Code) } enabledCfg := testRouterSettings(t, true) enabledApp := testRouter(t, enabledCfg) enabledToken := mustIssueToken(t, enabledCfg, "admin-user", []string{"admin"}) enabledReq := httptest.NewRequest(http.MethodPost, "/api/import/vm", strings.NewReader(`{`)) enabledReq.Header.Set("Authorization", "Bearer "+enabledToken) enabledReq.Header.Set("Content-Type", "application/json") enabledRR := httptest.NewRecorder() enabledApp.ServeHTTP(enabledRR, enabledReq) if enabledRR.Code != http.StatusBadRequest { t.Fatalf("expected legacy-enabled route to reach handler and return %d, got %d", http.StatusBadRequest, enabledRR.Code) } } func testRouter(t *testing.T, cfg *settings.Settings) http.Handler { t.Helper() logger := testRouterLogger() secretKey := []byte("0123456789abcdef0123456789abcdef") secretSvc := secrets.New(logger, secretKey) return New(logger, nil, "test-build", "test-sha", "go-test", nil, secretSvc, cfg) } func testRouterSettings(t *testing.T, enableLegacyAPI bool) *settings.Settings { t.Helper() cfg := &settings.Settings{Values: &settings.SettingsYML{}} cfg.Values.Settings.AuthEnabled = true cfg.Values.Settings.AuthMode = "required" cfg.Values.Settings.AuthJWTSigningKey = base64.StdEncoding.EncodeToString([]byte("router-auth-test-signing-key")) cfg.Values.Settings.AuthTokenLifespanMinutes = 120 cfg.Values.Settings.AuthJWTIssuer = "vctp" cfg.Values.Settings.AuthJWTAudience = "vctp-api" cfg.Values.Settings.AuthClockSkewSeconds = 60 cfg.Values.Settings.EnableLegacyAPI = enableLegacyAPI cfg.Values.Settings.ReportsDir = t.TempDir() return cfg } func mustIssueToken(t *testing.T, cfg *settings.Settings, subject string, roles []string) string { t.Helper() svc, err := auth.NewJWTService(auth.JWTConfig{ SigningKeyBase64: cfg.Values.Settings.AuthJWTSigningKey, Issuer: cfg.Values.Settings.AuthJWTIssuer, Audience: cfg.Values.Settings.AuthJWTAudience, TokenLifespan: time.Duration(cfg.Values.Settings.AuthTokenLifespanMinutes) * time.Minute, ClockSkew: time.Duration(cfg.Values.Settings.AuthClockSkewSeconds) * time.Second, }) if err != nil { t.Fatalf("failed to create jwt service: %v", err) } token, _, err := svc.IssueToken(subject, roles, nil) if err != nil { t.Fatalf("failed to issue token: %v", err) } return token } func testRouterLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }