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.
4687 lines
168 KiB
4687 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 TestDefaultPortProfilesUse115200EightNOneWithoutFlowControl(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 != 115200 || port.DataBits != 8 || port.Parity != "none" || port.StopBits != 1 || port.FlowControl != "none" {
|
|
t.Errorf("%s default line profile = %d/%d/%s/%d/%s, want 115200/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 != 115200 || beforeApply.DataBits != 8 || beforeApply.Parity != "none" || beforeApply.StopBits != 1 || beforeApply.FlowControl != "none" {
|
|
t.Fatalf("running profile before apply = %#v, want unchanged disabled 115200 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 != 115200 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" {
|
|
t.Fatalf("reset port = %#v, want default disabled operations and 115200", 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)
|
|
}
|
|
}
|
|
|