mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-15 21:13:00 +08:00
212 lines
6.9 KiB
Go
212 lines
6.9 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/matthewlu070111/smart-srun/core/internal/domain"
|
|
)
|
|
|
|
// T07 -- strict decoding. Each case names an input a lenient decoder accepts
|
|
// and states what accepting it would cost the user.
|
|
|
|
func TestDecodeRejectsDocumentsALenientDecoderWouldAccept(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
document string
|
|
wantPath string
|
|
because string
|
|
}{{
|
|
name: "duplicate key",
|
|
document: `{"schema_version":2,"enabled":true,"enabled":false}`,
|
|
wantPath: "enabled",
|
|
because: "encoding/json keeps the last value, silently disabling the service",
|
|
}, {
|
|
name: "duplicate key in a nested object",
|
|
document: `{"schema_version":2,"retry":{"max_retries":4,"max_retries":0}}`,
|
|
wantPath: "retry.max_retries",
|
|
because: "the same trap one level down",
|
|
}, {
|
|
name: "duplicate key inside an array element",
|
|
document: `{"schema_version":2,"campus_accounts":[{"id":"a","id":"b"}]}`,
|
|
wantPath: "campus_accounts[0].id",
|
|
because: "array paths must be reported precisely enough to fix",
|
|
}, {
|
|
name: "unknown key",
|
|
document: `{"schema_version":2,"backoff_exponent_factor":"1.5"}`,
|
|
wantPath: "backoff_exponent_factor",
|
|
because: "a knob that no longer exists must not look accepted",
|
|
}, {
|
|
name: "legacy account field alias",
|
|
document: `{"schema_version":2,"campus_accounts":[{"network_interface":"wan"}]}`,
|
|
wantPath: "network_interface",
|
|
because: "decision D10: only wired_iface is written, the alias is gone",
|
|
}, {
|
|
name: "null instead of a value",
|
|
document: `{"schema_version":2,"school":null}`,
|
|
wantPath: "school",
|
|
because: "null unmarshals into a string as \"\", indistinguishable from absent",
|
|
}, {
|
|
name: "null inside an account",
|
|
document: `{"schema_version":2,"campus_accounts":[{"id":"a","password":null}]}`,
|
|
wantPath: "campus_accounts[0].password",
|
|
because: "a null password would read as an empty one",
|
|
}, {
|
|
name: "second top-level value",
|
|
document: `{"schema_version":2}{"schema_version":2}`,
|
|
wantPath: "",
|
|
because: "a file appended to instead of replaced would load its stale prefix",
|
|
}, {
|
|
name: "root is not an object",
|
|
document: `[{"schema_version":2}]`,
|
|
wantPath: "",
|
|
because: "the configuration is an object",
|
|
}, {
|
|
name: "bool given as a string",
|
|
document: `{"schema_version":2,"enabled":"1"}`,
|
|
wantPath: "enabled",
|
|
because: "1.x stored \"1\"; v2 must say so rather than guess",
|
|
}, {
|
|
name: "number given as a string",
|
|
document: `{"schema_version":2,"checks":{"interval_seconds":"60"}}`,
|
|
wantPath: "interval_seconds",
|
|
because: "same, for the numeric knobs",
|
|
}, {
|
|
name: "seconds given as a string",
|
|
document: `{"schema_version":2,"retry":{"initial_seconds":"10"}}`,
|
|
wantPath: "",
|
|
because: "Seconds rejects non-numbers",
|
|
}, {
|
|
name: "clock time in the wrong shape",
|
|
document: `{"schema_version":2,"quiet":{"start":"6:00"}}`,
|
|
wantPath: "",
|
|
because: "HH:MM is exact; 6:00 means the UI did not produce this",
|
|
}, {
|
|
name: "truncated document",
|
|
document: `{"schema_version":2,"quiet":{`,
|
|
wantPath: "",
|
|
because: "a half-written file must not load as a half-configuration",
|
|
}}
|
|
|
|
for _, testCase := range cases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
_, err := Decode([]byte(testCase.document))
|
|
if err == nil {
|
|
t.Fatalf("accepted %s; %s", testCase.name, testCase.because)
|
|
}
|
|
code, ok := domain.CodeOf(err)
|
|
if !ok || code != domain.CodeInvalidConfig {
|
|
t.Fatalf("code = %q, want InvalidConfig (err: %v)", code, err)
|
|
}
|
|
if testCase.wantPath != "" && !strings.Contains(err.Error(), testCase.wantPath) {
|
|
t.Fatalf("error %q does not name %q, so the user cannot find the field",
|
|
err.Error(), testCase.wantPath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeRejectsOversizedDocuments(t *testing.T) {
|
|
padding := strings.Repeat("x", MaxConfigBytes)
|
|
document := `{"schema_version":2,"school":"` + padding + `"}`
|
|
|
|
_, err := Decode([]byte(document))
|
|
if err == nil {
|
|
t.Fatal("accepted a document over the 512 KiB limit")
|
|
}
|
|
if !strings.Contains(err.Error(), "上限") {
|
|
t.Fatalf("error %q does not explain the limit", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDecodeRejectsDeepNestingBeforeItExhaustsTheStack(t *testing.T) {
|
|
document := `{"schema_version":2,"school_extra":{"a":` +
|
|
strings.Repeat("[", 200) + strings.Repeat("]", 200) + `}}`
|
|
|
|
if _, err := Decode([]byte(document)); err == nil {
|
|
t.Fatal("accepted 200 levels of nesting")
|
|
}
|
|
}
|
|
|
|
func TestDecodeReportsLegacyConfigWithoutConverting(t *testing.T) {
|
|
cases := map[string]string{
|
|
"explicit schema_version 1": `{"schema_version":1,"enabled":"1"}`,
|
|
"1.x flat string map": `{"enabled":"1","backoff_enable":"1"}`,
|
|
}
|
|
for name, document := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := Decode([]byte(document))
|
|
if err == nil {
|
|
t.Fatal("a 1.x file must be reported, not loaded")
|
|
}
|
|
if !errors.Is(err, ErrLegacyConfig) {
|
|
t.Fatalf("err = %v, want it to wrap ErrLegacyConfig so the "+
|
|
"repository knows not to overwrite the old file", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "导出备份") {
|
|
t.Fatalf("error %q does not tell the user what to do", err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecodeRejectsUnsupportedSchemaVersions(t *testing.T) {
|
|
for _, document := range []string{
|
|
`{"schema_version":3}`,
|
|
`{"schema_version":0}`,
|
|
`{"schema_version":"2"}`,
|
|
} {
|
|
if _, err := Decode([]byte(document)); err == nil {
|
|
t.Fatalf("accepted %s", document)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Omitted fields keep their defaults: a hand-written file may set only what it
|
|
// cares about. What the document does say must still be exactly right.
|
|
func TestDecodeFillsOmittedFieldsFromDefaults(t *testing.T) {
|
|
cfg, err := Decode([]byte(`{"schema_version":2,"enabled":true}`))
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
|
|
defaults := Defaults()
|
|
if !cfg.Enabled {
|
|
t.Error("enabled was not applied")
|
|
}
|
|
if cfg.Quiet != defaults.Quiet {
|
|
t.Errorf("quiet = %+v, want the default %+v", cfg.Quiet, defaults.Quiet)
|
|
}
|
|
if cfg.Retry != defaults.Retry {
|
|
t.Errorf("retry = %+v, want the default %+v", cfg.Retry, defaults.Retry)
|
|
}
|
|
if cfg.Checks != defaults.Checks {
|
|
t.Errorf("checks = %+v, want the default %+v", cfg.Checks, defaults.Checks)
|
|
}
|
|
if cfg.Log.Level != domain.LogInfo {
|
|
t.Errorf("log.level = %q, want INFO", cfg.Log.Level)
|
|
}
|
|
if cfg.School != DefaultSchool {
|
|
t.Errorf("school = %q, want %q", cfg.School, DefaultSchool)
|
|
}
|
|
}
|
|
|
|
// A partially specified nested object keeps the defaults of its siblings.
|
|
func TestDecodeOverlaysNestedObjectsFieldByField(t *testing.T) {
|
|
cfg, err := Decode([]byte(`{"schema_version":2,"retry":{"max_retries":9}}`))
|
|
if err != nil {
|
|
t.Fatalf("Decode: %v", err)
|
|
}
|
|
if cfg.Retry.MaxRetries != 9 {
|
|
t.Errorf("max_retries = %d, want 9", cfg.Retry.MaxRetries)
|
|
}
|
|
if cfg.Retry.InitialSeconds != Defaults().Retry.InitialSeconds {
|
|
t.Errorf("initial_seconds = %v, want the default to survive",
|
|
cfg.Retry.InitialSeconds)
|
|
}
|
|
if !cfg.Retry.Enabled {
|
|
t.Error("retry.enabled default was lost by a partial object")
|
|
}
|
|
}
|