Files
vctp2/server/router/auth_integration_test.go
T
nathan 7848557002
continuous-integration/drone/push Build is passing
update docs
2026-04-17 14:00:48 +10:00

191 lines
6.2 KiB
Go

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))
}