package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "strings" "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 TestListMySessions_UsesConsentGrantForAppName(t *testing.T) { now := time.Date(2026, 4, 2, 4, 40, 0, 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: "c7c721ea-session", Active: true, AuthenticatedAt: now.Add(-5 * time.Minute), ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), }, }, nil).Once() auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { UserID: "user-123", EventType: "consent.granted", SessionID: "c7c721ea-session", Timestamp: now, Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`, }, }, } 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"` AppName string `json:"app_name"` ClientID string `json:"client_id"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&body) assert.NoError(t, err) if assert.Len(t, body.Items, 2) { assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) assert.Equal(t, "DevFront", body.Items[1].AppName) assert.Equal(t, "devfront", body.Items[1].ClientID) } mockKratos.AssertExpectations(t) } func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) { now := time.Date(2026, 4, 2, 4, 40, 0, 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: "c7c721ea-session", Active: true, AuthenticatedAt: now.Add(-5 * time.Minute), ExpiresAt: now.Add(23*time.Hour + 55*time.Minute), }, }, nil).Once() auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { UserID: "user-123", EventType: "consent.granted", SessionID: "c7c721ea-session", Timestamp: now.Add(-30 * time.Second), IPAddress: "203.0.113.10", Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`, }, { UserID: "user-123", EventType: "login_success", SessionID: "c7c721ea-session", Timestamp: now, IPAddress: "10.0.0.12", 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"` AppName string `json:"app_name"` ClientID string `json:"client_id"` IPAddress string `json:"ip_address"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&body) assert.NoError(t, err) if assert.Len(t, body.Items, 2) { assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID) assert.Equal(t, "DevFront", body.Items[1].AppName) assert.Equal(t, "devfront", body.Items[1].ClientID) assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress) } mockKratos.AssertExpectations(t) } func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) { now := time.Date(2026, 4, 6, 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), }, }, nil).Once() h := &AuthHandler{ KratosAdmin: mockKratos, AuditRepo: &mockAuditRepo{}, } 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") req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36") req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25") 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"` IPAddress string `json:"ip_address"` UserAgent string `json:"user_agent"` ClientID string `json:"client_id"` AppName string `json:"app_name"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&body) assert.NoError(t, err) if assert.Len(t, body.Items, 1) { assert.Equal(t, "current-sid", body.Items[0].SessionID) assert.True(t, body.Items[0].IsCurrent) assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress) assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0") assert.Equal(t, "userfront", body.Items[0].ClientID) assert.Equal(t, "UserFront", body.Items[0].AppName) } 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 TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(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" { 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, }, } 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) assert.Equal(t, 0, hydraRevokeCalls) if assert.Len(t, auditRepo.logs, 1) { assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType) assert.Equal(t, "user-123", auditRepo.logs[0].UserID) assert.Contains(t, auditRepo.logs[0].Details, "target-sid") } mockKratos.AssertExpectations(t) } func TestDeleteMySession_SendsBackchannelLogoutTokenWhenClientConfigured(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") t.Setenv("BACKCHANNEL_LOGOUT_ISSUER", "https://sso.example.com/oidc") var receivedBody string 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" { return httpResponse(r, http.StatusNoContent, ""), nil } if r.Method == http.MethodGet && r.URL.Path == "/clients/devfront" { return httpJSONAny(r, http.StatusOK, map[string]any{ "client_id": "devfront", "backchannel_logout_uri": "https://rp.example.com/backchannel-logout", }), nil } case "rp.example.com": if 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 })} 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() backchannelLogout, err := service.NewBackchannelLogoutService() assert.NoError(t, err) backchannelLogout.HTTPClient = client auditRepo := &mockAuditRepo{} h := &AuthHandler{ KratosAdmin: mockKratos, AuditRepo: auditRepo, BackchannelLogout: backchannelLogout, 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) assert.True(t, strings.Contains(receivedBody, "logout_token=")) values, err := url.ParseQuery(receivedBody) assert.NoError(t, err) assert.NotEmpty(t, values.Get("logout_token")) foundBackchannelAudit := false for _, log := range auditRepo.logs { if log.EventType == "backchannel_logout.sent" { foundBackchannelAudit = true break } } assert.True(t, foundBackchannelAudit) mockKratos.AssertExpectations(t) } func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) { t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") var hydraRevokeCalls int var revokedClient string 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" { revokedClient = 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/password/login", SessionID: "target-sid", Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`, }) 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) assert.Equal(t, 1, hydraRevokeCalls) assert.Equal(t, "adminfront", revokedClient) 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) } func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) { now := time.Date(2026, 4, 7, 4, 39, 0, 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 })) h := &AuthHandler{ AuditRepo: &mockAuditRepo{}, OathkeeperRepo: &mockOathkeeperRepo{ logs: []domain.OathkeeperAccessLog{ { Timestamp: now, RequestID: "req-1", Method: http.MethodGet, Path: "/api/v1/dev/sessions", Status: http.StatusOK, Subject: "user-123", ClientIP: "203.0.113.7", UserAgent: "Mozilla/5.0", Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`, }, }, }, } app := fiber.New() app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", 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"` ClientID string `json:"client_id"` AppName string `json:"app_name"` Source string `json:"source"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&body) assert.NoError(t, err) if assert.Len(t, body.Items, 1) { assert.Equal(t, "target-sid", body.Items[0].SessionID) assert.Equal(t, "devfront", body.Items[0].ClientID) assert.Equal(t, "devfront", body.Items[0].AppName) assert.Equal(t, "oathkeeper", body.Items[0].Source) } } func TestGetAuthTimeline_IncludesHeadlessPasswordLogin(t *testing.T) { now := time.Date(2026, 4, 7, 5, 10, 0, 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 })) h := &AuthHandler{ AuditRepo: &mockAuditRepo{ logs: []domain.AuditLog{ { EventID: "audit-1", Timestamp: now, UserID: "user-123", SessionID: "headless-session-1", EventType: "POST /api/v1/auth/headless/password/login", Status: "success", IPAddress: "203.0.113.20", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1","login_id":"user@example.com","login_challenge":"challenge-123"}`, }, }, }, } app := fiber.New() app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", 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"` ClientID string `json:"client_id"` AppName string `json:"app_name"` AuthMethod string `json:"auth_method"` EventType string `json:"event_type"` } `json:"items"` } err = json.NewDecoder(resp.Body).Decode(&body) assert.NoError(t, err) if assert.Len(t, body.Items, 1) { assert.Equal(t, "headless-session-1", body.Items[0].SessionID) assert.Equal(t, "headless-login-client", body.Items[0].ClientID) assert.Equal(t, "Headless Login Portal", body.Items[0].AppName) assert.Equal(t, "비밀번호(Email)", body.Items[0].AuthMethod) assert.Equal(t, "POST /api/v1/auth/headless/password/login", body.Items[0].EventType) } } func TestListMySessions_UsesHeadlessPasswordLoginForClientBinding(t *testing.T) { now := time.Date(2026, 4, 7, 5, 35, 0, 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: "headless-session-1", Active: true, AuthenticatedAt: now.Add(-10 * time.Minute), ExpiresAt: now.Add(23*time.Hour + 50*time.Minute), }, }, nil).Once() auditRepo := &mockAuditRepo{ logs: []domain.AuditLog{ { UserID: "user-123", EventType: "POST /api/v1/auth/headless/password/login", SessionID: "headless-session-1", Timestamp: now, IPAddress: "203.0.113.20", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1"}`, }, }, } 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"` AppName string `json:"app_name"` ClientID string `json:"client_id"` 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, "headless-session-1", body.Items[1].SessionID) assert.Equal(t, "Headless Login Portal", body.Items[1].AppName) assert.Equal(t, "headless-login-client", body.Items[1].ClientID) assert.Equal(t, "203.0.113.20", body.Items[1].IPAddress) assert.Equal(t, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", body.Items[1].UserAgent) } mockKratos.AssertExpectations(t) }