feat(admin-console): Stage 6 — mail & notifications domain
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user