package main import ( "errors" "os" "path/filepath" "strings" "testing" ) func newTestStore(t *testing.T) (*Store, string) { t.Helper() file := filepath.Join(t.TempDir(), "nested", "data.json") s := NewStore(file) if err := s.Init(); err != nil { t.Fatalf("Init: %v", err) } return s, file } func TestInitCreatesMissingFile(t *testing.T) { s, file := newTestStore(t) info, err := os.Stat(file) if err != nil { t.Fatalf("data file not created: %v", err) } if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("file perm = %o, want 600", perm) } if s.Data.SchemaVersion != 1 { t.Errorf("SchemaVersion = %d", s.Data.SchemaVersion) } if s.Data.Settings.BrandName != "amo" || s.Data.Settings.SessionHours != 12 || s.Data.Settings.ObservationTtlMinutes != 10 || s.Data.Settings.ActivityRetentionDays != 30 || s.Data.Settings.CompletedJobRetentionDays != 7 { t.Errorf("settings defaults = %+v", s.Data.Settings) } if s.Data.CreatedAt == "" { t.Errorf("CreatedAt empty") } for _, c := range collections { if c.isNil(s.Data) { t.Errorf("collection %s is nil after EmptyData", c.name) } } if s.Data.KeyStore == nil { t.Errorf("KeyStore nil") } } func TestInitLoadPreservesUnknownFields(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "data.json") raw := `{"schemaVersion":1,"createdAt":"2026-01-01T00:00:00.000Z",` + `"settings":{"brandName":"NexusGate","sessionHours":12,"observationTtlMinutes":10,"activityRetentionDays":30,"completedJobRetentionDays":7,"futureSetting":true},` + `"users":[{"id":"u1","username":"admin","passwordHash":"h","role":"admin","status":"active","createdAt":"2026-01-01T00:00:00.000Z","legacyNote":"keep-me"}],` + `"servers":[],"agents":[],"enrollmentTokens":[],"customers":[],"nodes":[],"nodeClients":[],"jobs":[],"activity":[],"observations":[],` + `"keyStore":{},"futureTop":{"x":1}}` + "\n" // subscriptionAccess / subscriptionClients deliberately absent: Init must default them. if err := os.WriteFile(file, []byte(raw), 0o600); err != nil { t.Fatal(err) } s := NewStore(file) if err := s.Init(); err != nil { t.Fatalf("Init with unknown fields: %v", err) } if s.Data.SubscriptionAccess == nil || s.Data.SubscriptionClients == nil { t.Fatalf("missing subscription collections not defaulted") } if _, err := s.Transaction(func(d *Data) (any, error) { return nil, nil }); err != nil { t.Fatalf("no-op transaction: %v", err) } out, err := os.ReadFile(file) if err != nil { t.Fatal(err) } for _, needle := range []string{`"futureTop"`, `"legacyNote":"keep-me"`, `"futureSetting":true`} { if !strings.Contains(string(out), needle) { t.Errorf("round trip dropped %s; file: %s", needle, out) } } } func TestTransactionPersistAndResult(t *testing.T) { s, file := newTestStore(t) result, err := s.Transaction(func(d *Data) (any, error) { d.Settings.BrandName = "Renamed" return "the-result", nil }) if err != nil { t.Fatalf("Transaction: %v", err) } if result != "the-result" { t.Errorf("result = %v", result) } if s.Data.Settings.BrandName != "Renamed" { t.Errorf("in-memory data not updated") } reloaded := NewStore(file) if err := reloaded.Init(); err != nil { t.Fatalf("reload: %v", err) } if reloaded.Data.Settings.BrandName != "Renamed" { t.Errorf("persisted brand = %q", reloaded.Data.Settings.BrandName) } } func TestTransactionErrorDoesNotPersist(t *testing.T) { s, file := newTestStore(t) before, _ := os.ReadFile(file) myErr := errors.New("boom") _, err := s.Transaction(func(d *Data) (any, error) { d.Settings.BrandName = "ShouldNotStick" return nil, myErr }) if !errors.Is(err, myErr) { t.Fatalf("err = %v, want myErr", err) } after, _ := os.ReadFile(file) if string(before) != string(after) { t.Errorf("file changed despite transaction error") } if s.Data.Settings.BrandName == "ShouldNotStick" { t.Errorf("in-memory data changed despite transaction error") } } func TestTransactionSkipDoesNotPersist(t *testing.T) { s, file := newTestStore(t) before, _ := os.ReadFile(file) result, err := s.Transaction(func(d *Data) (any, error) { d.Settings.BrandName = "Skipped" return "ignored", ErrSkip }) if err != nil { t.Fatalf("ErrSkip transaction err = %v", err) } if result != nil { t.Errorf("ErrSkip result = %v, want nil", result) } after, _ := os.ReadFile(file) if string(before) != string(after) { t.Errorf("file changed despite ErrSkip") } if s.Data.Settings.BrandName == "Skipped" { t.Errorf("in-memory data changed despite ErrSkip") } } func TestReplaceRoundTrip(t *testing.T) { s, file := newTestStore(t) next := EmptyData() next.Settings.BrandName = "Replaced" next.Users = []*User{{ID: "u9", Username: "root", PasswordHash: "h", Role: "admin", Status: "active", CreatedAt: "2026-01-01T00:00:00.000Z"}} next.SubscriptionAccess = nil // Replace must default these next.SubscriptionClients = nil next.KeyStore = nil if err := s.Replace(next); err != nil { t.Fatalf("Replace: %v", err) } // Mutating the caller's copy after Replace must not affect the store. next.Settings.BrandName = "MutatedAfterwards" snap := s.Snapshot() if snap.Settings.BrandName != "Replaced" { t.Errorf("snapshot brand = %q", snap.Settings.BrandName) } if len(snap.Users) != 1 || snap.Users[0].ID != "u9" { t.Errorf("snapshot users = %+v", snap.Users) } if snap.SubscriptionAccess == nil || snap.KeyStore == nil { t.Errorf("Replace did not default collections/keyStore") } reloaded := NewStore(file) if err := reloaded.Init(); err != nil { t.Fatalf("reload after Replace: %v", err) } if reloaded.Data.Settings.BrandName != "Replaced" || len(reloaded.Data.Users) != 1 { t.Errorf("reloaded data does not match Replace input") } } func TestValidateErrorMessages(t *testing.T) { s, _ := newTestStore(t) _, err := s.Transaction(func(d *Data) (any, error) { d.SchemaVersion = 2 return nil, nil }) if err == nil || err.Error() != "Unsupported or corrupt NexusGate data file" { t.Errorf("schemaVersion err = %v", err) } _, err = s.Transaction(func(d *Data) (any, error) { d.Nodes = nil return nil, nil }) if err == nil || err.Error() != "Invalid collection: nodes" { t.Errorf("collection err = %v", err) } // Failed validations must not have persisted the bad drafts. if s.Data.SchemaVersion != 1 || s.Data.Nodes == nil { t.Errorf("store data corrupted by failed validation") } } func TestInitRejectsCorruptFiles(t *testing.T) { dir := t.TempDir() bad := filepath.Join(dir, "bad.json") if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil { t.Fatal(err) } if err := NewStore(bad).Init(); err == nil { t.Errorf("corrupt JSON should error") } wrongVersion := filepath.Join(dir, "v2.json") raw, _ := marshalJSON(EmptyData()) var v2 map[string]any if err := unmarshalJSONUseNumber(raw, &v2); err != nil { t.Fatal(err) } v2["schemaVersion"] = 2 raw2, _ := marshalJSON(v2) if err := os.WriteFile(wrongVersion, raw2, 0o600); err != nil { t.Fatal(err) } err := NewStore(wrongVersion).Init() if err == nil || err.Error() != "Unsupported or corrupt NexusGate data file" { t.Errorf("schemaVersion 2 err = %v", err) } } func TestTransientAndViewDoNotPersist(t *testing.T) { s, file := newTestStore(t) before, _ := os.ReadFile(file) s.Transient(func(d *Data) { d.Settings.BrandName = "InMemoryOnly" }) after, _ := os.ReadFile(file) if string(before) != string(after) { t.Errorf("Transient persisted to disk") } var seen string s.View(func(d *Data) { seen = d.Settings.BrandName }) if seen != "InMemoryOnly" { t.Errorf("View saw %q", seen) } }