You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

4688 lines
168 KiB

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 := 85
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)
}
}