Pure-stdlib Go implementation of the whole control plane: same nexusgate.json schema (unknown fields preserved verbatim, explicit null/false tri-states modeled), same HTTP API and error strings, embedded frontend (NG_PUBLIC_DIR override), self-implemented scrypt auth, X25519 Reality keys, panel-hosted keyStore and heartbeat reconciliation included. Version stays 0.6.8. Verified: Go tests 33/33 (scrypt + Reality vectors generated from the Node implementation, subscription goldens byte-identical), Node suite 52/52 untouched, differential E2E against the Node panel 50/50 including two-way DB swap. See panel-go/PARITY.md and panel-go/INSTALL-GO.md.
244 lines
7.5 KiB
Go
244 lines
7.5 KiB
Go
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 != "NexusGate" || 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)
|
|
}
|
|
}
|