generated from coulomb/repo-seed
feat: implement T11, T12 — Authelia adapter, privacyIDEA adapter
- T11: AutheliaAdapter delegating login UI and session; Authelia tokens never leak to profile layer - T12: PrivacyIDEAAdapter delegating MFA 100% — no MFA logic in KeyCape 21 adapter tests pass, vet clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
309
src/internal/adapters/privacyidea/adapter_test.go
Normal file
309
src/internal/adapters/privacyidea/adapter_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
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_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")
|
||||
}
|
||||
Reference in New Issue
Block a user