From 7cac910de4c4a47156bbfcc97e5dca618aa219f8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 31 May 2026 20:43:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin-console):=20Stage=206=20=E2=80=94=20?= =?UTF-8?q?mail=20&=20notifications=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the mail, notifications, and broadcast pages over the mail, notification, and diplomail services (no new business logic), completing the operator console. - GET /_gm/mail deliveries (paginated) + dead-letters - GET /_gm/mail/deliveries/{id} delivery detail + attempts - POST /_gm/mail/deliveries/{id}/resend re-enqueue a non-sent delivery - GET /_gm/notifications notifications + dead-letters + malformed - GET/POST /_gm/broadcast multi-game admin diplomatic broadcast Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces (satisfied by the concrete services); pages render in tests without a database. Delivery detail and dead-letters live under /_gm/mail/deliveries/* and /_gm/mail/... static segments to avoid a param/static route conflict. Resend and broadcast flow through the CSRF guard. Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF), notifications overview, broadcast form + send (input assertions) + bad game ids, and unavailable. Plus an integration test that drives /_gm end to end through the real gateway → backend (401 challenge + authenticated dashboard). Docs: backend/docs/admin-console.md page inventory completed. --- backend/cmd/backend/main.go | 3 + backend/docs/admin-console.md | 5 + .../internal/adminconsole/assets/console.css | 1 + backend/internal/adminconsole/mail.go | 86 +++++ .../templates/pages/broadcast.gohtml | 21 ++ .../adminconsole/templates/pages/mail.gohtml | 32 ++ .../templates/pages/mail_delivery.gohtml | 33 ++ .../templates/pages/notifications.gohtml | 27 ++ .../internal/server/handlers_admin_console.go | 9 + .../server/handlers_admin_console_mail.go | 327 ++++++++++++++++++ .../handlers_admin_console_mail_test.go | 242 +++++++++++++ backend/internal/server/router.go | 7 + integration/admin_console_test.go | 63 ++++ 13 files changed, 856 insertions(+) create mode 100644 backend/internal/adminconsole/mail.go create mode 100644 backend/internal/adminconsole/templates/pages/broadcast.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/mail.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/mail_delivery.gohtml create mode 100644 backend/internal/adminconsole/templates/pages/notifications.gohtml create mode 100644 backend/internal/server/handlers_admin_console_mail.go create mode 100644 backend/internal/server/handlers_admin_console_mail_test.go create mode 100644 integration/admin_console_test.go diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 79968e1..8d87527 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -382,6 +382,9 @@ func run(ctx context.Context) (err error) { Runtime: runtimeSvc, EngineVersions: engineVersionSvc, Operators: adminSvc, + Mail: mailSvc, + Notifications: notifSvc, + Diplomail: diplomailSvc, Logger: logger, }) diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index 9694a11..74e908d 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -105,6 +105,11 @@ changes. | `/_gm/operators/{user}/disable` | POST | Disable an operator. | | `/_gm/operators/{user}/enable` | POST | Re-enable an operator. | | `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. | +| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. | +| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. | +| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. | +| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. | +| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. | Each page reuses the same service layer as the corresponding `/api/v1/admin/*` JSON endpoint; the console adds no business logic. Collection-mutating POSTs are diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index b4550df..6b276b7 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -100,3 +100,4 @@ button.danger { background: var(--danger); color: #1a0606; } code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } .actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } .actions form { margin: 0; } +.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; } diff --git a/backend/internal/adminconsole/mail.go b/backend/internal/adminconsole/mail.go new file mode 100644 index 0000000..f6983d7 --- /dev/null +++ b/backend/internal/adminconsole/mail.go @@ -0,0 +1,86 @@ +package adminconsole + +// MailDeliveryRow is one line in the mail deliveries table. +type MailDeliveryRow struct { + DeliveryID string + Template string + Status string + Attempts int32 + NextAttempt string + Created string +} + +// MailDeadLetterRow is one line in the mail dead-letters table. +type MailDeadLetterRow struct { + DeliveryID string + Reason string + Archived string +} + +// MailData is the view model for the mail page: a paginated deliveries list +// plus a snapshot of dead-letters. +type MailData struct { + Deliveries []MailDeliveryRow + DeadLetters []MailDeadLetterRow + Page int + PageSize int + Total int64 + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// MailAttemptRow is one delivery attempt on the mail detail page. +type MailAttemptRow struct { + AttemptNo int32 + Outcome string + Started string + Finished string + Error string +} + +// MailDeliveryDetail is the view model for a single delivery. +type MailDeliveryDetail struct { + DeliveryID string + Template string + Status string + Attempts int32 + NextAttempt string + LastError string + Created string + Sent string + DeadLettered string + CanResend bool + AttemptRows []MailAttemptRow +} + +// NotificationRow is one line in the notifications table. +type NotificationRow struct { + NotificationID string + Kind string + UserID string + Created string +} + +// NotificationDeadLetterRow is one line in the notification dead-letters table. +type NotificationDeadLetterRow struct { + NotificationID string + RouteID string + Reason string + Archived string +} + +// MalformedRow is one line in the malformed-intents table. +type MalformedRow struct { + ID string + Reason string + Received string +} + +// NotificationsData is the view model for the notifications overview page. +type NotificationsData struct { + Notifications []NotificationRow + DeadLetters []NotificationDeadLetterRow + Malformed []MalformedRow +} diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml new file mode 100644 index 0000000..317ac33 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml @@ -0,0 +1,21 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Broadcast

+ +
+

Admin broadcast

+
+ + + + + + + +
+
+{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/mail.gohtml b/backend/internal/adminconsole/templates/pages/mail.gohtml new file mode 100644 index 0000000..ea4a93e --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/mail.gohtml @@ -0,0 +1,32 @@ +{{define "content" -}} +

Mail

+ +{{with .Data}} +
+

Deliveries

+ + + +{{range .Deliveries}} + +{{else}}{{end}} + +
DeliveryTemplateStatusAttemptsNext attemptCreated
{{.DeliveryID}}{{.Template}}{{.Status}}{{.Attempts}}{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}{{.Created}}
no deliveries
+ +
+
+

Dead-letters

+ + + +{{range .DeadLetters}} +{{else}}{{end}} + +
DeliveryReasonArchived
{{.DeliveryID}}{{.Reason}}{{.Archived}}
no dead-letters
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml new file mode 100644 index 0000000..d587259 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml @@ -0,0 +1,33 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +{{with .Data}} +

« mail

+

Delivery

+
+ +{{if .CanResend}} +
+{{else}}

Already sent — resend is not available.

{{end}} +
+
+

Attempts

+ + + +{{range .AttemptRows}} +{{else}}{{end}} + +
#OutcomeStartedFinishedError
{{.AttemptNo}}{{.Outcome}}{{.Started}}{{if .Finished}}{{.Finished}}{{else}}—{{end}}{{.Error}}
no attempts
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/notifications.gohtml b/backend/internal/adminconsole/templates/pages/notifications.gohtml new file mode 100644 index 0000000..eb1d27d --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/notifications.gohtml @@ -0,0 +1,27 @@ +{{define "content" -}} +

Notifications

+ +{{with .Data}} +
+

Recent notifications

+ +{{range .Notifications}} +{{else}}{{end}} +
IDKindUserCreated
{{.NotificationID}}{{.Kind}}{{.UserID}}{{.Created}}
none
+
+
+

Dead-letters

+ +{{range .DeadLetters}} +{{else}}{{end}} +
NotificationRouteReasonArchived
{{.NotificationID}}{{.RouteID}}{{.Reason}}{{.Archived}}
none
+
+
+

Malformed intents

+ +{{range .Malformed}} +{{else}}{{end}} +
IDReasonReceived
{{.ID}}{{.Reason}}{{.Received}}
none
+
+{{end}} +{{- end}} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index d58e4ee..a23badc 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -33,6 +33,9 @@ type AdminConsoleHandlers struct { runtime RuntimeAdmin engineVersions EngineVersionAdmin operators OperatorAdmin + mail MailAdmin + notifications NotificationAdmin + diplomail DiplomailAdmin logger *zap.Logger } @@ -51,6 +54,9 @@ type AdminConsoleDeps struct { Runtime RuntimeAdmin EngineVersions EngineVersionAdmin Operators OperatorAdmin + Mail MailAdmin + Notifications NotificationAdmin + Diplomail DiplomailAdmin Logger *zap.Logger } @@ -92,6 +98,9 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { runtime: deps.Runtime, engineVersions: deps.EngineVersions, operators: deps.Operators, + mail: deps.Mail, + notifications: deps.Notifications, + diplomail: deps.Diplomail, logger: logger.Named("http.admin.console"), } } diff --git a/backend/internal/server/handlers_admin_console_mail.go b/backend/internal/server/handlers_admin_console_mail.go new file mode 100644 index 0000000..cfee71a --- /dev/null +++ b/backend/internal/server/handlers_admin_console_mail.go @@ -0,0 +1,327 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/mail" + "galaxy/backend/internal/notification" + "galaxy/backend/internal/server/clientip" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// MailAdmin is the subset of the mail service the console uses. +type MailAdmin interface { + AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error) + AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error) + AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error) + AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error) + AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error) +} + +// NotificationAdmin is the subset of the notification service the console uses. +type NotificationAdmin interface { + AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error) + AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error) + AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error) +} + +// DiplomailAdmin is the subset of the diplomail service the console uses. +type DiplomailAdmin interface { + SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) +} + +const consoleSnapshotPageSize = 50 + +// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot. +func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc { + return func(c *gin.Context) { + if h.mail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") + return + } + page := parsePositiveQueryInt(c.Query("page"), 1) + pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) + ctx := c.Request.Context() + + deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize) + if err != nil { + h.logger.Error("admin console: list deliveries", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/") + return + } + dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list mail dead-letters", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead)) + } +} + +// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id. +func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc { + return func(c *gin.Context) { + if h.mail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") + return + } + deliveryID, ok := parseConsoleDeliveryID(c, h) + if !ok { + return + } + ctx := c.Request.Context() + delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID) + if err != nil { + if errors.Is(err, mail.ErrDeliveryNotFound) { + h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail") + return + } + h.logger.Error("admin console: get delivery", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail") + return + } + attempts, err := h.mail.AdminListAttempts(ctx, deliveryID) + if err != nil { + h.logger.Error("admin console: list attempts", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail") + return + } + h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts)) + } +} + +// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend. +func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc { + return func(c *gin.Context) { + if h.mail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/") + return + } + deliveryID, ok := parseConsoleDeliveryID(c, h) + if !ok { + return + } + back := "/_gm/mail/deliveries/" + deliveryID.String() + if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil { + h.logger.Error("admin console: resend delivery", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters, +// and malformed intents on one overview page. +func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc { + return func(c *gin.Context) { + if h.notifications == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/") + return + } + ctx := c.Request.Context() + notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list notifications", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/") + return + } + dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list notification dead-letters", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/") + return + } + malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize) + if err != nil { + h.logger.Error("admin console: list malformed intents", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed)) + } +} + +// BroadcastForm renders GET /_gm/broadcast. +func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc { + return func(c *gin.Context) { + if h.diplomail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil) + } +} + +// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast. +func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc { + return func(c *gin.Context) { + if h.diplomail == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/") + return + } + username, _ := basicauth.UsernameFromContext(c.Request.Context()) + gameIDs, err := parseGameIDList(c.PostForm("game_ids")) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast") + return + } + _, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{ + CallerUsername: username, + Scope: strings.TrimSpace(c.PostForm("scope")), + GameIDs: gameIDs, + RecipientScope: strings.TrimSpace(c.PostForm("recipients")), + Subject: strings.TrimSpace(c.PostForm("subject")), + Body: c.PostForm("body"), + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + if errors.Is(err, diplomail.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast") + return + } + h.logger.Error("admin console: broadcast", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast") + return + } + h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast") + } +} + +// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a +// console message page on failure. +func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) { + parsed, err := uuid.Parse(c.Param("delivery_id")) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail") + return uuid.Nil, false + } + return parsed, true +} + +// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks. +func parseGameIDList(raw string) ([]uuid.UUID, error) { + fields := strings.Split(raw, ",") + ids := make([]uuid.UUID, 0, len(fields)) + for _, field := range fields { + field = strings.TrimSpace(field) + if field == "" { + continue + } + parsed, err := uuid.Parse(field) + if err != nil { + return nil, err + } + ids = append(ids, parsed) + } + return ids, nil +} + +func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData { + data := adminconsole.MailData{ + Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)), + DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)), + Page: deliveries.Page, + PageSize: deliveries.PageSize, + Total: deliveries.Total, + PrevPage: deliveries.Page - 1, + NextPage: deliveries.Page + 1, + HasPrev: deliveries.Page > 1, + HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total, + } + for _, d := range deliveries.Items { + data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{ + DeliveryID: d.DeliveryID.String(), + Template: d.TemplateID, + Status: d.Status, + Attempts: d.Attempts, + NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt), + Created: fmtConsoleTime(d.CreatedAt), + }) + } + for _, d := range dead.Items { + data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{ + DeliveryID: d.DeliveryID.String(), + Reason: d.Reason, + Archived: fmtConsoleTime(d.ArchivedAt), + }) + } + return data +} + +func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail { + detail := adminconsole.MailDeliveryDetail{ + DeliveryID: d.DeliveryID.String(), + Template: d.TemplateID, + Status: d.Status, + Attempts: d.Attempts, + NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt), + LastError: d.LastError, + Created: fmtConsoleTime(d.CreatedAt), + Sent: fmtConsoleTimePtr(d.SentAt), + DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt), + CanResend: d.Status != mail.StatusSent, + AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)), + } + for _, a := range attempts { + detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{ + AttemptNo: a.AttemptNo, + Outcome: a.Outcome, + Started: fmtConsoleTime(a.StartedAt), + Finished: fmtConsoleTimePtr(a.FinishedAt), + Error: a.Error, + }) + } + return detail +} + +func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData { + data := adminconsole.NotificationsData{ + Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)), + DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)), + Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)), + } + for _, n := range notifications.Items { + data.Notifications = append(data.Notifications, adminconsole.NotificationRow{ + NotificationID: n.NotificationID.String(), + Kind: n.Kind, + UserID: optionalUUID(n.UserID), + Created: fmtConsoleTime(n.CreatedAt), + }) + } + for _, d := range dead.Items { + data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{ + NotificationID: d.NotificationID.String(), + RouteID: d.RouteID.String(), + Reason: d.Reason, + Archived: fmtConsoleTime(d.ArchivedAt), + }) + } + for _, m := range malformed.Items { + data.Malformed = append(data.Malformed, adminconsole.MalformedRow{ + ID: m.ID.String(), + Reason: m.Reason, + Received: fmtConsoleTime(m.ReceivedAt), + }) + } + return data +} + +// optionalUUID renders a nullable user id; system-scoped rows have none. +func optionalUUID(id *uuid.UUID) string { + if id == nil { + return "—" + } + return id.String() +} diff --git a/backend/internal/server/handlers_admin_console_mail_test.go b/backend/internal/server/handlers_admin_console_mail_test.go new file mode 100644 index 0000000..449e82f --- /dev/null +++ b/backend/internal/server/handlers_admin_console_mail_test.go @@ -0,0 +1,242 @@ +package server + +import ( + "context" + "net/http" + "strings" + "testing" + "time" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/mail" + "galaxy/backend/internal/notification" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type fakeMailAdmin struct { + deliveries mail.AdminListDeliveriesPage + dead mail.AdminListDeadLettersPage + delivery mail.Delivery + getErr error + attempts []mail.Attempt + resendCalls int +} + +func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) { + return f.deliveries, nil +} +func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) { + return f.delivery, f.getErr +} +func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) { + return f.attempts, nil +} +func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) { + f.resendCalls++ + return f.delivery, nil +} +func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) { + return f.dead, nil +} + +type fakeNotificationAdmin struct { + notifications notification.AdminListNotificationsPage + dead notification.AdminListDeadLettersPage + malformed notification.AdminListMalformedPage +} + +func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) { + return f.notifications, nil +} +func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) { + return f.dead, nil +} +func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) { + return f.malformed, nil +} + +type fakeDiplomailAdmin struct { + total int + err error + broadcastCalls int + last diplomail.SendMultiGameBroadcastInput +} + +func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) { + f.broadcastCalls++ + f.last = in + if f.err != nil { + return nil, 0, f.err + } + return nil, f.total, nil +} + +func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func TestConsoleMailPage(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{ + deliveries: mail.AdminListDeliveriesPage{ + Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}}, + Page: 1, PageSize: 50, Total: 1, + }, + dead: mail.AdminListDeadLettersPage{ + Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}}, + }, + } + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("mail page missing %q", want) + } + } +} + +func TestConsoleMailDeliveryDetail(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{ + delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2}, + attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}}, + } + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("delivery detail missing %q", want) + } + } +} + +func TestConsoleMailDeliveryDetailNotFound(t *testing.T) { + m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound} + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String()) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } +} + +func TestConsoleMailResend(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}} + router, csrf := mailConsoleRouter(t, m, nil, nil) + + rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if m.resendCalls != 1 { + t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls) + } +} + +func TestConsoleMailResendRejectsBadCSRF(t *testing.T) { + id := uuid.New() + m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}} + router, _ := mailConsoleRouter(t, m, nil, nil) + + rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if m.resendCalls != 0 { + t.Error("resend must not run without a CSRF token") + } +} + +func TestConsoleNotificationsPage(t *testing.T) { + n := &fakeNotificationAdmin{ + notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}}, + dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}}, + malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}}, + } + router, _ := mailConsoleRouter(t, nil, n, nil) + + rec := consoleGet(t, router, "/_gm/notifications") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("notifications page missing %q", want) + } + } +} + +func TestConsoleBroadcastForm(t *testing.T) { + router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{}) + + rec := consoleGet(t, router, "/_gm/broadcast") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Send broadcast") { + t.Error("broadcast form missing") + } +} + +func TestConsoleBroadcastSend(t *testing.T) { + d := &fakeDiplomailAdmin{total: 5} + router, csrf := mailConsoleRouter(t, nil, nil, d) + + form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello" + rec := consolePost(t, router, "/_gm/broadcast", form) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "5 recipients") { + t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String()) + } + if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" { + t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls) + } +} + +func TestConsoleBroadcastSendBadGameIDs(t *testing.T) { + d := &fakeDiplomailAdmin{} + router, csrf := mailConsoleRouter(t, nil, nil, d) + + form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello" + rec := consolePost(t, router, "/_gm/broadcast", form) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if d.broadcastCalls != 0 { + t.Error("broadcast must not run with invalid game ids") + } +} + +func TestConsoleMailUnavailable(t *testing.T) { + router, _ := mailConsoleRouter(t, nil, nil, nil) + + rec := consoleGet(t, router, "/_gm/mail") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 4e5c3e5..5a44de0 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -414,6 +414,13 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable()) group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable()) group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword()) + + group.GET("/mail", deps.AdminConsole.MailPage()) + group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail()) + group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend()) + group.GET("/notifications", deps.AdminConsole.NotificationsPage()) + group.GET("/broadcast", deps.AdminConsole.BroadcastForm()) + group.POST("/broadcast", deps.AdminConsole.BroadcastSend()) } // allowedMethodsForPath returns the comma-separated list of methods diff --git a/integration/admin_console_test.go b/integration/admin_console_test.go new file mode 100644 index 0000000..2f378ff --- /dev/null +++ b/integration/admin_console_test.go @@ -0,0 +1,63 @@ +package integration_test + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "galaxy/integration/testenv" +) + +// TestAdminConsole_ThroughGateway verifies the full operator-console pipe: +// the edge gateway reverse-proxies `/_gm/*` to the backend, the backend +// enforces the admin Basic Auth and relays its 401 challenge unchanged, and an +// authenticated request renders the server-side dashboard. +func TestAdminConsole_ThroughGateway(t *testing.T) { + plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + base := plat.Gateway.HTTPURL + client := &http.Client{Timeout: 10 * time.Second} + + // Unauthenticated: the backend's 401 + Basic challenge must reach the client + // through the gateway. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET /_gm/ (no auth): %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("no-auth status = %d, want 401; body=%s", resp.StatusCode, body) + } + if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") { + t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", resp.Header.Get("WWW-Authenticate")) + } + + // Authenticated with the bootstrap operator: the dashboard renders. + req, err = http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.SetBasicAuth(plat.Backend.AdminUser, plat.Backend.AdminPassword) + resp, err = client.Do(req) + if err != nil { + t.Fatalf("GET /_gm/ (auth): %v", err) + } + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("authenticated status = %d, want 200; body=%s", resp.StatusCode, body) + } + if !strings.Contains(string(body), "Dashboard") { + t.Fatalf("dashboard body missing the heading; got: %s", body) + } +}