feat(admin-console): server-rendered operator console at /_gm #87
@@ -382,6 +382,9 @@ func run(ctx context.Context) (err error) {
|
|||||||
Runtime: runtimeSvc,
|
Runtime: runtimeSvc,
|
||||||
EngineVersions: engineVersionSvc,
|
EngineVersions: engineVersionSvc,
|
||||||
Operators: adminSvc,
|
Operators: adminSvc,
|
||||||
|
Mail: mailSvc,
|
||||||
|
Notifications: notifSvc,
|
||||||
|
Diplomail: diplomailSvc,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ changes.
|
|||||||
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
| `/_gm/operators/{user}/disable` | POST | Disable an operator. |
|
||||||
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
| `/_gm/operators/{user}/enable` | POST | Re-enable an operator. |
|
||||||
| `/_gm/operators/{user}/reset-password` | POST | Reset an operator's password. |
|
| `/_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/*`
|
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
|
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
|
||||||
|
|||||||
@@ -100,3 +100,4 @@ button.danger { background: var(--danger); color: #1a0606; }
|
|||||||
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
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 { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
|
||||||
.actions form { margin: 0; }
|
.actions form { margin: 0; }
|
||||||
|
.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
<h1>Broadcast</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Admin broadcast</h2>
|
||||||
|
<form method="post" action="/_gm/broadcast" class="form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$csrf}}">
|
||||||
|
<label>Scope
|
||||||
|
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
|
||||||
|
<label>Recipients
|
||||||
|
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Subject <input type="text" name="subject"></label>
|
||||||
|
<label>Body <input type="text" name="body" required></label>
|
||||||
|
<button type="submit">Send broadcast</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Mail</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Deliveries</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Deliveries}}
|
||||||
|
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<nav class="pager">
|
||||||
|
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&page_size={{.PageSize}}">« prev</a>{{end}}
|
||||||
|
<span>page {{.Page}} · {{.Total}} total</span>
|
||||||
|
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&page_size={{.PageSize}}">next »</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dead-letters</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
{{$csrf := .CSRFToken}}
|
||||||
|
{{with .Data}}
|
||||||
|
<p><a href="/_gm/mail">« mail</a></p>
|
||||||
|
<h1>Delivery</h1>
|
||||||
|
<section class="panel">
|
||||||
|
<ul class="kv">
|
||||||
|
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
|
||||||
|
<li>Template: {{.Template}}</li>
|
||||||
|
<li>Status: {{.Status}}</li>
|
||||||
|
<li>Attempts: {{.Attempts}}</li>
|
||||||
|
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
|
||||||
|
<li>Created: {{.Created}}</li>
|
||||||
|
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
|
||||||
|
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
|
||||||
|
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
|
||||||
|
</ul>
|
||||||
|
{{if .CanResend}}
|
||||||
|
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
|
||||||
|
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Attempts</h2>
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{{define "content" -}}
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
|
||||||
|
{{with .Data}}
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Recent notifications</h2>
|
||||||
|
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
|
||||||
|
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Dead-letters</h2>
|
||||||
|
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
|
||||||
|
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Malformed intents</h2>
|
||||||
|
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
|
||||||
|
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
|
||||||
|
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
|
||||||
|
</tbody></table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{- end}}
|
||||||
@@ -33,6 +33,9 @@ type AdminConsoleHandlers struct {
|
|||||||
runtime RuntimeAdmin
|
runtime RuntimeAdmin
|
||||||
engineVersions EngineVersionAdmin
|
engineVersions EngineVersionAdmin
|
||||||
operators OperatorAdmin
|
operators OperatorAdmin
|
||||||
|
mail MailAdmin
|
||||||
|
notifications NotificationAdmin
|
||||||
|
diplomail DiplomailAdmin
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +54,9 @@ type AdminConsoleDeps struct {
|
|||||||
Runtime RuntimeAdmin
|
Runtime RuntimeAdmin
|
||||||
EngineVersions EngineVersionAdmin
|
EngineVersions EngineVersionAdmin
|
||||||
Operators OperatorAdmin
|
Operators OperatorAdmin
|
||||||
|
Mail MailAdmin
|
||||||
|
Notifications NotificationAdmin
|
||||||
|
Diplomail DiplomailAdmin
|
||||||
Logger *zap.Logger
|
Logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +98,9 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
|
|||||||
runtime: deps.Runtime,
|
runtime: deps.Runtime,
|
||||||
engineVersions: deps.EngineVersions,
|
engineVersions: deps.EngineVersions,
|
||||||
operators: deps.Operators,
|
operators: deps.Operators,
|
||||||
|
mail: deps.Mail,
|
||||||
|
notifications: deps.Notifications,
|
||||||
|
diplomail: deps.Diplomail,
|
||||||
logger: logger.Named("http.admin.console"),
|
logger: logger.Named("http.admin.console"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -414,6 +414,13 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
|
|||||||
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
|
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
|
||||||
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
|
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
|
||||||
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
|
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
|
// allowedMethodsForPath returns the comma-separated list of methods
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user