package mock import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/netip" "os" "path/filepath" "reflect" "slices" "strings" "testing" "time" "tera-clone/services/tera-api/internal/alarmdelivery" "tera-clone/services/tera-api/internal/diagnostics" "tera-clone/services/tera-api/internal/domain" "tera-clone/services/tera-api/internal/externalauth" "tera-clone/services/tera-api/internal/networkconfig" "tera-clone/services/tera-api/internal/security/password" "tera-clone/services/tera-api/internal/snmpagent" "tera-clone/services/tera-api/internal/supportbundle" "tera-clone/services/tera-api/internal/systemtime" "tera-clone/services/tera-api/internal/tlscert" ) type cacheableCountingKDF struct { hashes int } type acceptingExternalAuthenticator struct{} type healthCheckingSNMPController struct { healthErr error } func (c healthCheckingSNMPController) Apply(context.Context, snmpagent.Config) error { return nil } func (c healthCheckingSNMPController) Health(context.Context) error { return c.healthErr } func (acceptingExternalAuthenticator) Authenticate(_ context.Context, method string, server externalauth.Server, username, credential string, _ time.Duration) (bool, error) { return method == "radius" && server.Secret == "radius-test-secret" && username == "remote-user" && credential == "remote-password", nil } func (kdf *cacheableCountingKDF) Algorithm() string { return "counting-test" } func (kdf *cacheableCountingKDF) Hash(passwordValue, salt string) (password.Record, error) { kdf.hashes++ sum := sha256.Sum256([]byte(salt + ":" + passwordValue)) return password.Record{ Algorithm: kdf.Algorithm(), Version: 1, Salt: salt, Hash: base64.StdEncoding.EncodeToString(sum[:]), Params: password.Params{ KeyLength: 32, }, }, nil } func TestBrowserTimeSyncRejectsUnavailableTimedAndAuditsFailure(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{ TimeController: systemtime.UnavailableController{Reason: "tera-timed test outage"}, }) if err != nil { t.Fatal(err) } ctx := domain.WithActor(context.Background(), domain.Actor{Name: "mock-admin"}) _, err = store.SyncBrowserTime(ctx, domain.BrowserTimeSyncRequest{ BrowserTime: time.Date(2026, 7, 23, 0, 0, 0, 0, time.UTC), Timezone: "Asia/Seoul", }) if !systemtime.IsUnavailable(err) { t.Fatalf("SyncBrowserTime() error = %v, want unavailable", err) } events, err := store.AuditEvents(context.Background()) if err != nil { t.Fatal(err) } if len(events) != 1 || events[0].Actor != "mock-admin" || events[0].Action != "time.sync_browser" || events[0].Result != "rejected" { t.Fatalf("audit events = %+v", events) } } func TestDefaultPortProfilesUse9600EightNOneWithoutFlowControl(t *testing.T) { store := NewStore(fixedClock) ports, err := store.Ports(context.Background()) if err != nil { t.Fatalf("read default ports: %v", err) } if len(ports) != 48 { t.Fatalf("default port count = %d, want 48", len(ports)) } for _, port := range ports { if port.BaudRate != 9600 || port.DataBits != 8 || port.Parity != "none" || port.StopBits != 1 || port.FlowControl != "none" { t.Errorf("%s default line profile = %d/%d/%s/%d/%s, want 9600/8/none/1/none", port.Channel, port.BaudRate, port.DataBits, port.Parity, port.StopBits, port.FlowControl) } if port.Mode != "disabled" || port.ListenerPort != 0 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" || port.Operation1.MaxClients != 4 || port.Operation2.MaxClients != 4 { t.Errorf("%s default operation profile = mode %q op1 %#v op2 %#v, want both Disabled with four-client ceiling", port.Channel, port.Mode, port.Operation1, port.Operation2) } } } func (kdf *cacheableCountingKDF) Verify(record password.Record, passwordValue string) (bool, error) { next, err := kdf.Hash(passwordValue, record.Salt) if err != nil { return false, err } return next.Hash == record.Hash, nil } func TestNormalizeMinimumPasswordLength(t *testing.T) { tests := []struct { name string value int want int }{ {name: "default", value: 0, want: 8}, {name: "lower bound", value: 7, want: 8}, {name: "configured", value: 10, want: 10}, {name: "upper bound", value: 129, want: 128}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { if got := normalizeMinimumPasswordLength(test.value); got != test.want { t.Fatalf("normalizeMinimumPasswordLength(%d) = %d, want %d", test.value, got, test.want) } }) } } func (kdf *cacheableCountingKDF) DefaultAuthSeedCacheKey() string { return fmt.Sprintf("%s-v1", kdf.Algorithm()) } func TestDefaultAuthUsersCachesStableKDFSeed(t *testing.T) { kdf := &cacheableCountingKDF{} first, err := defaultAuthUsers(kdf) if err != nil { t.Fatalf("first default auth users: %v", err) } second, err := defaultAuthUsers(kdf) if err != nil { t.Fatalf("second default auth users: %v", err) } if kdf.hashes != len(first) { t.Fatalf("hash count = %d, want %d", kdf.hashes, len(first)) } first["admin"] = authUserRecord{} if second["admin"].Username != "admin" { t.Fatalf("cached default auth users share mutable state") } } func TestAuditLogPersistsAcrossStoreInstances(t *testing.T) { path := filepath.Join(t.TempDir(), "audit.jsonl") first, err := NewStoreWithAuditLog(fixedClock, path, 10) if err != nil { t.Fatalf("create first store: %v", err) } _, err = first.RecordAudit(context.Background(), domain.AuditEvent{ Actor: "mock-admin", Action: "config.save", Target: "config", Result: "succeeded", Message: "persistent config saved", }) if err != nil { t.Fatalf("record audit: %v", err) } second, err := NewStoreWithAuditLog(fixedClock, path, 10) if err != nil { t.Fatalf("create second store: %v", err) } events, err := second.AuditEvents(context.Background()) if err != nil { t.Fatalf("read audit events: %v", err) } if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } if events[0].Action != "config.save" { t.Fatalf("action = %q, want config.save", events[0].Action) } if events[0].Actor != "mock-admin" { t.Fatalf("actor = %q, want mock-admin", events[0].Actor) } } func TestAuditLogRejectsInvalidEvents(t *testing.T) { path := filepath.Join(t.TempDir(), "audit.jsonl") content, err := json.Marshal(domain.AuditEvent{ AuditID: "audit_20260618_000001", Timestamp: fixedClock(), Actor: "mock-admin", Target: "config", Result: "succeeded", Message: "missing action", }) if err != nil { t.Fatalf("encode audit event: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write audit log: %v", err) } if _, err := NewStoreWithAuditLog(fixedClock, path, 10); err == nil { t.Fatalf("audit log with invalid event should be rejected") } } func TestSystemAndPortLogsLoadFromFileBackedPrototypeStores(t *testing.T) { dir := t.TempDir() systemPath := filepath.Join(dir, "system.jsonl") portPath := filepath.Join(dir, "port.jsonl") writeJSONLines(t, systemPath, domain.SystemLogEvent{ Timestamp: fixedClock(), Severity: "info", Source: "tera-api", Message: "boot complete", }, domain.SystemLogEvent{ Timestamp: fixedClock().Add(time.Minute), Severity: "warning", Source: "network", Message: "secondary link degraded", }, ) writeJSONLines(t, portPath, domain.PortLogEvent{ Timestamp: fixedClock(), Channel: "CH01", Direction: "RX", Actor: "admin/session-1", Message: "login ok", Match: "port", }, domain.PortLogEvent{ Timestamp: fixedClock().Add(time.Minute), Channel: "CH02", Direction: "TX", Actor: "ops/session-2", Message: "panic output captured", Match: "keyword", }, ) store, err := NewStoreWithOptions(fixedClock, StoreOptions{ SystemLogPath: systemPath, PortLogPath: portPath, }) if err != nil { t.Fatalf("create store: %v", err) } systemEvents, err := store.SystemLogEvents(context.Background(), domain.SystemLogFilter{ Severity: "warning", Keyword: "link", Limit: 10, }) if err != nil { t.Fatalf("system log events: %v", err) } if len(systemEvents) != 1 { t.Fatalf("system log count = %d, want 1", len(systemEvents)) } if systemEvents[0].Source != "network" { t.Fatalf("system log source = %q, want network", systemEvents[0].Source) } portEvents, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{ Channel: "CH02", Keyword: "panic", Limit: 10, }) if err != nil { t.Fatalf("port log events: %v", err) } if len(portEvents) != 1 { t.Fatalf("port log count = %d, want 1", len(portEvents)) } if portEvents[0].Actor != "ops/session-2" { t.Fatalf("port log actor = %q, want ops/session-2", portEvents[0].Actor) } } func TestProductionStoreDoesNotReturnSampleLogs(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{}) if err != nil { t.Fatal(err) } systemEvents, err := store.SystemLogEvents(context.Background(), domain.SystemLogFilter{Limit: 100}) if err != nil { t.Fatal(err) } portEvents, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{Limit: 100}) if err != nil { t.Fatal(err) } if len(systemEvents) != 0 || len(portEvents) != 0 { t.Fatalf("production store returned sample logs: system=%+v port=%+v", systemEvents, portEvents) } } func TestSystemLogSearchIncludesAppliedConfigurationAuditEvents(t *testing.T) { dir := t.TempDir() store, err := NewStoreWithOptions(fixedClock, StoreOptions{ AuditPath: filepath.Join(dir, "audit.jsonl"), SystemLogPath: filepath.Join(dir, "system.jsonl"), }) if err != nil { t.Fatal(err) } ctx := domain.WithActor(context.Background(), domain.Actor{Name: "admin"}) hostName := "tera" if _, err := store.UpdateNetwork(ctx, domain.NetworkUpdate{HostName: &hostName}); err != nil { t.Fatalf("stage network configuration: %v", err) } if _, err := store.ApplyPendingNetwork(ctx); err != nil { t.Fatalf("apply network configuration: %v", err) } events, err := store.SystemLogEvents(ctx, domain.SystemLogFilter{ Category: "configuration", Keyword: "network.apply", Limit: 100, }) if err != nil { t.Fatal(err) } if len(events) != 1 { t.Fatalf("applied configuration log count = %d, want 1: %+v", len(events), events) } if events[0].Action != "network.apply" || events[0].Actor != "admin" || events[0].Target != "network" { t.Fatalf("applied configuration log = %+v", events[0]) } if events[0].AuditID == "" || events[0].Source != "network" { t.Fatalf("applied configuration log identity = %+v", events[0]) } } func TestSystemLogSearchIncludesLoginFailureEvent(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{}) if err != nil { t.Fatal(err) } if _, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "wrong-password", }); err == nil { t.Fatal("login with wrong password succeeded") } events, err := store.SystemLogEvents(context.Background(), domain.SystemLogFilter{ Category: "system", Keyword: "auth.login", Limit: 100, }) if err != nil { t.Fatal(err) } if len(events) != 1 { t.Fatalf("login failure log count = %d, want 1: %+v", len(events), events) } if events[0].Severity != "warning" || events[0].Source != "authentication" || events[0].Action != "auth.login" { t.Fatalf("login failure log = %+v", events[0]) } } func TestFileBackedLogStoresRejectInvalidRecords(t *testing.T) { dir := t.TempDir() systemPath := filepath.Join(dir, "system-invalid.jsonl") portPath := filepath.Join(dir, "port-invalid.jsonl") writeJSONLines(t, systemPath, domain.SystemLogEvent{ Timestamp: fixedClock(), Severity: "debug", Source: "tera-api", Message: "bad severity", }) if _, err := NewStoreWithOptions(fixedClock, StoreOptions{SystemLogPath: systemPath}); err == nil { t.Fatalf("system log store with invalid event should be rejected") } writeJSONLines(t, portPath, domain.PortLogEvent{ Timestamp: fixedClock(), Channel: "CH99", Direction: "RX", Actor: "admin/session-1", Message: "bad channel", Match: "port", }) if _, err := NewStoreWithOptions(fixedClock, StoreOptions{PortLogPath: portPath}); err == nil { t.Fatalf("port log store with invalid event should be rejected") } } func TestAuditLogSequenceContinuesAfterReload(t *testing.T) { path := filepath.Join(t.TempDir(), "audit.jsonl") first, err := NewStoreWithAuditLog(fixedClock, path, 10) if err != nil { t.Fatalf("create first store: %v", err) } firstAuditID, err := first.RecordAudit(context.Background(), domain.AuditEvent{ Actor: "mock-admin", Action: "first", Target: "config", Result: "succeeded", Message: "first", }) if err != nil { t.Fatalf("record first audit: %v", err) } second, err := NewStoreWithAuditLog(fixedClock, path, 10) if err != nil { t.Fatalf("create second store: %v", err) } secondAuditID, err := second.RecordAudit(context.Background(), domain.AuditEvent{ Actor: "mock-admin", Action: "second", Target: "config", Result: "succeeded", Message: "second", }) if err != nil { t.Fatalf("record second audit: %v", err) } if firstAuditID != "audit_20260618_000001" { t.Fatalf("first audit id = %q, want audit_20260618_000001", firstAuditID) } if secondAuditID != "audit_20260618_000002" { t.Fatalf("second audit id = %q, want audit_20260618_000002", secondAuditID) } events, err := second.AuditEvents(context.Background()) if err != nil { t.Fatalf("read audit events: %v", err) } if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } if events[0].AuditID != firstAuditID || events[1].AuditID != secondAuditID { t.Fatalf("audit ids = %q, %q; want %q, %q", events[0].AuditID, events[1].AuditID, firstAuditID, secondAuditID) } } func writeJSONLines(t *testing.T, path string, values ...any) { t.Helper() var builder strings.Builder encoder := json.NewEncoder(&builder) for _, value := range values { if err := encoder.Encode(value); err != nil { t.Fatalf("encode JSON line: %v", err) } } if err := os.WriteFile(path, []byte(builder.String()), 0o600); err != nil { t.Fatalf("write JSON lines: %v", err) } } func TestAuditLogKeepsBoundedRecentEvents(t *testing.T) { path := filepath.Join(t.TempDir(), "audit.jsonl") store, err := NewStoreWithAuditLog(fixedClock, path, 2) if err != nil { t.Fatalf("create store: %v", err) } for _, action := range []string{"first", "second", "third"} { _, err := store.RecordAudit(context.Background(), domain.AuditEvent{ Actor: "mock-admin", Action: action, Target: "config", Result: "succeeded", Message: action, }) if err != nil { t.Fatalf("record %s: %v", action, err) } } reloaded, err := NewStoreWithAuditLog(fixedClock, path, 2) if err != nil { t.Fatalf("reload store: %v", err) } events, err := reloaded.AuditEvents(context.Background()) if err != nil { t.Fatalf("read audit events: %v", err) } if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } if events[0].Action != "second" || events[1].Action != "third" { t.Fatalf("actions = %q, %q; want second, third", events[0].Action, events[1].Action) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read audit log file: %v", err) } text := string(content) trimmed := strings.TrimSpace(text) if strings.Count(trimmed, "\n") != 1 { t.Fatalf("audit log file = %q, want exactly 2 jsonl entries", text) } if strings.Contains(text, `"action":"first"`) { t.Fatalf("audit log file still contains trimmed first event: %s", text) } if !strings.Contains(text, `"action":"second"`) || !strings.Contains(text, `"action":"third"`) { t.Fatalf("audit log file = %s, want second and third events", text) } } func TestServiceHealthIncludesRuntimeDaemons(t *testing.T) { store := NewStore(fixedClock) health, err := store.ServiceHealth(context.Background()) if err != nil { t.Fatalf("read service health: %v", err) } byService := make(map[string]domain.ServiceHealth, len(health)) for _, item := range health { byService[item.Service] = item } for _, service := range []string{ "tera-api", "tera-web", "tera-authd", "tera-sessiond", "tera-logd", "tera-alarmd", "tera-netd", "tera-timed", "tera-seriald", } { item, ok := byService[service] if !ok { t.Fatalf("service health missing %s: %#v", service, health) } if item.Status == "" || item.Detail == "" || item.LastCheckedAt.IsZero() { t.Fatalf("service health for %s = %#v, want status/detail/timestamp", service, item) } } } func TestServiceHealthDegradesUnreachableSNMPController(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{ SNMPAgentController: healthCheckingSNMPController{ healthErr: &snmpagent.UnavailableError{Reason: "missing socket"}, }, }) if err != nil { t.Fatalf("create store: %v", err) } health, err := store.ServiceHealth(context.Background()) if err != nil { t.Fatalf("read service health: %v", err) } for _, item := range health { if item.Service != "tera-snmpd" { continue } if item.Status != "degraded" || item.Detail != "SNMP agent control service is not reachable" { t.Fatalf("SNMP service health = %#v, want unreachable degraded status", item) } return } t.Fatal("SNMP service health is missing") } func TestNetworkStateIncludesStableInterfaceIdentity(t *testing.T) { store := NewStore(fixedClock) state, err := store.NetworkState(context.Background()) if err != nil { t.Fatalf("read network state: %v", err) } if len(state.Interfaces) != 2 { t.Fatalf("interfaces = %d, want 2", len(state.Interfaces)) } first := state.Interfaces[0] if first.Name != "Interface-1" || first.Device != "eth0" || first.DeviceTreeNode != "fe010000.ethernet" || first.ActiveMedium != "RJ45" || first.PHY != "YT8531S" || first.IdentitySource != "board-profile" { t.Fatalf("interface identity = %#v, want stable board-profile metadata", first) } } func TestPortOperationUsesFixedKeepaliveAndTimeouts(t *testing.T) { operation, err := normalizePortOperation(domain.PortOperation{ Mode: "reverse_ssh", Port: 10001, KeepAliveSeconds: 99, TimeoutMilliseconds: 1, InactivityTimeoutMilliseconds: 5000, MaxClients: 4, Authentication: "local", Newline: "cr", }) if err != nil { t.Fatal(err) } if operation.KeepAliveSeconds != 3 || operation.TimeoutMilliseconds != 10000 || operation.InactivityTimeoutMilliseconds != 0 { t.Fatalf("fixed operation timers = %+v", operation) } } func TestNetworkExtendedSettingsUseConfigTransactionAndPersist(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") store, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatal(err) } hostName := "console-gateway" operatingMode := "oob" addressMode := "static" address := "192.168.10.223/24" gateway := "192.168.10.1" ipv6Mode := "static" ipv6Address := "2001:db8::223/64" ipv6Gateway := "2001:db8::1" dns := []string{"8.8.8.8", "1.1.1.1"} ipv6DNS := []string{"2001:4860:4860::8888"} primary := "Interface-2" fallback := "wwan0" target := "9.9.9.9" interval := 15 threshold := 4 result, err := store.UpdateNetwork(context.Background(), domain.NetworkUpdate{ HostName: &hostName, OperatingMode: &operatingMode, OOB: &domain.OOBSettingsUpdate{ PrimaryInterface: &primary, FallbackInterface: &fallback, HealthCheckTarget: &target, HealthCheckIntervalSecond: &interval, FailureThreshold: &threshold, }, Interfaces: []domain.NetworkInterfaceUpdate{{ Name: "Interface-1", AddressMode: &addressMode, Address: &address, Gateway: &gateway, DNSServers: &dns, IPv6Mode: &ipv6Mode, IPv6Address: &ipv6Address, IPv6Gateway: &ipv6Gateway, IPv6DNSServers: &ipv6DNS, }}, }) if err != nil { t.Fatal(err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending summary = %#v, want one atomic network section", result.Config) } before, _ := store.NetworkState(context.Background()) if before.HostName != "iconsole-server" || before.OperatingMode != "single" { t.Fatalf("running network changed before Apply: %#v", before) } if _, err := store.SaveAndApplyConfig(context.Background()); err != nil { t.Fatal(err) } reloaded, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatal(err) } after, err := reloaded.NetworkState(context.Background()) if err != nil { t.Fatal(err) } if after.HostName != hostName || after.OperatingMode != operatingMode || after.OOB.HealthCheckTarget != target || after.OOB.HealthCheckIntervalSecond != interval { t.Fatalf("extended network settings were not persisted: %#v", after) } if after.OOB.PrimaryInterface != "Interface-1" || after.OOB.FallbackInterface != "Interface-2" { t.Fatalf("OOB topology = %+v, want Interface-1/eth0 primary and Interface-2/eth1 fallback", after.OOB) } first := after.Interfaces[0] if first.AddressMode != addressMode || first.Address != address || first.Gateway != gateway || first.IPv6Address != ipv6Address || len(first.DNSServers) != 2 || len(first.IPv6DNSServers) != 1 { t.Fatalf("extended interface settings were not persisted: %#v", first) } } func TestMigrateNetworkDefaultsCanonicalizesLegacyUnsafeOOB(t *testing.T) { configs := baseNetworkConfig() primary := configs[networkconfig.Interface1Name] primary.OperatingMode = networkconfig.OperatingModeOOB primary.OOBPrimaryInterface = networkconfig.Interface2Name primary.OOBFallbackInterface = "wwan0" primary.AddressMode = networkconfig.AddressModeDHCP primary.Address = "" primary.Gateway = "" configs[networkconfig.Interface1Name] = primary migrateNetworkDefaults(configs, baseNetworkConfig()) primary = configs[networkconfig.Interface1Name] fallback := configs[networkconfig.Interface2Name] if primary.OperatingMode != networkconfig.OperatingModeSingle { t.Fatalf("operating mode = %q, want safe Single migration", primary.OperatingMode) } if primary.OOBPrimaryInterface != networkconfig.Interface1Name || primary.OOBFallbackInterface != networkconfig.Interface2Name { t.Fatalf("OOB mapping = %q/%q, want Interface-1/Interface-2", primary.OOBPrimaryInterface, primary.OOBFallbackInterface) } if primary.Role != networkconfig.RoleInternal || fallback.Role != networkconfig.RoleExternal { t.Fatalf("Single roles = %q/%q, want internal/external", primary.Role, fallback.Role) } } func TestPortOperationCredentialUsesProtectedStoreAndTransactionPhases(t *testing.T) { directory := t.TempDir() configPath := filepath.Join(directory, "config.json") secretPath := filepath.Join(directory, "secrets.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: configPath, SecretPath: secretPath}) if err != nil { t.Fatal(err) } operation := domain.PortOperation{ Mode: "reverse_ssh", Port: 12001, KeepAliveSeconds: 3, TimeoutMilliseconds: 10000, MaxClients: 4, Authentication: "local", Newline: "cr", } if _, err := store.UpdatePort(context.Background(), "CH01", domain.PortUpdate{Operation1: &operation}); err != nil { t.Fatal(err) } exported, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } encoded, _ := json.Marshal(exported) if strings.Contains(string(encoded), `"credential"`) { t.Fatalf("configuration export leaked operation credential: %s", encoded) } pending, err := store.ResolvePortServiceCredentials(context.Background(), "pending", exported.Pending.Ports) if err != nil || pending[0].Operation1.Credential != "" || pending[0].Operation1.PasswordStored { t.Fatalf("pending credential = %#v, %v", pending[0].Operation1, err) } if _, err := store.SaveAndApplyConfig(context.Background()); err != nil { t.Fatal(err) } reloaded, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: configPath, SecretPath: secretPath}) if err != nil { t.Fatal(err) } runningExport, _ := reloaded.ConfigExport(context.Background()) running, err := reloaded.ResolvePortServiceCredentials(context.Background(), "running", runningExport.Running.Ports) if err != nil || running[0].Operation1.Credential != "" || running[0].Operation1.Authentication != "local" { t.Fatalf("reloaded running credential = %#v, %v", running[0].Operation1, err) } } func TestDiscardConfigClearsPendingStateAndSecretsWithoutChangingRunningOrPersistent(t *testing.T) { directory := t.TempDir() store, err := NewStoreWithOptions(fixedClock, StoreOptions{ ConfigPath: filepath.Join(directory, "config.json"), SecretPath: filepath.Join(directory, "secrets.json"), }) if err != nil { t.Fatal(err) } before, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } alias := "pending-only-alias" if _, err := store.UpdatePort(context.Background(), "CH01", domain.PortUpdate{Alias: &alias}); err != nil { t.Fatal(err) } result, err := store.DiscardConfig(context.Background()) if err != nil { t.Fatal(err) } if result.Result != "discard" || result.Config == nil || result.Config.PendingChanges != 0 { t.Fatalf("discard result = %#v", result) } after, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(after.Running, before.Running) || !reflect.DeepEqual(after.Persistent, before.Persistent) { t.Fatal("discard changed running or persistent configuration") } if after.Summary.RunningRevision != before.Summary.RunningRevision || after.Summary.PersistentRevision != before.Summary.PersistentRevision { t.Fatalf("discard changed revisions: before=%#v after=%#v", before.Summary, after.Summary) } events, err := store.AuditEvents(context.Background()) if err != nil { t.Fatal(err) } if len(events) == 0 || events[len(events)-1].Action != "config.discard" || events[len(events)-1].Result != "succeeded" { t.Fatalf("discard audit event = %#v", events) } } func TestConfigStorePersistsPendingRunningAndPersistentState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } mode := "Reverse Telnet" baudRate := 38400 dataBits := 7 parity := "Even" stopBits := 2 flowControl := "RTSCTS" result, err := first.UpdatePort(context.Background(), "CH01", domain.PortUpdate{ Mode: &mode, BaudRate: &baudRate, DataBits: &dataBits, Parity: &parity, StopBits: &stopBits, FlowControl: &flowControl, }) if err != nil { t.Fatalf("stage port update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } status, err := second.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read config status: %v", err) } if status.PendingChanges != 1 || status.RunningRevision != 1 || status.PersistentRevision != 1 { t.Fatalf("status = %#v, want pending 1 running r1 persistent r1", status) } beforeApply, err := second.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read port before apply: %v", err) } if beforeApply.Mode != "disabled" || beforeApply.BaudRate != 9600 || beforeApply.DataBits != 8 || beforeApply.Parity != "none" || beforeApply.StopBits != 1 || beforeApply.FlowControl != "none" { t.Fatalf("running profile before apply = %#v, want unchanged disabled 9600 8N1 none", beforeApply) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read port after apply: %v", err) } if afterApply.Mode != "reverse_telnet" || afterApply.BaudRate != 38400 || afterApply.DataBits != 7 || afterApply.Parity != "even" || afterApply.StopBits != 2 || afterApply.FlowControl != "rtscts" { t.Fatalf("running profile after apply = %#v, want reverse_telnet 38400 7E2 rtscts", afterApply) } reloadedStatus, err := third.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read reloaded config status: %v", err) } if reloadedStatus.PendingChanges != 0 || reloadedStatus.RunningRevision != 2 || reloadedStatus.PersistentRevision != 1 { t.Fatalf("reloaded status = %#v, want pending 0 running r2 persistent r1", reloadedStatus) } } func TestConfigStoreMigratesLegacyPortLineDefaults(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if _, err := NewStoreWithConfigFile(fixedClock, path); err != nil { t.Fatalf("create initial store: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read initial config store: %v", err) } var raw map[string]any if err := json.Unmarshal(content, &raw); err != nil { t.Fatalf("decode initial config store: %v", err) } for _, section := range []string{"running", "persistent"} { ports, ok := raw[section].(map[string]any) if !ok { t.Fatalf("%s shape = %T, want object", section, raw[section]) } for _, value := range ports { port, ok := value.(map[string]any) if !ok { t.Fatalf("%s port shape = %T, want object", section, value) } delete(port, "DataBits") delete(port, "Parity") delete(port, "StopBits") delete(port, "FlowControl") } } content, err = json.Marshal(raw) if err != nil { t.Fatalf("encode legacy config store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write legacy config store: %v", err) } reloaded, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("reload legacy config store: %v", err) } port, err := reloaded.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read migrated CH01: %v", err) } if port.DataBits != 8 || port.Parity != "none" || port.StopBits != 1 || port.FlowControl != "none" { t.Fatalf("migrated profile = %#v, want 8N1 none", port) } } func TestConfigStoreMigratesRemovedPortModesWithoutBlockingStartup(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if _, err := NewStoreWithConfigFile(fixedClock, path); err != nil { t.Fatalf("create initial store: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read initial config store: %v", err) } var raw map[string]any if err := json.Unmarshal(content, &raw); err != nil { t.Fatalf("decode initial config store: %v", err) } for _, section := range []string{"running", "persistent"} { ports := raw[section].(map[string]any) ch01 := ports["CH01"].(map[string]any) ch01["Mode"] = "realcom" ch01["ListenerPort"] = float64(10001) ch01["Operation1"] = map[string]any{ "mode": "realcom", "port": float64(10001), "max_clients": float64(16), "authentication": "none", "newline": "none", } ch01["Operation2"] = map[string]any{ "mode": "tcp_server", "port": float64(20001), "max_clients": float64(8), "authentication": "none", "newline": "none", } ch02 := ports["CH02"].(map[string]any) ch02["Mode"] = "reverse_ssh" ch02["ListenerPort"] = float64(10002) ch02["Operation1"] = map[string]any{ "mode": "reverse_ssh", "port": float64(10002), "max_clients": float64(16), "authentication": "local", "newline": "cr", } } content, err = json.Marshal(raw) if err != nil { t.Fatalf("encode legacy config store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write legacy config store: %v", err) } reloaded, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("reload legacy operation modes: %v", err) } ch01, err := reloaded.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read migrated CH01: %v", err) } if ch01.Mode != "disabled" || ch01.ListenerPort != 0 || ch01.Operation1.Mode != "disabled" || ch01.Operation1.Port != 0 || ch01.Operation2.Mode != "disabled" || ch01.Operation2.Port != 0 { t.Fatalf("migrated removed-mode CH01 = %#v, want both operations disabled", ch01) } ch02, err := reloaded.Port(context.Background(), "CH02") if err != nil { t.Fatalf("read migrated CH02: %v", err) } if ch02.Mode != "reverse_ssh" || ch02.ListenerPort != 10002 || ch02.Operation1.Mode != "reverse_ssh" || ch02.Operation1.MaxClients != 4 { t.Fatalf("migrated supported CH02 = %#v, want reverse SSH with max_clients 4", ch02) } } func TestConfigStoreMigratesMissingExternalAuthDefaultRoleWithoutBlockingStartup(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if _, err := NewStoreWithConfigFile(fixedClock, path); err != nil { t.Fatalf("create initial store: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read initial config store: %v", err) } var snapshot configSnapshot if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode initial config store: %v", err) } snapshot.RunningExternalAuth.DefaultRole = "" snapshot.PersistentExternalAuth.DefaultRole = "" pending := cloneExternalAuth(*snapshot.RunningExternalAuth) pending.DefaultRole = "" snapshot.PendingExternalAuth = &pending content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode legacy config store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write legacy config store: %v", err) } reloaded, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("reload legacy external auth: %v", err) } running, err := reloaded.ExternalAuth(context.Background()) if err != nil { t.Fatalf("read migrated external auth: %v", err) } if running.DefaultRole != "viewer" { t.Fatalf("migrated running default role = %q, want viewer", running.DefaultRole) } exported, err := reloaded.ConfigExport(context.Background()) if err != nil { t.Fatalf("export migrated external auth: %v", err) } if exported.Persistent.ExternalAuth.DefaultRole != "viewer" || exported.Pending.ExternalAuth == nil || exported.Pending.ExternalAuth.DefaultRole != "viewer" { t.Fatalf("migrated external auth export = %#v, want viewer in persistent and pending", exported) } } func TestConfigStoreRejectsUnknownExternalAuthDefaultRole(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if _, err := NewStoreWithConfigFile(fixedClock, path); err != nil { t.Fatalf("create initial store: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read initial config store: %v", err) } var snapshot configSnapshot if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode initial config store: %v", err) } snapshot.RunningExternalAuth.DefaultRole = "owner" content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode invalid config store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write invalid config store: %v", err) } if _, err := NewStoreWithConfigFile(fixedClock, path); err == nil || !strings.Contains(err.Error(), "external auth default_role") { t.Fatalf("reload error = %v, want external auth default_role rejection", err) } } func TestConfigStoreMigratesMissingDefaultAlarmRules(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") if _, err := NewStoreWithConfigFile(fixedClock, path); err != nil { t.Fatalf("create initial store: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read initial config store: %v", err) } var raw map[string]any if err := json.Unmarshal(content, &raw); err != nil { t.Fatalf("decode initial config store: %v", err) } for _, section := range []string{"running_alarms", "persistent_alarms"} { rules, ok := raw[section].(map[string]any) if !ok { t.Fatalf("%s shape = %T, want object", section, raw[section]) } delete(rules, "serial_link_down") delete(rules, "ethernet_link_down") } content, err = json.Marshal(raw) if err != nil { t.Fatalf("encode legacy config store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write legacy config store: %v", err) } reloaded, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("reload legacy config store: %v", err) } for ruleID, wantSource := range map[string]string{ "serial_link_down": "serial_link", "ethernet_link_down": "ethernet_link", } { rule, err := reloaded.AlarmRule(context.Background(), ruleID) if err != nil { t.Fatalf("read migrated %s rule: %v", ruleID, err) } if !rule.Enabled || rule.Source != wantSource || rule.Trigger != "up to down" { t.Fatalf("migrated %s rule = %#v, want enabled %s up-to-down default", ruleID, rule, wantSource) } } persisted, err := os.ReadFile(path) if err != nil { t.Fatalf("read migrated config store: %v", err) } var migrated map[string]any if err := json.Unmarshal(persisted, &migrated); err != nil { t.Fatalf("decode migrated config store: %v", err) } for _, section := range []string{"running_alarms", "persistent_alarms"} { rules, ok := migrated[section].(map[string]any) if !ok { t.Fatalf("persisted %s shape = %T, want object", section, migrated[section]) } for _, ruleID := range []string{"serial_link_down", "ethernet_link_down"} { if _, ok := rules[ruleID]; !ok { t.Fatalf("persisted %s missing migrated rule %s", section, ruleID) } } } } func TestConfigStorePersistsAlarmRuleTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } enabled := false result, err := first.UpdateAlarmRule(context.Background(), "auth_failures", domain.AlarmRuleUpdate{Enabled: &enabled, Delivery: []string{"web", "syslog"}}) if err != nil { t.Fatalf("stage alarm rule update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } status, err := second.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read config status: %v", err) } if status.PendingChanges != 1 || status.RunningRevision != 1 || status.PersistentRevision != 1 { t.Fatalf("status = %#v, want pending 1 running r1 persistent r1", status) } beforeApply, err := second.AlarmRule(context.Background(), "auth_failures") if err != nil { t.Fatalf("read alarm rule before apply: %v", err) } if !beforeApply.Enabled { t.Fatalf("enabled before apply = false, want true") } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.AlarmRule(context.Background(), "auth_failures") if err != nil { t.Fatalf("read alarm rule after apply: %v", err) } if afterApply.Enabled { t.Fatalf("enabled after apply = true, want false") } if len(afterApply.Delivery) != 2 || afterApply.Delivery[1] != "syslog" { t.Fatalf("delivery after apply = %#v, want web/syslog", afterApply.Delivery) } reloadedStatus, err := third.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read reloaded config status: %v", err) } if reloadedStatus.PendingChanges != 0 || reloadedStatus.RunningRevision != 2 || reloadedStatus.PersistentRevision != 1 { t.Fatalf("reloaded status = %#v, want pending 0 running r2 persistent r1", reloadedStatus) } } func TestConfigStorePersistsAlarmDeliveryPolicyTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } retryCount := 5 retryInterval := 120 queueLimit := 250 result, err := first.UpdateAlarmDeliveryPolicy(context.Background(), domain.AlarmDeliveryPolicyUpdate{ RetryCount: &retryCount, RetryIntervalSeconds: &retryInterval, PendingQueueLimit: &queueLimit, }) if err != nil { t.Fatalf("stage alarm delivery policy update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.AlarmDeliveryPolicy(context.Background()) if err != nil { t.Fatalf("read alarm delivery policy before apply: %v", err) } if beforeApply.RetryCount != 3 || beforeApply.RetryIntervalSeconds != 60 || beforeApply.PendingQueueLimit != 100 { t.Fatalf("alarm delivery policy before apply = %#v, want defaults", beforeApply) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.AlarmDelivery == nil || exported.Pending.AlarmDelivery.RetryCount != retryCount || exported.Pending.AlarmDelivery.RetryIntervalSeconds != retryInterval || exported.Pending.AlarmDelivery.PendingQueueLimit != queueLimit { t.Fatalf("pending alarm delivery export = %#v, want retry %d interval %d queue %d", exported.Pending.AlarmDelivery, retryCount, retryInterval, queueLimit) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.AlarmDeliveryPolicy(context.Background()) if err != nil { t.Fatalf("read alarm delivery policy after apply: %v", err) } if afterApply.RetryCount != retryCount || afterApply.RetryIntervalSeconds != retryInterval || afterApply.PendingQueueLimit != queueLimit { t.Fatalf("alarm delivery policy after apply = %#v, want retry %d interval %d queue %d", afterApply, retryCount, retryInterval, queueLimit) } } func TestConfigStorePersistsEmailNotificationTransactionState(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, "config.json") secretPath := filepath.Join(directory, "secrets.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create first store: %v", err) } enabled := true host := "smtp.local" port := 587 authEnabled := true username := "mailer" from := "tera@example.local" result, err := first.UpdateEmailNotification(context.Background(), domain.EmailNotificationUpdate{ Enabled: &enabled, SMTPHost: &host, SMTPPort: &port, AuthEnabled: &authEnabled, Username: &username, Password: "smtp-test-password", From: &from, To: []string{"ops@example.local"}, }) if err != nil { t.Fatalf("stage email notification update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.EmailNotification(context.Background()) if err != nil { t.Fatalf("read email notification before apply: %v", err) } if beforeApply.SMTPHost != "smtp.example.local" || beforeApply.Enabled { t.Fatalf("email notification before apply = %#v, want defaults", beforeApply) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.EmailNotification == nil || exported.Pending.EmailNotification.SMTPHost != "smtp.local" || len(exported.Pending.EmailNotification.To) != 1 { t.Fatalf("pending email notification export = %#v, want smtp.local", exported.Pending.EmailNotification) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.EmailNotification(context.Background()) if err != nil { t.Fatalf("read email notification after apply: %v", err) } if afterApply.SMTPHost != "smtp.local" || !afterApply.Enabled || len(afterApply.To) != 1 || afterApply.To[0] != "ops@example.local" { t.Fatalf("email notification after apply = %#v, want enabled smtp.local", afterApply) } } func TestConfigStorePersistsSNMPAgentTransactionState(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, "config.json") secretPath := filepath.Join(directory, "secrets.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create first store: %v", err) } enabled := true location := "Rack 42" v2cEnabled := false v3Enabled := true result, err := first.UpdateSNMPAgent(context.Background(), domain.SNMPAgentUpdate{ Enabled: &enabled, Location: &location, V2CEnabled: &v2cEnabled, V3Enabled: &v3Enabled, ReadOnlyCommunity: "tera-read-only", V3Users: []domain.SNMPV3User{ {Username: "snmp-admin", AuthProtocol: "sha256", PrivacyProtocol: "aes", AuthKey: "snmp-auth-passphrase", PrivacyKey: "snmp-privacy-passphrase", Enabled: true}, }, }) if err != nil { t.Fatalf("stage snmp agent update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.SNMPAgent(context.Background()) if err != nil { t.Fatalf("read snmp agent before apply: %v", err) } if beforeApply.Location != "Data center" || beforeApply.Enabled { t.Fatalf("snmp agent before apply = %#v, want defaults", beforeApply) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.SNMPAgent == nil || exported.Pending.SNMPAgent.Location != "Rack 42" || len(exported.Pending.SNMPAgent.V3Users) != 1 { t.Fatalf("pending snmp agent export = %#v, want Rack 42", exported.Pending.SNMPAgent) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.SNMPAgent(context.Background()) if err != nil { t.Fatalf("read snmp agent after apply: %v", err) } if afterApply.Location != "Rack 42" || !afterApply.Enabled || !afterApply.V3Enabled || len(afterApply.V3Users) != 1 || !afterApply.V3Users[0].Enabled { t.Fatalf("snmp agent after apply = %#v, want enabled Rack 42 v3", afterApply) } } func TestApplySNMPAgentPassesResolvedSecretsOnlyToRuntimeController(t *testing.T) { directory := t.TempDir() var applied snmpagent.Config controller := &snmpagent.MockController{ApplyFunc: func(_ context.Context, config snmpagent.Config) error { applied = config return snmpagent.Validate(config) }} store, err := NewStoreWithOptions(fixedClock, StoreOptions{ ConfigPath: filepath.Join(directory, "config.json"), SecretPath: filepath.Join(directory, "secrets.json"), SNMPAgentController: controller, }) if err != nil { t.Fatal(err) } enabled := true v2cEnabled := true v3Enabled := true if _, err := store.UpdateSNMPAgent(context.Background(), domain.SNMPAgentUpdate{ Enabled: &enabled, V2CEnabled: &v2cEnabled, V3Enabled: &v3Enabled, ReadOnlyCommunity: "runtime-read-only", ReadWriteCommunity: "runtime-read-write", V3Users: []domain.SNMPV3User{{ Username: "runtime-admin", Access: "write", AuthProtocol: "sha256", PrivacyProtocol: "aes", AuthKey: "runtime-auth-key", PrivacyKey: "runtime-privacy-key", Enabled: true, }}, }); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } if applied.ReadOnlyCommunity != "runtime-read-only" || applied.ReadWriteCommunity != "runtime-read-write" || len(applied.V3Users) != 1 || applied.V3Users[0].AuthKey != "runtime-auth-key" || applied.V3Users[0].PrivacyKey != "runtime-privacy-key" { t.Fatalf("runtime SNMP config did not receive resolved secrets: %#v", applied) } exported, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } encoded, err := json.Marshal(exported) if err != nil { t.Fatal(err) } for _, secret := range []string{"runtime-read-only", "runtime-read-write", "runtime-auth-key", "runtime-privacy-key"} { if strings.Contains(string(encoded), secret) { t.Fatalf("public config export leaked SNMP secret %q: %s", secret, encoded) } } } func TestApplySNMPAgentRuntimeFailureKeepsPendingAndRunningState(t *testing.T) { directory := t.TempDir() controller := &snmpagent.MockController{ApplyFunc: func(context.Context, snmpagent.Config) error { return fmt.Errorf("snmpd restart failed") }} store, err := NewStoreWithOptions(fixedClock, StoreOptions{ ConfigPath: filepath.Join(directory, "config.json"), SecretPath: filepath.Join(directory, "secrets.json"), SNMPAgentController: controller, }) if err != nil { t.Fatal(err) } enabled := true v2cEnabled := true location := "Pending rack" if _, err := store.UpdateSNMPAgent(context.Background(), domain.SNMPAgentUpdate{ Enabled: &enabled, V2CEnabled: &v2cEnabled, Location: &location, ReadOnlyCommunity: "pending-community", }); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err == nil || !strings.Contains(err.Error(), "snmpd restart failed") { t.Fatalf("ApplyConfig() error = %v, want runtime failure", err) } running, err := store.SNMPAgent(context.Background()) if err != nil { t.Fatal(err) } if running.Enabled || running.Location != "Data center" { t.Fatalf("running SNMP state changed after failed apply: %#v", running) } exported, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } if exported.Pending.SNMPAgent == nil || exported.Pending.SNMPAgent.Location != location { t.Fatalf("pending SNMP state was lost after failed apply: %#v", exported.Pending.SNMPAgent) } audits, err := store.AuditEvents(context.Background()) if err != nil { t.Fatal(err) } if !slices.ContainsFunc(audits, func(event domain.AuditEvent) bool { return event.Action == "snmp.agent.apply" && event.Result == "rejected" }) { t.Fatalf("SNMP runtime failure audit missing: %#v", audits) } } func TestConfigStorePersistsSNMPTrapTargetTransactionState(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, "config.json") secretPath := filepath.Join(directory, "secrets.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create first store: %v", err) } result, err := first.UpdateSNMPTrapTargets(context.Background(), domain.SNMPTrapTargetUpdate{ Targets: []domain.SNMPTrapTarget{ {TargetID: "snmp-primary", Host: "snmp.local", Port: 162, Version: "v3", Username: "trap-user", AuthProtocol: "sha1", PrivacyProtocol: "aes", EngineID: "tera-core-x48", Credential: "snmp-trap-passphrase", PrivacyKey: "snmp-privacy-passphrase", Enabled: true}, }, }) if err != nil { t.Fatalf("stage snmp trap target update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.SNMPTrapTargets(context.Background()) if err != nil { t.Fatalf("read snmp trap targets before apply: %v", err) } if len(beforeApply.Targets) != 1 || beforeApply.Targets[0].Host != "snmp.example.local" { t.Fatalf("snmp trap targets before apply = %#v, want defaults", beforeApply.Targets) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.SNMPTrapTargets == nil || len(exported.Pending.SNMPTrapTargets.Targets) != 1 || exported.Pending.SNMPTrapTargets.Targets[0].Host != "snmp.local" { t.Fatalf("pending snmp trap export = %#v, want snmp.local", exported.Pending.SNMPTrapTargets) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, SecretPath: secretPath}) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.SNMPTrapTargets(context.Background()) if err != nil { t.Fatalf("read snmp trap targets after apply: %v", err) } if len(afterApply.Targets) != 1 || afterApply.Targets[0].Host != "snmp.local" || !afterApply.Targets[0].Enabled { t.Fatalf("snmp trap targets after apply = %#v, want enabled snmp.local", afterApply.Targets) } } func TestConfigExportContainsRunningPersistentAndPendingConfig(t *testing.T) { store := NewStore(fixedClock) mode := "Reverse Telnet" if _, err := store.UpdatePort(context.Background(), "CH01", domain.PortUpdate{Mode: &mode}); err != nil { t.Fatalf("stage port update: %v", err) } enabled := false if _, err := store.UpdateAlarmRule(context.Background(), "auth_failures", domain.AlarmRuleUpdate{Enabled: &enabled}); err != nil { t.Fatalf("stage alarm update: %v", err) } address := "192.168.10.233/24" gateway := "192.168.10.1" if _, err := store.UpdateNetwork(context.Background(), domain.NetworkUpdate{ Interfaces: []domain.NetworkInterfaceUpdate{{Name: "Interface-1", Address: &address, Gateway: &gateway}}, }); err != nil { t.Fatalf("stage network update: %v", err) } exported, err := store.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config: %v", err) } if exported.FormatVersion != 1 { t.Fatalf("format version = %d, want 1", exported.FormatVersion) } if !exported.GeneratedAt.Equal(fixedClock()) { t.Fatalf("generated at = %s, want %s", exported.GeneratedAt, fixedClock()) } if exported.Summary.PendingChanges != 3 || exported.Summary.RunningRevision != 1 || exported.Summary.PersistentRevision != 1 { t.Fatalf("summary = %#v, want pending 3 running r1 persistent r1", exported.Summary) } if len(exported.Running.Ports) != 48 || len(exported.Persistent.Ports) != 48 { t.Fatalf("running/persistent port counts = %d/%d, want 48/48", len(exported.Running.Ports), len(exported.Persistent.Ports)) } if exported.Running.Ports[0].Mode != "disabled" || exported.Persistent.Ports[0].Mode != "disabled" { t.Fatalf("running/persistent CH01 modes = %q/%q, want disabled", exported.Running.Ports[0].Mode, exported.Persistent.Ports[0].Mode) } if len(exported.Pending.Ports) != 1 || exported.Pending.Ports[0].Mode != "reverse_telnet" { t.Fatalf("pending ports = %#v, want CH01 reverse_telnet", exported.Pending.Ports) } if len(exported.Pending.AlarmRules) != 1 || exported.Pending.AlarmRules[0].RuleID != "auth_failures" || exported.Pending.AlarmRules[0].Enabled { t.Fatalf("pending alarm rules = %#v, want disabled auth_failures", exported.Pending.AlarmRules) } if len(exported.Pending.NetworkInterfaces) != 2 || exported.Pending.NetworkInterfaces[0].Address != address { t.Fatalf("pending network interfaces = %#v, want address %q", exported.Pending.NetworkInterfaces, address) } if exported.Running.Time.Timezone != "Asia/Seoul" || exported.Persistent.Time.Timezone != "Asia/Seoul" { t.Fatalf("running/persistent time zones = %q/%q, want Asia/Seoul", exported.Running.Time.Timezone, exported.Persistent.Time.Timezone) } if exported.Pending.Time != nil { t.Fatalf("pending time = %#v, want nil", exported.Pending.Time) } } func TestConfigStorePersistsTimeSettingsTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } timezone := "UTC" result, err := first.UpdateTimeSettings(context.Background(), domain.TimeSettingsUpdate{ Timezone: &timezone, }) if err != nil { t.Fatalf("stage time settings update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.TimeSettings(context.Background()) if err != nil { t.Fatalf("read time settings before apply: %v", err) } if beforeApply.Timezone != "Asia/Seoul" { t.Fatalf("timezone before apply = %q, want Asia/Seoul", beforeApply.Timezone) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.Time == nil || exported.Pending.Time.Timezone != timezone { t.Fatalf("pending time export = %#v, want timezone %q", exported.Pending.Time, timezone) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.TimeSettings(context.Background()) if err != nil { t.Fatalf("read time settings after apply: %v", err) } if afterApply.Timezone != timezone { t.Fatalf("timezone after apply = %q, want %q", afterApply.Timezone, timezone) } } func TestConfigStorePersistsLogRetentionTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } portDays := 180 warningPct := 90 result, err := first.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{ PortDays: &portDays, ProtectedUsageWarningPct: &warningPct, }) if err != nil { t.Fatalf("stage log retention update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.LogRetention(context.Background()) if err != nil { t.Fatalf("read log retention before apply: %v", err) } if beforeApply.PortDays != 365 { t.Fatalf("port days before apply = %d, want 365", beforeApply.PortDays) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.Logs == nil || exported.Pending.Logs.PortDays != portDays || exported.Pending.Logs.ProtectedUsageWarningPct != warningPct { t.Fatalf("pending log retention export = %#v, want port days %d pct %d", exported.Pending.Logs, portDays, warningPct) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.LogRetention(context.Background()) if err != nil { t.Fatalf("read log retention after apply: %v", err) } if afterApply.PortDays != portDays || afterApply.ProtectedUsageWarningPct != warningPct { t.Fatalf("log retention after apply = %#v, want port days %d pct %d", afterApply, portDays, warningPct) } } func TestConfigStorePersistsManagementAccessTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } httpPort := 8081 httpsPort := 8443 idleTimeout := 30 captchaEnabled := true loginDetection := 9 loginHits := 4 result, err := first.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{ HTTPPort: &httpPort, HTTPSPort: &httpsPort, IdleTimeoutMinutes: &idleTimeout, CaptchaEnabled: &captchaEnabled, LoginDetectionMinutes: &loginDetection, LoginSecurityHitCount: &loginHits, }) if err != nil { t.Fatalf("stage management access update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.ManagementAccess(context.Background()) if err != nil { t.Fatalf("read management access before apply: %v", err) } if beforeApply.TelnetEnabled || beforeApply.IdleTimeoutMinutes != 15 { t.Fatalf("management access before apply = %#v, want default", beforeApply) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.Management == nil || exported.Pending.Management.SSHEnabled || exported.Pending.Management.TelnetEnabled || exported.Pending.Management.HTTPPort != httpPort || exported.Pending.Management.HTTPSPort != httpsPort || exported.Pending.Management.SSHPort != 22 || exported.Pending.Management.TelnetPort != 23 || exported.Pending.Management.IdleTimeoutMinutes != idleTimeout || !exported.Pending.Management.CaptchaEnabled || exported.Pending.Management.LoginDetectionMinutes != loginDetection || exported.Pending.Management.LoginSecurityHitCount != loginHits { t.Fatalf("pending management export = %#v, want custom ports and timeout %d", exported.Pending.Management, idleTimeout) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.ManagementAccess(context.Background()) if err != nil { t.Fatalf("read management access after apply: %v", err) } if afterApply.SSHEnabled || afterApply.TelnetEnabled || afterApply.HTTPPort != httpPort || afterApply.HTTPSPort != httpsPort || afterApply.SSHPort != 22 || afterApply.TelnetPort != 23 || afterApply.IdleTimeoutMinutes != idleTimeout || !afterApply.CaptchaEnabled || afterApply.LoginDetectionMinutes != loginDetection || afterApply.LoginSecurityHitCount != loginHits { t.Fatalf("management access after apply = %#v, want custom ports and timeout %d", afterApply, idleTimeout) } } func TestConfigStorePersistsRemoteSyslogTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } enabled := true host := "syslog.local" port := 1514 protocol := "tcp" facility := "local4" result, err := first.UpdateRemoteSyslog(context.Background(), domain.RemoteSyslogUpdate{ Enabled: &enabled, Host: &host, Port: &port, Protocol: &protocol, Facility: &facility, }) if err != nil { t.Fatalf("stage remote syslog update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.RemoteSyslog(context.Background()) if err != nil { t.Fatalf("read remote syslog before apply: %v", err) } if beforeApply.Enabled || beforeApply.Protocol != "udp" { t.Fatalf("remote syslog before apply = %#v, want default disabled udp", beforeApply) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.RemoteSyslog == nil || !exported.Pending.RemoteSyslog.Enabled || exported.Pending.RemoteSyslog.Host != host || exported.Pending.RemoteSyslog.Protocol != protocol { t.Fatalf("pending remote syslog export = %#v, want enabled host %q protocol %q", exported.Pending.RemoteSyslog, host, protocol) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.RemoteSyslog(context.Background()) if err != nil { t.Fatalf("read remote syslog after apply: %v", err) } if !afterApply.Enabled || afterApply.Host != host || afterApply.Port != port || afterApply.Protocol != protocol || afterApply.Facility != facility { t.Fatalf("remote syslog after apply = %#v, want configured syslog", afterApply) } } func TestConfigStorePersistsNetworkTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } address := "192.168.10.233/24" gateway := "192.168.10.1" result, err := first.UpdateNetwork(context.Background(), domain.NetworkUpdate{ Interfaces: []domain.NetworkInterfaceUpdate{{ Name: "Interface-1", Address: &address, Gateway: &gateway, }}, }) if err != nil { t.Fatalf("stage network update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } status, err := second.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read config status: %v", err) } if status.PendingChanges != 1 || status.RunningRevision != 1 || status.PersistentRevision != 1 { t.Fatalf("status = %#v, want pending 1 running r1 persistent r1", status) } beforeApply, err := second.NetworkState(context.Background()) if err != nil { t.Fatalf("read network before apply: %v", err) } if beforeApply.Interfaces[0].Address != "192.168.0.223/24" { t.Fatalf("running address before apply = %q, want default", beforeApply.Interfaces[0].Address) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.NetworkState(context.Background()) if err != nil { t.Fatalf("read network after apply: %v", err) } if afterApply.Interfaces[0].Address != address || afterApply.Interfaces[0].Gateway != gateway { t.Fatalf("network after apply = %#v, want updated address/gateway", afterApply.Interfaces[0]) } reloadedStatus, err := third.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read reloaded config status: %v", err) } if reloadedStatus.PendingChanges != 0 || reloadedStatus.RunningRevision != 2 || reloadedStatus.PersistentRevision != 1 { t.Fatalf("reloaded status = %#v, want pending 0 running r2 persistent r1", reloadedStatus) } } func TestConfigStorePersistsTLSCertificateTransactionState(t *testing.T) { root := t.TempDir() path := filepath.Join(root, "config.json") tlsRoot := filepath.Join(root, "tls") openTLSStore := func() *tlscert.Repository { repository, openErr := tlscert.Open(tlscert.Options{ Root: tlsRoot, ActiveCert: filepath.Join(root, "active.crt"), ActiveKey: filepath.Join(root, "active.key"), Clock: fixedClock, }) if openErr != nil { t.Fatalf("open TLS certificate store: %v", openErr) } return repository } certificateID := "operator-imported-2026" firstTLS := openTLSStore() if _, err := firstTLS.Generate(certificateID, tlscert.Subject{CommonName: "console.example.local"}); err != nil { t.Fatalf("generate TLS certificate: %v", err) } first, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, TLSCertificateStore: firstTLS}) if err != nil { t.Fatalf("create first store: %v", err) } result, err := first.UpdateTLSCertificate(context.Background(), domain.TLSCertificateUpdate{ ActiveCertificateID: &certificateID, }) if err != nil { t.Fatalf("stage tls certificate update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, TLSCertificateStore: openTLSStore()}) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.TLSCertificate(context.Background()) if err != nil { t.Fatalf("read tls certificate before apply: %v", err) } if beforeApply.ActiveCertificateID != "factory-default" { t.Fatalf("active certificate before apply = %q, want factory-default", beforeApply.ActiveCertificateID) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.TLSCertificate == nil || exported.Pending.TLSCertificate.ActiveCertificateID != certificateID { t.Fatalf("pending tls certificate export = %#v, want %q", exported.Pending.TLSCertificate, certificateID) } if exported.Pending.TLSCertificate.PrivateKeyStored != true { t.Fatalf("pending tls private key storage flag = false, want true") } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: path, TLSCertificateStore: openTLSStore()}) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.TLSCertificate(context.Background()) if err != nil { t.Fatalf("read tls certificate after apply: %v", err) } if afterApply.ActiveCertificateID != certificateID || afterApply.Source != "generated" { t.Fatalf("tls certificate after apply = %#v, want %q generated", afterApply, certificateID) } } func TestConfigStorePersistsExternalAuthTransactionState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") // External credentials persist separately from the exported configuration. // Reopening only the configuration previously hid a lost-secret failure. options := StoreOptions{ConfigPath: path, SecretPath: filepath.Join(filepath.Dir(path), "secrets.json")} first, err := NewStoreWithOptions(fixedClock, options) if err != nil { t.Fatalf("create first store: %v", err) } enabled := true method := "radius" timeoutSeconds := 10 servers := []domain.ExternalAuthServer{ {ServerID: "radius-primary", Host: "radius.local", Port: 1812, Secret: "radius-test-shared-secret", Enabled: true}, } result, err := first.UpdateExternalAuth(context.Background(), domain.ExternalAuthUpdate{ Enabled: &enabled, PrimaryMethod: &method, TimeoutSeconds: &timeoutSeconds, RadiusServers: servers, }) if err != nil { t.Fatalf("stage external auth update: %v", err) } if result.Config == nil || result.Config.PendingChanges != 1 { t.Fatalf("pending changes = %#v, want 1", result.Config) } second, err := NewStoreWithOptions(fixedClock, options) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.ExternalAuth(context.Background()) if err != nil { t.Fatalf("read external auth before apply: %v", err) } if beforeApply.PrimaryMethod != "local" || beforeApply.Enabled { t.Fatalf("external auth before apply = %#v, want disabled local", beforeApply) } exported, err := second.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config before apply: %v", err) } if exported.Pending.ExternalAuth == nil || !exported.Pending.ExternalAuth.Enabled || exported.Pending.ExternalAuth.PrimaryMethod != method { t.Fatalf("pending external auth export = %#v, want enabled %q", exported.Pending.ExternalAuth, method) } if len(exported.Pending.ExternalAuth.RadiusServers) != 1 || !exported.Pending.ExternalAuth.RadiusServers[0].SecretStored { t.Fatalf("pending radius servers = %#v, want one protected-secret server", exported.Pending.ExternalAuth.RadiusServers) } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply config: %v", err) } third, err := NewStoreWithOptions(fixedClock, options) if err != nil { t.Fatalf("create third store: %v", err) } afterApply, err := third.ExternalAuth(context.Background()) if err != nil { t.Fatalf("read external auth after apply: %v", err) } if !afterApply.Enabled || afterApply.PrimaryMethod != method || afterApply.TimeoutSeconds != timeoutSeconds { t.Fatalf("external auth after apply = %#v, want enabled %q timeout %d", afterApply, method, timeoutSeconds) } if !afterApply.RadiusServers[0].SecretStored { t.Fatal("protected radius secret was not preserved across store reloads") } } func TestCustomGroupAndUserPortPolicyIsEnforced(t *testing.T) { store := NewStore(fixedClock) group, err := store.CreateAuthGroup(context.Background(), domain.AuthGroupCreateRequest{GroupID: "rack-a", Name: "Rack A", Role: "viewer", PortPermissions: []domain.PortPermission{{Channel: "CH01", Access: "write"}}}) if err != nil || group.System { t.Fatalf("create group=%#v err=%v", group, err) } user, err := store.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{Username: "rack-user", Password: "rack-user-secret", Role: "viewer", Groups: []string{"rack-a"}}) if err != nil || !user.MustChangePassword { t.Fatalf("create user=%#v err=%v", user, err) } forceChange := false if _, err := store.UpdateAuthUserPassword(context.Background(), "rack-user", domain.AuthUserPasswordUpdateRequest{Password: "rack-user-secret-2", ForceChange: &forceChange}); err != nil { t.Fatal(err) } session, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "rack-user", Password: "rack-user-secret-2"}) if err != nil { t.Fatal(err) } actor, err := store.ActorBySession(context.Background(), session.SessionToken) if err != nil { t.Fatal(err) } if err := store.AuthorizePort(context.Background(), actor, "CH01", "write"); err != nil { t.Fatalf("CH01 write denied: %v", err) } if err := store.AuthorizePort(context.Background(), actor, "CH02", "read"); err == nil { t.Fatal("CH02 read unexpectedly allowed") } } func TestAddressFiltersEnforceIPv4AndMACRules(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{MACResolver: func(context.Context, netip.Addr) (string, error) { return "00:11:22:33:44:55", nil }}) if err != nil { t.Fatal(err) } settings := domain.AddressFilterSettings{Enabled: true, Mode: "whitelist", Rules: []domain.AddressFilterRule{{RuleID: "office", AddressType: "ipv4", Address: "192.168.10.0/24", Protocol: "https", DestinationPort: 443, Enabled: true}, {RuleID: "service-laptop", AddressType: "mac", Address: "00:11:22:33:44:55", Protocol: "ssh", DestinationPort: 22, Enabled: true}}} if _, err := store.UpdateAddressFilters(context.Background(), settings); err != nil { t.Fatal(err) } if err := store.AuthorizeClient(context.Background(), "192.168.10.5", "https", 443); err != nil { t.Fatal(err) } if err := store.AuthorizeClient(context.Background(), "192.168.20.5", "ssh", 22); err != nil { t.Fatal(err) } if err := store.AuthorizeClient(context.Background(), "192.168.20.5", "https", 443); err == nil { t.Fatal("unmatched client unexpectedly allowed") } } func TestAddressFiltersEnforceIPv6Rules(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{}) if err != nil { t.Fatal(err) } settings := domain.AddressFilterSettings{Enabled: true, Mode: "whitelist", Rules: []domain.AddressFilterRule{{RuleID: "office-v6", AddressType: "ipv6", Address: "2001:db8:42::/64", Protocol: "https", DestinationPort: 443, Enabled: true}}} if _, err := store.UpdateAddressFilters(context.Background(), settings); err != nil { t.Fatal(err) } if err := store.AuthorizeClient(context.Background(), "2001:db8:42::10", "https", 443); err != nil { t.Fatal(err) } if err := store.AuthorizeClient(context.Background(), "2001:db8:99::10", "https", 443); err == nil { t.Fatal("unmatched IPv6 client unexpectedly allowed") } } func TestExternalRadiusLoginUsesProtectedRunningSecretAndLocalFallback(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{SecretPath: filepath.Join(t.TempDir(), "secrets.json"), ExternalAuthenticator: acceptingExternalAuthenticator{}}) if err != nil { t.Fatal(err) } enabled, method, fallback, role := true, "radius", true, "port_operator" _, err = store.UpdateExternalAuth(context.Background(), domain.ExternalAuthUpdate{Enabled: &enabled, PrimaryMethod: &method, LocalFallback: &fallback, DefaultRole: &role, RadiusServers: []domain.ExternalAuthServer{{ServerID: "radius-primary", Host: "radius.local", Port: 1812, Secret: "radius-test-secret", Enabled: true}}}) if err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } session, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "remote-user", Password: "remote-password"}) if err != nil { t.Fatal(err) } if session.Role != "port_operator" || session.Actor != "external-remote-user" { t.Fatalf("external session=%#v", session) } exported, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } encoded, _ := json.Marshal(exported) if strings.Contains(string(encoded), "radius-test-secret") { t.Fatal("config export leaked external shared secret") } if _, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "admin", Password: "mock-admin-secret"}); err != nil { t.Fatalf("local fallback failed: %v", err) } } func TestExternalAuthenticationCannotDisableLocalRecoveryFallback(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{SecretPath: filepath.Join(t.TempDir(), "secrets.json")}) if err != nil { t.Fatal(err) } enabled, method, fallback := true, "radius", false _, err = store.UpdateExternalAuth(context.Background(), domain.ExternalAuthUpdate{ Enabled: &enabled, PrimaryMethod: &method, LocalFallback: &fallback, RadiusServers: []domain.ExternalAuthServer{{ ServerID: "radius-primary", Host: "radius.local", Port: 1812, Secret: "radius-test-secret", Enabled: true, }}, }) if err == nil || !strings.Contains(err.Error(), "requires local fallback") { t.Fatalf("external auth without local fallback error=%v", err) } if store.secrets.Has(externalAuthSecretKey("pending", "radius", "radius-primary")) { t.Fatal("rejected external auth request stored an orphaned pending secret") } } func TestConfigStorePersistsRestorePendingState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } snapshot, err := first.ConfigExport(context.Background()) if err != nil { t.Fatalf("export config: %v", err) } snapshot.Persistent.Ports[0].Mode = "reverse_telnet" snapshot.Persistent.Ports[0].Operation1 = defaultPortOperation(1, "reverse_telnet") snapshot.Persistent.Ports[0].BaudRate = 38400 snapshot.Persistent.Ports[0].AlarmKeyword = "RESTORED" result, err := first.RestoreConfig(context.Background(), snapshot) if err != nil { t.Fatalf("restore config: %v", err) } if result.Config == nil || result.Config.PendingChanges == 0 { t.Fatalf("restore config summary = %#v, want pending changes", result.Config) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } beforeApply, err := second.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read port before apply: %v", err) } if beforeApply.Mode != "disabled" { t.Fatalf("running mode before apply = %q, want disabled", beforeApply.Mode) } status, err := second.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read config status: %v", err) } if status.PendingChanges == 0 { t.Fatalf("pending changes after reload = 0, want restored pending state") } if _, err := second.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply restored config: %v", err) } afterApply, err := second.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read port after apply: %v", err) } if afterApply.Mode != "reverse_telnet" || afterApply.BaudRate != 38400 || afterApply.AlarmKeyword != "RESTORED" { t.Fatalf("restored port = %#v, want reverse_telnet 38400 RESTORED", afterApply) } } func TestRestoreKeepsUnchangedSecretMarkerWithoutCreatingInvalidPendingConfig(t *testing.T) { directory := t.TempDir() configPath := filepath.Join(directory, "config.json") secretPath := filepath.Join(directory, "secrets.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: configPath, SecretPath: secretPath}) if err != nil { t.Fatal(err) } // Model an appliance migrated from an older release: the configuration // records that an SMTP password was stored, but the protected value is no // longer present. An unrelated restore must not turn that marker false and // persist an invalid pending email configuration. first.mu.Lock() email := first.runningEmail email.AuthEnabled = true email.Username = "alerts" email.PasswordStored = true first.runningEmail = email first.persistentEmail = email if err := first.persistConfigStoreLocked(); err != nil { first.mu.Unlock() t.Fatal(err) } first.mu.Unlock() snapshot, err := first.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } snapshot.Persistent.Ports[47].AlarmKeyword = "RESTORED-CH48" if _, err := first.RestoreConfig(context.Background(), snapshot); err != nil { t.Fatalf("restore unrelated port setting: %v", err) } first.mu.Lock() if first.pendingEmail != nil { first.mu.Unlock() t.Fatalf("unchanged email configuration was staged: %#v", first.pendingEmail) } first.mu.Unlock() if _, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: configPath, SecretPath: secretPath}); err != nil { t.Fatalf("reload after restore: %v", err) } } func TestRestorePreservesChangedSecretDependentConfigWithoutPersistingInvalidState(t *testing.T) { directory := t.TempDir() configPath := filepath.Join(directory, "config.json") secretPath := filepath.Join(directory, "secrets.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: configPath, SecretPath: secretPath}) if err != nil { t.Fatal(err) } snapshot, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } snapshot.Persistent.EmailNotification.AuthEnabled = true snapshot.Persistent.EmailNotification.Username = "alerts" snapshot.Persistent.EmailNotification.PasswordStored = true snapshot.Persistent.EmailNotification.CustomTitle = "restored title" result, err := store.RestoreConfig(context.Background(), snapshot) if err != nil { t.Fatalf("restore missing protected SMTP password: %v", err) } if !strings.Contains(result.Message, "email notification") { t.Fatalf("restore message=%q, want preserved email warning", result.Message) } store.mu.Lock() if store.pendingEmail != nil { store.mu.Unlock() t.Fatalf("secret-dependent email configuration was staged: %#v", store.pendingEmail) } store.mu.Unlock() if _, err := NewStoreWithOptions(fixedClock, StoreOptions{ConfigPath: configPath, SecretPath: secretPath}); err != nil { t.Fatalf("restore corrupted persisted configuration: %v", err) } } func TestConfigStorePersistsFactoryResetState(t *testing.T) { path := filepath.Join(t.TempDir(), "config.json") first, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } mode := "Reverse Telnet" if _, err := first.UpdatePort(context.Background(), "CH01", domain.PortUpdate{Mode: &mode}); err != nil { t.Fatalf("stage port update: %v", err) } if _, err := first.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply port update: %v", err) } changed, err := first.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read changed port: %v", err) } if changed.Mode != "reverse_telnet" { t.Fatalf("changed mode = %q, want reverse_telnet", changed.Mode) } operation, err := first.FactoryReset(context.Background()) if err != nil { t.Fatalf("factory reset: %v", err) } if operation.State != "succeeded" || operation.ProgressPercent != 100 { t.Fatalf("operation = %#v, want succeeded progress 100", operation) } second, err := NewStoreWithConfigFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } port, err := second.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read reset port: %v", err) } if port.Mode != "disabled" || port.BaudRate != 9600 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" { t.Fatalf("reset port = %#v, want default disabled operations and 9600", port) } status, err := second.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read config status: %v", err) } if status.PendingChanges != 0 || status.RunningDiffersFromPersistent { t.Fatalf("reset status = %#v, want no pending and no drift", status) } } func TestOperationStorePersistsOperationsAcrossStoreInstances(t *testing.T) { path := filepath.Join(t.TempDir(), "operations.json") first, err := NewStoreWithOperationFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } operation, err := first.RunDiagnostics(context.Background(), "network-test", domain.DiagnosticsRequest{ Target: "192.168.0.1", TimeoutSeconds: 10, MaxOutputBytes: 4096, }) if err != nil { t.Fatalf("run diagnostics: %v", err) } assertStoredOperationMetadata(t, operation) assertOperationResult(t, operation, "kind", "network_test") assertOperationResult(t, operation, "target", "192.168.0.1") firstList, err := first.Operations(context.Background()) if err != nil { t.Fatalf("list first operations: %v", err) } if len(firstList) != 1 || firstList[0].OperationID != operation.OperationID { t.Fatalf("first operations = %#v, want single operation %q", firstList, operation.OperationID) } assertStoredOperationMetadata(t, firstList[0]) assertOperationResult(t, firstList[0], "kind", "network_test") second, err := NewStoreWithOperationFile(fixedClock, path) if err != nil { t.Fatalf("create second store: %v", err) } reloaded, err := second.Operation(context.Background(), operation.OperationID) if err != nil { t.Fatalf("read reloaded operation: %v", err) } if reloaded.OperationID != operation.OperationID { t.Fatalf("operation id = %q, want %q", reloaded.OperationID, operation.OperationID) } if reloaded.AuditID == "" { t.Fatalf("expected reloaded operation audit id") } assertStoredOperationMetadata(t, reloaded) assertOperationResult(t, reloaded, "kind", "network_test") assertOperationResult(t, reloaded, "target", "192.168.0.1") reloadedList, err := second.Operations(context.Background()) if err != nil { t.Fatalf("list reloaded operations: %v", err) } if len(reloadedList) != 1 || reloadedList[0].OperationID != operation.OperationID { t.Fatalf("reloaded operations = %#v, want single operation %q", reloadedList, operation.OperationID) } assertStoredOperationMetadata(t, reloadedList[0]) assertOperationResult(t, reloadedList[0], "kind", "network_test") next, err := second.RestartPort(context.Background(), "CH02") if err != nil { t.Fatalf("restart second port: %v", err) } if next.OperationID == operation.OperationID { t.Fatalf("operation id duplicated: %q", next.OperationID) } if next.OperationID != "op_20260618_000002" { t.Fatalf("next operation id = %q, want op_20260618_000002", next.OperationID) } assertStoredOperationMetadata(t, next) nextList, err := second.Operations(context.Background()) if err != nil { t.Fatalf("list next operations: %v", err) } if len(nextList) != 2 || nextList[0].OperationID != operation.OperationID || nextList[1].OperationID != next.OperationID { t.Fatalf("next operations = %#v, want ordered operation list", nextList) } assertStoredOperationMetadata(t, nextList[1]) } type blockingDiagnosticsRunner struct { started chan diagnostics.Request release chan struct{} result diagnostics.Result progress string err error } func (r *blockingDiagnosticsRunner) Run(ctx context.Context, request diagnostics.Request) (diagnostics.Result, error) { if r.progress != "" { diagnostics.ReportOutput(ctx, diagnostics.OutputProgress{Output: r.progress, Bytes: len(r.progress)}) } select { case r.started <- request: default: } select { case <-ctx.Done(): return diagnostics.Result{}, ctx.Err() case <-r.release: return r.result, r.err } } func TestAsyncDiagnosticsTransitionsAndAuditsFinalResult(t *testing.T) { reachable := true runner := &blockingDiagnosticsRunner{ started: make(chan diagnostics.Request, 1), release: make(chan struct{}), result: diagnostics.Result{ Kind: diagnostics.KindNetworkTest, Target: "192.0.2.1", ExitCode: 0, Output: "reachable", OutputBytes: 9, Reachable: &reachable, }, progress: "PING 192.0.2.1\n64 bytes from 192.0.2.1\n", } store, err := NewStoreWithOptions(fixedClock, StoreOptions{ DiagnosticsRunner: runner, DiagnosticsWorkers: 1, DiagnosticsQueue: 2, }) if err != nil { t.Fatal(err) } defer store.Close() ctx := domain.WithActor(context.Background(), domain.Actor{Name: "mock-admin"}) operation, err := store.RunDiagnostics(ctx, "network-test", domain.DiagnosticsRequest{ Target: "192.0.2.1", TimeoutSeconds: 5, MaxOutputBytes: 4096, }) if err != nil { t.Fatal(err) } if operation.State != "queued" && operation.State != "running" { t.Fatalf("initial state = %q, want queued or running", operation.State) } select { case request := <-runner.started: if request.Target != "192.0.2.1" { t.Fatalf("runner target = %q", request.Target) } case <-time.After(time.Second): t.Fatal("diagnostic runner did not start") } running := waitForOperationState(t, store, operation.OperationID, "running") if running.Result["output"] != runner.progress || running.Result["output_bytes"] != len(runner.progress) { t.Fatalf("running result = %+v, want live process output", running.Result) } close(runner.release) completed := waitForOperationState(t, store, operation.OperationID, "succeeded") if completed.ProgressPercent != 100 || completed.CompletedAt == nil { t.Fatalf("completed operation = %+v", completed) } if completed.Result["adapter"] != "process" || completed.Result["reachable"] != true || completed.Result["output"] != "reachable" { t.Fatalf("completed result = %+v", completed.Result) } events, err := store.AuditEvents(context.Background()) if err != nil { t.Fatal(err) } if len(events) != 2 || events[0].Result != "accepted" || events[1].Result != "succeeded" || events[1].Actor != "mock-admin" { t.Fatalf("audit events = %+v", events) } } func TestSupportBundleServiceCreatesAndReadsArchive(t *testing.T) { manager, err := supportbundle.NewManager(supportbundle.Options{Directory: filepath.Join(t.TempDir(), "bundles")}) if err != nil { t.Fatal(err) } store, err := NewStoreWithOptions(fixedClock, StoreOptions{ SupportBundleService: manager, DiagnosticsWorkers: 1, DiagnosticsQueue: 2, }) if err != nil { t.Fatal(err) } defer store.Close() ctx := domain.WithActor(context.Background(), domain.Actor{Name: "mock-admin"}) operation, err := store.RunDiagnostics(ctx, "support-bundle", domain.DiagnosticsRequest{}) if err != nil { t.Fatal(err) } if operation.State != "queued" && operation.State != "running" { t.Fatalf("initial state = %q, want queued or running", operation.State) } completed := waitForOperationState(t, store, operation.OperationID, "succeeded") if completed.Result["adapter"] != "support_bundle" || completed.Result["download_url"] == "" || completed.Result["sha256"] == "" { t.Fatalf("support bundle result = %+v", completed.Result) } bundleID, _ := completed.Result["bundle_id"].(string) artifact, err := store.SupportBundle(ctx, bundleID) if err != nil { t.Fatal(err) } if artifact.BundleID != bundleID || len(artifact.Content) < 4 || string(artifact.Content[:2]) != "PK" || len(artifact.SHA256) != 64 { t.Fatalf("artifact = %+v", artifact) } events, err := store.AuditEvents(context.Background()) if err != nil { t.Fatal(err) } if len(events) != 3 || events[0].Result != "accepted" || events[1].Result != "succeeded" || events[2].Action != "diagnostics.support_bundle.download" { t.Fatalf("support bundle audit events = %+v", events) } } func TestAsyncDiagnosticsQueueIsBoundedAndShutdownFinalizesWork(t *testing.T) { runner := &blockingDiagnosticsRunner{started: make(chan diagnostics.Request, 1), release: make(chan struct{})} store, err := NewStoreWithOptions(fixedClock, StoreOptions{ DiagnosticsRunner: runner, DiagnosticsWorkers: 1, DiagnosticsQueue: 1, }) if err != nil { t.Fatal(err) } first, err := store.RunDiagnostics(context.Background(), "network-test", domain.DiagnosticsRequest{Target: "192.0.2.1"}) if err != nil { t.Fatal(err) } select { case <-runner.started: case <-time.After(time.Second): t.Fatal("first diagnostic did not start") } second, err := store.RunDiagnostics(context.Background(), "network-test", domain.DiagnosticsRequest{Target: "192.0.2.2"}) if err != nil { t.Fatal(err) } if _, err := store.RunDiagnostics(context.Background(), "network-test", domain.DiagnosticsRequest{Target: "192.0.2.3"}); err == nil || !strings.Contains(err.Error(), "queue is full") { t.Fatalf("third diagnostic error = %v, want full queue", err) } store.Close() for _, operationID := range []string{first.OperationID, second.OperationID} { operation, err := store.Operation(context.Background(), operationID) if err != nil { t.Fatal(err) } if operation.State != "failed" || operation.CompletedAt == nil || !strings.Contains(operation.Message, "shutdown") { t.Fatalf("shutdown operation = %+v", operation) } } } func TestAsyncDiagnosticsRetentionPreservesActiveWork(t *testing.T) { runner := &blockingDiagnosticsRunner{started: make(chan diagnostics.Request, 1), release: make(chan struct{})} store, err := NewStoreWithOptions(fixedClock, StoreOptions{ OperationMaxEvents: 1, DiagnosticsRunner: runner, DiagnosticsWorkers: 1, DiagnosticsQueue: 2, }) if err != nil { t.Fatal(err) } first, err := store.RunDiagnostics(context.Background(), "network-test", domain.DiagnosticsRequest{Target: "192.0.2.1"}) if err != nil { t.Fatal(err) } select { case <-runner.started: case <-time.After(time.Second): t.Fatal("first diagnostic did not start") } second, err := store.RunDiagnostics(context.Background(), "network-test", domain.DiagnosticsRequest{Target: "192.0.2.2"}) if err != nil { t.Fatal(err) } operations, err := store.Operations(context.Background()) if err != nil { t.Fatal(err) } if len(operations) != 2 { t.Fatalf("active operations = %d, want 2", len(operations)) } for _, operationID := range []string{first.OperationID, second.OperationID} { if _, err := store.Operation(context.Background(), operationID); err != nil { t.Fatalf("active operation %s was trimmed: %v", operationID, err) } } store.Close() operations, err = store.Operations(context.Background()) if err != nil { t.Fatal(err) } if len(operations) != 1 || operations[0].State != "failed" { t.Fatalf("operations after shutdown = %+v, want one retained failed operation", operations) } } func waitForOperationState(t *testing.T, store *Store, operationID, state string) domain.Operation { t.Helper() deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { operation, err := store.Operation(context.Background(), operationID) if err != nil { t.Fatal(err) } if operation.State == state { return operation } time.Sleep(10 * time.Millisecond) } operation, _ := store.Operation(context.Background(), operationID) t.Fatalf("operation state = %q, want %q", operation.State, state) return domain.Operation{} } func TestOperationResultClonesDoNotShareSlices(t *testing.T) { store := NewStore(fixedClock) operation, err := store.RunDiagnostics(context.Background(), "support-bundle", domain.DiagnosticsRequest{}) if err != nil { t.Fatalf("run support bundle: %v", err) } sections, ok := operation.Result["included_sections"].([]string) if !ok || len(sections) == 0 { t.Fatalf("included_sections shape = %T length %d, want non-empty []string", operation.Result["included_sections"], len(sections)) } sections[0] = "mutated" reloaded, err := store.Operation(context.Background(), operation.OperationID) if err != nil { t.Fatalf("read operation: %v", err) } reloadedSections, ok := reloaded.Result["included_sections"].([]string) if !ok || len(reloadedSections) == 0 { t.Fatalf("reloaded included_sections shape = %T length %d, want non-empty []string", reloaded.Result["included_sections"], len(reloadedSections)) } if reloadedSections[0] != "system_status" { t.Fatalf("stored included_sections[0] = %q, want system_status", reloadedSections[0]) } } func TestOperationStoreRejectsInvalidOperationProgress(t *testing.T) { path := filepath.Join(t.TempDir(), "operations.json") first, err := NewStoreWithOperationFile(fixedClock, path) if err != nil { t.Fatalf("create first store: %v", err) } operation, err := first.RestartPort(context.Background(), "CH01") if err != nil { t.Fatalf("restart port: %v", err) } var snapshot operationSnapshot content, err := os.ReadFile(path) if err != nil { t.Fatalf("read operation store: %v", err) } if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode operation store: %v", err) } tampered := snapshot.Operations[operation.OperationID] tampered.ProgressPercent = 101 snapshot.Operations[operation.OperationID] = tampered content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode tampered operation store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write tampered operation store: %v", err) } if _, err := NewStoreWithOperationFile(fixedClock, path); err == nil { t.Fatalf("operation store with invalid progress should be rejected") } } func TestSaveAndRestartPersistsConfigAndOperationAcrossStoreInstances(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") operationPath := filepath.Join(tempDir, "operations.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{ ConfigPath: configPath, OperationPath: operationPath, }) if err != nil { t.Fatalf("create first store: %v", err) } mode := "Reverse Telnet" if _, err := first.UpdatePort(context.Background(), "CH01", domain.PortUpdate{Mode: &mode}); err != nil { t.Fatalf("stage port update: %v", err) } operation, err := first.SaveAndRestart(context.Background()) if err != nil { t.Fatalf("save and restart: %v", err) } assertStoredOperationMetadata(t, operation) if !strings.Contains(operation.Message, "saving 1 pending change") { t.Fatalf("operation message = %q, want pending change count", operation.Message) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{ ConfigPath: configPath, OperationPath: operationPath, }) if err != nil { t.Fatalf("create second store: %v", err) } reloadedOperation, err := second.Operation(context.Background(), operation.OperationID) if err != nil { t.Fatalf("read reloaded operation: %v", err) } if reloadedOperation.OperationID != operation.OperationID || reloadedOperation.AuditID == "" { t.Fatalf("reloaded operation = %#v, want operation %q with audit id", reloadedOperation, operation.OperationID) } assertStoredOperationMetadata(t, reloadedOperation) port, err := second.Port(context.Background(), "CH01") if err != nil { t.Fatalf("read reloaded port: %v", err) } if port.Mode != "reverse_telnet" { t.Fatalf("reloaded CH01 mode = %q, want reverse_telnet", port.Mode) } status, err := second.ConfigStatus(context.Background()) if err != nil { t.Fatalf("read reloaded config status: %v", err) } if status.PendingChanges != 0 || status.RunningRevision != 2 || status.PersistentRevision != 2 || status.RunningDiffersFromPersistent { t.Fatalf("reloaded config status = %#v, want saved/applied revision 2 with no drift", status) } } func TestAlarmDeliveryTestOperationPersistsAndValidatesTargets(t *testing.T) { path := filepath.Join(t.TempDir(), "operations.json") store, err := NewStoreWithOperationFile(fixedClock, path) if err != nil { t.Fatalf("create store: %v", err) } operation, err := store.TestAlarmDelivery(context.Background(), domain.AlarmDeliveryTestRequest{ Delivery: []string{"web"}, Message: "Operator verification", }) if err != nil { t.Fatalf("test alarm delivery: %v", err) } assertStoredOperationMetadata(t, operation) if operation.OperationID != "op_20260618_000001" { t.Fatalf("operation id = %q, want op_20260618_000001", operation.OperationID) } if !strings.Contains(operation.Message, "Operator verification") || !strings.Contains(operation.Message, "web mock delivery") { t.Fatalf("operation message = %q, want verification through web mock delivery", operation.Message) } if _, err := store.TestAlarmDelivery(context.Background(), domain.AlarmDeliveryTestRequest{Delivery: []string{"email"}}); err == nil { t.Fatalf("email delivery test should require enabled email notification") } reloaded, err := NewStoreWithOperationFile(fixedClock, path) if err != nil { t.Fatalf("reload store: %v", err) } operations, err := reloaded.Operations(context.Background()) if err != nil { t.Fatalf("list reloaded operations: %v", err) } if len(operations) != 1 || operations[0].OperationID != operation.OperationID { t.Fatalf("reloaded operations = %#v, want single alarm delivery operation %q", operations, operation.OperationID) } assertStoredOperationMetadata(t, operations[0]) } func TestAlarmDeliveryTestUsesEnabledRuleDelivery(t *testing.T) { store := NewStore(fixedClock) update, err := store.UpdateAlarmRule(context.Background(), "auth_failures", domain.AlarmRuleUpdate{ Delivery: []string{"web"}, }) if err != nil { t.Fatalf("stage alarm rule update: %v", err) } if update.Config == nil || update.Config.PendingChanges != 1 { t.Fatalf("pending config = %#v, want one change", update.Config) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatalf("apply alarm rule update: %v", err) } operation, err := store.TestAlarmDelivery(context.Background(), domain.AlarmDeliveryTestRequest{ RuleID: "auth_failures", }) if err != nil { t.Fatalf("test rule alarm delivery: %v", err) } assertStoredOperationMetadata(t, operation) if !strings.Contains(operation.Message, "for rule auth_failures") || !strings.Contains(operation.Message, "web mock delivery") { t.Fatalf("operation message = %q, want rule delivery message", operation.Message) } } func TestAlarmDeliveryTestQueuesThroughDispatcher(t *testing.T) { dispatcher, err := alarmdelivery.NewDispatcher(map[string]alarmdelivery.Transport{ alarmdelivery.ChannelWeb: alarmdelivery.WebTransport{}, }, alarmdelivery.DispatcherOptions{ StatePath: filepath.Join(t.TempDir(), "deliveries.json"), PollInterval: 10 * time.Millisecond, }) if err != nil { t.Fatal(err) } defer dispatcher.Close() store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: dispatcher}) if err != nil { t.Fatal(err) } operation, err := store.TestAlarmDelivery(context.Background(), domain.AlarmDeliveryTestRequest{ Delivery: []string{"web"}, Message: "Operator verification", }) if err != nil { t.Fatal(err) } if operation.Result["adapter"] != "tera-alarmd" || operation.Result["state"] != "queued" { t.Fatalf("alarm delivery operation = %+v", operation) } deliveryID, _ := operation.Result["delivery_id"].(string) deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { status, err := dispatcher.Status(context.Background(), deliveryID) if err != nil { t.Fatal(err) } if status.State == "delivered" { if status.Attempts != 1 { t.Fatalf("delivery status = %+v", status) } return } time.Sleep(10 * time.Millisecond) } t.Fatal("alarm delivery did not complete") } func TestOperationStoreCapsOperationHistory(t *testing.T) { path := filepath.Join(t.TempDir(), "operations.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{ OperationPath: path, OperationMaxEvents: 2, }) if err != nil { t.Fatalf("create store: %v", err) } first, err := store.RestartPort(context.Background(), "CH01") if err != nil { t.Fatalf("first operation: %v", err) } second, err := store.RestartPort(context.Background(), "CH02") if err != nil { t.Fatalf("second operation: %v", err) } third, err := store.RestartPort(context.Background(), "CH03") if err != nil { t.Fatalf("third operation: %v", err) } if _, err := store.Operation(context.Background(), first.OperationID); err == nil { t.Fatalf("first operation should have been trimmed") } operations, err := store.Operations(context.Background()) if err != nil { t.Fatalf("list operations: %v", err) } if len(operations) != 2 || operations[0].OperationID != second.OperationID || operations[1].OperationID != third.OperationID { t.Fatalf("operations = %#v, want second and third only", operations) } reloaded, err := NewStoreWithOptions(fixedClock, StoreOptions{ OperationPath: path, OperationMaxEvents: 2, }) if err != nil { t.Fatalf("reload store: %v", err) } reloadedOperations, err := reloaded.Operations(context.Background()) if err != nil { t.Fatalf("list reloaded operations: %v", err) } if len(reloadedOperations) != 2 || reloadedOperations[0].OperationID != second.OperationID || reloadedOperations[1].OperationID != third.OperationID { t.Fatalf("reloaded operations = %#v, want second and third only", reloadedOperations) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read operation store file: %v", err) } text := string(content) if strings.Contains(text, first.OperationID) { t.Fatalf("operation store file still contains trimmed first operation %q: %s", first.OperationID, text) } if !strings.Contains(text, second.OperationID) || !strings.Contains(text, third.OperationID) { t.Fatalf("operation store file = %s, want second and third operations", text) } next, err := reloaded.RestartPort(context.Background(), "CH04") if err != nil { t.Fatalf("next operation: %v", err) } if next.OperationID != "op_20260618_000004" { t.Fatalf("next operation id = %q, want op_20260618_000004", next.OperationID) } content, err = os.ReadFile(path) if err != nil { t.Fatalf("read operation store file after next operation: %v", err) } text = string(content) if strings.Contains(text, second.OperationID) { t.Fatalf("operation store file still contains trimmed second operation %q: %s", second.OperationID, text) } if !strings.Contains(text, third.OperationID) || !strings.Contains(text, next.OperationID) { t.Fatalf("operation store file = %s, want third and next operations", text) } } func TestAuthStorePersistsHashesWithoutPlaintextPasswords(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("login with default admin: %v", err) } if session.SessionToken == "" || session.Actor != "mock-admin" { t.Fatalf("session = %#v, want mock-admin with token", session) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } body := string(content) if strings.Contains(body, "mock-admin-secret") { t.Fatalf("auth store contains plaintext password") } if !strings.Contains(body, `"password"`) || !strings.Contains(body, `"algorithm"`) || !strings.Contains(body, `"salt"`) || !strings.Contains(body, `"hash"`) { t.Fatalf("auth store missing hash metadata: %s", body) } if !strings.Contains(body, `"algorithm": "argon2id"`) { t.Fatalf("auth store algorithm metadata = %s, want argon2id", body) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } reloaded, err := second.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("login after reload: %v", err) } if reloaded.Actor != "mock-admin" { t.Fatalf("reloaded actor = %q, want mock-admin", reloaded.Actor) } } func TestAuthStoreRemovesLegacyPrototypeUsers(t *testing.T) { authPath := filepath.Join(t.TempDir(), "auth.json") sessionPath := filepath.Join(filepath.Dir(authPath), "sessions.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: authPath, SessionPath: sessionPath}) if err != nil { t.Fatalf("create legacy store: %v", err) } if _, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }); err != nil { t.Fatalf("create legacy viewer session: %v", err) } first.Close() migrated, err := NewStoreWithOptions(fixedClock, StoreOptions{ AuthPath: authPath, SessionPath: sessionPath, RemoveLegacyAuthUsers: true, }) if err != nil { t.Fatalf("migrate legacy users: %v", err) } defer migrated.Close() users, err := migrated.AuthUsers(context.Background()) if err != nil { t.Fatalf("read migrated users: %v", err) } if len(users) != 1 || users[0].Username != "admin" { t.Fatalf("migrated users = %#v, want only admin", users) } if _, err := migrated.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }); err == nil { t.Fatal("removed legacy viewer should not authenticate") } sessions, err := migrated.AuthSessions(context.Background()) if err != nil { t.Fatalf("read migrated sessions: %v", err) } if len(sessions) != 0 { t.Fatalf("legacy sessions were not revoked: %#v", sessions) } content, err := os.ReadFile(authPath) if err != nil { t.Fatalf("read migrated auth store: %v", err) } if strings.Contains(string(content), "mock-viewer") || strings.Contains(string(content), "mock-port-operator") { t.Fatalf("migrated auth store still contains legacy users: %s", content) } } func TestAuthStoreRejectsUserPermissionDrift(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") if _, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}); err != nil { t.Fatalf("create store: %v", err) } var snapshot authSnapshot content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode auth store: %v", err) } viewer := snapshot.Users["viewer"] viewer.Permissions = append(viewer.Permissions, "auth.write") snapshot.Users["viewer"] = viewer content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode tampered auth store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write tampered auth store: %v", err) } if _, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}); err == nil { t.Fatalf("auth store with user permission drift should be rejected") } } func TestAuthStoreMigratesExactLegacyAdministratorPermissions(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create store: %v", err) } store.Close() var snapshot authSnapshot content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode auth store: %v", err) } admin := snapshot.Users["admin"] admin.Permissions = legacyAdministratorPermissions() snapshot.Users["admin"] = admin content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode legacy auth store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write legacy auth store: %v", err) } reloaded, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("reload legacy auth store: %v", err) } defer reloaded.Close() users, err := reloaded.AuthUsers(context.Background()) if err != nil { t.Fatalf("read migrated users: %v", err) } for _, user := range users { if user.Username == "admin" { for _, wanted := range []string{"firmware.read", "firmware.upload", "firmware.apply"} { if !slices.Contains(user.Permissions, wanted) { t.Fatalf("migrated admin permissions omit %s: %v", wanted, user.Permissions) } } return } } t.Fatal("migrated admin user not found") } func TestAuthStoreCanUseInjectedPBKDF2PasswordKDF(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") fipsKDF := password.NewPBKDF2SHA256(password.Params{Iterations: 2, KeyLength: 32}) options := StoreOptions{ AuthPath: path, PasswordKDF: fipsKDF, } first, err := NewStoreWithOptions(fixedClock, options) if err != nil { t.Fatalf("create first store: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("login with pbkdf2 admin: %v", err) } if session.Actor != "mock-admin" { t.Fatalf("session actor = %q, want mock-admin", session.Actor) } if _, err := first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fips-user", Password: "fips-user-secret-1", Role: "viewer", }); err != nil { t.Fatalf("create pbkdf2 auth user: %v", err) } if _, err := first.UpdateAuthUserPassword(context.Background(), "fips-user", domain.AuthUserPasswordUpdateRequest{Password: "fips-user-secret-2"}); err != nil { t.Fatalf("update pbkdf2 auth user password: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } body := string(content) if strings.Contains(body, "mock-admin-secret") || strings.Contains(body, "fips-user-secret-1") || strings.Contains(body, "fips-user-secret-2") { t.Fatalf("auth store contains plaintext password") } if !strings.Contains(body, `"algorithm": "pbkdf2-hmac-sha256"`) { t.Fatalf("auth store missing pbkdf2 metadata: %s", body) } if strings.Contains(body, `"algorithm": "argon2id"`) { t.Fatalf("auth store should use injected pbkdf2 KDF for all local password records: %s", body) } second, err := NewStoreWithOptions(fixedClock, options) if err != nil { t.Fatalf("create second store: %v", err) } reloaded, err := second.Login(context.Background(), domain.AuthLoginRequest{ Username: "fips-user", Password: "fips-user-secret-2", }) if err != nil { t.Fatalf("login pbkdf2 user after reload: %v", err) } if reloaded.Actor != "local-fips-user" { t.Fatalf("reloaded actor = %q, want local-fips-user", reloaded.Actor) } if _, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}); err == nil { t.Fatalf("default KDF registry should reject pbkdf2 auth store until FIPS KDF is configured") } } func TestAuthSessionCapEvictsOldestSession(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthMaxSessions: 1}) if err != nil { t.Fatalf("create store: %v", err) } first, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("first login: %v", err) } second, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }) if err != nil { t.Fatalf("second login: %v", err) } if _, err := store.ActorBySession(context.Background(), first.SessionToken); err == nil { t.Fatalf("first session should have been evicted") } actor, err := store.ActorBySession(context.Background(), second.SessionToken) if err != nil { t.Fatalf("second session should remain valid: %v", err) } if actor.Name != "mock-viewer" { t.Fatalf("second actor = %q, want mock-viewer", actor.Name) } sessions, err := store.AuthSessions(context.Background()) if err != nil { t.Fatalf("list sessions: %v", err) } if len(sessions) != 1 || sessions[0].Actor != "mock-viewer" { t.Fatalf("sessions = %#v, want only mock-viewer", sessions) } events, err := store.AuditEvents(context.Background()) if err != nil { t.Fatalf("read audit events: %v", err) } evictionCount := 0 for _, event := range events { if event.Action == "auth.session.evict" { evictionCount++ if event.Target != "mock-admin" { t.Fatalf("eviction target = %q, want mock-admin", event.Target) } } } if evictionCount != 1 { t.Fatalf("eviction audit count = %d, want 1 in %#v", evictionCount, events) } } func TestAuthLoginFailureCapTrimsOldestFailureRecords(t *testing.T) { path := filepath.Join(t.TempDir(), "login-failures.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthMaxLoginFailures: 2, LoginFailurePath: path}) if err != nil { t.Fatalf("create store: %v", err) } for _, username := range []string{"missing-a", "missing-b", "missing-c"} { if _, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: username, Password: "wrong-password", }); err == nil { t.Fatalf("login for %s should fail", username) } } store.mu.Lock() defer store.mu.Unlock() if len(store.loginFailures) != 2 { t.Fatalf("login failure records = %d, want 2", len(store.loginFailures)) } if _, ok := store.loginFailures["missing-a"]; ok { t.Fatalf("oldest login failure record should be trimmed") } for _, username := range []string{"missing-b", "missing-c"} { record, ok := store.loginFailures[username] if !ok { t.Fatalf("login failure record for %s missing", username) } if record.Count != 1 || record.LastAttempt.IsZero() { t.Fatalf("login failure record for %s = %#v, want count and last attempt", username, record) } } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read login failure store: %v", err) } text := string(content) if strings.Contains(text, "missing-a") { t.Fatalf("login failure store still contains trimmed oldest record: %s", text) } if !strings.Contains(text, "missing-b") || !strings.Contains(text, "missing-c") { t.Fatalf("login failure store = %s, want missing-b and missing-c records", text) } if strings.Contains(text, "wrong-password") { t.Fatalf("login failure store contains password material") } } func TestLoginFailureStorePersistsTemporaryLockoutAcrossStoreInstances(t *testing.T) { path := filepath.Join(t.TempDir(), "login-failures.json") now := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) clock := func() time.Time { return now } first, err := NewStoreWithOptions(clock, StoreOptions{LoginFailurePath: path}) if err != nil { t.Fatalf("create first store: %v", err) } for attempt := 0; attempt < failedLoginLockThreshold; attempt++ { if _, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "wrong-password", }); err == nil { t.Fatalf("failed login attempt %d should fail", attempt+1) } } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read login failure store: %v", err) } body := string(content) if strings.Contains(body, "wrong-password") || strings.Contains(body, "mock-viewer-secret") { t.Fatalf("login failure store contains password material") } if !strings.Contains(body, `"viewer"`) || !strings.Contains(body, `"locked_until"`) { t.Fatalf("login failure store missing viewer lockout metadata: %s", body) } second, err := NewStoreWithOptions(clock, StoreOptions{LoginFailurePath: path}) if err != nil { t.Fatalf("create second store: %v", err) } if _, err := second.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }); err == nil { t.Fatalf("persisted lockout should reject correct password before expiry") } now = now.Add(failedLoginLockDuration + time.Second) recovered, err := second.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }) if err != nil { t.Fatalf("login after lockout expiry: %v", err) } if recovered.Actor != "mock-viewer" { t.Fatalf("recovered actor = %q, want mock-viewer", recovered.Actor) } content, err = os.ReadFile(path) if err != nil { t.Fatalf("read login failure store after recovery: %v", err) } if strings.Contains(string(content), `"viewer"`) { t.Fatalf("expired lockout should be cleared after successful login: %s", string(content)) } } func TestLoginFailureStoreRejectsNonCanonicalUsernameKeys(t *testing.T) { path := filepath.Join(t.TempDir(), "login-failures.json") snapshot := loginFailureSnapshot{ Failures: map[string]loginFailureRecord{ "Viewer": { Count: 1, LastAttempt: fixedClock(), }, }, } content, err := json.Marshal(snapshot) if err != nil { t.Fatalf("encode login failure store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write login failure store: %v", err) } if _, err := NewStoreWithOptions(fixedClock, StoreOptions{LoginFailurePath: path}); err == nil { t.Fatalf("login failure store with non-canonical username should be rejected") } } func TestLoginFailureStoreClearsFailureAfterSuccessfulLogin(t *testing.T) { path := filepath.Join(t.TempDir(), "login-failures.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{LoginFailurePath: path}) if err != nil { t.Fatalf("create store: %v", err) } if _, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "wrong-password", }); err == nil { t.Fatalf("failed admin login should fail") } if _, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }); err != nil { t.Fatalf("successful admin login: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read login failure store: %v", err) } if strings.Contains(string(content), `"admin"`) { t.Fatalf("successful login should clear persisted failure: %s", string(content)) } } func TestAuthStorePersistsCreatedUsersWithoutPlaintextPasswords(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } created, err := first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "FieldOps", Password: "fieldops-secret-1", Role: "port_operator", }) if err != nil { t.Fatalf("create auth user: %v", err) } if created.Username != "fieldops" || created.Actor != "local-fieldops" || created.Role != "port_operator" { t.Fatalf("created user = %#v, want normalized fieldops port operator", created) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } body := string(content) if strings.Contains(body, "fieldops-secret-1") { t.Fatalf("auth store contains plaintext password") } if !strings.Contains(body, `"fieldops"`) || !strings.Contains(body, `"algorithm": "argon2id"`) { t.Fatalf("auth store missing created user hash metadata: %s", body) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } reloaded, err := second.Login(context.Background(), domain.AuthLoginRequest{ Username: "fieldops", Password: "fieldops-secret-1", }) if err != nil { t.Fatalf("login created user after reload: %v", err) } if reloaded.Actor != "local-fieldops" || reloaded.Role != "port_operator" { t.Fatalf("reloaded session = %#v, want local-fieldops port_operator", reloaded) } } func TestAuthStorePersistsGroupUpdates(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } name := "Read only users" updated, err := first.UpdateAuthGroup(context.Background(), "viewers", domain.AuthGroupUpdateRequest{Name: &name}) if err != nil { t.Fatalf("update auth group: %v", err) } if updated.Name != name { t.Fatalf("updated name = %q, want %q", updated.Name, name) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } body := string(content) if !strings.Contains(body, `"groups"`) || !strings.Contains(body, name) { t.Fatalf("auth store missing group update: %s", body) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } groups, err := second.AuthGroups(context.Background()) if err != nil { t.Fatalf("read auth groups: %v", err) } var found domain.AuthGroup for _, group := range groups { if group.GroupID == "viewers" { found = group break } } if found.Name != name { t.Fatalf("reloaded viewers name = %q, want %q", found.Name, name) } } func TestAuthStorePersistsDisabledUsers(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } _, err = first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fieldops", Password: "fieldops-secret-1", Role: "viewer", }) if err != nil { t.Fatalf("create auth user: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("login before disable: %v", err) } disabled := false updated, err := first.UpdateAuthUser(context.Background(), "fieldops", domain.AuthUserUpdateRequest{Enabled: &disabled}) if err != nil { t.Fatalf("disable auth user: %v", err) } if updated.Enabled { t.Fatalf("updated enabled = true, want false") } if _, err := first.ActorBySession(context.Background(), session.SessionToken); err == nil { t.Fatalf("disabled user session should be revoked") } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } if _, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}); err == nil { t.Fatalf("disabled user should not log in after reload") } enabled := true reenabled, err := second.UpdateAuthUser(context.Background(), "fieldops", domain.AuthUserUpdateRequest{Enabled: &enabled}) if err != nil { t.Fatalf("enable auth user: %v", err) } if !reenabled.Enabled { t.Fatalf("reenabled enabled = false, want true") } enabledSession, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("enabled user should log in: %v", err) } if enabledSession.Actor != "local-fieldops" { t.Fatalf("session actor = %q, want local-fieldops", enabledSession.Actor) } } func TestAuthStorePersistsRoleUpdatesAndRevokesSessions(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } _, err = first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fieldops", Password: "fieldops-secret-1", Role: "viewer", }) if err != nil { t.Fatalf("create auth user: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("login before role update: %v", err) } role := "port_operator" updated, err := first.UpdateAuthUser(context.Background(), "fieldops", domain.AuthUserUpdateRequest{Role: &role}) if err != nil { t.Fatalf("update auth user role: %v", err) } if updated.Role != "port_operator" { t.Fatalf("updated role = %q, want port_operator", updated.Role) } if _, err := first.ActorBySession(context.Background(), session.SessionToken); err == nil { t.Fatalf("role update should revoke existing session") } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } reloaded, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("login after role update reload: %v", err) } if reloaded.Role != "port_operator" { t.Fatalf("reloaded role = %q, want port_operator", reloaded.Role) } } func TestAuthStorePersistsPasswordUpdatesWithoutPlaintext(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } _, err = first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fieldops", Password: "fieldops-secret-1", Role: "viewer", }) if err != nil { t.Fatalf("create auth user: %v", err) } oldSession, err := first.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("login before password update: %v", err) } _, err = first.UpdateAuthUserPassword(context.Background(), "fieldops", domain.AuthUserPasswordUpdateRequest{Password: "fieldops-secret-2"}) if err != nil { t.Fatalf("update auth password: %v", err) } if _, err := first.ActorBySession(context.Background(), oldSession.SessionToken); err == nil { t.Fatalf("password reset should revoke existing session") } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } body := string(content) if strings.Contains(body, "fieldops-secret-1") || strings.Contains(body, "fieldops-secret-2") { t.Fatalf("auth store contains plaintext password") } if !strings.Contains(body, `"algorithm": "argon2id"`) { t.Fatalf("auth store missing argon2id metadata: %s", body) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } if _, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}); err == nil { t.Fatalf("old password should not log in after reload") } session, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-2"}) if err != nil { t.Fatalf("new password should log in after reload: %v", err) } if session.Actor != "local-fieldops" { t.Fatalf("session actor = %q, want local-fieldops", session.Actor) } } func TestAuthStorePersistsDeletedUsers(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } _, err = first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fieldops", Password: "fieldops-secret-1", Role: "viewer", }) if err != nil { t.Fatalf("create auth user: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("login before delete: %v", err) } result, err := first.DeleteAuthUser(context.Background(), "fieldops") if err != nil { t.Fatalf("delete auth user: %v", err) } if result.Result != "auth_user_deleted" || result.AuditID == "" { t.Fatalf("delete result = %#v, want auth_user_deleted with audit id", result) } if _, err := first.ActorBySession(context.Background(), session.SessionToken); err == nil { t.Fatalf("deleted user session should be revoked") } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } if strings.Contains(string(content), "fieldops") { t.Fatalf("auth store still contains deleted user: %s", string(content)) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } if _, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}); err == nil { t.Fatalf("deleted user should not log in after reload") } users, err := second.AuthUsers(context.Background()) if err != nil { t.Fatalf("read auth users: %v", err) } for _, user := range users { if user.Username == "fieldops" { t.Fatalf("deleted user still listed: %#v", users) } } } func TestAuthStoreRejectsDeletingLastEnabledAdministrator(t *testing.T) { store := NewStore(fixedClock) if _, err := store.DeleteAuthUser(context.Background(), "admin"); err == nil { t.Fatalf("expected last administrator delete to fail") } _, err := store.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "admin2", Password: "admin2-secret-1", Role: "administrator", }) if err != nil { t.Fatalf("create second admin: %v", err) } if _, err := store.DeleteAuthUser(context.Background(), "admin"); err != nil { t.Fatalf("delete admin with second enabled administrator: %v", err) } } func TestSessionTokensAreHashedAndPersistentUntilLogout(t *testing.T) { now := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) store := NewStore(func() time.Time { return now }) session, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("login: %v", err) } if session.SessionToken == "" { t.Fatalf("expected session token") } if _, ok := store.sessions[session.SessionToken]; ok { t.Fatalf("session map stores raw token") } if _, err := store.ActorBySession(context.Background(), session.SessionToken); err != nil { t.Fatalf("session should authenticate before expiry: %v", err) } if err := store.Logout(context.Background(), session.SessionToken); err != nil { t.Fatalf("logout: %v", err) } if _, err := store.ActorBySession(context.Background(), session.SessionToken); err == nil { t.Fatalf("revoked session should not authenticate") } persistent, err := store.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("second login: %v", err) } if !persistent.Persistent { t.Fatalf("new login session must be persistent") } now = persistent.ExpiresAt.Add(24 * time.Hour) if _, err := store.ActorBySession(context.Background(), persistent.SessionToken); err != nil { t.Fatalf("persistent session should authenticate after legacy expiry metadata: %v", err) } if err := store.Logout(context.Background(), persistent.SessionToken); err != nil { t.Fatalf("persistent logout: %v", err) } if _, err := store.ActorBySession(context.Background(), persistent.SessionToken); err == nil { t.Fatalf("logged out persistent session should not authenticate") } } func TestSessionStorePersistsHashedSessionsAcrossStoreInstances(t *testing.T) { path := filepath.Join(t.TempDir(), "sessions.json") now := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) clock := func() time.Time { return now } first, err := NewStoreWithOptions(clock, StoreOptions{SessionPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("login: %v", err) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read session store: %v", err) } if strings.Contains(string(content), session.SessionToken) { t.Fatalf("session store contains raw bearer token") } if !strings.Contains(string(content), sessionTokenHash(session.SessionToken)) { t.Fatalf("session store missing token hash: %s", string(content)) } now = session.ExpiresAt.Add(24 * time.Hour) second, err := NewStoreWithOptions(clock, StoreOptions{SessionPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } actor, err := second.ActorBySession(context.Background(), session.SessionToken) if err != nil { t.Fatalf("persisted session should authenticate after reload: %v", err) } if actor.Name != "mock-admin" { t.Fatalf("actor = %q, want mock-admin", actor.Name) } if err := second.Logout(context.Background(), session.SessionToken); err != nil { t.Fatalf("logout persisted session: %v", err) } third, err := NewStoreWithOptions(clock, StoreOptions{SessionPath: path}) if err != nil { t.Fatalf("create third store: %v", err) } if _, err := third.ActorBySession(context.Background(), session.SessionToken); err == nil { t.Fatalf("logged out session should not authenticate after reload") } } func TestSessionStoreRejectsActorPermissionDrift(t *testing.T) { path := filepath.Join(t.TempDir(), "sessions.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{SessionPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }) if err != nil { t.Fatalf("login: %v", err) } var snapshot sessionSnapshot content, err := os.ReadFile(path) if err != nil { t.Fatalf("read session store: %v", err) } if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode session store: %v", err) } tokenHash := sessionTokenHash(session.SessionToken) record := snapshot.Sessions[tokenHash] record.Actor.Permissions["auth.write"] = true snapshot.Sessions[tokenHash] = record content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode tampered session store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write tampered session store: %v", err) } if _, err := NewStoreWithOptions(fixedClock, StoreOptions{SessionPath: path}); err == nil { t.Fatalf("session store with actor permission drift should be rejected") } } func TestSessionSequenceContinuesAfterReload(t *testing.T) { path := filepath.Join(t.TempDir(), "sessions.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{SessionPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } firstSession, err := first.Login(context.Background(), domain.AuthLoginRequest{ Username: "admin", Password: "mock-admin-secret", }) if err != nil { t.Fatalf("first login: %v", err) } firstSessions, err := first.AuthSessions(context.Background()) if err != nil { t.Fatalf("list first sessions: %v", err) } if len(firstSessions) != 1 || firstSessions[0].SessionID != "session_000001" { t.Fatalf("first sessions = %#v, want session_000001", firstSessions) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{SessionPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } secondSession, err := second.Login(context.Background(), domain.AuthLoginRequest{ Username: "viewer", Password: "mock-viewer-secret", }) if err != nil { t.Fatalf("second login: %v", err) } sessions, err := second.AuthSessions(context.Background()) if err != nil { t.Fatalf("list second sessions: %v", err) } if len(sessions) != 2 || sessions[0].SessionID != "session_000001" || sessions[1].SessionID != "session_000002" { t.Fatalf("sessions = %#v, want session_000001 and session_000002", sessions) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read session store: %v", err) } text := string(content) if strings.Contains(text, firstSession.SessionToken) || strings.Contains(text, secondSession.SessionToken) { t.Fatalf("session store contains raw bearer token material") } if !strings.Contains(text, sessionTokenHash(firstSession.SessionToken)) || !strings.Contains(text, sessionTokenHash(secondSession.SessionToken)) { t.Fatalf("session store = %s, want both token hashes", text) } if !strings.Contains(text, `"sequence": 2`) { t.Fatalf("session store = %s, want persisted session sequence 2", text) } } func TestAuthTokenStorePersistsHashedTokensAcrossStoreInstances(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") now := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) clock := func() time.Time { return now } first, err := NewStoreWithOptions(clock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } expiresAt := now.Add(24 * time.Hour) created, err := first.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{ Name: "integration smoke", Username: "admin", ExpiresAt: &expiresAt, }) if err != nil { t.Fatalf("create auth token: %v", err) } if created.TokenID != "token_000001" || created.Token == "" || created.Actor != "mock-admin" { t.Fatalf("created token = %#v, want first mock-admin token with raw secret", created) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } text := string(content) if strings.Contains(text, created.Token) { t.Fatalf("auth store contains raw api token") } if !strings.Contains(text, sessionTokenHash(created.Token)) { t.Fatalf("auth store missing api token hash: %s", text) } second, err := NewStoreWithOptions(clock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } actor, err := second.ActorBySession(context.Background(), created.Token) if err != nil { t.Fatalf("persisted api token should authenticate after reload: %v", err) } if actor.Name != "mock-admin" || !actor.Permissions["auth.write"] { t.Fatalf("token actor = %#v, want mock-admin with auth.write", actor) } tokens, err := second.AuthTokens(context.Background()) if err != nil { t.Fatalf("list api tokens: %v", err) } if len(tokens) != 1 || tokens[0].TokenID != created.TokenID || tokens[0].Name != "integration smoke" || tokens[0].LastUsedAt == nil { t.Fatalf("tokens = %#v, want single listed token with last_used_at", tokens) } if _, err := second.RevokeAuthToken(context.Background(), created.TokenID); err != nil { t.Fatalf("revoke api token: %v", err) } third, err := NewStoreWithOptions(clock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create third store: %v", err) } if _, err := third.ActorBySession(context.Background(), created.Token); err == nil { t.Fatalf("revoked api token should not authenticate after reload") } } func TestAuthTokenStoreRejectsInvalidTokenHashKeys(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create store: %v", err) } created, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{ Name: "automation", Username: "admin", }) if err != nil { t.Fatalf("create auth token: %v", err) } var snapshot authSnapshot content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode auth store: %v", err) } if len(snapshot.Tokens) != 1 { t.Fatalf("token count = %d, want 1", len(snapshot.Tokens)) } for tokenHash, token := range snapshot.Tokens { delete(snapshot.Tokens, tokenHash) snapshot.Tokens[created.Token] = token break } content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode tampered auth store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write tampered auth store: %v", err) } if _, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}); err == nil { t.Fatalf("auth store with raw token key should be rejected") } } func TestAuthTokenStoreRejectsTokenPermissionDrift(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create store: %v", err) } created, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{ Name: "viewer automation", Username: "viewer", }) if err != nil { t.Fatalf("create auth token: %v", err) } var snapshot authSnapshot content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } if err := json.Unmarshal(content, &snapshot); err != nil { t.Fatalf("decode auth store: %v", err) } for tokenHash, token := range snapshot.Tokens { if token.TokenID != created.TokenID { continue } token.Permissions = append(token.Permissions, "auth.write") snapshot.Tokens[tokenHash] = token break } content, err = json.Marshal(snapshot) if err != nil { t.Fatalf("encode tampered auth store: %v", err) } if err := os.WriteFile(path, append(content, '\n'), 0o600); err != nil { t.Fatalf("write tampered auth store: %v", err) } if _, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}); err == nil { t.Fatalf("auth store with token permission drift should be rejected") } } func TestAuthTokenSequenceContinuesAfterReload(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") first, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create first store: %v", err) } firstToken, err := first.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{ Name: "first automation", Username: "admin", }) if err != nil { t.Fatalf("create first auth token: %v", err) } if firstToken.TokenID != "token_000001" { t.Fatalf("first token id = %q, want token_000001", firstToken.TokenID) } second, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create second store: %v", err) } secondToken, err := second.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{ Name: "second automation", Username: "admin", }) if err != nil { t.Fatalf("create second auth token: %v", err) } if secondToken.TokenID != "token_000002" { t.Fatalf("second token id = %q, want token_000002", secondToken.TokenID) } content, err := os.ReadFile(path) if err != nil { t.Fatalf("read auth store: %v", err) } text := string(content) if strings.Contains(text, firstToken.Token) || strings.Contains(text, secondToken.Token) { t.Fatalf("auth store contains raw api token material") } if !strings.Contains(text, sessionTokenHash(firstToken.Token)) || !strings.Contains(text, sessionTokenHash(secondToken.Token)) { t.Fatalf("auth store = %s, want both token hashes", text) } if !strings.Contains(text, `"token_sequence": 2`) { t.Fatalf("auth store = %s, want persisted token sequence 2", text) } } func TestAuthTokenRevokedOnUserSecurityChanges(t *testing.T) { path := filepath.Join(t.TempDir(), "auth.json") store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthPath: path}) if err != nil { t.Fatalf("create store: %v", err) } if _, err := store.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fieldops", Password: "fieldops-secret-1", Role: "viewer", }); err != nil { t.Fatalf("create auth user: %v", err) } token, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{ Name: "field automation", Username: "fieldops", }) if err != nil { t.Fatalf("create auth token: %v", err) } if _, err := store.ActorBySession(context.Background(), token.Token); err != nil { t.Fatalf("api token should authenticate before user change: %v", err) } enabled := false if _, err := store.UpdateAuthUser(context.Background(), "fieldops", domain.AuthUserUpdateRequest{Enabled: &enabled}); err != nil { t.Fatalf("disable auth user: %v", err) } if _, err := store.ActorBySession(context.Background(), token.Token); err == nil { t.Fatalf("disabled user api token should be revoked") } } func TestAuthTokenStoreRejectsTokenCap(t *testing.T) { store, err := NewStoreWithOptions(fixedClock, StoreOptions{AuthMaxTokens: 1}) if err != nil { t.Fatalf("create store: %v", err) } if _, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{Name: "first", Username: "admin"}); err != nil { t.Fatalf("create first auth token: %v", err) } if _, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{Name: "second", Username: "admin"}); err == nil { t.Fatalf("second auth token should be rejected at cap") } } func TestAuthTokenExpiryValidationAndPruning(t *testing.T) { now := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) store := NewStore(func() time.Time { return now }) past := now.Add(-time.Second) if _, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{Name: "past", Username: "admin", ExpiresAt: &past}); err == nil { t.Fatalf("past token expiry should be rejected") } expiresAt := now.Add(time.Hour) created, err := store.CreateAuthToken(context.Background(), domain.AuthTokenCreateRequest{Name: "short lived", Username: "admin", ExpiresAt: &expiresAt}) if err != nil { t.Fatalf("create expiring auth token: %v", err) } now = expiresAt.Add(time.Second) if _, err := store.ActorBySession(context.Background(), created.Token); err == nil { t.Fatalf("expired api token should not authenticate") } tokens, err := store.AuthTokens(context.Background()) if err != nil { t.Fatalf("list tokens after expiry: %v", err) } if len(tokens) != 0 { t.Fatalf("tokens after expiry = %#v, want empty", tokens) } } func TestSessionStorePersistsRevocationsAndExpiry(t *testing.T) { sessionPath := filepath.Join(t.TempDir(), "sessions.json") authPath := filepath.Join(t.TempDir(), "auth.json") now := time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) clock := func() time.Time { return now } first, err := NewStoreWithOptions(clock, StoreOptions{AuthPath: authPath, SessionPath: sessionPath}) if err != nil { t.Fatalf("create first store: %v", err) } if _, err := first.CreateAuthUser(context.Background(), domain.AuthUserCreateRequest{ Username: "fieldops", Password: "fieldops-secret-1", Role: "viewer", }); err != nil { t.Fatalf("create auth user: %v", err) } session, err := first.Login(context.Background(), domain.AuthLoginRequest{Username: "fieldops", Password: "fieldops-secret-1"}) if err != nil { t.Fatalf("login before disable: %v", err) } enabled := false if _, err := first.UpdateAuthUser(context.Background(), "fieldops", domain.AuthUserUpdateRequest{Enabled: &enabled}); err != nil { t.Fatalf("disable auth user: %v", err) } second, err := NewStoreWithOptions(clock, StoreOptions{AuthPath: authPath, SessionPath: sessionPath}) if err != nil { t.Fatalf("create second store: %v", err) } if _, err := second.ActorBySession(context.Background(), session.SessionToken); err == nil { t.Fatalf("disabled user session should remain revoked after reload") } adminSession, err := second.Login(context.Background(), domain.AuthLoginRequest{Username: "admin", Password: "mock-admin-secret"}) if err != nil { t.Fatalf("admin login: %v", err) } legacyHash := sessionTokenHash(adminSession.SessionToken) legacySession := second.sessions[legacyHash] legacySession.Persistent = false second.sessions[legacyHash] = legacySession if err := second.persistSessionStoreLocked(); err != nil { t.Fatalf("persist legacy expiring session: %v", err) } now = adminSession.ExpiresAt.Add(time.Second) if _, err := second.ActorBySession(context.Background(), adminSession.SessionToken); err == nil { t.Fatalf("expired session should not authenticate") } third, err := NewStoreWithOptions(clock, StoreOptions{SessionPath: sessionPath}) if err != nil { t.Fatalf("create third store: %v", err) } sessions, err := third.AuthSessions(context.Background()) if err != nil { t.Fatalf("read sessions after expiry reload: %v", err) } if len(sessions) != 0 { t.Fatalf("expired sessions after reload = %#v, want empty", sessions) } } func TestLogRetentionRejectsMoreThanOneYear(t *testing.T) { store := NewStore(fixedClock) days := 366 if _, err := store.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{PortDays: &days}); err == nil || !strings.Contains(err.Error(), "between 1 and 365") { t.Fatalf("UpdateLogRetention(366) error = %v, want one-year maximum", err) } } func TestPortCommandLogPersistsRedactedCommandResultAndKeywordMatch(t *testing.T) { path := filepath.Join(t.TempDir(), "port.jsonl") store, err := NewStoreWithOptions(fixedClock, StoreOptions{PortLogPath: path}) if err != nil { t.Fatal(err) } ctx := domain.WithActor(context.Background(), domain.Actor{Name: "operator"}) recorded, err := store.RecordPortLog(ctx, domain.PortLogEvent{ Channel: "CH01", EventType: "command", Command: "show LOGIN FAILED password=hunter2", ReturnValue: "token=abc123 result=5", Message: "command completed", }) if err != nil { t.Fatal(err) } if recorded.Actor != "operator" || !recorded.Redacted || !recorded.AlarmTriggered || strings.Join(recorded.KeywordMatches, ",") != "port_ch01_keyword" { t.Fatalf("recorded event = %+v", recorded) } content, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(string(content), "hunter2") || strings.Contains(string(content), "abc123") { t.Fatalf("persistent port log leaked credential: %s", content) } reloaded, err := NewStoreWithOptions(fixedClock, StoreOptions{PortLogPath: path}) if err != nil { t.Fatal(err) } events, err := reloaded.PortLogEvents(context.Background(), domain.PortLogFilter{EventType: "command", Keyword: "LOGIN FAILED"}) if err != nil || len(events) != 1 || events[0].ReturnValue == "" { t.Fatalf("reloaded events = %+v, err=%v", events, err) } } type recordingAlarmDeliveryController struct { requests []alarmdelivery.Request } func (controller *recordingAlarmDeliveryController) Deliver(_ context.Context, request alarmdelivery.Request) (alarmdelivery.Result, error) { controller.requests = append(controller.requests, request) return alarmdelivery.Result{DeliveryID: fmt.Sprintf("delivery-%d", len(controller.requests)), State: "delivered"}, nil } func (*recordingAlarmDeliveryController) Status(context.Context, string) (alarmdelivery.Result, error) { return alarmdelivery.Result{}, nil } func TestAuthenticationFailureThresholdDispatchesConfiguredAlarmOnce(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } for attempt := 1; attempt <= failedLoginLockThreshold; attempt++ { if _, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "unknown-user", Password: "wrong-password"}); err == nil { t.Fatalf("login attempt %d unexpectedly succeeded", attempt) } } if len(controller.requests) != 1 || controller.requests[0].RuleID != "auth_failures" || !slices.Contains(controller.requests[0].Channels, alarmdelivery.ChannelWeb) { t.Fatalf("authentication failure alarm requests = %+v, want one auth_failures web alarm", controller.requests) } if _, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "unknown-user", Password: "wrong-password"}); err == nil { t.Fatal("locked login unexpectedly succeeded") } if len(controller.requests) != 1 { t.Fatalf("locked retries emitted duplicate threshold alarms: %+v", controller.requests) } } func TestAuthenticationFailureThresholdUsesAppliedManagementPolicy(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } detectionMinutes, securityHits := 7, 3 if _, err := store.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{ LoginDetectionMinutes: &detectionMinutes, LoginSecurityHitCount: &securityHits, }); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } for attempt := 1; attempt <= securityHits; attempt++ { if _, err := store.Login(context.Background(), domain.AuthLoginRequest{Username: "policy-user", Password: "wrong-password"}); err == nil { t.Fatalf("login attempt %d unexpectedly succeeded", attempt) } } if len(controller.requests) != 1 || controller.requests[0].RuleID != "auth_failures" { t.Fatalf("authentication alarm requests = %+v, want one configured-threshold alarm", controller.requests) } failure := store.loginFailures["policy-user"] if failure.Count != securityHits || !failure.LockedUntil.Equal(fixedClock().Add(7*time.Minute)) { t.Fatalf("login protection record = %+v", failure) } } func TestPrimaryEthernetDownDispatchesFailoverAndLinkAlarms(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } start := fixedClock() if _, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "ethernet", Resource: "Interface-1", State: "up", Signal: "carrier", ObservedAt: start}); err != nil { t.Fatal(err) } event, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "ethernet", Resource: "Interface-1", State: "down", Signal: "carrier", ObservedAt: start.Add(time.Minute)}) if err != nil { t.Fatal(err) } if event == nil || strings.Join(event.MatchedRuleIDs, ",") != "ethernet_link_down,link_failover" { t.Fatalf("primary link down event = %+v, want both ethernet and failover rules", event) } ruleIDs := make([]string, 0, len(controller.requests)) for _, request := range controller.requests { ruleIDs = append(ruleIDs, request.RuleID) } slices.Sort(ruleIDs) if strings.Join(ruleIDs, ",") != "ethernet_link_down,link_failover" { t.Fatalf("primary link delivery rules = %v", ruleIDs) } } func TestPortAlarmKeywordStagesWithPortAndMatchesReceivedPayload(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } keyword := "READY>" result, err := store.UpdatePort(context.Background(), "CH12", domain.PortUpdate{AlarmKeyword: &keyword}) if err != nil { t.Fatal(err) } if result.Config == nil || !slices.Contains(result.Config.PendingSections, "ports") || !slices.Contains(result.Config.PendingSections, "alarm_rules") { t.Fatalf("pending config = %+v, want ports and alarms", result.Config) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } rule, err := store.AlarmRule(context.Background(), "port_ch12_keyword") if err != nil || !rule.Enabled || rule.Trigger != keyword || rule.Channel != "CH12" { t.Fatalf("CH12 keyword rule = %+v, %v", rule, err) } event, err := store.RecordPortLog(context.Background(), domain.PortLogEvent{ Channel: "CH12", EventType: "traffic", Direction: "RX", Payload: []byte("switch READY>"), PayloadBytes: 13, PayloadEncoding: "base64", Message: "serial payload", }) if err != nil { t.Fatal(err) } if !event.AlarmTriggered || strings.Join(event.KeywordMatches, ",") != "port_ch12_keyword" { t.Fatalf("received payload event = %+v", event) } if len(controller.requests) != 1 || controller.requests[0].RuleID != "port_ch12_keyword" { t.Fatalf("delivery requests = %+v", controller.requests) } } func TestLegacyAlarmExportGainsMissingPerPortAndLifecycleRules(t *testing.T) { legacy := []domain.AlarmRule{{ RuleID: "auth_failures", Name: "Legacy auth", Source: "auth", Trigger: "5 failures / 10 min", Severity: "warning", Delivery: []string{"web"}, Enabled: true, }} restored, err := alarmRuleConfigFromExport(legacy) if err != nil { t.Fatal(err) } if restored["auth_failures"].Name != "Legacy auth" { t.Fatalf("legacy rule was overwritten: %+v", restored["auth_failures"]) } for _, ruleID := range []string{"cold_start", "warm_start", "port_ch01_keyword", "port_ch48_dcd", "port_ch48_dsr"} { if _, ok := restored[ruleID]; !ok { t.Fatalf("restored legacy export is missing %s", ruleID) } } } func TestSerialDCDAndDSRChangeRulesTrackSignalsIndependently(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } enabled := true for _, ruleID := range []string{"port_ch12_dcd", "port_ch12_dsr"} { if _, err := store.UpdateAlarmRule(context.Background(), ruleID, domain.AlarmRuleUpdate{Enabled: &enabled}); err != nil { t.Fatal(err) } } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } start := fixedClock() for _, signal := range []string{"dcd", "dsr"} { event, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{ Kind: "serial", Resource: "CH12", State: "up", Signal: signal, ObservedAt: start, }) if err != nil || event == nil || event.EventType != "snapshot" || event.AlarmTriggered { t.Fatalf("initial %s event = %+v, %v", signal, event, err) } } dcd, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{ Kind: "serial", Resource: "CH12", State: "down", Signal: "dcd", ObservedAt: start.Add(time.Minute), }) if err != nil || strings.Join(dcd.MatchedRuleIDs, ",") != "port_ch12_dcd,serial_link_down" { t.Fatalf("DCD transition = %+v, %v", dcd, err) } dsr, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{ Kind: "serial", Resource: "CH12", State: "down", Signal: "dsr", ObservedAt: start.Add(2 * time.Minute), }) if err != nil || strings.Join(dsr.MatchedRuleIDs, ",") != "port_ch12_dsr,serial_link_down" { t.Fatalf("DSR transition = %+v, %v", dsr, err) } } func TestLifecycleAlarmRecordsSystemEventAndDispatchesRule(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } if err := store.EmitLifecycleAlarm(context.Background(), "cold_start", "device cold start detected"); err != nil { t.Fatal(err) } if len(controller.requests) != 1 || controller.requests[0].RuleID != "cold_start" { t.Fatalf("lifecycle deliveries = %+v", controller.requests) } events, err := store.SystemLogEvents(context.Background(), domain.SystemLogFilter{Limit: 10}) if err != nil || len(events) == 0 || events[len(events)-1].Source != "system-lifecycle" { t.Fatalf("lifecycle system events = %+v, %v", events, err) } } func TestPortCommandLogForwardsConfiguredSyslogAndKeywordAlarm(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } enabled := true if _, err := store.UpdateRemoteSyslog(context.Background(), domain.RemoteSyslogUpdate{Enabled: &enabled}); err != nil { t.Fatal(err) } if _, err := store.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{RemoteOperationEnabled: &enabled}); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } controller.requests = nil if _, err := store.RecordPortLog(context.Background(), domain.PortLogEvent{ Channel: "CH01", EventType: "command", Command: "LOGIN FAILED", ReturnValue: "rejected", Message: "command rejected", }); err != nil { t.Fatal(err) } if len(controller.requests) != 2 { t.Fatalf("delivery requests = %+v, want keyword alarm and general syslog forward", controller.requests) } ruleIDs := []string{controller.requests[0].RuleID, controller.requests[1].RuleID} slices.Sort(ruleIDs) if strings.Join(ruleIDs, ",") != "log_forward_operation,port_ch01_keyword" { t.Fatalf("delivery rule IDs = %v", ruleIDs) } for _, request := range controller.requests { if !slices.Contains(request.Channels, alarmdelivery.ChannelSyslog) || request.Syslog == nil { t.Fatalf("delivery request is missing configured syslog target: %+v", request) } } } func TestLogCategorySelectorsGateLocalOperationAndRemoteForwarding(t *testing.T) { controller := &recordingAlarmDeliveryController{} store, err := NewStoreWithOptions(fixedClock, StoreOptions{AlarmDeliveryController: controller}) if err != nil { t.Fatal(err) } disabled := false enabled := true if _, err := store.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{LocalOperationEnabled: &disabled}); err != nil { t.Fatal(err) } if _, err := store.UpdateRemoteSyslog(context.Background(), domain.RemoteSyslogUpdate{Enabled: &enabled}); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } controller.requests = nil if _, err := store.RecordPortLog(context.Background(), domain.PortLogEvent{ Channel: "CH02", EventType: "command", Command: "show version", ReturnValue: "ok", Message: "completed", }); err != nil { t.Fatal(err) } events, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{Channel: "CH02"}) if err != nil { t.Fatal(err) } if len(events) != 0 || len(controller.requests) != 0 { t.Fatalf("disabled local/remote operation selectors stored=%+v forwarded=%+v", events, controller.requests) } if _, err := store.UpdateLogRetention(context.Background(), domain.LogRetentionUpdate{RemoteOperationEnabled: &enabled}); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } if _, err := store.RecordPortLog(context.Background(), domain.PortLogEvent{ Channel: "CH02", EventType: "command", Command: "show clock", ReturnValue: "ok", Message: "completed", }); err != nil { t.Fatal(err) } if len(controller.requests) != 1 || controller.requests[0].RuleID != "log_forward_operation" { t.Fatalf("enabled remote operation selector requests = %+v", controller.requests) } policy, err := store.LogPolicy(context.Background()) if err != nil || !policy.RemoteSyslogForwarding { t.Fatalf("effective log policy = %+v, err=%v", policy, err) } } func TestLinkLogPersistsSnapshotsAndOnlyAlarmsOnUpToDown(t *testing.T) { path := filepath.Join(t.TempDir(), "links.jsonl") store, err := NewStoreWithOptions(fixedClock, StoreOptions{LinkLogPath: path, LinkSnapshotInterval: 5 * time.Minute}) if err != nil { t.Fatal(err) } start := fixedClock() first, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "serial", Resource: "CH01", State: "down", Signal: "dcd", ObservedAt: start}) if err != nil || first == nil || first.AlarmTriggered { t.Fatalf("first event = %+v, err=%v", first, err) } quiet, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "serial", Resource: "CH01", State: "down", Signal: "dcd", ObservedAt: start.Add(time.Minute)}) if err != nil || quiet != nil { t.Fatalf("quiet event = %+v, err=%v", quiet, err) } if _, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "serial", Resource: "CH01", State: "up", Signal: "dcd", ObservedAt: start.Add(2 * time.Minute)}); err != nil { t.Fatal(err) } down, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "serial", Resource: "CH01", State: "down", Signal: "dcd", ObservedAt: start.Add(3 * time.Minute)}) if err != nil || down == nil || !down.AlarmTriggered || strings.Join(down.MatchedRuleIDs, ",") != "serial_link_down" { t.Fatalf("down event = %+v, err=%v", down, err) } periodic, err := store.RecordLinkState(context.Background(), domain.LinkStateObservation{Kind: "serial", Resource: "CH01", State: "down", Signal: "dcd", ObservedAt: start.Add(9 * time.Minute)}) if err != nil || periodic == nil || periodic.EventType != "snapshot" || periodic.AlarmTriggered { t.Fatalf("periodic event = %+v, err=%v", periodic, err) } reloaded, err := NewStoreWithOptions(fixedClock, StoreOptions{LinkLogPath: path}) if err != nil { t.Fatal(err) } events, err := reloaded.LinkLogEvents(context.Background(), domain.LinkLogFilter{Kind: "serial", Resource: "CH01"}) if err != nil || len(events) != 4 { t.Fatalf("reloaded link events = %+v, err=%v", events, err) } snapshots, err := reloaded.LinkLogEvents(context.Background(), domain.LinkLogFilter{ Kind: "serial", Resource: "CH01", State: "down", EventType: "snapshot", Keyword: "dcd", From: start.Add(5 * time.Minute), To: start.Add(10 * time.Minute), }) if err != nil || len(snapshots) != 1 || snapshots[0].EventType != "snapshot" { t.Fatalf("filtered link snapshots = %+v, err=%v", snapshots, err) } if _, err := reloaded.LinkLogEvents(context.Background(), domain.LinkLogFilter{EventType: "command"}); err == nil { t.Fatal("invalid link event type was accepted") } } func fixedClock() time.Time { return time.Date(2026, 6, 18, 0, 0, 0, 0, time.UTC) } func assertStoredOperationMetadata(t *testing.T, operation domain.Operation) { t.Helper() now := fixedClock() if operation.ProgressPercent != 100 { t.Fatalf("operation progress = %d, want 100", operation.ProgressPercent) } if !operation.CreatedAt.Equal(now) { t.Fatalf("operation created_at = %s, want %s", operation.CreatedAt, now) } if !operation.UpdatedAt.Equal(now) { t.Fatalf("operation updated_at = %s, want %s", operation.UpdatedAt, now) } if operation.CompletedAt == nil || !operation.CompletedAt.Equal(now) { t.Fatalf("operation completed_at = %v, want %s", operation.CompletedAt, now) } } func assertOperationResult(t *testing.T, operation domain.Operation, key string, want any) { t.Helper() got, ok := operation.Result[key] if !ok { t.Fatalf("operation result missing %q: %#v", key, operation.Result) } if got != want { t.Fatalf("operation result[%q] = %#v, want %#v", key, got, want) } }