feat(api): список событий безопасности аккаунта

GET /users/@me/security-events отдаёт последние входы, неудачные попытки,
смены пароля и изменения 2FA (AGENT.md 7.1: уведомления о новых входах —
основа для настроек безопасности в клиенте).
This commit is contained in:
2026-09-19 21:55:52 +03:00
parent bf0d130ee8
commit b189b3592b
2 changed files with 70 additions and 0 deletions
+21
View File
@@ -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")
+49
View File
@@ -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,