Pulse/internal/api/security_tokens_handlers_test.go

394 lines
13 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
func TestSecurityTokens_ListCreateDelete(t *testing.T) {
capture := &auditCaptureLogger{}
prevLogger := audit.GetLogger()
prevManager := GetTenantAuditManager()
audit.SetLogger(capture)
SetTenantAuditManager(nil)
t.Cleanup(func() {
audit.SetLogger(prevLogger)
SetTenantAuditManager(prevManager)
})
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
router := &Router{
config: cfg,
persistence: persistence,
}
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", nil)
rr := httptest.NewRecorder()
router.handleListAPITokens(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader("{bad"))
rr = httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader(`{"name":"test","scopes":["monitoring:read"]}`))
req = req.WithContext(authpkg.WithUser(req.Context(), "alice"))
rr = httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var createResp struct {
Token string `json:"token"`
Record apiTokenDTO `json:"record"`
}
if err := json.NewDecoder(rr.Body).Decode(&createResp); err != nil {
t.Fatalf("decode create response: %v", err)
}
if createResp.Token == "" {
t.Fatal("expected raw token in response")
}
if createResp.Record.Name != "test" {
t.Fatalf("expected record name 'test', got %q", createResp.Record.Name)
}
if len(cfg.APITokens) != 1 {
t.Fatalf("expected token stored in config")
}
if got := cfg.APITokens[0].OrgID; got != "default" {
t.Fatalf("expected token org binding default, got %q", got)
}
req = httptest.NewRequest(http.MethodGet, "/api/security/tokens", nil)
rr = httptest.NewRecorder()
router.handleListAPITokens(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var listResp struct {
Tokens []apiTokenDTO `json:"tokens"`
}
if err := json.NewDecoder(rr.Body).Decode(&listResp); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listResp.Tokens) != 1 {
t.Fatalf("expected 1 token, got %d", len(listResp.Tokens))
}
req = httptest.NewRequest(http.MethodGet, "/api/security/tokens/"+createResp.Record.ID, nil)
rr = httptest.NewRecorder()
router.handleGetAPIToken(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var getResp struct {
Record apiTokenDTO `json:"record"`
}
if err := json.NewDecoder(rr.Body).Decode(&getResp); err != nil {
t.Fatalf("decode get response: %v", err)
}
if getResp.Record.ID != createResp.Record.ID {
t.Fatalf("expected record id %q, got %q", createResp.Record.ID, getResp.Record.ID)
}
req = httptest.NewRequest(http.MethodDelete, "/api/security/tokens/", nil)
rr = httptest.NewRecorder()
router.handleDeleteAPIToken(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
req = httptest.NewRequest(http.MethodDelete, "/api/security/tokens/missing", nil)
rr = httptest.NewRecorder()
router.handleDeleteAPIToken(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rr.Code)
}
req = httptest.NewRequest(http.MethodGet, "/api/security/tokens/missing", nil)
rr = httptest.NewRecorder()
router.handleGetAPIToken(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rr.Code)
}
// Deleting an existing token should succeed.
record := config.APITokenRecord{
ID: "migrated",
Name: "Migrated from .env token",
Hash: "hash-migrated",
CreatedAt: time.Now(),
}
cfg.APITokens = append(cfg.APITokens, record)
req = httptest.NewRequest(http.MethodDelete, "/api/security/tokens/migrated", nil)
req = req.WithContext(authpkg.WithUser(req.Context(), "alice"))
rr = httptest.NewRecorder()
router.handleDeleteAPIToken(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, rr.Code)
}
events, err := capture.Query(audit.QueryFilter{})
if err != nil {
t.Fatalf("query audit events: %v", err)
}
var sawCreate, sawDelete bool
for _, event := range events {
if event.EventType == "token_created" && event.Success {
sawCreate = true
if event.User != "alice" {
t.Fatalf("token_created audit user = %q, want %q", event.User, "alice")
}
if strings.Contains(event.Details, createResp.Token) {
t.Fatalf("token_created audit details leaked raw token")
}
}
if event.EventType == "token_deleted" && event.Success {
sawDelete = true
}
}
if !sawCreate {
t.Fatalf("expected successful token_created audit event")
}
if !sawDelete {
t.Fatalf("expected successful token_deleted audit event")
}
}
func TestSecurityTokens_Create_WithExpiresIn(t *testing.T) {
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
router := &Router{
config: cfg,
persistence: persistence,
}
start := time.Now().UTC()
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader(`{"name":"test","scopes":["monitoring:read"],"expiresIn":"1h"}`))
rr := httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
end := time.Now().UTC()
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var createResp struct {
Token string `json:"token"`
Record apiTokenDTO `json:"record"`
}
if err := json.NewDecoder(rr.Body).Decode(&createResp); err != nil {
t.Fatalf("decode create response: %v", err)
}
if createResp.Record.ExpiresAt == nil {
t.Fatalf("expected expiresAt to be set in response record")
}
if exp := *createResp.Record.ExpiresAt; exp.Before(start.Add(time.Hour)) || exp.After(end.Add(time.Hour).Add(2*time.Second)) {
t.Fatalf("unexpected expiresAt: %v (start=%v end=%v)", exp, start, end)
}
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ExpiresAt == nil {
t.Fatalf("expected expiresAt to be stored in config")
}
}
func TestSecurityTokens_Create_WithInvalidExpiresIn(t *testing.T) {
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
router := &Router{
config: cfg,
persistence: persistence,
}
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader(`{"name":"test","scopes":["monitoring:read"],"expiresIn":"not-a-duration"}`))
rr := httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
if len(cfg.APITokens) != 0 {
t.Fatalf("expected no token stored in config on invalid expiresIn")
}
req = httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader(`{"name":"test","scopes":["monitoring:read"],"expiresIn":"30s"}`))
rr = httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
if len(cfg.APITokens) != 0 {
t.Fatalf("expected no token stored in config when expiresIn < 1m")
}
}
func TestSecurityTokens_CreateRelayMobileAccessToken(t *testing.T) {
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
router := &Router{
config: cfg,
persistence: persistence,
}
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens/relay-mobile", nil)
req = req.WithContext(authpkg.WithUser(req.Context(), "alice"))
rr := httptest.NewRecorder()
router.handleCreateRelayMobileAccessToken(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var createResp struct {
Token string `json:"token"`
Record apiTokenDTO `json:"record"`
}
if err := json.NewDecoder(rr.Body).Decode(&createResp); err != nil {
t.Fatalf("decode create response: %v", err)
}
if createResp.Token == "" {
t.Fatal("expected raw token in response")
}
if !strings.HasPrefix(createResp.Record.Name, "Pulse Mobile relay access ") {
t.Fatalf("unexpected relay mobile token name %q", createResp.Record.Name)
}
wantScopes := []string{config.ScopeRelayMobileAccess}
if len(createResp.Record.Scopes) != len(wantScopes) {
t.Fatalf("relay mobile scopes length = %d, want %d", len(createResp.Record.Scopes), len(wantScopes))
}
for i, scope := range wantScopes {
if createResp.Record.Scopes[i] != scope {
t.Fatalf("relay mobile scope[%d] = %q, want %q", i, createResp.Record.Scopes[i], scope)
}
}
if got := cfg.APITokens[0].Metadata[apiTokenMetadataPurpose]; got != apiTokenPurposeRelayMobileAccess {
t.Fatalf("relay mobile token purpose metadata = %q, want %q", got, apiTokenPurposeRelayMobileAccess)
}
if got := cfg.APITokens[0].Metadata[apiTokenMetadataOwnerUserID]; got != "alice" {
t.Fatalf("relay mobile owner user metadata = %q, want alice", got)
}
}
func TestSecurityTokens_Create_BindsTokenToRequestOrg(t *testing.T) {
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
router := &Router{
config: cfg,
persistence: persistence,
}
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader(`{"name":"test","scopes":["monitoring:read"]}`))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "acme"))
rr := httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
if len(cfg.APITokens) != 1 {
t.Fatalf("expected token stored in config")
}
if got := cfg.APITokens[0].OrgID; got != "acme" {
t.Fatalf("token org binding = %q, want acme", got)
}
}
func TestSecurityTokens_Create_RejectsScopeEscalationForTokenCaller(t *testing.T) {
tests := []struct {
name string
body string
wantFragment string
}{
{
name: "explicit scope escalation",
body: `{"name":"escalated","scopes":["monitoring:read","settings:write"]}`,
wantFragment: `Cannot grant scope "settings:write"`,
},
{
name: "omitted scopes defaults to wildcard escalation",
body: `{"name":"full-access"}`,
wantFragment: `Cannot grant scope "*"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
router := &Router{
config: cfg,
persistence: persistence,
}
caller := newTokenRecord(t, "limited-caller-token-123.12345678", []string{config.ScopeMonitoringRead}, nil)
caller.OrgID = "acme"
req := httptest.NewRequest(http.MethodPost, "/api/security/tokens", strings.NewReader(tt.body))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "acme"))
req = req.WithContext(authpkg.WithAPIToken(req.Context(), &caller))
rr := httptest.NewRecorder()
router.handleCreateAPIToken(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rr.Code)
}
if !strings.Contains(rr.Body.String(), tt.wantFragment) {
t.Fatalf("expected response to contain %q, got %q", tt.wantFragment, rr.Body.String())
}
if len(cfg.APITokens) != 0 {
t.Fatalf("expected no tokens to be stored after denied creation, got %d", len(cfg.APITokens))
}
})
}
}
func TestSecurityTokens_Delete_RejectsScopeEscalationForTokenCaller(t *testing.T) {
cfg := &config.Config{
APITokens: []config.APITokenRecord{
{
ID: "broad-token",
Name: "broad",
Hash: "hash-broad",
CreatedAt: time.Now().Add(-time.Hour),
Scopes: []string{config.ScopeWildcard},
OrgID: "default",
},
},
}
router := &Router{config: cfg}
caller := newTokenRecord(t, "limited-caller-token-123.12345678", []string{config.ScopeSettingsWrite}, nil)
caller.OrgID = "default"
req := httptest.NewRequest(http.MethodDelete, "/api/security/tokens/broad-token", nil)
req = req.WithContext(authpkg.WithAPIToken(req.Context(), &caller))
rr := httptest.NewRecorder()
router.handleDeleteAPIToken(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rr.Code)
}
if !strings.Contains(rr.Body.String(), config.ScopeWildcard) {
t.Fatalf("expected forbidden response to mention missing scope, got %q", rr.Body.String())
}
if len(cfg.APITokens) != 1 || cfg.APITokens[0].ID != "broad-token" {
t.Fatalf("expected target token to remain configured, got %+v", cfg.APITokens)
}
}