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) } }