Files
key-cape/src/internal/adapters/privacyidea/adapter_test.go
tegwick 937cb39de6
Some checks failed
Build and Publish Container Image / build-and-push (push) Has been cancelled
Require MFA during bootstrap mode
2026-05-25 00:09:40 +02:00

331 lines
10 KiB
Go

package privacyidea_test
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"keycape/internal/adapters/privacyidea"
"keycape/internal/domain"
)
// ---------------------------------------------------------------------------
// Mock HTTP client
// ---------------------------------------------------------------------------
// mockHTTPClient implements privacyidea.HTTPClient for test injection.
type mockHTTPClient struct {
doFn func(req *http.Request) (*http.Response, error)
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
if m.doFn != nil {
return m.doFn(req)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("{}")),
}, nil
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// testConfig returns a minimal Config suitable for tests.
func testConfig() privacyidea.Config {
return privacyidea.Config{
BaseURL: "https://privacyidea.local",
AdminToken: "service-jwt",
Realm: "netkingdom",
}
}
// jsonResponse returns a *http.Response with a JSON body and status 200.
func jsonResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
// tokenListResponse builds a privacyIDEA /token/ JSON response.
func tokenListResponse(tokens []map[string]interface{}) string {
tokenJSON := "["
for i, t := range tokens {
if i > 0 {
tokenJSON += ","
}
active, _ := t["active"].(bool)
tokenJSON += fmt.Sprintf(`{"serial":"TOK%d","active":%v}`, i, active)
}
tokenJSON += "]"
return fmt.Sprintf(`{"result":{"status":true,"value":{"tokens":%s}}}`, tokenJSON)
}
// validateResponse builds a privacyIDEA /validate/check JSON response.
func validateResponse(success bool) string {
return fmt.Sprintf(`{"result":{"status":true,"value":%v}}`, success)
}
// ---------------------------------------------------------------------------
// CheckMFARequired — tokens present
// ---------------------------------------------------------------------------
func TestCheckMFARequired_ActiveTokenPresent_ReturnsTrue(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodGet {
t.Errorf("expected GET, got %s", req.Method)
}
if !strings.Contains(req.URL.String(), "alice") {
t.Errorf("expected user in URL, got: %s", req.URL)
}
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": true},
})), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !required {
t.Error("expected MFA required=true when active token present")
}
}
func TestCheckMFARequired_RequireForAll_ReturnsTrueWithoutTokenList(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
t.Fatal("token-list endpoint must not be called when RequireForAll is enabled")
return nil, nil
},
}
cfg := testConfig()
cfg.RequireForAll = true
adapter := privacyidea.New(cfg, client)
required, err := adapter.CheckMFARequired(context.Background(), "alice")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !required {
t.Error("expected MFA required=true when RequireForAll is enabled")
}
}
func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse([]map[string]interface{}{
{"active": false},
})), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "bob")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if required {
t.Error("expected MFA required=false when only inactive tokens present")
}
}
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
required, err := adapter.CheckMFARequired(context.Background(), "charlie")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if required {
t.Error("expected MFA required=false when no tokens")
}
}
// ---------------------------------------------------------------------------
// CheckMFARequired — error cases (fail closed)
// ---------------------------------------------------------------------------
func TestCheckMFARequired_HTTPError_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("connection refused")
},
}
adapter := privacyidea.New(testConfig(), client)
_, err := adapter.CheckMFARequired(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
}
}
func TestCheckMFARequired_Non200Status_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusInternalServerError,
Body: io.NopCloser(strings.NewReader(`{"result":{"status":false}}`)),
}, nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, err := adapter.CheckMFARequired(context.Background(), "alice")
if err == nil {
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
}
}
// ---------------------------------------------------------------------------
// CheckMFARequired — Authorization header
// ---------------------------------------------------------------------------
func TestCheckMFARequired_SendsAdminToken(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
auth := req.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("expected Bearer token in Authorization, got %q", auth)
}
if !strings.Contains(auth, "service-jwt") {
t.Errorf("expected admin token in Authorization header, got %q", auth)
}
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — success
// ---------------------------------------------------------------------------
func TestValidateMFAToken_ValidOTP_ReturnsNil(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost {
t.Errorf("expected POST, got %s", req.Method)
}
body, _ := io.ReadAll(req.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, "alice") {
t.Errorf("expected user in POST body, got: %s", bodyStr)
}
if !strings.Contains(bodyStr, "123456") {
t.Errorf("expected OTP in POST body, got: %s", bodyStr)
}
return jsonResponse(validateResponse(true)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err != nil {
t.Errorf("expected nil error for valid OTP, got %v", err)
}
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — failure
// ---------------------------------------------------------------------------
func TestValidateMFAToken_InvalidOTP_ReturnsErrMFAFailed(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return jsonResponse(validateResponse(false)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "wrong")
if err == nil {
t.Fatal("expected ErrMFAFailed, got nil")
}
if err != domain.ErrMFAFailed {
t.Errorf("expected domain.ErrMFAFailed, got %v", err)
}
}
func TestValidateMFAToken_HTTPError_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("network failure")
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err == nil {
t.Fatal("expected error on HTTP failure, got nil (must fail closed)")
}
}
func TestValidateMFAToken_Non200Status_ReturnsError(t *testing.T) {
client := &mockHTTPClient{
doFn: func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader(`{}`)),
}, nil
},
}
adapter := privacyidea.New(testConfig(), client)
err := adapter.ValidateMFAToken(context.Background(), "alice", "123456")
if err == nil {
t.Fatal("expected error on non-200 status, got nil (must fail closed)")
}
}
// ---------------------------------------------------------------------------
// ValidateMFAToken — realm is included in request
// ---------------------------------------------------------------------------
func TestValidateMFAToken_IncludesRealm(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
body, _ := io.ReadAll(req.Body)
if !strings.Contains(string(body), "netkingdom") {
t.Errorf("expected realm in POST body, got: %s", body)
}
return jsonResponse(validateResponse(true)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_ = adapter.ValidateMFAToken(context.Background(), "alice", "000000")
}
func TestCheckMFARequired_IncludesRealm(t *testing.T) {
client := &mockHTTPClient{
doFn: func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.String(), "netkingdom") {
t.Errorf("expected realm in request URL, got: %s", req.URL)
}
return jsonResponse(tokenListResponse(nil)), nil
},
}
adapter := privacyidea.New(testConfig(), client)
_, _ = adapter.CheckMFARequired(context.Background(), "alice")
}