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.
1176 lines
40 KiB
Go
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`)
|
|
}
|
|
}
|