diff --git a/cmd/soundtouch-service/admin_area_auth_gate_test.go b/cmd/soundtouch-service/admin_area_auth_gate_test.go index 372a303..57c9f33 100644 --- a/cmd/soundtouch-service/admin_area_auth_gate_test.go +++ b/cmd/soundtouch-service/admin_area_auth_gate_test.go @@ -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) diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 5f5e34f..f9506d0 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -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 diff --git a/cmd/soundtouch-service/testdata/router_routes.txt b/cmd/soundtouch-service/testdata/router_routes.txt index 2163aeb..8c3b2ce 100644 --- a/cmd/soundtouch-service/testdata/router_routes.txt +++ b/cmd/soundtouch-service/testdata/router_routes.txt @@ -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 diff --git a/pkg/service/handlers/handlers_announcements.go b/pkg/service/handlers/handlers_announcements.go new file mode 100644 index 0000000..ec23876 --- /dev/null +++ b/pkg/service/handlers/handlers_announcements.go @@ -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) +} diff --git a/pkg/service/handlers/handlers_announcements_test.go b/pkg/service/handlers/handlers_announcements_test.go new file mode 100644 index 0000000..9afe391 --- /dev/null +++ b/pkg/service/handlers/handlers_announcements_test.go @@ -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) + } +}