package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" ) func TestRevokeLinkedRp_Success(t *testing.T) { // Mock Hydra transport for revocation transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { // 1. Kratos whoami if r.URL.Path == "/sessions/whoami" { return httpJSONAny(r, http.StatusOK, map[string]any{ "identity": map[string]any{"id": "user-123"}, }), nil } // 2. Hydra Revoke if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { assert.Equal(t, "user-123", r.URL.Query().Get("subject")) assert.Equal(t, "app-1", r.URL.Query().Get("client")) return httpResponse(r, http.StatusNoContent, ""), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil }) client := &http.Client{Transport: transport} origDefault := http.DefaultClient http.DefaultClient = client defer func() { http.DefaultClient = origDefault }() auditRepo := &mockAuditRepo{} rpUsageSink := &mockRPUsageEventSink{} consentRepo := &mockConsentRepo{ consents: []domain.ClientConsent{ { ClientID: "app-1", Subject: "user-123", GrantedScopes: []string{"openid", "profile"}, }, }, } h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, AuditRepo: auditRepo, ConsentRepo: consentRepo, RPUsageSink: rpUsageSink, } app := fiber.New() app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil) req.Header.Set("Cookie", "valid") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, len(auditRepo.logs)) assert.Equal(t, "consent.revoked", auditRepo.logs[0].EventType) assert.Equal(t, "user-123", auditRepo.logs[0].UserID) assert.Equal(t, "success", auditRepo.logs[0].Status) auditDetails, err := utils.ParseAuditDetails(auditRepo.logs[0].Details) assert.NoError(t, err) assert.Equal(t, "app-1", auditDetails["client_id"]) assert.Equal(t, 1, len(rpUsageSink.events)) assert.Equal(t, domain.RPUsageEventTypeAuthorizationRevoked, rpUsageSink.events[0].EventType) assert.Equal(t, "user-123", rpUsageSink.events[0].Subject) assert.Equal(t, "app-1", rpUsageSink.events[0].ClientID) remaining, err := consentRepo.Find(req.Context(), "app-1", "user-123") assert.NoError(t, err) assert.Nil(t, remaining) } func TestRevokeLinkedRp_SendsBackchannelLogoutTokenWhenConfigured(t *testing.T) { t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc") var receivedBody string transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/sessions/whoami" { return httpJSONAny(r, http.StatusOK, map[string]any{ "identity": map[string]any{"id": "user-123"}, }), nil } if r.URL.Host == "hydra.test" && r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { return httpResponse(r, http.StatusNoContent, ""), nil } if r.URL.Host == "hydra.test" && r.Method == http.MethodGet && r.URL.Path == "/clients/app-1" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "app-1", "backchannel_logout_uri": "https://rp.example.com/backchannel-logout", }), nil } if r.URL.Host == "rp.example.com" && r.Method == http.MethodPost && r.URL.Path == "/backchannel-logout" { raw, _ := io.ReadAll(r.Body) receivedBody = string(raw) return httpResponse(r, http.StatusNoContent, ""), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil }) client := &http.Client{Transport: transport} origDefault := http.DefaultClient http.DefaultClient = client defer func() { http.DefaultClient = origDefault }() backchannelLogout, err := service.NewBackchannelLogoutService() assert.NoError(t, err) backchannelLogout.HTTPClient = client auditRepo := &mockAuditRepo{} h := &AuthHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, BackchannelLogout: backchannelLogout, AuditRepo: auditRepo, } app := fiber.New() app.Delete("/api/v1/user/rp/linked/:id", h.RevokeLinkedRp) req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/rp/linked/app-1", nil) req.Header.Set("Cookie", "valid") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.True(t, strings.Contains(receivedBody, "logout_token=")) values, err := url.ParseQuery(receivedBody) assert.NoError(t, err) assert.NotEmpty(t, values.Get("logout_token")) assert.Len(t, auditRepo.logs, 2) assert.Equal(t, "backchannel_logout.sent", auditRepo.logs[1].EventType) } func TestListRpHistory_Aggregation(t *testing.T) { now := time.Now() auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { UserID: "user-123", EventType: "consent.revoked", // Newest Timestamp: now, Details: `{"client_id":"app-1"}`, }, { UserID: "user-123", EventType: "consent.granted", // Oldest Timestamp: now.Add(-1 * time.Hour), Details: `{"client_id":"app-1", "client_name":"App One"}`, }, }, } h := &AuthHandler{ AuditRepo: auditRepo, } app := fiber.New() app.Get("/api/v1/user/rp/history", h.ListRpHistory) transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { return httpJSONAny(r, http.StatusOK, map[string]any{ "identity": map[string]any{"id": "user-123"}, }), nil }) http.DefaultClient = &http.Client{Transport: transport} req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/history", nil) req.Header.Set("Cookie", "valid") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var res struct { Items []struct { ClientID string `json:"client_id"` Status string `json:"status"` } `json:"items"` } json.NewDecoder(resp.Body).Decode(&res) assert.Equal(t, 1, len(res.Items)) assert.Equal(t, "app-1", res.Items[0].ClientID) // Newest event (revoked) should win assert.Equal(t, "revoked", res.Items[0].Status) }