Files
silo-server/internal/api/handlers/settings_values_admin_test.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

457 lines
18 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/userstore"
)
const (
adminValuesAdminID = 1
adminValuesTargetID = 7
)
// adminValuesEnv mounts the admin projection exactly as the router does: the
// canonical handler behind RequireActingAdmin, with the target user's store
// distinct from the admin's own so a route that resolved the wrong user is
// caught rather than masked by a shared store.
type adminValuesEnv struct {
router chi.Router
handler *SettingValuesHandler
adminStore userstore.UserStore
targetStore userstore.UserStore
}
func newAdminValuesEnv(t *testing.T) adminValuesEnv {
t.Helper()
adminStore := newIsolatedProfileTestStore(t, "admin")
targetStore := newIsolatedProfileTestStore(t, "target")
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
handler := NewSettingValuesHandler(mappedTestUserStoreProvider{
stores: map[int]userstore.UserStore{
adminValuesAdminID: adminStore,
adminValuesTargetID: targetStore,
},
}, contract)
router := chi.NewRouter()
router.Group(func(r chi.Router) {
r.Use(apimw.RequireActingAdmin(nil))
r.Get("/admin/users/{id}/settings/values", handler.HandleAdminListUserSettingValues)
r.Put("/admin/users/{id}/settings/values/{key}", handler.HandleAdminSetUserSettingValue)
r.Delete("/admin/users/{id}/settings/values/{key}", handler.HandleAdminDeleteUserSettingValue)
})
return adminValuesEnv{router: router, handler: handler, adminStore: adminStore, targetStore: targetStore}
}
// do sends a request through the mounted routes as a caller with the given
// role; an empty role sends no session at all.
func (env adminValuesEnv) do(t *testing.T, role, method, target string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var req *http.Request
if body == nil {
req = httptest.NewRequest(method, target, nil)
} else {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
}
if role != "" {
req = req.WithContext(apimw.SetClaims(req.Context(), &auth.Claims{UserID: adminValuesAdminID, Role: role}))
}
rec := httptest.NewRecorder()
env.router.ServeHTTP(rec, req)
return rec
}
func TestAdminSettingValuesRefuseNonAdmins(t *testing.T) {
env := newAdminValuesEnv(t)
for name, req := range map[string]struct {
method, target string
body []byte
}{
"list": {http.MethodGet, "/admin/users/7/settings/values", nil},
"set": {http.MethodPut, "/admin/users/7/settings/values/playback.subtitle_mode?scope=account", []byte(`{"value":"always"}`)},
"delete": {http.MethodDelete, "/admin/users/7/settings/values/playback.subtitle_mode?scope=account", nil},
} {
t.Run(name, func(t *testing.T) {
if rec := env.do(t, "user", req.method, req.target, req.body); rec.Code != http.StatusForbidden {
t.Errorf("non-admin %s = %d, want 403: %s", name, rec.Code, rec.Body.String())
}
if rec := env.do(t, "", req.method, req.target, req.body); rec.Code != http.StatusUnauthorized {
t.Errorf("anonymous %s = %d, want 401: %s", name, rec.Code, rec.Body.String())
}
})
}
}
func TestAdminSettingValuesRejectNonexistentLibraryContext(t *testing.T) {
env := newAdminValuesEnv(t)
env.handler.SetLibraryLookup(settingValuesLibraryLookup{existing: map[int]bool{7: true}})
for _, method := range []string{http.MethodPut, http.MethodDelete} {
var body []byte
if method == http.MethodPut {
body = []byte(`{"value":"de"}`)
}
rec := env.do(t, "admin", method,
"/admin/users/7/settings/values/playback.subtitle_language?scope=profile_library&profile_id=profile-1&library_id=99",
body)
if rec.Code != http.StatusNotFound {
t.Errorf("%s nonexistent library = %d, want 404: %s", method, rec.Code, rec.Body.String())
}
}
}
func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
seeded := map[settingscontract.Scope]userstore.SettingIdentity{
settingscontract.ScopeAccount: {
Key: "catalog.metadata_language", Scope: settingscontract.ScopeAccount,
},
settingscontract.ScopeProfile: {
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
},
settingscontract.ScopeProfileClient: {
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
},
settingscontract.ScopeProfileDevice: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "tv-1",
},
settingscontract.ScopeProfileLibrary: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileLibrary,
ProfileID: "profile-1", LibraryID: 42,
},
settingscontract.ScopeProfileSeries: {
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileSeries,
ProfileID: "profile-1", SeriesID: "s-1",
},
}
values := map[settingscontract.Scope]string{
settingscontract.ScopeAccount: `"de"`,
settingscontract.ScopeProfile: `"always"`,
settingscontract.ScopeProfileClient: `{"items":[{"type":"builtin","destination":"home"}]}`,
settingscontract.ScopeProfileDevice: `"en"`,
settingscontract.ScopeProfileLibrary: `"fr"`,
settingscontract.ScopeProfileSeries: `"ja"`,
}
for scope, id := range seeded {
if _, err := env.targetStore.UpsertSettingValue(ctx, id, json.RawMessage(values[scope])); err != nil {
t.Fatalf("seeding %s: %v", scope, err)
}
}
// A value in the admin's own store must not leak into the target's list.
if _, err := env.adminStore.UpsertSettingValue(ctx, userstore.SettingIdentity{
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeAccount,
}, json.RawMessage(`"off"`)); err != nil {
t.Fatalf("seeding admin store: %v", err)
}
rec := env.do(t, "admin", http.MethodGet, "/admin/users/7/settings/values", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d: %s", rec.Code, rec.Body.String())
}
var body struct {
Values []settingValueResponse `json:"values"`
Revision int `json:"revision"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
if len(body.Values) != len(seeded) {
t.Fatalf("listed %d values, want %d: %s", len(body.Values), len(seeded), rec.Body.String())
}
contract, _ := settingscontract.Load()
if body.Revision != contract.Revision {
t.Errorf("revision = %d, want %d", body.Revision, contract.Revision)
}
for _, got := range body.Values {
want, ok := seeded[settingscontract.Scope(got.Scope)]
if !ok {
t.Errorf("unexpected scope %q in list", got.Scope)
continue
}
if got.Key != want.Key || got.ProfileID != want.ProfileID ||
got.ClientFamily != string(want.ClientFamily) ||
got.DeviceID != want.DeviceID || got.LibraryID != want.LibraryID ||
got.SeriesID != want.SeriesID {
t.Errorf("listed identity at %s = %+v, want %+v", got.Scope, got, want)
}
if string(got.Value) != values[settingscontract.Scope(got.Scope)] {
t.Errorf("value at %s = %s, want %s", got.Scope, got.Value, values[settingscontract.Scope(got.Scope)])
}
}
// A user with no store is a 404, not an empty list pretending to be truth.
if rec := env.do(t, "admin", http.MethodGet, "/admin/users/99/settings/values", nil); rec.Code != http.StatusNotFound {
t.Errorf("list for unknown user = %d, want 404", rec.Code)
}
}
func TestAdminSetAndDeleteAtExplicitScopeRoundTrip(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
target := "/admin/users/7/settings/values/playback.subtitle_language" +
"?scope=profile_device&profile_id=profile-1&device_id=tv-1"
identity := userstore.SettingIdentity{
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
ProfileID: "profile-1", DeviceID: "tv-1",
}
rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":"de"}`))
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding PUT response: %v", err)
}
if string(stored.Value) != `"de"` || stored.Scope != "profile_device" ||
stored.ProfileID != "profile-1" || stored.DeviceID != "tv-1" {
t.Errorf("PUT stored %+v, want \"de\" at profile-1/tv-1", stored)
}
// The write landed in the target user's store and only there.
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil {
t.Fatalf("target store value = %+v, %v; want stored", got, err)
}
if got, err := env.adminStore.GetSettingValue(ctx, identity); err != nil || got != nil {
t.Errorf("admin store value = %+v, %v; want none", got, err)
}
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got != nil {
t.Errorf("value after delete = %+v, %v; want gone", got, err)
}
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusNotFound {
t.Errorf("second DELETE = %d, want 404", rec.Code)
}
}
func TestAdminNavigationShortcutRepairPreservesRevisionHistory(t *testing.T) {
env := newAdminValuesEnv(t)
ctx := context.Background()
identity := userstore.SettingIdentity{
Key: "nav.shortcuts", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
}
seeded, err := env.targetStore.UpsertSettingValue(ctx, identity,
json.RawMessage(`{"items":[{"type":"library","library_id":42,"label":"Movies"}]}`))
if err != nil {
t.Fatalf("seed shortcuts: %v", err)
}
if seeded.Revision != 1 {
t.Fatalf("seed revision = %d, want 1", seeded.Revision)
}
target := "/admin/users/7/settings/values/nav.shortcuts?scope=profile&profile_id=profile-1"
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusBadRequest {
t.Fatalf("admin repair DELETE = %d, want 400: %s", rec.Code, rec.Body.String())
} else if !bytes.Contains(rec.Body.Bytes(), []byte(`"error":"atomic_update_required"`)) {
t.Fatalf("admin repair DELETE body = %s, want atomic_update_required", rec.Body.String())
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil || got.Revision != 1 {
t.Fatalf("shortcut after rejected DELETE = %+v err=%v, want revision 1 intact", got, err)
}
rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":{"items":[]}}`))
if rec.Code != http.StatusOK {
t.Fatalf("admin repair PUT empty = %d: %s", rec.Code, rec.Body.String())
}
var repaired settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &repaired); err != nil {
t.Fatalf("decode repair response: %v", err)
}
if repaired.Revision != 2 || string(repaired.Value) != `{"items":[]}` {
t.Fatalf("repair response = revision %d value %s, want empty revision 2",
repaired.Revision, repaired.Value)
}
cas, ok := env.targetStore.(userstore.SettingValueCompareAndSetter)
if !ok {
t.Fatal("target store does not support compare-and-set")
}
_, err = cas.CompareAndSetSettingValue(ctx, identity,
json.RawMessage(`{"items":[{"type":"library","library_id":99,"label":"Shows"}]}`),
seeded.Revision)
if !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
t.Fatalf("stale CAS after admin repair = %v, want revision conflict", err)
}
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil ||
got.Revision != 2 || string(got.Value) != `{"items":[]}` {
t.Fatalf("shortcut after stale CAS = %+v err=%v, want empty revision 2", got, err)
}
}
func TestAdminProfileClientUsesExplicitQueryIdentity(t *testing.T) {
env := newAdminValuesEnv(t)
target := "/admin/users/7/settings/values/nav.primary_menu" +
"?scope=profile_client&profile_id=profile-1&client_family=tv"
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"}]}}`)
rec := env.do(t, "admin", http.MethodPut, target, body)
if rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
var stored settingValueResponse
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
t.Fatalf("decoding: %v", err)
}
if stored.Scope != "profile_client" || stored.ProfileID != "profile-1" || stored.ClientFamily != "tv" {
t.Fatalf("stored identity = %+v, want profile-1/tv", stored)
}
missing := "/admin/users/7/settings/values/nav.primary_menu?scope=profile_client&profile_id=profile-1"
if rec := env.do(t, "admin", http.MethodPut, missing, body); rec.Code != http.StatusBadRequest {
t.Fatalf("PUT without family = %d, want 400: %s", rec.Code, rec.Body.String())
}
}
// TestAdminSetRejectsInvalidValueLikeTheSessionRoute pins that the admin write
// is the same validation path as /settings/values, not a second validator: an
// invalid value fails with the identical status, code and message.
func TestAdminSetRejectsInvalidValueLikeTheSessionRoute(t *testing.T) {
env := newAdminValuesEnv(t)
invalid := []byte(`{"value":"sideways"}`)
adminRec := env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=profile-1", invalid)
if adminRec.Code != http.StatusBadRequest {
t.Fatalf("admin PUT = %d, want 400: %s", adminRec.Code, adminRec.Body.String())
}
// The same write through the session route, as the target user.
sessionReq := httptest.NewRequest(http.MethodPut,
"/settings/values/playback.subtitle_mode?scope=profile", bytes.NewReader(invalid))
sessionCtx := apimw.SetClaims(sessionReq.Context(), &auth.Claims{UserID: adminValuesTargetID})
sessionReq = sessionReq.WithContext(apimw.SetProfileID(sessionCtx, "profile-1"))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", "playback.subtitle_mode")
sessionReq = sessionReq.WithContext(context.WithValue(sessionReq.Context(), chi.RouteCtxKey, routeCtx))
sessionRec := httptest.NewRecorder()
env.handler.HandleSetValue(sessionRec, sessionReq)
if sessionRec.Code != adminRec.Code {
t.Errorf("status: session %d, admin %d", sessionRec.Code, adminRec.Code)
}
var adminErr, sessionErr errorResponse
if err := json.Unmarshal(adminRec.Body.Bytes(), &adminErr); err != nil {
t.Fatalf("decoding admin error: %v", err)
}
if err := json.Unmarshal(sessionRec.Body.Bytes(), &sessionErr); err != nil {
t.Fatalf("decoding session error: %v", err)
}
if adminErr.Error != "invalid_value" {
t.Errorf("admin error code = %q, want invalid_value", adminErr.Error)
}
if adminErr != sessionErr {
t.Errorf("error bodies differ: admin %+v, session %+v", adminErr, sessionErr)
}
}
func TestAdminSetRefusesUnknownKeysAndProfiles(t *testing.T) {
env := newAdminValuesEnv(t)
rec := env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/totally.invented.key?scope=profile&profile_id=profile-1",
[]byte(`{"value":"x"}`))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown key = %d, want 404: %s", rec.Code, rec.Body.String())
}
var unknownErr errorResponse
if err := json.Unmarshal(rec.Body.Bytes(), &unknownErr); err != nil {
t.Fatalf("decoding unknown-key error: %v", err)
}
if unknownErr.Error != "unknown_setting" {
t.Errorf("unknown key code = %q, want unknown_setting", unknownErr.Error)
}
// A client_local key is refused as server storage, same as the session route.
rec = env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/downloads.wifi_only?scope=profile&profile_id=profile-1",
[]byte(`{"value":true}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("client_local key = %d, want 400: %s", rec.Code, rec.Body.String())
}
// A profile the target user does not have is a 404, which also keeps
// Postgres's profile FK from turning the typo into a 500.
rec = env.do(t, "admin", http.MethodPut,
"/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=ghost",
[]byte(`{"value":"always"}`))
if rec.Code != http.StatusNotFound {
t.Errorf("unknown profile = %d, want 404: %s", rec.Code, rec.Body.String())
}
}
// TestAdminMutationsAttributeEventsToTheTargetUser pins the admin-specific
// half of the change-event contract: the envelope is addressed to the user
// whose settings moved — the target named in the path — never to the acting
// admin. Addressing the admin instead would leave the target's devices stale
// on exactly the change they most need to hear about, while poking the
// admin's own devices for nothing. The acting admin (user 1) and the target
// (user 7) are distinct here precisely so the two attributions cannot alias.
func TestAdminMutationsAttributeEventsToTheTargetUser(t *testing.T) {
env := newAdminValuesEnv(t)
env.handler.EventsHub = evt.NewHub("test", &cache.NoopEventBus{})
events, unsubscribe := env.handler.EventsHub.Subscribe()
defer unsubscribe()
target := "/admin/users/7/settings/values/playback.subtitle_mode?scope=profile&profile_id=profile-1"
assertTargetEnvelope := func(operation string) {
t.Helper()
var envelope evt.Envelope
select {
case envelope = <-events:
default:
t.Fatalf("admin %s published no event", operation)
}
if envelope.UserID != adminValuesTargetID {
t.Errorf("admin %s event addressed to user %d, want the target %d",
operation, envelope.UserID, adminValuesTargetID)
}
if envelope.UserID == adminValuesAdminID {
t.Errorf("admin %s event addressed to the acting admin", operation)
}
if envelope.ProfileID != "profile-1" {
t.Errorf("admin %s event profile = %q, want profile-1", operation, envelope.ProfileID)
}
if envelope.Channel != evt.ChannelUserSettings || envelope.Event != userSettingsChangedEvent {
t.Errorf("admin %s published %s on %s, want %s on %s",
operation, envelope.Event, envelope.Channel,
userSettingsChangedEvent, evt.ChannelUserSettings)
}
}
if rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
}
assertTargetEnvelope("PUT")
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
}
assertTargetEnvelope("DELETE")
}