package config import ( "os" "path/filepath" "strings" "testing" ) func TestAFreshInstallLoadsDefaultsInsteadOfFailing(t *testing.T) { // There is no config until the operator logs in, and refusing to start // would leave them with no UI to log in from. cfg, err := Load(filepath.Join(t.TempDir(), "nope.json")) if err != nil { t.Fatalf("missing config treated as an error: %v", err) } if cfg.Configured() { t.Fatal("a blank install reported itself as configured") } if cfg.APIBase == "" || cfg.EngineExe == "" { t.Fatal("defaults were not applied") } } func TestRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "agent.json") cfg := Defaults() cfg.ClientID, cfg.SiteID, cfg.BrokerURL = "acme", "store-1", "tls://b:8883" cfg.BrokerPassword, cfg.APIPassword = "broker-secret", "api-secret" if err := cfg.Save(path); err != nil { t.Fatal(err) } back, err := Load(path) if err != nil { t.Fatal(err) } if back.BrokerPassword != "broker-secret" || back.APIPassword != "api-secret" { t.Fatalf("secrets did not survive the round trip: %+v", back) } if !back.Configured() { t.Fatal("a claimed install reported itself unconfigured") } } func TestSaveIsAtomic(t *testing.T) { // A crash mid-write must not leave a config that parses but is half old // and half new. dir := t.TempDir() path := filepath.Join(dir, "agent.json") cfg := Defaults() cfg.ClientID = "acme" if err := cfg.Save(path); err != nil { t.Fatal(err) } entries, _ := os.ReadDir(dir) for _, e := range entries { if strings.HasSuffix(e.Name(), ".tmp") { t.Fatalf("temp file left behind: %s", e.Name()) } } } func TestAnUndecryptableSecretBlanksRatherThanBlocksStartup(t *testing.T) { // DPAPI is machine-scoped, so a config copied between PCs cannot be read. // Refusing to start would be unrecoverable without a UI; blanking it means // the operator just logs in again. path := filepath.Join(t.TempDir(), "agent.json") os.WriteFile(path, []byte(`{"client_id":"acme","site_id":"s1", "broker_url":"tls://b","broker_password":"dpapi:!!!not-base64!!!"}`), 0o600) cfg, err := Load(path) if err != nil { t.Fatalf("unreadable secret blocked startup: %v", err) } if cfg.BrokerPassword != "" { t.Fatal("a secret that could not be decrypted was kept") } if cfg.ClientID != "acme" { t.Fatal("the rest of the config was discarded too") } } func TestPlaintextSecretsAreMarkedDifferentlyFromProtectedOnes(t *testing.T) { // So a dev config is never mistaken for a protected one on inspection. stored, err := conceal("secret") if err != nil { t.Fatal(err) } if SecretsProtected() && !strings.HasPrefix(stored, protectedPrefix) { t.Fatal("protected value is not marked") } if !SecretsProtected() && strings.HasPrefix(stored, protectedPrefix) { t.Fatal("plaintext value claims to be protected") } } func TestSaveDoesNotLeakThePathFieldIntoJSON(t *testing.T) { path := filepath.Join(t.TempDir(), "agent.json") Defaults().Save(path) blob, _ := os.ReadFile(path) if strings.Contains(string(blob), t.TempDir()) { t.Fatal("internal path field was serialised") } } func TestTheSessionSurvivesARestart(t *testing.T) { // A shop PC reboots overnight. Without this someone logs in every morning // before the store can record anything. path := filepath.Join(t.TempDir(), "agent.json") cfg := Defaults() cfg.SessionToken, cfg.SessionRefresh = "access-tok", "refresh-tok" cfg.SessionEmail = "manager@acme.test" if err := cfg.Save(path); err != nil { t.Fatal(err) } back, err := Load(path) if err != nil { t.Fatal(err) } if back.SessionToken != "access-tok" || back.SessionRefresh != "refresh-tok" { t.Fatalf("session lost: %+v", back) } if back.SessionEmail != "manager@acme.test" { t.Fatal("email not kept") } } func TestSessionTokensAreProtectedLikeOtherSecrets(t *testing.T) { // A bearer token in plaintext on disk is a credential anyone with the file // can replay. path := filepath.Join(t.TempDir(), "agent.json") cfg := Defaults() cfg.SessionToken = "super-secret-jwt" cfg.Save(path) raw, _ := os.ReadFile(path) if SecretsProtected() && strings.Contains(string(raw), "super-secret-jwt") { t.Fatal("session token written in plaintext") } } // Standalone has to survive a restart. It is a setup choice made once at a // counter, and a flag that only lives in memory would put the enrolment-code // screen back in front of a shop that already answered "we have no head // office" - which reads as the app forgetting the setup step was ever done. func TestStandaloneSurvivesSaveAndLoad(t *testing.T) { path := filepath.Join(t.TempDir(), "agent.json") cfg := Defaults() cfg.Standalone = true if err := cfg.Save(path); err != nil { t.Fatalf("save: %v", err) } back, err := Load(path) if err != nil { t.Fatalf("load: %v", err) } if !back.Standalone { t.Fatal("standalone was not persisted") } // Independent of being claimed: a standalone PC has no tenant, and a // claimed one is not standalone even if the flag was once set. if back.Configured() { t.Fatal("a standalone config must not report itself as claimed") } } // The engine is a PyInstaller one-FOLDER build living in its own subdirectory, // and on Windows `Behavision.exe` (the app) could not share a directory with // `behavision.exe` (the engine) anyway. Asserted here because the installer // lays the tree out to match, and a rename would otherwise fail only inside // the package - the one place nothing is tested. func TestDefaultEngineExeIsInTheEngineFolder(t *testing.T) { got := Defaults().EngineExe if dir := filepath.Dir(got); dir != "engine" { t.Fatalf("engine exe %q is not under engine/, got dir %q", got, dir) } if filepath.IsAbs(got) { t.Fatalf("engine exe %q must be relative to the install root", got) } }