Files
silo-server/internal/settingscontract/contract_test.go
T
Quick 6535203791 ci: gate Go lint on the lines a branch changes
AGENTS.md told contributors CI ran the same checks as `make lint`, and the Go
job ran only gofmt and vet. A change failing the documented Go lint gate
passed all three jobs.

Running the linter as-is is not an option: the tree has ~296 findings today,
which is why this half of `make lint` was never enforced. Blocking every PR
on a cleanup nobody has scheduled gets the gate deleted again, so CI runs
with --new-from-merge-base and only the lines a branch touches have to be
clean. The count can then only fall.

golangci-lint is built from source at a pinned version rather than
downloaded. A released binary refuses to run against a Go newer than the one
it was built with, and go.mod here tracks Go closely enough that the current
release already fails that way on 1.26.4.

.golangci.yml declared version 2 while still using v1's issues.exclude-rules
key. Current golangci-lint ignores it, so the "allow repeated strings and
unchecked cleanup errors in tests" exclusions silently did not apply — 16
findings in test files that the config says to skip. Moved to
linters.exclusions, which `golangci-lint config verify` accepts.

The four lines this surfaced in scantrigger are fixed rather than excluded:
its repeated status codes and messages are now named constants, so one
condition cannot end up worded two ways.

Also drops the workflow token to contents:read and stops persisting
credentials in the three checkouts, neither of which any job needs.

Reported by CodeRabbit and Codex review on #479.
2026-07-27 00:32:15 +00:00

1176 lines
40 KiB
Go

package settingscontract
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"testing"
)
// Keys referenced by more than one test. These are deliberately test-scoped:
// production code reads keys through generated bindings, not handwritten Go
// constants, so a parallel set of literals here would be a second source of
// truth for the very thing the manifest exists to own.
const (
keyAudioLanguage = "playback.audio_language"
keyAutoPlayNext = "playback.auto_play_next"
keyNextUpPrompt = "playback.next_up_prompt_seconds"
keyPlaybackSpeed = "player.playback_speed"
keyPreferredQuality = "playback.preferred_quality"
keySubtitleAppearance = "playback.subtitle_appearance"
keySubtitleMode = "playback.subtitle_mode"
// fixtureKey names definitions built inside tests, never the manifest.
fixtureKey = "playback.example"
jsonNull = "null"
)
// TestEmbeddedManifestLoads is the gate the whole contract rests on: the
// checked-in manifest parses, satisfies its own JSON Schema, and passes every
// structural invariant.
func TestEmbeddedManifestLoads(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("embedded manifest failed to load: %v", err)
}
if manifest.APIVersion != 1 {
t.Errorf("api_version = %d, want 1", manifest.APIVersion)
}
if manifest.Revision < 1 {
t.Errorf("revision = %d, want at least 1", manifest.Revision)
}
if len(manifest.Definitions) == 0 {
t.Fatal("manifest declares no definitions")
}
}
// TestEveryCurrentServerKeyIsRegistered pins the inventory. The design makes an
// unregistered official key a release blocker, so a key that exists in the
// legacy registry but not in the manifest has to fail here rather than be
// discovered during migration.
func TestEveryCurrentServerKeyIsRegistered(t *testing.T) {
// Keys the legacy settingsRegistry in internal/api/handlers/settings.go
// accepts today, mapped to their canonical names. A legacy key whose
// canonical name differs is renamed deliberately; see the manifest notes.
legacyToCanonical := map[string]string{
"playback.preferred_quality": keyPreferredQuality,
"playback.audio_language": keyAudioLanguage,
"playback.auto_skip_intro": "playback.auto_skip_intro",
"playback.auto_skip_credits": "playback.auto_skip_credits",
"playback.auto_skip_recap": "playback.auto_skip_recap",
"playback.auto_play_next_preview": "playback.auto_play_next_preview",
"playback.auto_play_next": keyAutoPlayNext,
"playback.next_up_prompt_seconds": keyNextUpPrompt,
"subtitle_appearance": keySubtitleAppearance,
"ui.library_page_state": "ui.library_page_state",
"ui.remember_library_page_state": "ui.remember_library_page_state",
"search.media_scope": "search.media_scope",
"ui.date_format": "ui.date_format",
"ui.time_format": "ui.time_format",
"player.hdr_enabled": "player.hdr_enabled",
"player.dolby_vision_enabled": "player.dolby_vision_enabled",
"player.dv_profile7_hdr10_fallback": "player.dv_profile7_hdr10_fallback",
"player.seek_cache_enabled": "player.seek_cache_enabled",
"player.playback_speed": keyPlaybackSpeed,
"player.audio_sync_ms": "player.audio_sync_ms",
"player.subtitle_sync_ms": "player.subtitle_sync_ms",
"player.video_gravity": "player.video_gravity",
"player.orientation_mode": "player.orientation_mode",
// Unregistered keys the extension bag accepted from the web client.
"ui_theme": "ui.theme",
"ui_text_scale": "ui.text_scale",
"ui_text_weight": "ui.text_weight",
"ui_high_contrast": "ui.high_contrast",
"ui_custom_theme_vars": "ui.custom_theme_vars",
"ui_custom_css": "ui.custom_css",
// Unregistered device-setting keys Android writes today.
"player.match_frame_rate": "player.match_frame_rate",
"player.sleep_timer_default_minutes": "player.sleep_timer_default_minutes",
"player.next_up_prompt_seconds": keyNextUpPrompt,
// Profile columns that become settings.
"user_profiles.language": keyAudioLanguage,
"user_profiles.subtitle_language": "playback.subtitle_language",
"user_profiles.subtitle_mode": keySubtitleMode,
"user_profiles.quality_preference": keyPreferredQuality,
"user_profiles.preferred_metadata_language": "catalog.metadata_language",
"user_profiles.show_forced_subtitles": "playback.show_forced_subtitles",
}
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for legacy, canonical := range legacyToCanonical {
if _, ok := manifest.Lookup(canonical); !ok {
t.Errorf("legacy key %q maps to %q, which has no manifest definition", legacy, canonical)
}
}
}
func TestDefaultsValidateAgainstTheirOwnSchema(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for i := range manifest.Definitions {
def := &manifest.Definitions[i]
t.Run(def.Key, func(t *testing.T) {
if len(def.DefaultValue) == 0 {
t.Fatal("default_value is missing")
}
if string(def.DefaultValue) == jsonNull {
if !def.ValueSchema.Nullable {
t.Fatal("default is null but the schema is not nullable")
}
return
}
if err := def.ValueSchema.ValidateValue(def.DefaultValue, objSchemas); err != nil {
t.Fatalf("default %s is invalid: %v", def.DefaultValue, err)
}
})
}
}
func TestResolutionOrdersAreComplete(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for i := range manifest.Definitions {
def := &manifest.Definitions[i]
t.Run(def.Key, func(t *testing.T) {
order := def.ResolutionOrder
if len(order) == 0 {
t.Fatal("resolution_order is empty")
}
if order[len(order)-1] != ScopeDefault {
t.Fatalf("resolution_order ends with %q, want %q", order[len(order)-1], ScopeDefault)
}
seen := map[Scope]int{}
for _, scope := range order[:len(order)-1] {
seen[scope]++
if seen[scope] > 1 {
t.Errorf("resolution_order repeats %q", scope)
}
if !def.AllowsScope(scope) {
t.Errorf("resolution_order resolves %q, absent from allowed_scopes", scope)
}
}
for _, entry := range def.AllowedScopes {
if seen[entry.Scope] == 0 {
t.Errorf("scope %q is writable but never read", entry.Scope)
}
}
})
}
}
// TestCeilingConstraintsAreOrdered guards the failure the policy seam exists to
// prevent: a ceiling declared on a type where "cap this" has no meaning would
// silently do nothing.
func TestCeilingConstraintsAreOrdered(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for i := range manifest.Definitions {
def := &manifest.Definitions[i]
if def.ConstrainedBy == nil {
continue
}
kind := def.ConstrainedBy.Constraint
if kind != ConstraintCeiling && kind != ConstraintFloor {
continue
}
ordered := def.ValueSchema.Type == TypeInteger ||
def.ValueSchema.Type == TypeNumber ||
(def.ValueSchema.Type == TypeEnum && def.ValueSchema.Ordered)
if !ordered {
t.Errorf("%s declares a %s constraint on unordered type %s",
def.Key, kind, def.ValueSchema.Type)
}
}
}
func TestRevisionTagsNeverExceedManifestRevision(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for i := range manifest.Definitions {
def := &manifest.Definitions[i]
if def.IntroducedIn > manifest.Revision {
t.Errorf("%s: introduced_in %d exceeds manifest revision %d",
def.Key, def.IntroducedIn, manifest.Revision)
}
for _, entry := range def.AllowedScopes {
if entry.IntroducedIn > manifest.Revision {
t.Errorf("%s: scope %q introduced_in %d exceeds manifest revision %d",
def.Key, entry.Scope, entry.IntroducedIn, manifest.Revision)
}
}
for _, member := range def.ValueSchema.Values {
if member.IntroducedIn > manifest.Revision {
t.Errorf("%s: enum member %v introduced_in %d exceeds manifest revision %d",
def.Key, member.Value, member.IntroducedIn, manifest.Revision)
}
}
}
}
func TestClientLocalDefinitionsNeverReachTheAPI(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for i := range manifest.Definitions {
def := &manifest.Definitions[i]
if def.Persistence != PersistenceClientLocal {
continue
}
if def.IsRemote() {
t.Errorf("%s: client_local definition reports IsRemote", def.Key)
}
for _, entry := range def.AllowedScopes {
if entry.Scope.IsRemote() {
t.Errorf("%s: client_local definition allows remote scope %q", def.Key, entry.Scope)
}
}
if def.ConstrainedBy != nil {
t.Errorf("%s: client_local definition declares a policy constraint the server cannot apply", def.Key)
}
}
}
// TestPrivateLocalKeysAreRejected covers the escape hatch's boundary: a
// local.* key must never appear in the shared contract.
func TestPrivateLocalKeysAreRejected(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
for i := range manifest.Definitions {
if strings.HasPrefix(manifest.Definitions[i].Key, "local.") {
t.Errorf("%s: private local.* keys must stay out of the shared manifest",
manifest.Definitions[i].Key)
}
}
}
func TestCanonicalBytesAreStable(t *testing.T) {
first, err := CanonicalBytes()
if err != nil {
t.Fatalf("canonicalizing: %v", err)
}
second, err := CanonicalBytes()
if err != nil {
t.Fatalf("canonicalizing again: %v", err)
}
if string(first) != string(second) {
t.Fatal("canonical bytes differ between calls")
}
// Canonical output must itself be canonical, or the digest depends on how
// many times you ran it.
recanonicalized, err := canonicalize(first)
if err != nil {
t.Fatalf("re-canonicalizing: %v", err)
}
if string(recanonicalized) != string(first) {
t.Fatal("canonicalization is not idempotent")
}
tag, err := ETag()
if err != nil {
t.Fatalf("computing ETag: %v", err)
}
if !strings.HasPrefix(tag, `"`) || !strings.HasSuffix(tag, `"`) || len(tag) != 66 {
t.Fatalf("ETag %q is not a quoted SHA-256 hex digest", tag)
}
}
func TestCanonicalizationSortsKeysAndNormalizesNumbers(t *testing.T) {
got, err := canonicalize([]byte(`{"b":1,"a":{"d":3.0,"c":0.05},"e":[2.50,1e2]}`))
if err != nil {
t.Fatalf("canonicalizing: %v", err)
}
want := `{"a":{"c":0.05,"d":3},"b":1,"e":[2.5,100]}`
if string(got) != want {
t.Fatalf("canonicalize() = %s, want %s", got, want)
}
}
// TestNumbersMatchECMAScript pins the cases where Go's own float formatting
// disagrees with ECMAScript's Number::toString, which is what RFC 8785
// requires. Every expectation below is the literal output of String(x) in
// node; a client running a conforming JCS library must agree byte for byte or
// its digest of the same manifest will differ from the server's.
func TestNumbersMatchECMAScript(t *testing.T) {
cases := []struct{ in, want string }{
// Go's 'g' would switch to exponential at 1e-5; ECMAScript holds off
// until the exponent drops below -6.
{"0.00001", "0.00001"},
{"1e-5", "0.00001"},
{"0.000001", "0.000001"},
{"1e-6", "0.000001"},
// Below the threshold both use exponential, but Go zero-pads.
{"1e-7", "1e-7"},
{"-1e-7", "-1e-7"},
{"5e-324", "5e-324"},
// Go prints negative zero as "-0"; JCS has no negative zero.
{"-0", "0"},
{"0", "0"},
// Integers render without a fraction up to 1e21, exponential above.
{"1e20", "100000000000000000000"},
{"1e21", "1e+21"},
{"1e22", "1e+22"},
{"1e100", "1e+100"},
{"3.0", "3"},
{"1e2", "100"},
// Shortest round-trip digits, not the exact binary value.
{"1234567.5", "1234567.5"},
{"0.1", "0.1"},
{"2.50", "2.5"},
{"-2.5", "-2.5"},
{"1.5e300", "1.5e+300"},
{"123.456", "123.456"},
}
for _, tc := range cases {
got, err := canonicalize([]byte(tc.in))
if err != nil {
t.Errorf("canonicalize(%s): %v", tc.in, err)
continue
}
if string(got) != tc.want {
t.Errorf("canonicalize(%s) = %s, want %s (ECMAScript String())", tc.in, got, tc.want)
}
}
}
// TestStringsAreNotHTMLEscaped guards the other half of JCS string handling.
// encoding/json escapes <, > and & by default, which would silently fork the
// server's digest from every conforming client the first time a label contains
// an ampersand.
func TestStringsAreNotHTMLEscaped(t *testing.T) {
got, err := canonicalize([]byte(`{"label":"Audio & subtitles","hint":"<auto> or >90%"}`))
if err != nil {
t.Fatalf("canonicalizing: %v", err)
}
want := `{"hint":"<auto> or >90%","label":"Audio & subtitles"}`
if string(got) != want {
t.Fatalf("canonicalize() = %s, want %s", got, want)
}
// The escapes JCS does require are still applied.
got, err = canonicalize([]byte(`{"a":"q\"uote\\back\ttab\nline"}`))
if err != nil {
t.Fatalf("canonicalizing escapes: %v", err)
}
if want := `{"a":"q\"uote\\back\ttab\nline"}`; string(got) != want {
t.Fatalf("canonicalize() = %s, want %s", got, want)
}
// Control characters below 0x20 with no short escape use \u00xx.
got, err = canonicalize([]byte(`{"a":"\u0001"}`))
if err != nil {
t.Fatalf("canonicalizing control character: %v", err)
}
if want := `{"a":"\u0001"}`; string(got) != want {
t.Fatalf("canonicalize() = %s, want %s", got, want)
}
}
// TestETagCoversValueSchemas fails if the entity tag is ever narrowed back to
// manifest.json alone. The schemas decide which object values the server
// accepts, so a change to one changes the contract; if it did not move the tag,
// every client would 304 forever against validation rules that had shifted
// underneath them.
func TestETagCoversValueSchemas(t *testing.T) {
schemas, err := SchemaBytes()
if err != nil {
t.Fatalf("reading value schemas: %v", err)
}
if len(schemas) == 0 {
t.Fatal("no value schemas, so this test proves nothing")
}
manifest, err := CanonicalBytes()
if err != nil {
t.Fatalf("canonicalizing manifest: %v", err)
}
baseline, err := digestWithSchemas(manifest)
if err != nil {
t.Fatalf("digesting: %v", err)
}
live, err := ETag()
if err != nil {
t.Fatalf("computing ETag: %v", err)
}
if live != baseline {
t.Fatalf("ETag() = %s, want the schema-inclusive digest %s", live, baseline)
}
if reference := sha256Of(manifest, schemas); reference != baseline {
t.Fatalf("test digest helper disagrees with digestWithSchemas: %s vs %s",
reference, baseline)
}
// Tightening a schema — the change that alters what the server accepts
// while leaving manifest.json byte-identical — must move the digest.
names := make([]string, 0, len(schemas))
for name := range schemas {
names = append(names, name)
}
sort.Strings(names)
tightened := map[string][]byte{}
for name, body := range schemas {
tightened[name] = body
}
tightened[names[0]] = []byte(`{"type":"object","additionalProperties":false}`)
if sha256Of(manifest, tightened) == baseline {
t.Error("changing a value schema's contents did not change the contract digest")
}
// So must renaming one, since the filename is what a definition's
// schema_ref binds to.
renamed := map[string][]byte{}
for name, body := range schemas {
renamed[name] = body
}
renamed["renamed-"+names[0]] = renamed[names[0]]
delete(renamed, names[0])
if sha256Of(manifest, renamed) == baseline {
t.Error("renaming a value schema did not change the contract digest")
}
// Whitespace, on the other hand, must not: the schemas are canonicalized
// before hashing, so reformatting a file is not a contract change.
reformatted := map[string][]byte{}
for name, body := range schemas {
reformatted[name] = body
}
reformatted[names[0]] = append(append([]byte(nil), reformatted[names[0]]...), '\n', ' ')
if sha256Of(manifest, reformatted) != baseline {
t.Error("reformatting a value schema changed the contract digest")
}
}
func sha256Of(manifest []byte, schemas map[string][]byte) string {
names := make([]string, 0, len(schemas))
for name := range schemas {
names = append(names, name)
}
sort.Strings(names)
digest := sha256.New()
digest.Write(manifest)
for _, name := range names {
canonical, err := canonicalize(schemas[name])
if err != nil {
// A deliberately-perturbed schema may not parse; hash the raw bytes
// so the test still observes a change.
canonical = schemas[name]
}
_, _ = fmt.Fprintf(digest, "\n%d:%s\n%d:", len(name), name, len(canonical))
digest.Write(canonical)
}
return `"` + hex.EncodeToString(digest.Sum(nil)) + `"`
}
// TestDerivedRepresentationsAreMemoized keeps the manifest endpoint cheap: all
// four values are pure functions of files fixed at compile time, and a
// conditional GET must not pay a full parse and re-serialize to answer 304.
func TestDerivedRepresentationsAreMemoized(t *testing.T) {
first, err := CanonicalBytes()
if err != nil {
t.Fatalf("canonicalizing: %v", err)
}
second, err := CanonicalBytes()
if err != nil {
t.Fatalf("canonicalizing again: %v", err)
}
// Callers get their own copy, so mutating one must not corrupt the cache.
if len(first) > 0 {
first[0] = 'X'
}
third, err := CanonicalBytes()
if err != nil {
t.Fatalf("canonicalizing a third time: %v", err)
}
if string(second) != string(third) {
t.Fatal("mutating a returned slice corrupted the memoized canonical bytes")
}
allocs := testing.AllocsPerRun(100, func() {
if _, err := ETag(); err != nil {
t.Fatalf("computing ETag: %v", err)
}
if _, err := PublicETag(); err != nil {
t.Fatalf("computing PublicETag: %v", err)
}
})
if allocs > 0 {
t.Errorf("ETag()+PublicETag() allocate %.0f times per call; both should be memoized", allocs)
}
}
func TestPublicManifestStripsMaintainerNotes(t *testing.T) {
public, err := PublicBytes()
if err != nil {
t.Fatalf("building public manifest: %v", err)
}
var doc struct {
Definitions []map[string]json.RawMessage `json:"definitions"`
}
if err := json.Unmarshal(public, &doc); err != nil {
t.Fatalf("parsing public manifest: %v", err)
}
if len(doc.Definitions) == 0 {
t.Fatal("public manifest has no definitions")
}
for _, def := range doc.Definitions {
if _, present := def["notes"]; present {
t.Errorf("public manifest leaked maintainer notes: %s", def["key"])
}
}
// The private manifest is what carries notes; if it stopped carrying any,
// this test would pass vacuously.
raw, err := RawBytes()
if err != nil {
t.Fatalf("reading raw manifest: %v", err)
}
if !strings.Contains(string(raw), `"notes"`) {
t.Fatal("no definition carries notes, so the stripping test proves nothing")
}
}
func TestRevisionAwareFilteringHidesNewerElements(t *testing.T) {
def := &Definition{
Key: fixtureKey,
IntroducedIn: 5,
AllowedScopes: []ScopeEntry{
{Scope: ScopeProfile},
{Scope: ScopeProfileDevice, IntroducedIn: 9},
},
ValueSchema: ValueSchema{
Type: TypeEnum,
Values: []EnumMember{
{Value: "old"},
{Value: "new", IntroducedIn: 9},
},
},
}
if def.VisibleAtRevision(4) {
t.Error("definition introduced at 5 should be hidden from a revision-4 peer")
}
if !def.VisibleAtRevision(5) {
t.Error("definition introduced at 5 should be visible to a revision-5 peer")
}
if got := def.ScopesAtRevision(5); len(got) != 1 || got[0] != ScopeProfile {
t.Errorf("ScopesAtRevision(5) = %v, want [profile]", got)
}
if got := def.ScopesAtRevision(9); len(got) != 2 {
t.Errorf("ScopesAtRevision(9) = %v, want both scopes", got)
}
if got := def.EnumValuesAtRevision(5); len(got) != 1 || got[0].Value != "old" {
t.Errorf("EnumValuesAtRevision(5) = %v, want [old]", got)
}
if got := def.EnumValuesAtRevision(9); len(got) != 2 {
t.Errorf("EnumValuesAtRevision(9) = %v, want both members", got)
}
}
// TestWidenedBoundsStayResolvableAtOlderRevisions is the case a bare scalar
// plus a revision tag could not express. player.sleep_timer_default_minutes
// ships with a 240 maximum; widening it to 480 later must not erase the 240,
// because a client pinned to the newer revision still has to talk to servers
// that enforce the older one.
func TestWidenedBoundsStayResolvableAtOlderRevisions(t *testing.T) {
widened := &Bound{History: []BoundEntry{
{Value: 240},
{Value: 480, IntroducedIn: 3},
}}
for _, tc := range []struct {
revision int
want float64
}{
{1, 240}, // the revision that introduced the definition
{2, 240}, // still before the widening
{3, 480}, // the widening itself
{9, 480}, // and everything after it
} {
got, ok := widened.AtRevision(tc.revision)
if !ok {
t.Errorf("AtRevision(%d) found no bound", tc.revision)
continue
}
if got != tc.want {
t.Errorf("AtRevision(%d) = %g, want %g", tc.revision, got, tc.want)
}
}
if got, _ := widened.Current(); got != 480 {
t.Errorf("Current() = %g, want the newest bound 480", got)
}
// The server validates against its own newest bound regardless of any
// peer's revision.
schema := &ValueSchema{Type: TypeInteger, Minimum: fixedBound(0), Maximum: widened}
if err := schema.ValidateValue(json.RawMessage(`480`), nil); err != nil {
t.Errorf("ValidateValue(480) = %v, want nil", err)
}
if err := schema.ValidateValue(json.RawMessage(`481`), nil); err == nil {
t.Error("481 was accepted above the widened maximum")
}
}
// TestBoundsRoundTripInTheirAuthoredShape keeps the manifest diff honest: a
// bound nobody has widened must not sprout a history array when the contract is
// re-serialized, or the ETag changes for a file nobody edited.
func TestBoundsRoundTripInTheirAuthoredShape(t *testing.T) {
for _, tc := range []struct {
name string
raw string
}{
{"bare", `240`},
{"history", `[{"value":240},{"value":480,"introduced_in":3}]`},
} {
t.Run(tc.name, func(t *testing.T) {
var bound Bound
if err := json.Unmarshal([]byte(tc.raw), &bound); err != nil {
t.Fatalf("unmarshalling %s: %v", tc.raw, err)
}
encoded, err := json.Marshal(bound)
if err != nil {
t.Fatalf("marshaling: %v", err)
}
if string(encoded) != tc.raw {
t.Errorf("round trip = %s, want %s", encoded, tc.raw)
}
})
}
}
func TestBoundHistoriesAreValidated(t *testing.T) {
for _, tc := range []struct {
name string
maximum *Bound
want string
}{
{
name: "narrowing is not a widening",
maximum: &Bound{History: []BoundEntry{{Value: 480}, {Value: 240, IntroducedIn: 6}}},
want: "narrows",
},
{
name: "history must move forward",
maximum: &Bound{History: []BoundEntry{{Value: 240}, {Value: 480, IntroducedIn: 5}}},
want: "not ordered",
},
{
name: "later entries must say when they arrived",
maximum: &Bound{History: []BoundEntry{{Value: 240}, {Value: 480}}},
want: "must declare introduced_in",
},
{
name: "a bound cannot predate its definition",
maximum: &Bound{History: []BoundEntry{{Value: 240, IntroducedIn: 2}}},
want: "the definition was introduced in 5",
},
{
name: "a bound cannot postdate the manifest",
maximum: &Bound{History: []BoundEntry{{Value: 240}, {Value: 480, IntroducedIn: 99}}},
want: "after the manifest revision",
},
} {
t.Run(tc.name, func(t *testing.T) {
schema := &ValueSchema{Type: TypeInteger, Minimum: fixedBound(0), Maximum: tc.maximum}
errs := schema.validate(5, 10, nil)
if len(errs) == 0 {
t.Fatalf("validate accepted %s", tc.name)
}
if joined := errors.Join(errs...).Error(); !strings.Contains(joined, tc.want) {
t.Errorf("error %q does not mention %q", joined, tc.want)
}
})
}
}
// TestEnumMembersCannotPredateTheirDefinition is the enum half of the same
// lower-bound rule allowed_scopes has always enforced.
func TestEnumMembersCannotPredateTheirDefinition(t *testing.T) {
schema := &ValueSchema{
Type: TypeEnum,
Values: []EnumMember{{Value: "old"}, {Value: "new", IntroducedIn: 2}},
}
errs := schema.validate(5, 10, nil)
if len(errs) == 0 {
t.Fatal("an enum member claiming to predate its definition was accepted")
}
if joined := errors.Join(errs...).Error(); !strings.Contains(joined, "before the definition's own 5") {
t.Errorf("error %q does not name the definition revision", joined)
}
}
// TestDuplicatePropertiesAreRejected covers the case where the parser, not the
// contract, decides what a value means. Both encoding/json and the schema
// validator keep the last occurrence silently.
func TestDuplicatePropertiesAreRejected(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
def, ok := manifest.Lookup(keySubtitleAppearance)
if !ok {
t.Fatal("playback.subtitle_appearance is not registered")
}
for name, raw := range map[string]string{
"top level": `{"fontSize":"small","fontSize":"large"}`,
"repeated with other properties": `{"fontSize":"small","position":"top",` +
`"fontSize":"large"}`,
} {
t.Run(name, func(t *testing.T) {
err := def.ValueSchema.ValidateValue(json.RawMessage(raw), objSchemas)
if err == nil {
t.Fatal("a value with a repeated property was accepted")
}
if !strings.Contains(err.Error(), "repeats the property") {
t.Errorf("error %q does not name the repeated property", err)
}
})
}
// The same value without the repeat still validates, so the check is not
// simply rejecting everything.
if err := def.ValueSchema.ValidateValue(
json.RawMessage(`{"fontSize":"large","position":"top"}`), objSchemas); err != nil {
t.Fatalf("a value with distinct properties was rejected: %v", err)
}
}
// TestLoneSurrogatesAreRejected pins RFC 8785's requirement to terminate rather
// than substitute. encoding/json turns an unpaired escape into U+FFFD and
// reports success, which would have the server issue an ETag for bytes a
// conforming client must refuse.
func TestLoneSurrogatesAreRejected(t *testing.T) {
for name, raw := range map[string]string{
"leading alone": `"\ud800"`,
"trailing alone": `"\udc00"`,
"leading then normal": `"\ud800A"`,
"nested in an object": `{"a":"x\udbffy"}`,
} {
t.Run(name, func(t *testing.T) {
if _, err := canonicalize([]byte(raw)); err == nil {
t.Fatalf("canonicalized %s despite a lone surrogate", raw)
}
})
}
// A well-formed pair is a real character and must survive untouched.
encoded, err := canonicalize([]byte(`"😀"`))
if err != nil {
t.Fatalf("a valid surrogate pair was rejected: %v", err)
}
if want := "\"\U0001F600\""; string(encoded) != want {
t.Errorf("canonicalized pair = %s, want %s", encoded, want)
}
}
func TestValidateValueRejectsOutOfContractValues(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
cases := []struct {
name string
key string
value string
wantErr bool
}{
// The verified Android drift: the server contract stops at 3.0.
{name: "speed at contract maximum", key: keyPlaybackSpeed, value: "3.0"},
{name: "speed above contract maximum", key: keyPlaybackSpeed, value: "4.0", wantErr: true},
{name: "speed below contract minimum", key: keyPlaybackSpeed, value: "0.1", wantErr: true},
{name: "prompt seconds in range", key: keyNextUpPrompt, value: "30"},
{name: "prompt seconds above range", key: keyNextUpPrompt, value: "121", wantErr: true},
{name: "prompt seconds not an integer", key: keyNextUpPrompt, value: "30.5", wantErr: true},
{name: "boolean true", key: keyAutoPlayNext, value: "true"},
{name: "stringly boolean rejected", key: keyAutoPlayNext, value: `"true"`, wantErr: true},
{name: "known enum member", key: keySubtitleMode, value: `"always"`},
{name: "unknown enum member", key: keySubtitleMode, value: `"sometimes"`, wantErr: true},
{name: "legacy empty string is not a mode", key: keySubtitleMode, value: `""`, wantErr: true},
{name: "language tag", key: keyAudioLanguage, value: `"en-US"`},
{name: "language tag with script", key: keyAudioLanguage, value: `"zh-Hans-CN"`},
{name: "nullable language tag", key: keyAudioLanguage, value: jsonNull},
{name: "malformed language tag", key: keyAudioLanguage, value: `"not a language"`, wantErr: true},
{name: "non-nullable boolean rejects null", key: keyAutoPlayNext, value: jsonNull, wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
def, ok := manifest.Lookup(tc.key)
if !ok {
t.Fatalf("no definition for %q", tc.key)
}
err := def.ValueSchema.ValidateValue(json.RawMessage(tc.value), objSchemas)
if tc.wantErr && err == nil {
t.Fatalf("ValidateValue(%s) succeeded, want rejection", tc.value)
}
if !tc.wantErr && err != nil {
t.Fatalf("ValidateValue(%s) = %v, want success", tc.value, err)
}
})
}
}
func TestObjectValuesValidateAgainstTheirSchema(t *testing.T) {
manifest, err := Load()
if err != nil {
t.Fatalf("loading manifest: %v", err)
}
def, ok := manifest.Lookup(keySubtitleAppearance)
if !ok {
t.Fatal("playback.subtitle_appearance is not registered")
}
valid := `{
"fontSize": "large", "fontFamily": "sans-serif", "fontColor": "#ffffff",
"backgroundColor": "#000000", "backgroundStyle": "shadow", "backgroundOpacity": 75,
"textOutline": false, "textOutlineColor": "#000000", "position": "bottom"
}`
if err := def.ValueSchema.ValidateValue(json.RawMessage(valid), objSchemas); err != nil {
t.Fatalf("valid subtitle appearance rejected: %v", err)
}
// A stored value is a sparse override merged over the default, and the
// current API already stores exactly these shapes — see the round trip in
// settings_device_test.go. Requiring all nine properties would make the
// migration quarantine preferences users really set.
for name, partial := range map[string]string{
"one property": `{"fontSize":"small"}`,
"two properties": `{"fontSize":"xxlarge","position":"top"}`,
"only a color": `{"fontColor":"#ff0000"}`,
} {
t.Run(name, func(t *testing.T) {
if err := def.ValueSchema.ValidateValue(json.RawMessage(partial), objSchemas); err != nil {
t.Fatalf("partial subtitle appearance %s rejected: %v", partial, err)
}
})
}
for name, invalid := range map[string]string{
"unknown field": `{"fontSize":"large","fontFamily":"sans-serif","fontColor":"#ffffff",
"backgroundColor":"#000000","backgroundStyle":"shadow","backgroundOpacity":75,
"textOutline":false,"textOutlineColor":"#000000","position":"bottom","extra":1}`,
"opacity out of range": `{"fontSize":"large","fontFamily":"sans-serif","fontColor":"#ffffff",
"backgroundColor":"#000000","backgroundStyle":"shadow","backgroundOpacity":250,
"textOutline":false,"textOutlineColor":"#000000","position":"bottom"}`,
"malformed color": `{"fontSize":"large","fontFamily":"sans-serif","fontColor":"white",
"backgroundColor":"#000000","backgroundStyle":"shadow","backgroundOpacity":75,
"textOutline":false,"textOutlineColor":"#000000","position":"bottom"}`,
// Sparse is allowed; empty is not. An override that overrides nothing is
// the same state as no override, and the contract represents that as
// unset rather than as a stored value.
"empty override": `{}`,
"arbitrary json": `{"anything":"goes"}`,
} {
t.Run(name, func(t *testing.T) {
if err := def.ValueSchema.ValidateValue(json.RawMessage(invalid), objSchemas); err == nil {
t.Fatal("invalid subtitle appearance was accepted")
}
})
}
}
// TestValidateRejectsMalformedManifests exercises the invariant checks against
// hand-built manifests, since the checked-in one is expected to be valid.
func TestValidateRejectsMalformedManifests(t *testing.T) {
if _, err := Load(); err != nil {
t.Fatalf("loading manifest: %v", err)
}
base := func() Definition {
return Definition{
Key: fixtureKey,
IntroducedIn: 1,
Persistence: PersistenceRemote,
AllowedScopes: []ScopeEntry{{Scope: ScopeProfile}},
ResolutionOrder: []Scope{ScopeProfile, ScopeDefault},
ValueSchema: ValueSchema{Type: TypeBoolean},
DefaultValue: json.RawMessage("false"),
Category: "playback",
Label: "Example",
Description: "Example.",
}
}
cases := map[string]func(*Definition){
"resolution order missing default": func(d *Definition) {
d.ResolutionOrder = []Scope{ScopeProfile}
},
"resolution order references unlisted scope": func(d *Definition) {
d.ResolutionOrder = []Scope{ScopeProfileDevice, ScopeProfile, ScopeDefault}
},
"writable scope never read": func(d *Definition) {
d.AllowedScopes = append(d.AllowedScopes, ScopeEntry{Scope: ScopeProfileDevice})
},
"duplicate scope": func(d *Definition) {
d.AllowedScopes = []ScopeEntry{{Scope: ScopeProfile}, {Scope: ScopeProfile}}
},
"default violates schema": func(d *Definition) {
d.DefaultValue = json.RawMessage(`"yes"`)
},
"null default on non-nullable schema": func(d *Definition) {
d.DefaultValue = json.RawMessage(jsonNull)
},
"remote setting with client_local scope": func(d *Definition) {
d.AllowedScopes = []ScopeEntry{{Scope: ScopeClientLocal}}
d.ResolutionOrder = []Scope{ScopeClientLocal, ScopeDefault}
},
"client_local setting with remote scope": func(d *Definition) {
d.Persistence = PersistenceClientLocal
},
"ceiling on unordered enum": func(d *Definition) {
d.ValueSchema = ValueSchema{
Type: TypeEnum,
Values: []EnumMember{{Value: "a"}, {Value: "b"}},
}
d.DefaultValue = json.RawMessage(`"a"`)
d.ConstrainedBy = &Constraint{PolicyInput: "example", Constraint: ConstraintCeiling}
},
"introduced_in after manifest revision": func(d *Definition) {
d.IntroducedIn = 99
},
"scope introduced before its definition": func(d *Definition) {
d.AllowedScopes = []ScopeEntry{{Scope: ScopeProfile, IntroducedIn: 1}}
d.IntroducedIn = 2
},
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
def := base()
mutate(&def)
manifest := &Manifest{APIVersion: 1, Revision: 2, Definitions: []Definition{def}}
if err := manifest.index(); err != nil {
t.Fatalf("indexing: %v", err)
}
if err := manifest.Validate(objSchemas); err == nil {
t.Fatal("Validate accepted a manifest that violates an invariant")
}
})
}
}
func TestDuplicateKeysAreRejected(t *testing.T) {
manifest := &Manifest{
APIVersion: 1,
Revision: 1,
Definitions: []Definition{
{Key: fixtureKey},
{Key: fixtureKey},
},
}
if err := manifest.index(); err == nil {
t.Fatal("index accepted a duplicate key")
}
}
// TestStrictUnmarshalRejectsTrailingContent covers the bytes a caller slicing a
// value out of a larger document is most likely to hand over. json.Decoder's
// More() answers false for a stray closing bracket, so relying on it let
// `true]` validate as a boolean.
func TestStrictUnmarshalRejectsTrailingContent(t *testing.T) {
rejected := []string{
`true]`, `true}`, `30]`, `30}`, `"always"}`, `"a" ]`,
`true false`, `30 40`, `{} {}`, `[] []`,
}
for _, raw := range rejected {
var value any
if err := strictUnmarshal([]byte(raw), &value); err == nil {
t.Errorf("strictUnmarshal(%s) accepted trailing content", raw)
}
}
accepted := []string{`true`, `30`, `"always"`, `{"a":1}`, `[1,2]`, `null`, ` 30 `}
for _, raw := range accepted {
var value any
if err := strictUnmarshal([]byte(raw), &value); err != nil {
t.Errorf("strictUnmarshal(%s) = %v, want nil", raw, err)
}
}
}
// TestEnumMatchingIsTypeSafe guards the value types manifest.schema.json
// already permits. Comparing formatted tokens made the string "3" satisfy an
// integer member and "true" satisfy a boolean one.
func TestEnumMatchingIsTypeSafe(t *testing.T) {
schema := &ValueSchema{
Type: TypeEnum,
Values: []EnumMember{
{Value: "auto"},
{Value: float64(3)},
{Value: true},
{Value: float64(1000000)},
},
}
accepted := []string{`"auto"`, `3`, `true`, `3.0`, `1000000`, `1e6`}
for _, raw := range accepted {
if err := schema.ValidateValue(json.RawMessage(raw), nil); err != nil {
t.Errorf("ValidateValue(%s) = %v, want nil", raw, err)
}
}
rejected := []string{`"3"`, `"true"`, `"1000000"`, `4`, `false`, `"AUTO"`}
for _, raw := range rejected {
if err := schema.ValidateValue(json.RawMessage(raw), nil); err == nil {
t.Errorf("ValidateValue(%s) was accepted; wrong JSON type or value", raw)
}
}
// A string member and a numeric member that print alike are distinct, not
// a duplicate.
mixed := &ValueSchema{
Type: TypeEnum,
Values: []EnumMember{{Value: "3"}, {Value: float64(3)}},
}
if errs := mixed.validate(1, 1, nil); len(errs) != 0 {
t.Errorf(`members "3" and 3 reported as duplicates: %v`, errs)
}
}
// fixedBound is a bound that has never been widened, which is every bound built
// by hand in a test.
func fixedBound(value float64) *Bound {
return &Bound{History: []BoundEntry{{Value: value}}}
}
// TestStepIsEnforced closes the gap between a declared constraint and the
// single validation path. player.playback_speed advertises a 0.05 step, so a
// server that stores 1.4372 hands every client a value its stepper cannot
// represent.
func TestStepIsEnforced(t *testing.T) {
step := 0.05
schema := &ValueSchema{
Type: TypeNumber, Minimum: fixedBound(0.25), Maximum: fixedBound(3.0), Step: &step,
}
for _, raw := range []string{`0.25`, `0.75`, `1`, `1.25`, `1.4`, `2.5`, `3`} {
if err := schema.ValidateValue(json.RawMessage(raw), nil); err != nil {
t.Errorf("ValidateValue(%s) = %v, want nil", raw, err)
}
}
for _, raw := range []string{`0.26`, `1.4372`, `1.01`, `2.99`} {
if err := schema.ValidateValue(json.RawMessage(raw), nil); err == nil {
t.Errorf("ValidateValue(%s) was accepted despite the 0.05 step", raw)
}
}
// Range still wins where both apply.
if err := schema.ValidateValue(json.RawMessage(`3.05`), nil); err == nil {
t.Error("a value above the maximum was accepted")
}
}
// TestLanguageTagsAcceptWhatClientsProduce pins the shapes the narrower
// original pattern rejected. Every tag here is one a shipped client can emit
// without the user doing anything unusual.
func TestLanguageTagsAcceptWhatClientsProduce(t *testing.T) {
wellFormed := map[string]string{
"en": "en",
"EN": "en",
"en-US": "en-US",
"en-us": "en-US",
"EN-us": "en-US",
"en_US": "en-US", // iOS Locale.identifier, Android Locale.toString()
"zh-Hant-TW": "zh-Hant-TW",
"zh-hant-tw": "zh-Hant-TW",
"ca-ES-valencia": "ca-ES-valencia",
"ar-EG-u-nu-latn": "ar-EG-u-nu-latn",
"de-DE-u-co-phonebk": "de-DE-u-co-phonebk",
"es-419": "es-419",
"en-US-x-private": "en-US-x-private",
}
for input, want := range wellFormed {
got, ok := NormalizeLanguageTag(input)
if !ok {
t.Errorf("NormalizeLanguageTag(%q) rejected a tag a client produces", input)
continue
}
if got != want {
t.Errorf("NormalizeLanguageTag(%q) = %q, want %q", input, got, want)
}
}
// The empty string is not a language tag: "no preference" is null, which
// the nullable flag on each language setting expresses.
malformed := []string{"", " ", "e", "toolongprimary", "en-", "-US", "en--US", "123"}
for _, input := range malformed {
if got, ok := NormalizeLanguageTag(input); ok {
t.Errorf("NormalizeLanguageTag(%q) = %q, want rejection", input, got)
}
}
}
// TestNormalizeValueCanonicalizesLanguageTags proves normalization is reachable
// through the shared path, not just available as a helper. Without it en-US,
// en-us and EN-us are three rows for one preference and track matching misses
// on two of them.
func TestNormalizeValueCanonicalizesLanguageTags(t *testing.T) {
schema := &ValueSchema{Type: TypeLanguageTag, Nullable: true}
got, err := schema.NormalizeValue(json.RawMessage(`"en_us"`), nil)
if err != nil {
t.Fatalf("NormalizeValue: %v", err)
}
if string(got) != `"en-US"` {
t.Fatalf("NormalizeValue = %s, want \"en-US\"", got)
}
got, err = schema.NormalizeValue(json.RawMessage(`null`), nil)
if err != nil {
t.Fatalf("NormalizeValue(null): %v", err)
}
if string(got) != jsonNull {
t.Fatalf("NormalizeValue(null) = %s, want null", got)
}
if _, err := schema.NormalizeValue(json.RawMessage(`""`), nil); err == nil {
t.Error(`NormalizeValue("") was accepted; unset must be null, not the empty string`)
}
}