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