feat(admin): add announcements list + dismiss endpoint

Fourth piece of #419: a small in-code (not admin-authored) announcement
list, target-scoped ("app"/"admin", "chooser" reserved but not wired since
the landing page has no JS yet) and filterable by live server state via
ShowWhile. First entry: the admin-area-gate heads-up, shown on the admin
target while AdminAreaAuth is unset.

GET /api/announcements?target=... and POST /api/announcements/{id}/dismiss
are deliberately NOT behind BasicAuthAdmin — the whole point of the gate
notice is to reach operators who haven't set up credentials yet, the exact
audience an admin-only endpoint would exclude. The dismiss endpoint
validates id against the known announcement list before it reaches
RecordActivity, since this is the one call site where an id comes from an
HTTP request rather than a compile-time constant.

Updated the router snapshot (testdata/router_routes.txt) for the two new
routes.

Not wired into any UI yet — nothing calls these endpoints.

Refs #419
This commit is contained in:
Tobias Gesellchen
2026-08-08 23:49:57 +02:00
parent d930886f07
commit 8a4e4191a9
5 changed files with 325 additions and 7 deletions
@@ -17,10 +17,12 @@ import (
// BasicAuthAdmin middleware in isolation, to pin two things at once:
// 1. /admin and /api/setup/* (and their /setup/* legacy aliases) are open
// by default and become gated once AdminAreaAuth is "enabled".
// 2. The three routes shared with soundtouch-cli/soundtouch-player
// (ca.crt, tts/speak, tts/config) stay reachable WITHOUT credentials
// regardless of the gate — the whole reason mountSetupAPI was split
// into mountSetupAPIShared/mountSetupAPIAdmin.
// 2. A handful of routes deliberately stay reachable WITHOUT credentials
// regardless of the gate: ca.crt/tts/speak/tts/config because
// soundtouch-cli/soundtouch-player call them directly (the whole reason
// mountSetupAPI was split into mountSetupAPIShared/mountSetupAPIAdmin),
// and /api/announcements because it specifically needs to reach
// operators who haven't set up credentials yet.
func TestAdminAreaAuthGate(t *testing.T) {
tempDir := t.TempDir()
@@ -46,11 +48,12 @@ func TestAdminAreaAuthGate(t *testing.T) {
"/setup/settings",
"/api/setup/settings",
}
sharedUngatedPaths := []string{
alwaysUngatedPaths := []string{
"/setup/ca.crt",
"/api/setup/ca.crt",
"/setup/tts/config",
"/api/setup/tts/config",
"/api/announcements?target=admin",
}
t.Run("open by default (AdminAreaAuth unset)", func(t *testing.T) {
@@ -83,8 +86,8 @@ func TestAdminAreaAuthGate(t *testing.T) {
}
})
t.Run("shared cli/player routes stay reachable without credentials", func(t *testing.T) {
for _, path := range sharedUngatedPaths {
t.Run("routes intentionally left outside the gate stay reachable without credentials", func(t *testing.T) {
for _, path := range alwaysUngatedPaths {
status := getStatus(t, ts.URL, path, "", "")
if status != http.StatusOK {
t.Errorf("%s: expected 200 without credentials even with the gate enabled, got %d", path, status)
+4
View File
@@ -1297,6 +1297,10 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler, w
r.Get("/", server.HandleRoot)
r.With(server.BasicAuthAdmin()).Get("/admin", server.HandleAdmin)
r.Get("/health", server.HandleHealth)
// Deliberately not behind BasicAuthAdmin — see HandleListAnnouncements'
// doc comment. #419.
r.Get("/api/announcements", server.HandleListAnnouncements)
r.Post("/api/announcements/{id}/dismiss", server.HandleDismissAnnouncement)
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
// The favicon lives in the embedded web/img bundle, not under
// static/media — HandleMedia would 404. HandleWeb serves from
+2
View File
@@ -36,6 +36,7 @@ GET /accounts/{account}/devices/{device}/recents handlers.(
GET /accounts/{account}/full handlers.(*Server).HandleUnsupported-fm
GET /accounts/{account}/sources handlers.(*Server).HandleUnsupported-fm
GET /admin handlers.(*Server).HandleAdmin-fm
GET /api/announcements handlers.(*Server).HandleListAnnouncements-fm
GET /api/control/devices/ soundtouchweb.(*WebApp).HandleAPIDevices-fm
GET /api/control/devices/{id}/ soundtouchweb.(*WebApp).HandleAPIDevice-fm
GET /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
@@ -182,6 +183,7 @@ POST /accounts/{account}/group handlers.(
POST /accounts/{account}/group/ handlers.(*Server).HandleUnsupported-fm
POST /accounts/{account}/group/{groupId} handlers.(*Server).HandleUnsupported-fm
POST /alexa/certificate handlers.(*Server).HandleAlexaCertificate-fm
POST /api/announcements/{id}/dismiss handlers.(*Server).HandleDismissAnnouncement-fm
POST /api/control/devices/{id}/action/{action} soundtouchweb.(*WebApp).HandleAPIControl-fm
POST /api/control/devices/{id}/key/{key} soundtouchweb.(*WebApp).HandleDeviceKey-fm
POST /api/control/devices/{id}/library/play soundtouchweb.(*WebApp).HandlePlayLibrary-fm
@@ -0,0 +1,144 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
// Announcement target values. Mirrors the vocabulary Settings.DefaultLanding
// already uses (see defaultLanding() in handlers_media.go) rather than
// inventing new names — "chooser" is the neutral welcome page, "app" is the
// embedded player, "admin" is this admin console.
const (
announcementTargetChooser = "chooser"
announcementTargetApp = "app"
announcementTargetAdmin = "admin"
)
// Announcement is one entry in the small, in-code (not admin-authored)
// announcement list — see #419 design doc,
// _/i419/design-admin-area-auth-gate.md. ShowWhile lets an entry key off
// live server state (e.g. "only while the admin-area gate hasn't been
// decided yet"); nil means always show (until dismissed).
type Announcement struct {
ID string
Message string
Level string
Targets []string
ShowWhile func(*Server) bool
}
// announcements is the full, in-code list. announcementTargetChooser is
// prepared as a valid target value (see the constant above) but no entry
// here uses it yet: the chooser landing page (handlers_media.go, landingHTML)
// is currently fully static with no JS at all, unlike /admin and /app, so it
// can't render or dismiss a banner yet. Wire a chooser-targeted entry only
// once that client-side logic exists.
var announcements = []Announcement{
{
ID: "admin-area-auth-419",
Level: "info",
Targets: []string{announcementTargetAdmin},
Message: "A future release will require login for this entire admin area by default (today, only " +
"Spotify/Amazon linking and the Local Account tab do). You can opt in now in Settings, or " +
"dismiss this once you've decided. See issue #419 for details.",
ShowWhile: func(s *Server) bool {
return s.AdminAreaAuthMode() == ""
},
},
}
// announcementDTO is the JSON shape returned by HandleListAnnouncements —
// deliberately smaller than Announcement (no ShowWhile func, no Targets;
// the caller already asked for a specific target).
type announcementDTO struct {
ID string `json:"id"`
Message string `json:"message"`
Level string `json:"level"`
}
func containsString(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
// HandleListAnnouncements returns the announcements currently active for
// the requested target (query param, one of "app" or "admin" — "chooser" is
// a reserved value, not yet wired to any handler), filtered by ShowWhile and
// excluding anything already dismissed. Deliberately NOT behind
// BasicAuthAdmin: the admin-area-gate notice specifically needs to reach
// operators who haven't set up credentials yet, the exact audience an
// admin-only endpoint would exclude.
func (s *Server) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("target")
switch target {
case announcementTargetApp, announcementTargetAdmin:
default:
http.Error(w, "target must be app or admin", http.StatusBadRequest)
return
}
active := make([]announcementDTO, 0, len(announcements))
for _, a := range announcements {
if !containsString(a.Targets, target) {
continue
}
if a.ShowWhile != nil && !a.ShowWhile(s) {
continue
}
if s.IsAnnouncementDismissed(a.ID) {
continue
}
active = append(active, announcementDTO{ID: a.ID, Message: a.Message, Level: a.Level})
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{"announcements": active}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleDismissAnnouncement records a dismissal for the given announcement
// id (see Server.RecordDismissal). The id is validated against the known
// announcements list rather than accepted as arbitrary input — it ends up
// as part of a filename in the local activity log (datastore.RecordActivity),
// and this is the one call site where the id comes from an HTTP request
// rather than a compile-time constant. Also not behind BasicAuthAdmin, for
// the same reason as HandleListAnnouncements.
func (s *Server) HandleDismissAnnouncement(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
found := false
for _, a := range announcements {
if a.ID == id {
found = true
break
}
}
if !found {
http.Error(w, "Unknown announcement id", http.StatusNotFound)
return
}
if err := s.RecordDismissal(id); err != nil {
http.Error(w, "Failed to record dismissal: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
@@ -0,0 +1,165 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
"github.com/go-chi/chi/v5"
)
func newAnnouncementsTestServer(t *testing.T) *Server {
t.Helper()
tempDir, err := os.MkdirTemp("", "announcements-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
ds := datastore.NewDataStore(tempDir)
_ = ds.Initialize()
return NewServer(ds, nil, "http://127.0.0.1:8000", false, false, false)
}
func listAnnouncements(t *testing.T, s *Server, target string) (int, []announcementDTO) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
rr := httptest.NewRecorder()
s.HandleListAnnouncements(rr, req)
if rr.Code != http.StatusOK {
return rr.Code, nil
}
var body struct {
Announcements []announcementDTO `json:"announcements"`
}
if err := json.NewDecoder(rr.Body).Decode(&body); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
return rr.Code, body.Announcements
}
func TestHandleListAnnouncements_InvalidTarget(t *testing.T) {
s := newAnnouncementsTestServer(t)
for _, target := range []string{"", "chooser", "bogus"} {
req := httptest.NewRequest(http.MethodGet, "/api/announcements?target="+target, nil)
rr := httptest.NewRecorder()
s.HandleListAnnouncements(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("target=%q: expected 400 (chooser is reserved, not wired yet), got %d", target, rr.Code)
}
}
}
// TestHandleListAnnouncements_AdminGateNotice is a regression test for the
// #419 admin-gate announcement's ShowWhile/Targets/dismissal behavior end to
// end: visible for "admin" while AdminAreaAuth is unset, invisible for
// "app", invisible once the mode is set, and invisible once dismissed.
func TestHandleListAnnouncements_AdminGateNotice(t *testing.T) {
const noticeID = "admin-area-auth-419"
t.Run("visible for admin target while unset", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
status, active := listAnnouncements(t, s, announcementTargetAdmin)
if status != http.StatusOK {
t.Fatalf("expected 200, got %d", status)
}
if !containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to be active for target=admin while unset, got %+v", noticeID, active)
}
})
t.Run("not visible for app target", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
_, active := listAnnouncements(t, s, announcementTargetApp)
if containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to NOT be active for target=app (Targets is admin-only), got %+v", noticeID, active)
}
})
t.Run("not visible once AdminAreaAuth is decided", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
s.SetAdminAreaAuth("enabled")
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to disappear once the mode is decided, got %+v", noticeID, active)
}
})
t.Run("not visible once dismissed", func(t *testing.T) {
s := newAnnouncementsTestServer(t)
if err := s.RecordDismissal(noticeID); err != nil {
t.Fatalf("RecordDismissal failed: %v", err)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, noticeID) {
t.Errorf("expected %q to disappear once dismissed, got %+v", noticeID, active)
}
})
}
func containsAnnouncementID(active []announcementDTO, id string) bool {
for _, a := range active {
if a.ID == id {
return true
}
}
return false
}
func TestHandleDismissAnnouncement_UnknownID(t *testing.T) {
s := newAnnouncementsTestServer(t)
r := chi.NewRouter()
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
req := httptest.NewRequest(http.MethodPost, "/api/announcements/not-a-real-id/dismiss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404 for an unknown announcement id, got %d", rr.Code)
}
}
// TestHandleDismissAnnouncement_Success verifies dismissing a known
// announcement both succeeds and is reflected by a subsequent list call —
// end-to-end through the HTTP handlers, not just the underlying
// Server.RecordDismissal/IsAnnouncementDismissed pair.
func TestHandleDismissAnnouncement_Success(t *testing.T) {
s := newAnnouncementsTestServer(t)
r := chi.NewRouter()
r.Post("/api/announcements/{id}/dismiss", s.HandleDismissAnnouncement)
req := httptest.NewRequest(http.MethodPost, "/api/announcements/admin-area-auth-419/dismiss", nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
_, active := listAnnouncements(t, s, announcementTargetAdmin)
if containsAnnouncementID(active, "admin-area-auth-419") {
t.Errorf("expected the notice to be gone from the list after dismissal, got %+v", active)
}
}