diff --git a/internal/server/api_test.go b/internal/server/api_test.go index c2a2ce8..8345f9b 100644 --- a/internal/server/api_test.go +++ b/internal/server/api_test.go @@ -154,6 +154,27 @@ func TestProfileUpdateFlow(t *testing.T) { } } +func TestSecurityEventsListed(t *testing.T) { + srv, _ := newTestServer(t) + cookie := registerAndLogin(t, srv, "events_user", "events@example.com") + + rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/security-events", "", cookie) + if rec.Code != http.StatusOK { + t.Fatalf("security events = %d, body = %s", rec.Code, rec.Body.String()) + } + payload := decodeResponse[struct { + Events []struct { + Type string `json:"type"` + } `json:"events"` + }](t, rec) + if len(payload.Events) == 0 { + t.Fatal("registration must leave a security event") + } + if payload.Events[0].Type != "register" { + t.Fatalf("first event = %q, want register", payload.Events[0].Type) + } +} + func TestGuildLifecycleAndPermissions(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com") diff --git a/internal/server/api_users.go b/internal/server/api_users.go index 582a61b..9e94cb8 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "strings" + "time" "github.com/danielgtaylor/huma/v2" @@ -105,6 +106,21 @@ type onboardingInput struct { } } +// securityEventPayload — событие безопасности без служебных метаданных. +type securityEventPayload struct { + ID string `json:"id"` + Type string `json:"type"` + IP string `json:"ip,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt string `json:"created_at"` +} + +type securityEventListOutput struct { + Body struct { + Events []securityEventPayload `json:"events"` + } +} + type guildSummary struct { ID string `json:"id"` Name string `json:"name"` @@ -236,6 +252,39 @@ func (s *Server) registerUserRoutes(api huma.API) { return output, nil }) + huma.Register(api, huma.Operation{ + OperationID: "listSecurityEvents", + Method: http.MethodGet, + Path: "/users/@me/security-events", + Summary: "Последние события безопасности аккаунта (входы, смена пароля, 2FA)", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, input *struct { + Limit int `query:"limit" default:"20" minimum:"1" maximum:"100"` + }, + ) (*securityEventListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + events, err := s.store.ListSecurityEvents(ctx, user.ID, input.Limit) + if err != nil { + return nil, humaError(err) + } + output := &securityEventListOutput{} + output.Body.Events = make([]securityEventPayload, 0, len(events)) + for _, event := range events { + output.Body.Events = append(output.Body.Events, securityEventPayload{ + ID: formatSnowflake(event.ID), + Type: event.Type, + IP: event.IP, + UserAgent: event.UserAgent, + CreatedAt: event.CreatedAt.UTC().Format(time.RFC3339), + }) + } + return output, nil + }) + huma.Register(api, huma.Operation{ OperationID: "getUser", Method: http.MethodGet,