Four defects the existing tests did not reach. The web theme resolver compared the server's value against the appearance cache and fell back when they agreed, but the mirroring effect writes the server's value into that same cache — so the comparison held on the first render and stopped holding on the second, reverting an explicitly chosen theme to the default. The server's value is this account's own stored choice, so it now simply wins. The regression test re-renders rather than asserting on the first paint, which is why the original one passed. golangci-lint's exclusions.paths is a path regex, not a directory list, so a bare `web` also excluded internal/jellycompat/web_component.go, internal/webhooksync/, internal/notifications/webhook*.go and eleven other non-test files that were being linted before. Anchored. json.Number is a string kind, so `"1.5"` unmarshalled into it happily and Float64 parsed the quoted digits: a numeric setting validated as a JSON string and NormalizeValue stored the quoted form into jsonb. Rejected. The lone-surrogate check ran only on the object branch, so a lone surrogate in ui.custom_css decoded to U+FFFD on SQLite and was refused outright by Postgres jsonb — the two backends disagreeing about whether the same value could be stored. Hoisted to cover every type. The strict language-tag validation this branch added is correct, but it rejects what the shipped Android client sends; the companion fix is silo-android 4aeb78b4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1255 lines
43 KiB
Go
1255 lines
43 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`)
|
|
}
|
|
}
|
|
|
|
// TestQuotedNumbersAreRejected covers a type confusion encoding/json will not
|
|
// catch. json.Number is a string kind, so `"1.5"` unmarshals into it happily
|
|
// and Float64 then parses the quoted digits — the value validates, and
|
|
// NormalizeValue stores the quoted form into jsonb where every consumer reading
|
|
// it as a number disagrees with the row.
|
|
func TestQuotedNumbersAreRejected(t *testing.T) {
|
|
for name, tc := range map[string]struct {
|
|
schema *ValueSchema
|
|
raw string
|
|
}{
|
|
"number": {&ValueSchema{Type: TypeNumber}, `"1.5"`},
|
|
"integer": {&ValueSchema{Type: TypeInteger}, `"3"`},
|
|
"integer with sign": {&ValueSchema{Type: TypeInteger}, `"-3"`},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if err := tc.schema.ValidateValue(json.RawMessage(tc.raw), nil); err == nil {
|
|
t.Fatalf("%s was accepted for a %s setting", tc.raw, tc.schema.Type)
|
|
}
|
|
if _, err := tc.schema.NormalizeValue(json.RawMessage(tc.raw), nil); err == nil {
|
|
t.Fatalf("%s was stored for a %s setting", tc.raw, tc.schema.Type)
|
|
}
|
|
})
|
|
}
|
|
|
|
// The unquoted forms still validate, so the check is not rejecting numbers
|
|
// outright.
|
|
if err := (&ValueSchema{Type: TypeNumber}).ValidateValue(
|
|
json.RawMessage(`1.5`), nil); err != nil {
|
|
t.Errorf("1.5 was rejected for a number setting: %v", err)
|
|
}
|
|
if err := (&ValueSchema{Type: TypeInteger}).ValidateValue(
|
|
json.RawMessage(`3`), nil); err != nil {
|
|
t.Errorf("3 was rejected for an integer setting: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestLoneSurrogatesAreRejectedForEveryType pins the check to the whole
|
|
// validation path rather than the object branch it started in. A lone surrogate
|
|
// decodes to U+FFFD on SQLite and is refused outright by Postgres jsonb, so a
|
|
// string setting that skipped the check made the two backends disagree about
|
|
// whether the same value could be stored at all.
|
|
func TestLoneSurrogatesAreRejectedForEveryType(t *testing.T) {
|
|
for name, schema := range map[string]*ValueSchema{
|
|
"string": {Type: TypeString},
|
|
"language tag": {Type: TypeLanguageTag},
|
|
"enum": {Type: TypeEnum, Values: []EnumMember{{Value: "a"}}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
raw := json.RawMessage(`"\ud800"`)
|
|
if err := schema.ValidateValue(raw, nil); err == nil {
|
|
t.Fatal("a lone surrogate was accepted")
|
|
}
|
|
if _, err := schema.NormalizeValue(raw, nil); err == nil {
|
|
t.Fatal("a lone surrogate was stored")
|
|
}
|
|
})
|
|
}
|
|
|
|
// ui.custom_css is the setting that actually carries free text, so pin it
|
|
// against the real manifest definition too.
|
|
manifest, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("loading manifest: %v", err)
|
|
}
|
|
def, ok := manifest.Lookup("ui.custom_css")
|
|
if !ok {
|
|
t.Fatal("ui.custom_css is not registered")
|
|
}
|
|
if err := def.ValueSchema.ValidateValue(
|
|
json.RawMessage(`"body { content: \"\ud800\"; }"`), objSchemas); err == nil {
|
|
t.Error("ui.custom_css accepted a lone surrogate")
|
|
}
|
|
// A well-formed pair is an ordinary character and must still store.
|
|
if err := def.ValueSchema.ValidateValue(
|
|
json.RawMessage(`"body::after { content: \"😀\"; }"`), objSchemas); err != nil {
|
|
t.Errorf("ui.custom_css rejected a valid surrogate pair: %v", err)
|
|
}
|
|
}
|