Files
silo-server/internal/api/handlers/settings_values_test.go
T
QuickandClaude Opus 5 cdbb2997fd feat(settings): add the canonical settings API
The routes that make the typed storage reachable. Until now the manifest,
the resolver and the migration all existed with nothing able to call them.

GET /settings/contract serves the public manifest behind an ETag — clients
vendor a pinned copy and generate bindings from it, so the common request
asks "still the same contract?" rather than transferring it. Its
capabilities sibling reports revision and supported scopes for feature
detection instead of version sniffing.

/settings/values/{key} reads, writes and clears an explicit value at one
named scope, which is what a reset affordance needs: "did I set this here"
is a different question from "what applies", and the old endpoint could
only answer a blurred version of both. Scope comes from the query while
profile and device come from session headers, so one profile cannot address
another's settings by naming it.

/settings/values/effective resolves any number of keys in one request, with
the resolution ladder and the source of each answer reported so a client can
offer "reset this device's override" against the exact row holding it.
Asking for no keys returns every remote setting, which is what a settings
screen wants.

Writes are idempotent when a client sends X-Silo-Mutation-Id: a retry after
a dropped response replays the receipt, and reusing an id with different
content is a conflict rather than a silent overwrite of the wrong thing.

Three things the string-only endpoint could not do, each pinned by a test:
an unknown key is refused rather than stored in the extension bag, values
are checked against their declared type and range, and a write to a scope
the definition does not allow is rejected.

Registered before the catch-all /{key} routes, which would otherwise
swallow "contract" and "values" as setting names. The legacy endpoints stay
live for now; deleting them is the next commit, once their consumers move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:02:09 +00:00

425 lines
15 KiB
Go

package handlers
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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/settingscontract"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func newValuesTestHandler(t *testing.T) (*SettingValuesHandler, userstore.UserStore) {
t.Helper()
dsn := "file:" + strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()) +
"?mode=memory&cache=shared"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := userdb.InitSchema(db); err != nil {
t.Fatalf("init schema: %v", err)
}
store := userdb.NewSQLiteUserStore(db)
if err := store.CreateProfile(context.Background(),
userstore.Profile{ID: "profile-1", Name: "Main"}); err != nil {
t.Fatalf("create profile: %v", err)
}
contract, err := settingscontract.Load()
if err != nil {
t.Fatalf("loading contract: %v", err)
}
return NewSettingValuesHandler(testUserStoreProvider{store: store}, contract), store
}
// valuesRequest builds a request carrying the session identity the handlers
// read: user, profile and device all come from context or headers rather than
// the query string, so one profile cannot address another's settings.
func valuesRequest(method, target string, body []byte) *http.Request {
var req *http.Request
if body == nil {
req = httptest.NewRequest(method, target, nil)
} else {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
}
req.Header.Set(deviceIDHeader, "device-1")
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
return req.WithContext(apimw.SetProfileID(ctx, "profile-1"))
}
// routeValues wires the chi URL params the handlers read from the path.
func routeValues(t *testing.T, h *SettingValuesHandler, method, key, query string, body []byte) *httptest.ResponseRecorder {
t.Helper()
target := "/settings/values/" + key
if query != "" {
target += "?" + query
}
req := valuesRequest(method, target, body)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", key)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
switch method {
case http.MethodGet:
h.HandleGetValue(rec, req)
case http.MethodPut:
h.HandleSetValue(rec, req)
case http.MethodDelete:
h.HandleDeleteValue(rec, req)
}
return rec
}
func TestSettingValuesRoundTrip(t *testing.T) {
handler, _ := newValuesTestHandler(t)
// Nothing stored yet.
if rec := routeValues(t, handler, http.MethodGet,
"playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNotFound {
t.Fatalf("GET before write = %d, want 404", rec.Code)
}
rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile", []byte(`{"value":"ja"}`))
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) != `"ja"` || stored.Scope != "profile" {
t.Errorf("stored %s at %s, want \"ja\" at profile", stored.Value, stored.Scope)
}
rec = routeValues(t, handler, http.MethodGet, "playback.subtitle_language", "scope=profile", nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET after write = %d", rec.Code)
}
if rec := routeValues(t, handler, http.MethodDelete,
"playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNoContent {
t.Fatalf("DELETE = %d", rec.Code)
}
if rec := routeValues(t, handler, http.MethodGet,
"playback.subtitle_language", "scope=profile", nil); rec.Code != http.StatusNotFound {
t.Errorf("GET after delete = %d, want 404", rec.Code)
}
}
// TestUnknownKeysAreRefused is the extension bag closing. The legacy endpoint
// stored any unregistered key as an unvalidated string, which is how six ui.*
// settings and five orphan keys reached production untyped.
func TestUnknownKeysAreRefused(t *testing.T) {
handler, _ := newValuesTestHandler(t)
rec := routeValues(t, handler, http.MethodPut, "totally.invented.key",
"scope=profile", []byte(`{"value":"x"}`))
if rec.Code != http.StatusNotFound {
t.Fatalf("PUT of an unknown key = %d, want 404: %s", rec.Code, rec.Body.String())
}
// A contract-known local setting is not server storage either.
rec = routeValues(t, handler, http.MethodPut, "downloads.wifi_only",
"scope=profile", []byte(`{"value":true}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("PUT of a client_local key = %d, want 400: %s", rec.Code, rec.Body.String())
}
}
// TestInvalidValuesAreRefusedByType covers what the string-only endpoint could
// not check at all.
func TestInvalidValuesAreRefusedByType(t *testing.T) {
handler, _ := newValuesTestHandler(t)
for name, tc := range map[string]struct{ key, body string }{
"enum member": {"playback.subtitle_mode", `{"value":"sideways"}`},
"integer range": {"playback.next_up_prompt_seconds", `{"value":9999}`},
"wrong type": {"playback.auto_skip_intro", `{"value":"yes"}`},
"quoted number": {"playback.next_up_prompt_seconds", `{"value":"30"}`},
"bad language": {"playback.subtitle_language", `{"value":"!!!"}`},
"object schema": {"playback.subtitle_appearance", `{"value":{"fontSize":"enormous"}}`},
"null when not ok": {"playback.subtitle_mode", `{"value":null}`},
} {
t.Run(name, func(t *testing.T) {
rec := routeValues(t, handler, http.MethodPut, tc.key, "scope=profile", []byte(tc.body))
if rec.Code != http.StatusBadRequest {
t.Errorf("PUT %s = %d, want 400: %s", tc.body, rec.Code, rec.Body.String())
}
})
}
}
// TestScopeMustBeAllowedByTheContract. A definition declares where it may be
// written; the identity being well-formed is a separate question.
func TestScopeMustBeAllowedByTheContract(t *testing.T) {
handler, _ := newValuesTestHandler(t)
// ui.custom_css is profile-only, so a device override is refused.
rec := routeValues(t, handler, http.MethodPut, "ui.custom_css",
"scope=profile_device", []byte(`{"value":"body{}"}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("device write to a profile-only setting = %d, want 400: %s",
rec.Code, rec.Body.String())
}
// A missing scope is a request error rather than a silent default: writing
// to the wrong scope is exactly the mistake this API exists to prevent.
rec = routeValues(t, handler, http.MethodPut, "playback.subtitle_mode", "",
[]byte(`{"value":"always"}`))
if rec.Code != http.StatusBadRequest {
t.Errorf("write with no scope = %d, want 400", rec.Code)
}
}
// TestLibraryAndSeriesScopesNeedTheirIdentity.
func TestLibraryAndSeriesScopesNeedTheirIdentity(t *testing.T) {
handler, _ := newValuesTestHandler(t)
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_library", []byte(`{"value":"de"}`)); rec.Code != http.StatusBadRequest {
t.Errorf("library scope without library_id = %d, want 400", rec.Code)
}
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_series", []byte(`{"value":"de"}`)); rec.Code != http.StatusBadRequest {
t.Errorf("series scope without series_id = %d, want 400", rec.Code)
}
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_library&library_id=7", []byte(`{"value":"de"}`)); rec.Code != http.StatusOK {
t.Errorf("library write = %d, want 200", rec.Code)
}
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
"scope=profile_series&series_id=s1", []byte(`{"value":"ja"}`)); rec.Code != http.StatusOK {
t.Errorf("series write = %d, want 200", rec.Code)
}
}
// TestEffectiveResolvesThroughTheLadder proves the route is wired to the real
// resolver rather than reading one scope.
func TestEffectiveResolvesThroughTheLadder(t *testing.T) {
handler, _ := newValuesTestHandler(t)
write := func(query, value string) {
t.Helper()
if rec := routeValues(t, handler, http.MethodPut, "playback.subtitle_language",
query, []byte(`{"value":`+value+`}`)); rec.Code != http.StatusOK {
t.Fatalf("seeding %s = %d: %s", query, rec.Code, rec.Body.String())
}
}
write("scope=profile", `"en"`)
write("scope=profile_device", `"de"`)
write("scope=profile_series&series_id=s1", `"ja"`)
effective := func(query string) effectiveSettingValueResponse {
t.Helper()
req := valuesRequest(http.MethodGet, "/settings/values/effective?"+query, nil)
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("effective %s = %d: %s", query, rec.Code, rec.Body.String())
}
var body struct {
Settings []effectiveSettingValueResponse `json:"settings"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
if len(body.Settings) != 1 {
t.Fatalf("got %d settings, want 1", len(body.Settings))
}
return body.Settings[0]
}
// Without a series context the device override is the most specific match.
got := effective("keys=playback.subtitle_language")
if string(got.Value) != `"de"` || got.Source != "profile_device" {
t.Errorf("no-series resolution = %s from %s, want \"de\" from profile_device",
got.Value, got.Source)
}
// Naming the series promotes its override.
got = effective("keys=playback.subtitle_language&series_ids=s1")
if string(got.Value) != `"ja"` || got.Source != "profile_series" {
t.Errorf("series resolution = %s from %s, want \"ja\" from profile_series",
got.Value, got.Source)
}
if got.SeriesID != "s1" {
t.Errorf("resolved identity series = %q, want s1 so a client can reset it", got.SeriesID)
}
}
// TestEffectiveWithNoKeysReturnsEveryRemoteSetting, which is what a settings
// screen opening for the first time asks for.
func TestEffectiveWithNoKeysReturnsEveryRemoteSetting(t *testing.T) {
handler, _ := newValuesTestHandler(t)
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
rec := httptest.NewRecorder()
handler.HandleGetEffective(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("effective = %d: %s", rec.Code, rec.Body.String())
}
var body struct {
Settings []effectiveSettingValueResponse `json:"settings"`
Revision int `json:"revision"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
contract, _ := settingscontract.Load()
remote := 0
for i := range contract.Definitions {
if contract.Definitions[i].IsRemote() {
remote++
}
}
if len(body.Settings) != remote {
t.Errorf("returned %d settings, want every remote definition (%d)",
len(body.Settings), remote)
}
if body.Revision != contract.Revision {
t.Errorf("revision = %d, want %d", body.Revision, contract.Revision)
}
// Everything unset resolves to its contract default.
for _, setting := range body.Settings {
if setting.Source != string(settingscontract.ScopeDefault) {
t.Errorf("%s resolved from %s with nothing stored", setting.Key, setting.Source)
}
}
}
// TestMutationIDMakesWritesIdempotent covers the retry a mobile client performs
// when a response is lost.
func TestMutationIDMakesWritesIdempotent(t *testing.T) {
handler, store := newValuesTestHandler(t)
send := func(mutationID, value string) *httptest.ResponseRecorder {
req := valuesRequest(http.MethodPut,
"/settings/values/playback.subtitle_mode?scope=profile",
[]byte(`{"value":`+value+`}`))
req.Header.Set(mutationIDHeader, mutationID)
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("key", "playback.subtitle_mode")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rec := httptest.NewRecorder()
handler.HandleSetValue(rec, req)
return rec
}
if rec := send("mut-1", `"always"`); rec.Code != http.StatusOK {
t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String())
}
// The same id and body replays the receipt rather than writing again.
replay := send("mut-1", `"always"`)
if replay.Code != http.StatusOK {
t.Fatalf("replay = %d: %s", replay.Code, replay.Body.String())
}
if replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
t.Error("a repeated mutation id was not reported as a replay")
}
// The same id with different content is a conflict, not a silent overwrite.
conflict := send("mut-1", `"off"`)
if conflict.Code != http.StatusConflict {
t.Errorf("reused id with new content = %d, want 409", conflict.Code)
}
// The stored value is still the first write's.
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
Key: "playback.subtitle_mode",
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
})
if err != nil || value == nil {
t.Fatalf("reading stored value: %v", err)
}
if string(value.Value) != `"always"` {
t.Errorf("stored value = %s, want the first write preserved", value.Value)
}
}
// TestContractIsServedWithAnETag. Clients vendor a pinned copy and generate
// bindings from it, so the common request asks "still the same contract?".
func TestContractIsServedWithAnETag(t *testing.T) {
handler, _ := newValuesTestHandler(t)
rec := httptest.NewRecorder()
handler.HandleGetContract(rec, httptest.NewRequest(http.MethodGet, "/settings/contract", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET contract = %d", rec.Code)
}
etag := rec.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag on the contract response")
}
var manifest map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
t.Fatalf("contract body is not JSON: %v", err)
}
// Maintainer notes are stripped from the public projection.
if definitions, ok := manifest["definitions"].([]any); ok && len(definitions) > 0 {
if first, ok := definitions[0].(map[string]any); ok {
if _, leaked := first["notes"]; leaked {
t.Error("maintainer notes leaked into the public contract")
}
}
}
conditional := httptest.NewRequest(http.MethodGet, "/settings/contract", nil)
conditional.Header.Set("If-None-Match", etag)
rec = httptest.NewRecorder()
handler.HandleGetContract(rec, conditional)
if rec.Code != http.StatusNotModified {
t.Errorf("conditional GET = %d, want 304", rec.Code)
}
}
func TestCapabilitiesReportTheContractRevision(t *testing.T) {
handler, _ := newValuesTestHandler(t)
rec := httptest.NewRecorder()
handler.HandleGetCapabilities(rec,
httptest.NewRequest(http.MethodGet, "/settings/contract/capabilities", nil))
if rec.Code != http.StatusOK {
t.Fatalf("capabilities = %d", rec.Code)
}
var body struct {
APIVersion int `json:"api_version"`
Revision int `json:"revision"`
Scopes []string `json:"scopes"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decoding: %v", err)
}
contract, _ := settingscontract.Load()
if body.APIVersion != contract.APIVersion || body.Revision != contract.Revision {
t.Errorf("reported %d/%d, want %d/%d",
body.APIVersion, body.Revision, contract.APIVersion, contract.Revision)
}
if len(body.Scopes) != 5 {
t.Errorf("reported %d scopes, want 5", len(body.Scopes))
}
}