package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "context" "encoding/json" "io" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) func TestListMySessions_Success(t *testing.T) { now := time.Date(2026, 4, 2, 1, 2, 3, 0, time.UTC) setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/sessions/whoami" { return httpJSONAny(r, http.StatusOK, map[string]any{ "id": "current-sid", "authenticated_at": now.Format(time.RFC3339), "identity": map[string]any{ "id": "user-123", "traits": map[string]any{ "email": "user@example.com", "name": "User", "role": "user", }, }, }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil })) mockKratos := new(MockKratosAdminService) mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ { ID: "current-sid", Active: true, AuthenticatedAt: now, ExpiresAt: now.Add(24 * time.Hour), }, { ID: "other-sid", Active: true, AuthenticatedAt: now.Add(-2 * time.Hour), ExpiresAt: now.Add(22 * time.Hour), }, }, nil).Once() auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { UserID: "user-123", EventType: "login_success", SessionID: "other-sid", Timestamp: now.Add(-30 * time.Minute), IPAddress: "203.0.113.10", UserAgent: "Mozilla/5.0", }, }, } h := &AuthHandler{ KratosAdmin: mockKratos, AuditRepo: auditRepo, } app := fiber.New() app.Get("/api/v1/user/sessions", h.ListMySessions) req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) req.Header.Set("Cookie", "ory_kratos_session=valid") resp, err := app.Test(req, -1) assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) var body struct { Items []struct { SessionID string `json:"session_id"` IsCurrent bool `json:"is_current"` IsActive bool `json:"is_active"` IPAddress string `json:"ip_address"` UserAgent string `json:"user_agent"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&body) assert.NoError(t, err) if assert.Len(t, body.Items, 2) { assert.Equal(t, "current-sid", body.Items[0].SessionID) assert.True(t, body.Items[0].IsCurrent) assert.Equal(t, "other-sid", body.Items[1].SessionID) assert.True(t, body.Items[1].IsActive) assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent) } mockKratos.AssertExpectations(t) } func TestDeleteMySession_Success(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") var hydraRevokeCalls int client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { switch r.URL.Host { case "kratos.test": if r.URL.Path == "/sessions/whoami" { return httpJSONAny(r, http.StatusOK, map[string]any{ "id": "current-sid", "authenticated_at": time.Now().UTC().Format(time.RFC3339), "identity": map[string]any{ "id": "user-123", "traits": map[string]any{ "email": "user@example.com", "name": "User", "role": "user", }, }, }), nil } case "hydra.test": if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" { if r.URL.Query().Get("subject") != "user-123" { t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject")) } if r.URL.Query().Get("client") != "devfront" { t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client")) } hydraRevokeCalls++ return httpResponse(r, http.StatusNoContent, ""), nil } } return httpResponse(r, http.StatusNotFound, "not found"), nil })} setDefaultHTTPClientForTest(t, client.Transport) mockKratos := new(MockKratosAdminService) mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ {ID: "target-sid", Active: true}, }, nil).Once() mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ ID: "target-sid", Active: true, }, nil).Once() mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once() auditRepo := &mockAuditRepo{} h := &AuthHandler{ KratosAdmin: mockKratos, AuditRepo: auditRepo, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, } auditRepo.logs = append(auditRepo.logs, domain.AuditLog{ UserID: "user-123", EventType: "POST /api/v1/auth/oidc/login/accept", SessionID: "target-sid", Details: `{"client_id":"devfront","client_name":"Devfront"}`, }) app := fiber.New() app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession) req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil) req.Header.Set("Cookie", "ory_kratos_session=valid") req.Header.Set("User-Agent", "session-test-agent") resp, err := app.Test(req, -1) assert.NoError(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) if assert.Len(t, auditRepo.logs, 2) { assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType) assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID) assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID) assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid") } assert.Equal(t, 1, hydraRevokeCalls) mockKratos.AssertExpectations(t) } func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) { client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" { body, _ := io.ReadAll(r.Body) if string(body) != "token=opaque-token" { t.Fatalf("unexpected introspect body: %s", string(body)) } return httpJSONAny(r, http.StatusOK, map[string]any{ "active": true, "sub": "user-123", "client_id": "devfront", "ext": map[string]any{ "session_id": "target-sid", }, }), nil } return httpResponse(r, http.StatusNotFound, "not found"), nil })} mockKratos := new(MockKratosAdminService) mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{ ID: "target-sid", Active: false, Identity: &service.KratosIdentity{ ID: "user-123", }, }, nil).Once() h := &AuthHandler{ KratosAdmin: mockKratos, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: client, }, } profile, err := h.getHydraProfile(context.Background(), "opaque-token") assert.Nil(t, profile) assert.Error(t, err) assert.Contains(t, err.Error(), "inactive") mockKratos.AssertExpectations(t) }