package api import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "testing" "time" "tera-clone/services/tera-api/internal/adapters/mock" "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/hardware/greenled" "tera-clone/services/tera-api/internal/networkconfig" "tera-clone/services/tera-api/internal/serial" "tera-clone/services/tera-api/internal/storage/capacity" "tera-clone/services/tera-api/internal/supportbundle" "tera-clone/services/tera-api/internal/systemtime" "tera-clone/services/tera-api/internal/update" ) func TestManagementClientForRequestTrustsForwardedValuesOnlyFromLoopback(t *testing.T) { loopback := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8082/api/v1/system/status", nil) loopback.RemoteAddr = "127.0.0.1:41000" loopback.Header.Set("X-Tera-Client-IP", "2001:db8::42") loopback.Header.Set("X-Tera-Client-Protocol", "https") loopback.Header.Set("X-Tera-Client-Port", "443") address, protocol, port := managementClientForRequest(loopback) if address != "2001:db8::42" || protocol != "https" || port != 443 { t.Fatalf("loopback forwarded client = %s %s %d", address, protocol, port) } remote := httptest.NewRequest(http.MethodGet, "http://192.168.0.223:8082/api/v1/system/status", nil) remote.RemoteAddr = "192.168.0.55:41000" remote.Header.Set("X-Tera-Client-IP", "192.168.0.100") remote.Header.Set("X-Tera-Client-Protocol", "https") remote.Header.Set("X-Tera-Client-Port", "443") address, protocol, port = managementClientForRequest(remote) if address != "192.168.0.55" || protocol != "http" || port != 8082 { t.Fatalf("remote spoofed client = %s %s %d", address, protocol, port) } } func TestStartupGateBlocksLoginUntilReady(t *testing.T) { ready := false handler := NewRouterWithOptions(newPasswordAuthTestStore(t, fixedClock), RouterOptions{ CommandsReady: func() bool { return ready }, }) loginPolicy := httptest.NewRecorder() handler.ServeHTTP(loginPolicy, httptest.NewRequest(http.MethodGet, "/api/v1/auth/login-policy", nil)) if loginPolicy.Code != http.StatusOK { t.Fatalf("login policy status=%d body=%s", loginPolicy.Code, loginPolicy.Body.String()) } login := httptest.NewRecorder() handler.ServeHTTP(login, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`))) if login.Code != http.StatusServiceUnavailable { t.Fatalf("login status=%d body=%s", login.Code, login.Body.String()) } blocked := httptest.NewRecorder() blockedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/system/reboot", nil) blockedRequest.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(blocked, blockedRequest) if blocked.Code != http.StatusServiceUnavailable || !strings.Contains(blocked.Body.String(), `"code":"service_initializing"`) { t.Fatalf("blocked command status=%d body=%s", blocked.Code, blocked.Body.String()) } if blocked.Header().Get("Retry-After") != "1" { t.Fatalf("Retry-After=%q, want 1", blocked.Header().Get("Retry-After")) } captcha := httptest.NewRecorder() handler.ServeHTTP(captcha, httptest.NewRequest(http.MethodPost, "/api/v1/auth/captcha", nil)) if captcha.Code != http.StatusServiceUnavailable { t.Fatal("captcha allowed during startup") } status := httptest.NewRecorder() handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/api/v1/auth/startup", nil)) if status.Code != 200 || !strings.Contains(status.Body.String(), `"ready":false`) { t.Fatal(status.Body.String()) } ready = true login = httptest.NewRecorder() handler.ServeHTTP(login, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`))) if login.Code != http.StatusOK { t.Fatal(login.Body.String()) } accepted := httptest.NewRecorder() acceptedRequest := httptest.NewRequest(http.MethodPost, "/api/v1/system/reboot", nil) acceptedRequest.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(accepted, acceptedRequest) if strings.Contains(accepted.Body.String(), `"code":"service_initializing"`) { t.Fatalf("ready command was still blocked: status=%d body=%s", accepted.Code, accepted.Body.String()) } } func TestFirmwareUploadUsesDedicatedLimitAuthorizationAndAudit(t *testing.T) { store := mock.NewStore(fixedClock) controller := &firmwareControllerSpy{} handler := NewRouterWithOptions(store, RouterOptions{FirmwareController: controller, MaxFirmwareBytes: 3 << 20}) payload := bytes.Repeat([]byte("x"), 2<<20) forbidden := httptest.NewRecorder() forbiddenRequest := httptest.NewRequest(http.MethodPost, "/api/v1/firmware/packages", bytes.NewReader(payload)) forbiddenRequest.Header.Set("X-Mock-Actor", "mock-viewer") forbiddenRequest.Header.Set("Content-Type", "application/octet-stream") forbiddenRequest.Header.Set("X-Tera-Filename", "release.bin") handler.ServeHTTP(forbidden, forbiddenRequest) if forbidden.Code != http.StatusForbidden || controller.uploads != 0 { t.Fatalf("viewer upload = %d, uploads = %d", forbidden.Code, controller.uploads) } response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/firmware/packages", bytes.NewReader(payload)) request.Header.Set("X-Mock-Actor", "mock-admin") request.Header.Set("Content-Type", "application/octet-stream") request.Header.Set("X-Tera-Filename", "release.bin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK || controller.uploads != 1 || controller.received != int64(len(payload)) || controller.actor != "mock-admin" { t.Fatalf("upload status=%d uploads=%d received=%d actor=%q body=%s", response.Code, controller.uploads, controller.received, controller.actor, response.Body.String()) } events, err := store.AuditEvents(context.Background()) if err != nil { t.Fatal(err) } found := false for _, event := range events { if event.Action == "firmware.upload" && event.Result == "succeeded" { found = true } } if !found { t.Fatal("firmware upload audit event not recorded") } } func TestFirmwareUploadRejectsOversizeBeforeController(t *testing.T) { controller := &firmwareControllerSpy{} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{FirmwareController: controller, MaxFirmwareBytes: 16}) request := httptest.NewRequest(http.MethodPost, "/api/v1/firmware/packages", strings.NewReader("this package is too large")) request.Header.Set("X-Mock-Actor", "mock-admin") request.Header.Set("Content-Type", "application/octet-stream") request.Header.Set("X-Tera-Filename", "release.bin") response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusRequestEntityTooLarge || controller.uploads != 0 { t.Fatalf("status=%d uploads=%d", response.Code, controller.uploads) } } func TestFirmwareUploadUsesRouteSpecificDeadline(t *testing.T) { controller := &firmwareControllerSpy{} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ FirmwareController: controller, MaxFirmwareBytes: 1024, FirmwareTimeout: 2 * time.Minute, }) response := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} request := httptest.NewRequest(http.MethodPost, "/api/v1/firmware/packages", strings.NewReader("package")) request.Header.Set("X-Mock-Actor", "mock-admin") request.Header.Set("Content-Type", "application/octet-stream") request.Header.Set("X-Tera-Filename", "release.bin") handler.ServeHTTP(response, request) for name, deadline := range map[string]time.Time{"read": response.readDeadline, "write": response.writeDeadline} { remaining := time.Until(deadline) if remaining < 119*time.Second || remaining > 121*time.Second { t.Fatalf("%s deadline remaining = %s, want about 2m", name, remaining) } } } func TestFirmwareApplyForwardsAuthenticatedActorAndExactOperationID(t *testing.T) { controller := &firmwareControllerSpy{applyStatus: update.Status{ FormatVersion: 1, State: update.StateRebootPending, OperationID: "fw-operation-123", }} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{FirmwareController: controller}) forbidden := httptest.NewRecorder() forbiddenRequest := httptest.NewRequest(http.MethodPost, "/api/v1/firmware/apply", nil) forbiddenRequest.Header.Set("X-Mock-Actor", "mock-viewer") forbiddenRequest.Header.Set("X-Tera-Operation-ID", "fw-operation-123") handler.ServeHTTP(forbidden, forbiddenRequest) if forbidden.Code != http.StatusForbidden || controller.applyCalls != 0 { t.Fatalf("viewer apply status=%d calls=%d", forbidden.Code, controller.applyCalls) } response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/firmware/apply", nil) request.Header.Set("X-Mock-Actor", "mock-admin") request.Header.Set("X-Tera-Operation-ID", "fw-operation-123") handler.ServeHTTP(response, request) if response.Code != http.StatusOK || controller.applyCalls != 1 { t.Fatalf("admin apply status=%d calls=%d body=%s", response.Code, controller.applyCalls, response.Body.String()) } if controller.applyRequest.Actor != "mock-admin" || controller.applyRequest.OperationID != "fw-operation-123" { t.Fatalf("apply request=%+v", controller.applyRequest) } } func TestServiceHealthReflectsFirmwareController(t *testing.T) { controller := &firmwareControllerSpy{status: update.Status{ FormatVersion: 1, State: update.StateValidated, Capacity: capacity.Snapshot{Pressure: "normal"}, }} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{FirmwareController: controller}) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/system/services", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("service health status=%d body=%s", response.Code, response.Body.String()) } var services []domain.ServiceHealth if err := json.Unmarshal(response.Body.Bytes(), &services); err != nil { t.Fatal(err) } for _, service := range services { if service.Service == "tera-updated" { if service.Status != "ok" || !strings.Contains(service.Detail, "state=validated") || !strings.Contains(service.Detail, "storage_pressure=normal") { t.Fatalf("unexpected updater health: %+v", service) } return } } t.Fatal("tera-updated health entry not found") } type deadlineRecorder struct { *httptest.ResponseRecorder readDeadline time.Time writeDeadline time.Time } func (r *deadlineRecorder) SetReadDeadline(value time.Time) error { r.readDeadline = value return nil } func (r *deadlineRecorder) SetWriteDeadline(value time.Time) error { r.writeDeadline = value return nil } type firmwareControllerSpy struct { uploads int received int64 actor string status update.Status statusErr error applyCalls int applyRequest update.ApplyRequest applyStatus update.Status applyErr error } func (f *firmwareControllerSpy) Status(context.Context) (update.Status, error) { if f.status.State == "" { f.status = update.Status{FormatVersion: 1, State: update.StateIdle} } return f.status, f.statusErr } func (f *firmwareControllerSpy) Upload(_ context.Context, request update.UploadRequest) (update.Status, error) { f.uploads++ f.actor = request.Actor received, err := io.Copy(io.Discard, request.Body) f.received = received return update.Status{FormatVersion: 1, State: update.StateValidated, OperationID: "fw-test"}, err } func (f *firmwareControllerSpy) Apply(_ context.Context, request update.ApplyRequest) (update.Status, error) { f.applyCalls++ f.applyRequest = request if f.applyStatus.State != "" || f.applyErr != nil { return f.applyStatus, f.applyErr } return update.Status{FormatVersion: 1, State: update.StateValidated}, &update.Error{Code: "update_apply_unsupported", Message: "validation-only"} } func TestSystemStatus(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", response.Code, http.StatusOK) } var status domain.SystemStatus if err := json.Unmarshal(response.Body.Bytes(), &status); err != nil { t.Fatalf("decode status: %v", err) } if status.SerialPortCount != 0 { t.Fatalf("serial port count = %d, want 0 without a hardware adapter", status.SerialPortCount) } if status.OOB.PrimaryLink != "up" { t.Fatalf("primary link = %q, want up", status.OOB.PrimaryLink) } } type fixedSystemMetricsReader struct { metrics domain.SystemRuntimeMetrics } func (r fixedSystemMetricsReader) Read(context.Context, []domain.NetworkInterface) domain.SystemRuntimeMetrics { return r.metrics } func TestSystemStatusIncludesMemoryMetrics(t *testing.T) { usage := 75.0 handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ SystemMetricsReader: fixedSystemMetricsReader{metrics: domain.SystemRuntimeMetrics{ MemoryTotalBytes: 2097152000, MemoryUsedBytes: 1572864000, MemoryAvailableBytes: 524288000, MemoryUsagePercent: &usage, Storage: &domain.StorageMetrics{Path: "/userdata", TotalBytes: 1000, UsedBytes: 200, AvailableBytes: 750, ReservedBytes: 50, UsagePercent: 20}, }}, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", response.Code, http.StatusOK) } var status domain.SystemStatus if err := json.Unmarshal(response.Body.Bytes(), &status); err != nil { t.Fatalf("decode status: %v", err) } if status.MemoryTotalBytes != 2097152000 || status.MemoryUsedBytes != 1572864000 || status.MemoryAvailableBytes != 524288000 || status.MemoryUsagePercent == nil || *status.MemoryUsagePercent != 75 { t.Fatalf("memory status = %+v", status) } if status.Storage == nil || status.Storage.UsagePercent != 20 || status.Storage.AvailableBytes != 750 || status.Storage.Path != "/userdata" { t.Fatalf("storage status=%+v", status.Storage) } } func TestDefaultCORSRejectsCrossOriginPreflight(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodOptions, "/api/v1/system/status", nil) request.Header.Set("Origin", "https://operator.example") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } if got := response.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got) } } func TestExplicitCORSWildcardAllowsAnyOrigin(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ CORSAllowedOrigins: []string{"*"}, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodOptions, "/api/v1/system/status", nil) request.Header.Set("Origin", "https://operator.example") handler.ServeHTTP(response, request) if response.Code != http.StatusNoContent { t.Fatalf("status code = %d, want %d", response.Code, http.StatusNoContent) } if got := response.Header().Get("Access-Control-Allow-Origin"); got != "*" { t.Fatalf("Access-Control-Allow-Origin = %q, want *", got) } } func TestCORSAllowlistReflectsAllowedOrigin(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ CORSAllowedOrigins: []string{"https://console.example", "http://localhost:5173"}, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodOptions, "/api/v1/system/status", nil) request.Header.Set("Origin", "https://console.example") handler.ServeHTTP(response, request) if response.Code != http.StatusNoContent { t.Fatalf("status code = %d, want %d", response.Code, http.StatusNoContent) } if got := response.Header().Get("Access-Control-Allow-Origin"); got != "https://console.example" { t.Fatalf("Access-Control-Allow-Origin = %q, want reflected origin", got) } if got := response.Header().Get("Vary"); got != "Origin" { t.Fatalf("Vary = %q, want Origin", got) } } func TestCORSHeadersOmitMockActorWhenMockAuthDisabled(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ CORSAllowedOrigins: []string{"https://console.example"}, DisableMockActors: true, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodOptions, "/api/v1/system/status", nil) request.Header.Set("Origin", "https://console.example") handler.ServeHTTP(response, request) if response.Code != http.StatusNoContent { t.Fatalf("status code = %d, want %d", response.Code, http.StatusNoContent) } got := response.Header().Get("Access-Control-Allow-Headers") if !strings.Contains(got, "Authorization") || !strings.Contains(got, "Content-Type") { t.Fatalf("Access-Control-Allow-Headers = %q, want auth and content type headers", got) } if strings.Contains(got, "X-Mock-Actor") { t.Fatalf("Access-Control-Allow-Headers = %q, want no X-Mock-Actor", got) } } func TestCORSAllowlistRejectsUnknownPreflightOrigin(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ CORSAllowedOrigins: []string{"https://console.example"}, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodOptions, "/api/v1/system/status", nil) request.Header.Set("Origin", "https://untrusted.example") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } if got := response.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got) } } func TestRequestBodyLimitRejectsOversizedJSON(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ CORSAllowedOrigins: []string{"*"}, MaxRequestBytes: 48, }) body := `{"username":"admin","password":"` + strings.Repeat("x", 96) + `"}` response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(response, request) if response.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status code = %d, want %d", response.Code, http.StatusRequestEntityTooLarge) } assertErrorCode(t, response, "payload_too_large") } func TestOptionalRequestBodyLimitRejectsOversizedJSON(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ MaxRequestBytes: 48, }) body := `{"log_type":"port","channel":"CH01","keyword":"` + strings.Repeat("x", 96) + `"}` response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusRequestEntityTooLarge { t.Fatalf("status code = %d, want %d", response.Code, http.StatusRequestEntityTooLarge) } assertErrorCode(t, response, "payload_too_large") } func TestRequestBodyRejectsTrailingJSON(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) body := `{"username":"admin","password":"mock-admin-secret"}{}` response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestLoginRequestBodyDecodeFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) body := `{"username":"admin","password":"mock-admin-secret"}{}` response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "auth.login") assertString(t, events[0], "target", "auth") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "request body must contain a single JSON value") } func TestOptionalRequestBodyRejectsTrailingJSON(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) body := `{"log_type":"port","channel":"CH01"}{}` response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestCommandRequestBodyDecodeFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) body := `{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24"}]}{}` response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/network/running", strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "network.update") assertString(t, events[0], "target", "network") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "request body must contain a single JSON value") } func TestReadAPIContractShapes(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) t.Run("auth me", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/auth/me", "", "mock-admin") requireKeys(t, payload, "actor", "role", "permissions", "prototype") assertString(t, payload, "actor", "mock-admin") assertString(t, payload, "role", "administrator") permissions, ok := payload["permissions"].([]any) if !ok || len(permissions) == 0 { t.Fatalf("permissions shape = %T length %d, want non-empty array", payload["permissions"], len(permissions)) } }) t.Run("system status", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/system/status", "", "mock-admin") requireKeys(t, payload, "model", "hardware_profile", "firmware_version", "serial_port_count", "active_sessions", "active_alarms", "protected_log_usage_percent", "oob") oob, ok := payload["oob"].(map[string]any) if !ok { t.Fatalf("oob shape = %T, want object", payload["oob"]) } requireKeys(t, oob, "primary_link", "fallback_active", "failover_reason", "last_failover_at") }) t.Run("service health", func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/system/services", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", response.Code, http.StatusOK) } var services []map[string]any if err := json.Unmarshal(response.Body.Bytes(), &services); err != nil { t.Fatalf("decode service health: %v", err) } if len(services) == 0 { t.Fatalf("service health length = 0, want non-empty") } requireKeys(t, services[0], "service", "status", "detail", "last_checked_at") byService := make(map[string]map[string]any, len(services)) for _, service := range services { name, _ := service["service"].(string) byService[name] = service } for _, name := range []string{ "tera-api", "tera-web", "tera-authd", "tera-sessiond", "tera-logd", "tera-alarmd", "tera-netd", "tera-timed", "tera-seriald", "tera-updated", "tera-snmpd", "tera-hwd", } { service, ok := byService[name] if !ok { t.Fatalf("service health missing %s: %#v", name, services) } requireKeys(t, service, "service", "status", "detail", "last_checked_at") } }) t.Run("network", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/network", "", "mock-admin") requireKeys(t, payload, "host_name", "operating_mode", "interfaces", "oob", "rollback_timeout_seconds") assertString(t, payload, "host_name", "iconsole-server") assertString(t, payload, "operating_mode", "single") interfaces, ok := payload["interfaces"].([]any) if !ok || len(interfaces) == 0 { t.Fatalf("interfaces shape = %T length %d, want non-empty array", payload["interfaces"], len(interfaces)) } first, ok := interfaces[0].(map[string]any) if !ok { t.Fatalf("interface shape = %T, want object", interfaces[0]) } requireKeys(t, first, "name", "device", "active_medium", "link", "speed", "address_mode", "address", "gateway", "dns_servers", "ipv6_mode", "ipv6_address", "ipv6_gateway", "ipv6_dns_servers") }) t.Run("ports", func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/ports", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", response.Code, http.StatusOK) } var ports []map[string]any if err := json.Unmarshal(response.Body.Bytes(), &ports); err != nil { t.Fatalf("decode ports: %v", err) } if len(ports) != 48 { t.Fatalf("ports length = %d, want 48", len(ports)) } requireKeys(t, ports[0], "channel", "status", "mode", "listener_port", "baud_rate", "data_bits", "parity", "stop_bits", "flow_control", "connected_users", "max_viewers", "writer", "alarm_keyword", "data_log_mode") }) t.Run("config status", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") requireKeys(t, payload, "pending_changes", "pending_sections", "running_revision", "persistent_revision", "running_differs_from_persistent") }) t.Run("alarm rules", func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/alarms/rules", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", response.Code, http.StatusOK) } var rules []map[string]any if err := json.Unmarshal(response.Body.Bytes(), &rules); err != nil { t.Fatalf("decode alarm rules: %v", err) } if len(rules) == 0 { t.Fatalf("alarm rules length = 0, want non-empty") } requireKeys(t, rules[0], "rule_id", "name", "source", "channel", "trigger", "severity", "delivery", "enabled") }) t.Run("alarm delivery policy", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/delivery-policy", "", "mock-admin") requireKeys(t, payload, "retry_count", "retry_interval_seconds", "pending_queue_limit", "notify_on_retry_exhausted") assertNumber(t, payload, "retry_count", 3) assertNumber(t, payload, "retry_interval_seconds", 60) assertNumber(t, payload, "pending_queue_limit", 100) }) } func TestCommandAPIContractShapes(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string method string path string body string }{ {name: "apply", method: http.MethodPost, path: "/api/v1/config/apply", body: ""}, {name: "save", method: http.MethodPost, path: "/api/v1/config/save", body: ""}, {name: "save and apply", method: http.MethodPost, path: "/api/v1/config/save-and-apply", body: ""}, {name: "discard", method: http.MethodPost, path: "/api/v1/config/discard", body: ""}, {name: "network update", method: http.MethodPatch, path: "/api/v1/network/running", body: `{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24","gateway":"192.168.10.1"}]}`}, {name: "port update", method: http.MethodPatch, path: "/api/v1/ports/CH01", body: `{"mode":"Reverse Telnet"}`}, {name: "alarm rule update", method: http.MethodPatch, path: "/api/v1/alarms/rules/auth_failures", body: `{"enabled":false}`}, {name: "alarm delivery policy update", method: http.MethodPatch, path: "/api/v1/alarms/delivery-policy", body: `{"retry_count":5}`}, } { t.Run(item.name, func(t *testing.T) { payload := getJSONAs(t, handler, item.method, item.path, item.body, "mock-admin") requireKeys(t, payload, "result", "message", "audit_id") }) } t.Run("save and restart operation", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save-and-restart", "", "mock-admin") requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertNumber(t, payload, "progress_percent", 100) result, ok := payload["result"].(map[string]any) if !ok || result["reboot_scheduled"] != true || strings.TrimSpace(fmt.Sprint(result["reboot_operation_id"])) == "" { t.Fatalf("save-and-restart result = %#v, want scheduled device reboot", payload["result"]) } }) t.Run("port restart operation", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH01/restart", "", "mock-admin") requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertNumber(t, payload, "progress_percent", 100) }) t.Run("system reboot operation", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/system/reboot", "", "mock-admin") requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertString(t, payload, "state", "succeeded") assertNumber(t, payload, "progress_percent", 100) }) t.Run("browser time sync operation", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/time/sync-browser", `{"browser_time":"2026-06-16T10:31:12+09:00","timezone":"Asia/Seoul"}`, "mock-admin") requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertString(t, payload, "state", "succeeded") assertNumber(t, payload, "progress_percent", 100) }) t.Run("NTP time sync operation", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/time/sync-ntp", "", "mock-admin") requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertString(t, payload, "state", "succeeded") assertNumber(t, payload, "progress_percent", 100) }) for _, item := range []struct { name string path string body string }{ {name: "network test", path: "/api/v1/diagnostics/network-test", body: `{"target":"192.168.0.1"}`}, {name: "support bundle", path: "/api/v1/diagnostics/support-bundle", body: ""}, } { t.Run("diagnostics "+item.name+" operation", func(t *testing.T) { payload := getJSONAs(t, handler, http.MethodPost, item.path, item.body, "mock-admin") requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertString(t, payload, "state", "succeeded") assertNumber(t, payload, "progress_percent", 100) }) } } func TestAuthMeRequiresKnownActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("X-Mock-Actor", "unknown") handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("unknown actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") } func TestAuthMeShowsViewerPermissionBoundary(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/auth/me", "", "mock-viewer") assertString(t, payload, "actor", "mock-viewer") assertString(t, payload, "role", "viewer") assertPermissions(t, payload, []string{"direct.view"}) } func TestMockActorCanBeDisabledForBearerOnlyAuth(t *testing.T) { handler := NewRouterWithOptions(newPasswordAuthTestStore(t, fixedClock), RouterOptions{ DisableMockActors: true, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("mock actor status code = %d, want %d; body: %s", response.Code, http.StatusUnauthorized, response.Body.String()) } assertErrorCode(t, response, "authentication_required") login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) profile := getJSONWithBearer(t, handler, http.MethodGet, "/api/v1/auth/me", "", stringValue(t, login, "session_token")) assertString(t, profile, "actor", "mock-admin") assertString(t, profile, "role", "administrator") } func TestAuthLoginReturnsSessionForBearerAuth(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) requireKeys(t, login, "session_token", "actor", "role", "permissions", "prototype", "expires_at", "persistent") if persistent, ok := login["persistent"].(bool); !ok || !persistent { t.Fatalf("persistent = %#v, want true", login["persistent"]) } assertString(t, login, "actor", "mock-admin") assertString(t, login, "role", "administrator") token := stringValue(t, login, "session_token") profile := getJSONWithBearer(t, handler, http.MethodGet, "/api/v1/auth/me", "", token) assertString(t, profile, "actor", "mock-admin") assertString(t, profile, "role", "administrator") } func TestAuthTokenCreateListAuthenticateAndRevoke(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) sessionToken := stringValue(t, login, "session_token") created := getJSONWithBearer(t, handler, http.MethodPost, "/api/v1/auth/tokens", `{"name":"automation"}`, sessionToken) requireKeys(t, created, "token_id", "token", "name", "actor", "role", "created_at") assertString(t, created, "name", "automation") assertString(t, created, "actor", "mock-admin") apiToken := stringValue(t, created, "token") tokenID := stringValue(t, created, "token_id") profile := getJSONWithBearer(t, handler, http.MethodGet, "/api/v1/auth/me", "", apiToken) assertString(t, profile, "actor", "mock-admin") assertString(t, profile, "role", "administrator") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/tokens?limit=100", nil) request.Header.Set("Authorization", "Bearer "+sessionToken) handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("GET auth tokens status code = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) } if strings.Contains(response.Body.String(), apiToken) { t.Fatalf("auth token list exposed raw token") } var tokens []map[string]any if err := json.Unmarshal(response.Body.Bytes(), &tokens); err != nil { t.Fatalf("decode auth token list: %v", err) } if len(tokens) != 1 { t.Fatalf("auth token list length = %d, want 1", len(tokens)) } assertString(t, tokens[0], "token_id", tokenID) if _, ok := tokens[0]["token"]; ok { t.Fatalf("auth token list included token material: %#v", tokens[0]) } revoke := getJSONWithBearer(t, handler, http.MethodDelete, "/api/v1/auth/tokens/"+tokenID, "", sessionToken) assertString(t, revoke, "result", "auth_token_revoked") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+apiToken) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("revoked token status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) actions := map[string]bool{} for _, event := range events { actions[stringValue(t, event, "action")] = true } if !actions["auth.token.create"] || !actions["auth.token.revoke"] { t.Fatalf("audit actions = %#v, want token create and revoke", actions) } } func TestAuthTokenAPIsRequireAuthPermissions(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/tokens", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") for _, item := range []struct { method string path string body string }{ {method: http.MethodGet, path: "/api/v1/auth/tokens"}, {method: http.MethodPost, path: "/api/v1/auth/tokens", body: `{"name":"blocked"}`}, {method: http.MethodDelete, path: "/api/v1/auth/tokens/token_000001"}, } { response = httptest.NewRecorder() request = httptest.NewRequest(item.method, item.path, strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("%s %s status code = %d, want %d", item.method, item.path, response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } } func TestAuthTokenRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", strings.NewReader(`{"name":""}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("empty name status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", strings.NewReader(`{"name":"automation","expires_at":"2020-01-01T00:00:00Z"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("past expiry status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestAuthTokenCreateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", strings.NewReader(`{"name":""}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.token.create") assertString(t, events[0], "target", "auth") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "token name must not be empty") } func TestAuthTokenCreateReturnsConflictAtTokenCap(t *testing.T) { store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{AuthMaxTokens: 1}) if err != nil { t.Fatalf("create store: %v", err) } handler := NewRouter(store) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/tokens", `{"name":"first"}`, "mock-admin") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/tokens", strings.NewReader(`{"name":"second"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusConflict { t.Fatalf("token cap status code = %d, want %d; body: %s", response.Code, http.StatusConflict, response.Body.String()) } assertErrorCode(t, response, "conflict") } func TestAuthTokenRevokeValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/tokens/token_missing", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.token.revoke") assertString(t, events[0], "target", "token_missing") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "token not found") } func TestAuthLoginRejectsInvalidPasswordAndAudits(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "admin") assertString(t, events[0], "action", "auth.login") assertString(t, events[0], "target", "admin") assertString(t, events[0], "result", "authentication_failed") } func TestAuthLoginTemporarilyLocksAfterRepeatedFailures(t *testing.T) { now := time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC) handler := NewRouter(newPasswordAuthTestStore(t, func() time.Time { return now })) for attempt := 0; attempt < 5; attempt++ { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"wrong"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("attempt %d status code = %d, want %d", attempt+1, response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_failed") } response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"admin","password":"mock-admin-secret"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("locked status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_failed") events := getAuditEvents(t, handler) if len(events) != 6 { t.Fatalf("audit event count = %d, want 6", len(events)) } assertString(t, events[5], "actor", "admin") assertString(t, events[5], "action", "auth.login") assertString(t, events[5], "target", "admin") assertString(t, events[5], "result", "authentication_locked") now = now.Add(15*time.Minute + time.Second) login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) assertString(t, login, "actor", "mock-admin") } func TestAuthLogoutRevokesBearerSession(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) token := stringValue(t, login, "session_token") logout := getJSONWithBearer(t, handler, http.MethodPost, "/api/v1/auth/logout", "", token) assertString(t, logout, "result", "logged_out") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+token) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") } func TestAuthLogoutMissingTokenRecordsAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "auth.logout") assertString(t, events[0], "target", "auth") assertString(t, events[0], "result", "authentication_failed") assertString(t, events[0], "message", "authentication required") } func TestAuthLogoutInvalidTokenRecordsAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) request.Header.Set("Authorization", "Bearer invalid-token") handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "auth.logout") assertString(t, events[0], "target", "auth") assertString(t, events[0], "result", "authentication_failed") assertString(t, events[0], "message", "invalid session token") } func TestAuthReadAPIsRequireAuthReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/users", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/groups", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestAuthSessionsRequireAuthPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/sessions", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/session_000001", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer revoke status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestAuthSessionListAndRevokeContract(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) token := stringValue(t, login, "session_token") sessions := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/sessions", "", "mock-admin") if len(sessions) != 1 { t.Fatalf("session count = %d, want 1", len(sessions)) } requireKeys(t, sessions[0], "session_id", "actor", "role", "created_at", "expires_at", "persistent") if _, ok := sessions[0]["session_token"]; ok { t.Fatalf("session list exposes session_token: %#v", sessions[0]) } if strings.Contains(fmt.Sprintf("%#v", sessions[0]), token) { t.Fatalf("session list exposes bearer token material: %#v", sessions[0]) } assertString(t, sessions[0], "actor", "mock-admin") sessionID := stringValue(t, sessions[0], "session_id") result := getJSONAs(t, handler, http.MethodDelete, "/api/v1/auth/sessions/"+sessionID, "", "mock-admin") assertString(t, result, "result", "auth_session_revoked") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+token) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("revoked session status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[1], "action", "auth.session.revoke") assertString(t, events[1], "target", "mock-admin") assertString(t, events[1], "result", "succeeded") } func TestAuthSessionRevokeValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/sessions/session_missing", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.session.revoke") assertString(t, events[0], "target", "session_missing") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "session not found") } func TestAuthSessionsSupportBoundedPagination(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) _ = getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"admin","password":"mock-admin-secret"}`) viewerLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"viewer","password":"mock-viewer-secret"}`) sessions := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/sessions?limit=1&offset=1", "", "mock-admin") if len(sessions) != 1 { t.Fatalf("session count = %d, want 1", len(sessions)) } assertString(t, sessions[0], "actor", "mock-viewer") if strings.Contains(fmt.Sprintf("%#v", sessions[0]), stringValue(t, viewerLogin, "session_token")) { t.Fatalf("session page exposes bearer token material: %#v", sessions[0]) } empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/sessions?limit=1&offset=5", "", "mock-admin") if len(empty) != 0 { t.Fatalf("session count = %d, want empty page", len(empty)) } } func TestAuthSessionsRejectInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/auth/sessions?limit=501", "/api/v1/auth/sessions?limit=-1", "/api/v1/auth/sessions?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestExternalAuthRequiresAuthPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/external", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/external", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/external", strings.NewReader(`{"enabled":true}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestExternalAuthUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/auth/external", "", "mock-admin") assertString(t, before, "primary_method", "local") if got, ok := before["enabled"].(bool); !ok || got { t.Fatalf("enabled = %#v, want false", before["enabled"]) } body := `{"enabled":true,"primary_method":"radius","timeout_seconds":10,"radius_servers":[{"server_id":"radius-primary","host":"radius.local","port":1812,"secret":"radius-test-shared-secret","enabled":true}]}` update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/auth/external", body, "mock-admin") assertConfigSummary(t, update, 1, 1, 1, false) exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingAuth, ok := pending["external_auth"].(map[string]any) if !ok { t.Fatalf("pending external_auth shape = %T, want object", pending["external_auth"]) } assertString(t, pendingAuth, "primary_method", "radius") if strings.Contains(strings.ToLower(fmt.Sprintf("%#v", pendingAuth)), "shared_secret") { t.Fatalf("external auth export exposes shared secret material: %#v", pendingAuth) } beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/auth/external", "", "mock-admin") assertString(t, beforeApply, "primary_method", "local") apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/auth/external", "", "mock-admin") assertString(t, afterApply, "primary_method", "radius") assertNumber(t, afterApply, "timeout_seconds", 10) events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "action", "auth.external.update") assertString(t, events[0], "target", "external_auth") assertString(t, events[0], "result", "succeeded") } func TestExternalAuthRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, body := range []string{ `{}`, `{"primary_method":"ldap"}`, `{"timeout_seconds":31}`, `{"enabled":true,"primary_method":"radius"}`, `{"primary_method":"radius","radius_servers":[{"server_id":"radius-primary","host":"radius.local","port":1812,"secret_stored":false,"enabled":true}]}`, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/external", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestExternalAuthValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/external", strings.NewReader(`{"primary_method":"ldap"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.external.update") assertString(t, events[0], "target", "external_auth") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "external auth primary_method must be local, radius, or tacacs") } func TestUpdateAuthGroupRequiresAuthWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/groups/viewers", strings.NewReader(`{"name":"Read only users"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/groups/viewers", strings.NewReader(`{"name":"Read only users"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestUpdateAuthGroupContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) group := getJSONAs(t, handler, http.MethodPatch, "/api/v1/auth/groups/viewers", `{"name":"Read only users"}`, "mock-admin") requireKeys(t, group, "group_id", "name", "role", "permissions", "members") assertString(t, group, "group_id", "viewers") assertString(t, group, "name", "Read only users") assertString(t, group, "role", "viewer") groups := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/groups", "", "mock-admin") viewers := findObjectByString(t, groups, "group_id", "viewers") assertString(t, viewers, "name", "Read only users") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.group.update") assertString(t, events[0], "target", "viewers") assertString(t, events[0], "result", "succeeded") } func TestUpdateAuthGroupRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string groupID string body string wantStatus int wantCode string }{ {name: "missing name", groupID: "viewers", body: `{}`, wantStatus: http.StatusBadRequest, wantCode: "validation_failed"}, {name: "empty name", groupID: "viewers", body: `{"name":" "}`, wantStatus: http.StatusBadRequest, wantCode: "validation_failed"}, {name: "unknown group", groupID: "unknown", body: `{"name":"Unknown"}`, wantStatus: http.StatusNotFound, wantCode: "not_found"}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/groups/"+item.groupID, strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != item.wantStatus { t.Fatalf("status code = %d, want %d; body: %s", response.Code, item.wantStatus, response.Body.String()) } assertErrorCode(t, response, item.wantCode) }) } } func TestAuthGroupUpdateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/groups/viewers", strings.NewReader(`{}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.group.update") assertString(t, events[0], "target", "viewers") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "auth group name must be provided") } func TestCreateAuthUserRequiresAuthWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/users", strings.NewReader(`{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/users", strings.NewReader(`{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestCreateAuthUserContract(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) user := getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"FieldOps","password":"fieldops-secret-1","role":"port_operator"}`, "mock-admin") requireKeys(t, user, "username", "actor", "role", "groups", "permissions", "enabled") assertString(t, user, "username", "fieldops") assertString(t, user, "actor", "local-fieldops") assertString(t, user, "role", "port_operator") if _, ok := user["password"]; ok { t.Fatalf("create response exposes password field: %#v", user) } login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) assertString(t, login, "actor", "local-fieldops") assertString(t, login, "role", "port_operator") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.user.create") assertString(t, events[0], "target", "fieldops") assertString(t, events[0], "result", "succeeded") } func TestCreateAuthUserCanDisableInitialPasswordChange(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) user := getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"noc-user","password":"noc-user-secret-1","role":"viewer","force_change":false}`, "mock-admin") if forceChange, ok := user["must_change_password"].(bool); !ok || forceChange { t.Fatalf("must_change_password = %#v, want false", user["must_change_password"]) } } func TestCreateAuthUserRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "empty username", body: `{"username":"","password":"fieldops-secret-1","role":"viewer"}`}, {name: "invalid username", body: `{"username":"field ops","password":"fieldops-secret-1","role":"viewer"}`}, {name: "short password", body: `{"username":"fieldops","password":"short","role":"viewer"}`}, {name: "unknown role", body: `{"username":"fieldops","password":"fieldops-secret-1","role":"root"}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/users", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } } func TestAuthUserCreateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/users", strings.NewReader(`{"username":"","password":"fieldops-secret-1","role":"viewer"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.user.create") assertString(t, events[0], "target", "auth") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "username must not be empty") } func TestCreateAuthUserRejectsDuplicateUsername(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/users", strings.NewReader(`{"username":"fieldops","password":"fieldops-secret-2","role":"viewer"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusConflict { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusConflict, response.Body.String()) } assertErrorCode(t, response, "conflict") } func TestCreateAuthUserRejectsUserLimit(t *testing.T) { store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{AuthMaxUsers: 3}) if err != nil { t.Fatalf("create store: %v", err) } handler := NewRouter(store) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/users", strings.NewReader(`{"username":"extra","password":"extra-secret-1","role":"viewer"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestUpdateAuthUserRequiresAuthWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin", strings.NewReader(`{"enabled":false}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin", strings.NewReader(`{"enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestUpdateAuthUserEnabledContract(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin") fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) fieldopsToken := stringValue(t, fieldopsLogin, "session_token") disabled := getJSONAs(t, handler, http.MethodPatch, "/api/v1/auth/users/fieldops", `{"enabled":false}`, "mock-admin") assertString(t, disabled, "username", "fieldops") if enabled, ok := disabled["enabled"].(bool); !ok || enabled { t.Fatalf("enabled = %v, want false", disabled["enabled"]) } response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+fieldopsToken) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("revoked session status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"fieldops","password":"fieldops-secret-1"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("disabled login status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_failed") enabled := getJSONAs(t, handler, http.MethodPatch, "/api/v1/auth/users/fieldops", `{"enabled":true}`, "mock-admin") if got, ok := enabled["enabled"].(bool); !ok || !got { t.Fatalf("enabled = %v, want true", enabled["enabled"]) } login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) assertString(t, login, "actor", "local-fieldops") events := getAuditEvents(t, handler) if len(events) != 6 { t.Fatalf("audit event count = %d, want 6", len(events)) } assertString(t, events[2], "action", "auth.user.update") assertString(t, events[2], "target", "fieldops") assertString(t, events[2], "result", "succeeded") } func TestUpdateAuthUserRoleContract(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin") fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) fieldopsToken := stringValue(t, fieldopsLogin, "session_token") updated := getJSONAs(t, handler, http.MethodPatch, "/api/v1/auth/users/fieldops", `{"role":"port_operator"}`, "mock-admin") assertString(t, updated, "username", "fieldops") assertString(t, updated, "role", "port_operator") assertPermissions(t, updated, []string{"config.read", "config.write", "direct.view", "direct.write", "network.read", "port.read", "port.restart", "port.write", "system.read"}) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+fieldopsToken) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("revoked session status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) assertString(t, login, "role", "port_operator") groups := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/groups", "", "mock-admin") portOperators := findObjectByString(t, groups, "group_id", "port_operators") members, ok := portOperators["members"].([]any) if !ok { t.Fatalf("members shape = %T, want array", portOperators["members"]) } if !containsStringValue(members, "fieldops") { t.Fatalf("port operator members missing fieldops: %#v", members) } } func TestUpdateAuthUserRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin", strings.NewReader(`{}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/missing", strings.NewReader(`{"enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/viewer", strings.NewReader(`{"role":"root"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("invalid role status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin", strings.NewReader(`{"role":"viewer"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusConflict { t.Fatalf("last admin demotion status code = %d, want %d; body: %s", response.Code, http.StatusConflict, response.Body.String()) } assertErrorCode(t, response, "conflict") } func TestAuthUserUpdateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/missing", strings.NewReader(`{"enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.user.update") assertString(t, events[0], "target", "missing") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "auth user not found") } func TestUpdateAuthUserPasswordRequiresAuthWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin/password", strings.NewReader(`{"password":"new-admin-secret-1"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin/password", strings.NewReader(`{"password":"new-admin-secret-1"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestUpdateAuthUserPasswordContract(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin") fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) fieldopsToken := stringValue(t, fieldopsLogin, "session_token") updated := getJSONAs(t, handler, http.MethodPatch, "/api/v1/auth/users/fieldops/password", `{"password":"fieldops-secret-2"}`, "mock-admin") assertString(t, updated, "username", "fieldops") if _, ok := updated["password"]; ok { t.Fatalf("password update response exposes password field: %#v", updated) } response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+fieldopsToken) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("revoked session status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"fieldops","password":"fieldops-secret-1"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("old password status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_failed") login := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-2"}`) assertString(t, login, "actor", "local-fieldops") events := getAuditEvents(t, handler) if len(events) != 5 { t.Fatalf("audit event count = %d, want 5", len(events)) } assertString(t, events[2], "action", "auth.user.password.update") assertString(t, events[2], "target", "fieldops") assertString(t, events[2], "result", "succeeded") } func TestUpdateAuthUserPasswordRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin/password", strings.NewReader(`{"password":"short"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/missing/password", strings.NewReader(`{"password":"new-secret-1"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") } func TestAuthUserPasswordValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/auth/users/admin/password", strings.NewReader(`{"password":"short"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.user.password.update") assertString(t, events[0], "target", "admin") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "password must be at least 8 characters") } func TestDeleteAuthUserRequiresAuthWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/users/viewer", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodDelete, "/api/v1/auth/users/viewer", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestDeleteAuthUserContract(t *testing.T) { handler := NewRouter(newPasswordAuthTestStore(t, fixedClock)) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/auth/users", `{"username":"fieldops","password":"fieldops-secret-1","role":"viewer"}`, "mock-admin") fieldopsLogin := getJSON(t, handler, http.MethodPost, "/api/v1/auth/login", `{"username":"fieldops","password":"fieldops-secret-1"}`) fieldopsToken := stringValue(t, fieldopsLogin, "session_token") result := getJSONAs(t, handler, http.MethodDelete, "/api/v1/auth/users/fieldops", "", "mock-admin") requireKeys(t, result, "result", "message", "audit_id") assertString(t, result, "result", "auth_user_deleted") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) request.Header.Set("Authorization", "Bearer "+fieldopsToken) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("revoked session status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"username":"fieldops","password":"fieldops-secret-1"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("deleted login status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_failed") users := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/users", "", "mock-admin") for _, user := range users { if user["username"] == "fieldops" { t.Fatalf("deleted user still listed: %#v", users) } } events := getAuditEvents(t, handler) if len(events) != 4 { t.Fatalf("audit event count = %d, want 4", len(events)) } assertString(t, events[2], "action", "auth.user.delete") assertString(t, events[2], "target", "fieldops") assertString(t, events[2], "result", "succeeded") } func TestDeleteAuthUserRejectsMissingAndLastAdmin(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/users/missing", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("missing status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodDelete, "/api/v1/auth/users/admin", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusConflict { t.Fatalf("last admin status code = %d, want %d; body: %s", response.Code, http.StatusConflict, response.Body.String()) } assertErrorCode(t, response, "conflict") } func TestAuthUserDeleteValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/auth/users/missing", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "auth.user.delete") assertString(t, events[0], "target", "missing") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "auth user not found") } func TestAuthReadAPIContractShapes(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) users := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/users", "", "mock-admin") if len(users) != 3 { t.Fatalf("user count = %d, want 3", len(users)) } requireKeys(t, users[0], "username", "actor", "role", "groups", "permissions", "enabled") if _, ok := users[0]["password"]; ok { t.Fatalf("user response exposes password field: %#v", users[0]) } if _, ok := users[0]["password_hash"]; ok { t.Fatalf("user response exposes password_hash field: %#v", users[0]) } groups := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/groups", "", "mock-admin") if len(groups) != 3 { t.Fatalf("group count = %d, want 3", len(groups)) } requireKeys(t, groups[0], "group_id", "name", "role", "permissions", "members") permissions := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/permissions", "", "mock-admin") if len(permissions) == 0 { t.Fatalf("permissions length = 0, want non-empty") } requireKeys(t, permissions[0], "permission", "description", "sensitive") if !containsPermission(t, permissions, "alarm.read") { t.Fatalf("permissions missing alarm.read: %#v", permissions) } if !containsPermission(t, permissions, "auth.read") { t.Fatalf("permissions missing auth.read: %#v", permissions) } if !containsPermission(t, permissions, "auth.write") { t.Fatalf("permissions missing auth.write: %#v", permissions) } if !containsPermission(t, permissions, "config.read") { t.Fatalf("permissions missing config.read: %#v", permissions) } if !containsPermission(t, permissions, "audit.read") { t.Fatalf("permissions missing audit.read: %#v", permissions) } if !containsPermission(t, permissions, "log.read") { t.Fatalf("permissions missing log.read: %#v", permissions) } if !containsPermission(t, permissions, "network.read") { t.Fatalf("permissions missing network.read: %#v", permissions) } if !containsPermission(t, permissions, "network.write") { t.Fatalf("permissions missing network.write: %#v", permissions) } if !containsPermission(t, permissions, "operation.read") { t.Fatalf("permissions missing operation.read: %#v", permissions) } if !containsPermission(t, permissions, "port.read") { t.Fatalf("permissions missing port.read: %#v", permissions) } if !containsPermission(t, permissions, "system.read") { t.Fatalf("permissions missing system.read: %#v", permissions) } if !containsPermission(t, permissions, "system.reboot") { t.Fatalf("permissions missing system.reboot: %#v", permissions) } if !containsPermission(t, permissions, "system.factory_reset") { t.Fatalf("permissions missing system.factory_reset: %#v", permissions) } } func TestAuthPrincipalListsSupportBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) users := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/users?limit=1&offset=1", "", "mock-admin") if len(users) != 1 { t.Fatalf("user count = %d, want 1", len(users)) } assertString(t, users[0], "username", "port-operator") if _, ok := users[0]["password"]; ok { t.Fatalf("user page exposes password field: %#v", users[0]) } if _, ok := users[0]["password_hash"]; ok { t.Fatalf("user page exposes password_hash field: %#v", users[0]) } emptyUsers := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/users?limit=1&offset=5", "", "mock-admin") if len(emptyUsers) != 0 { t.Fatalf("user count = %d, want empty page", len(emptyUsers)) } groups := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/groups?limit=1&offset=1", "", "mock-admin") if len(groups) != 1 { t.Fatalf("group count = %d, want 1", len(groups)) } assertString(t, groups[0], "group_id", "port_operators") emptyGroups := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/groups?limit=1&offset=5", "", "mock-admin") if len(emptyGroups) != 0 { t.Fatalf("group count = %d, want empty page", len(emptyGroups)) } } func TestAuthPrincipalListsRejectInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/auth/users?limit=501", "/api/v1/auth/users?limit=-1", "/api/v1/auth/users?offset=bad", "/api/v1/auth/groups?limit=501", "/api/v1/auth/groups?limit=-1", "/api/v1/auth/groups?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestAuthPermissionsSupportBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) permissions := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/permissions?limit=1&offset=1", "", "mock-admin") if len(permissions) != 1 { t.Fatalf("permission count = %d, want 1", len(permissions)) } requireKeys(t, permissions[0], "permission", "description", "sensitive") empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/auth/permissions?limit=1&offset=1000", "", "mock-admin") if len(empty) != 0 { t.Fatalf("permission count = %d, want empty page", len(empty)) } } func TestAuthPermissionsRejectInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/auth/permissions?limit=501", "/api/v1/auth/permissions?limit=-1", "/api/v1/auth/permissions?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestAuditEventsRequireAuditReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/audit", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/logs/audit", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestAuditEventsSupportBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH01/restart", "", "mock-admin") _ = getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH02/restart", "", "mock-admin") events := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/audit?limit=1&offset=1", "", "mock-admin") if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "target", "CH02") empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/audit?limit=1&offset=5", "", "mock-admin") if len(empty) != 0 { t.Fatalf("audit event count = %d, want empty page", len(empty)) } latest := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/audit?order=desc&limit=1", "", "mock-admin") if len(latest) != 1 { t.Fatalf("latest audit event count = %d, want 1", len(latest)) } assertString(t, latest[0], "target", "CH02") filtered := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/audit?actor=mock-admin&keyword=CH01&from=2026-06-15T23:00:00Z&to=2026-06-16T01:00:00Z", "", "mock-admin") if len(filtered) != 1 { t.Fatalf("filtered audit event count = %d, want 1", len(filtered)) } assertString(t, filtered[0], "target", "CH01") } func TestConfigStatusRequiresConfigReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/config/status", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestConfigExportRequiresConfigReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/config/export", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/config/export", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestEncryptedConfigExportAndRestoreWorkWithoutBrowserCrypto(t *testing.T) { store := mock.NewStore(fixedClock) handler := NewRouter(store) exported := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/export/encrypted", `{"key":"backup-key-123"}`, "mock-admin") filename := stringValue(t, exported, "filename") if !strings.HasSuffix(filename, ".tera-config.json") { t.Fatalf("encrypted config filename = %q, want product extension", filename) } content := stringValue(t, exported, "content") if strings.Contains(content, "iconsole-server") { t.Fatal("encrypted config content exposed plaintext configuration") } snapshot, err := decryptConfigEnvelope(content, "backup-key-123") if err != nil { t.Fatalf("decrypt server export: %v", err) } if snapshot.FormatVersion != 1 || len(snapshot.Persistent.Ports) != 48 { t.Fatalf("decrypted snapshot = format %d ports %d, want format 1 and 48 ports", snapshot.FormatVersion, len(snapshot.Persistent.Ports)) } getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH48", `{"alias":"BACKUP-TEST"}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save", "", "mock-admin") // Reproduce a stale network edit that must not be applied by a restore // whose include_network flag is false. getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24","gateway":"192.168.10.1"}]}`, "mock-admin") restoreBody, err := json.Marshal(encryptedConfigRestoreRequest{ Key: "backup-key-123", Content: content, IncludeNetwork: false, }) if err != nil { t.Fatalf("marshal encrypted restore request: %v", err) } restored := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/restore/encrypted", string(restoreBody), "mock-admin") assertString(t, restored, "result", "config_restore_staged") config := objectValue(t, restored, "config") if pending := int(numberValue(t, config, "pending_changes")); pending == 0 { t.Fatalf("pending_changes = 0, want encrypted restore pending changes") } if sections := arrayValue(t, config, "pending_sections"); containsStringValue(sections, "network_interfaces") { t.Fatalf("pending_sections = %#v, must not include network_interfaces when include_network is false", sections) } assertStringArray(t, config, "pending_sections", []string{"ports"}) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/restore/encrypted", strings.NewReader(strings.Replace(string(restoreBody), "backup-key-123", "wrong-key-123", 1))) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("wrong-key status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestEncryptedConfigEndpointsRequireConfigurationPermissions(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { path string body string }{ {path: "/api/v1/config/export/encrypted", body: `{"key":"backup-key-123"}`}, {path: "/api/v1/config/restore/encrypted", body: `{"key":"backup-key-123","content":"{}","include_network":false}`}, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, item.path, strings.NewReader(item.body)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("%s unauthenticated status code = %d, want %d", item.path, response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") } } func TestConfigRestoreRequiresConfigWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/restore", strings.NewReader(`{"format_version":1}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/config/restore", strings.NewReader(`{"format_version":1}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestConfigRestoreStagesExportedPersistentConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") persistent := objectValue(t, exported, "persistent") ports := arrayValue(t, persistent, "ports") firstPort, ok := ports[0].(map[string]any) if !ok { t.Fatalf("port shape = %T, want object", ports[0]) } firstPort["mode"] = "reverse_telnet" operation1 := firstPort["operation_1"].(map[string]any) operation1["mode"] = "reverse_telnet" operation1["port"] = float64(2301) firstPort["baud_rate"] = float64(38400) firstPort["data_bits"] = float64(7) firstPort["parity"] = "odd" firstPort["stop_bits"] = float64(2) firstPort["flow_control"] = "rtscts" firstPort["alarm_keyword"] = "RESTORED" body, err := json.Marshal(exported) if err != nil { t.Fatalf("marshal restore payload: %v", err) } restore := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/restore", string(body), "mock-admin") assertString(t, restore, "result", "config_restore_staged") config := objectValue(t, restore, "config") if pending := int(numberValue(t, config, "pending_changes")); pending == 0 { t.Fatalf("pending_changes = 0, want restore pending changes") } beforeApply := getPort(t, handler, "CH01") if beforeApply.Mode != "disabled" { t.Fatalf("mode before apply = %q, want disabled", beforeApply.Mode) } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getPort(t, handler, "CH01") if afterApply.Mode != "reverse_telnet" || afterApply.BaudRate != 38400 || afterApply.DataBits != 7 || afterApply.Parity != "odd" || afterApply.StopBits != 2 || afterApply.FlowControl != "rtscts" || afterApply.AlarmKeyword != "RESTORED" { t.Fatalf("restored port = %#v, want reverse_telnet 38400 7O2 rtscts RESTORED", afterApply) } events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "action", "config.restore") assertString(t, events[0], "target", "config") assertString(t, events[0], "result", "succeeded") } func TestConfigRestoreRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/restore", strings.NewReader(`{"format_version":0}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestConfigRestoreRejectsSecretMaterialFields(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ { name: "email password", body: `{"format_version":1,"persistent":{"email_notification":{"smtp_password":"plain-secret"}}}`, }, { name: "external auth shared secret", body: `{"format_version":1,"persistent":{"external_auth":{"radius_servers":[{"server_id":"radius-primary","host":"radius.local","port":1812,"shared_secret":"plain-secret","secret_stored":true}]}}}`, }, { name: "tls private key", body: `{"format_version":1,"persistent":{"tls_certificate":{"active_certificate_id":"factory-default","private_key_pem":"-----BEGIN PRIVATE KEY-----"}}}`, }, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/restore", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") if !strings.Contains(response.Body.String(), "secret material field") { t.Fatalf("response should mention secret material field: %s", response.Body.String()) } }) } events := getAuditEvents(t, handler) if len(events) != 3 { t.Fatalf("audit event count = %d, want 3", len(events)) } for _, event := range events { assertString(t, event, "actor", "mock-admin") assertString(t, event, "action", "config.restore") assertString(t, event, "target", "config") assertString(t, event, "result", "rejected") message, ok := event["message"].(string) if !ok || !strings.Contains(message, "secret material field") { t.Fatalf("audit message = %#v, want secret material rejection", event["message"]) } } } func TestConfigRestoreRejectsUnknownFields(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() body := `{"format_version":1,"persistent":{"email_notification":{"enabled":false,"unexpected_field":true}}}` request := httptest.NewRequest(http.MethodPost, "/api/v1/config/restore", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "config.restore") assertString(t, events[0], "target", "config") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "invalid JSON body") } func TestConfigRestoreValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/restore", strings.NewReader(`{"format_version":0}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "config.restore") assertString(t, events[0], "target", "config") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "config restore format_version must be 1") } func TestConfigStatusReportsPendingSections(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") assertStringArray(t, status, "pending_sections", []string{}) updatePort := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet"}`, "mock-admin") assertStringArray(t, objectValue(t, updatePort, "config"), "pending_sections", []string{"ports"}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24","gateway":"192.168.10.1"}]}`, "mock-admin") getJSONAs(t, handler, http.MethodPatch, "/api/v1/time/settings", `{"timezone":"Asia/Seoul"}`, "mock-admin") status = getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") assertNumber(t, status, "pending_changes", 3) assertStringArray(t, status, "pending_sections", []string{"ports", "network_interfaces", "time"}) apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertStringArray(t, objectValue(t, apply, "config"), "pending_sections", []string{}) } func TestAlarmRulesRequireAlarmReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/alarms/rules", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/alarms/rules/auth_failures", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestAlarmRulesSupportBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) rules := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/alarms/rules?limit=1", "", "mock-admin") if len(rules) != 1 { t.Fatalf("alarm rule count = %d, want 1", len(rules)) } requireKeys(t, rules[0], "rule_id", "name", "source", "channel", "trigger", "severity", "delivery", "enabled") empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/alarms/rules?limit=1&offset=1000", "", "mock-admin") if len(empty) != 0 { t.Fatalf("alarm rule count = %d, want empty page", len(empty)) } } func TestAlarmRulesRejectInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/alarms/rules?limit=501", "/api/v1/alarms/rules?limit=-1", "/api/v1/alarms/rules?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestPortsRequirePortReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/ports", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/ports/CH01", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") payload := getJSONAs(t, handler, http.MethodGet, "/api/v1/ports/CH01", "", "mock-port-operator") assertString(t, payload, "channel", "CH01") } func TestPortsSupportBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) ports := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/ports?limit=2&offset=1", "", "mock-admin") if len(ports) != 2 { t.Fatalf("port count = %d, want 2", len(ports)) } assertString(t, ports[0], "channel", "CH02") assertString(t, ports[1], "channel", "CH03") empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/ports?limit=1&offset=1000", "", "mock-admin") if len(empty) != 0 { t.Fatalf("port count = %d, want empty page", len(empty)) } } func TestPortsRejectInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/ports?limit=501", "/api/v1/ports?limit=-1", "/api/v1/ports?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestNetworkRequiresNetworkReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/network", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/network", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestNetworkUpdateRequiresNetworkWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/network/running", strings.NewReader(`{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24"}]}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/network/running", strings.NewReader(`{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24"}]}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestTimeSettingsRequiresConfigPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/time/settings", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/time/settings", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/time/settings", strings.NewReader(`{"timezone":"Asia/Seoul"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestManagementAccessRequiresConfigPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/security/management-access", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/security/management-access", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/security/management-access", strings.NewReader(`{"telnet_enabled":true}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestNetworkUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","address":"192.168.10.233/24","gateway":"192.168.10.1"}]}`, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "network_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := firstNetworkInterface(t, getJSONAs(t, handler, http.MethodGet, "/api/v1/network", "", "mock-admin")) assertString(t, beforeApply, "address", "192.168.0.223/24") apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := firstNetworkInterface(t, getJSONAs(t, handler, http.MethodGet, "/api/v1/network", "", "mock-admin")) assertString(t, afterApply, "address", "192.168.10.233/24") assertString(t, afterApply, "gateway", "192.168.10.1") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "action", "network.update") assertString(t, events[0], "target", "network") assertString(t, events[0], "result", "succeeded") } func TestNetworkOnlyApplyLeavesPortAndAlarmChangesPending(t *testing.T) { store := mock.NewStore(fixedClock) controller := &networkconfig.MockController{} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"baud_rate":38400}`, "mock-admin") getJSONAs(t, handler, http.MethodPatch, "/api/v1/alarms/rules/auth_failures", `{"enabled":false}`, "mock-admin") getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"host_name":"tera"}`, "mock-admin") applied := getJSONAs(t, handler, http.MethodPost, "/api/v1/network/apply", "", "mock-admin") transaction := objectValue(t, applied, "network_apply") assertString(t, transaction, "state", "confirmed") snapshot, err := store.ConfigExport(context.Background()) if err != nil { t.Fatalf("config export after network apply: %v", err) } if snapshot.Running.Network.HostName != "tera" || snapshot.Pending.Network != nil || len(snapshot.Pending.NetworkInterfaces) != 0 { t.Fatalf("network state after isolated apply = running %q pending %#v interfaces %#v", snapshot.Running.Network.HostName, snapshot.Pending.Network, snapshot.Pending.NetworkInterfaces) } if len(snapshot.Pending.Ports) != 1 || snapshot.Pending.Ports[0].BaudRate != 38400 || snapshot.Running.Ports[0].BaudRate == 38400 { t.Fatalf("port state was not left pending: running=%+v pending=%+v", snapshot.Running.Ports[0], snapshot.Pending.Ports) } if len(snapshot.Pending.AlarmRules) != 1 || snapshot.Pending.AlarmRules[0].Enabled || !snapshot.Running.AlarmRules[0].Enabled { t.Fatalf("alarm state was not left pending: running=%+v pending=%+v", snapshot.Running.AlarmRules[0], snapshot.Pending.AlarmRules) } if got := snapshot.Summary.PendingSections; !reflect.DeepEqual(got, []string{"ports", "alarm_rules"}) { t.Fatalf("pending sections after isolated network apply = %v", got) } if snapshot.Persistent.Network.HostName != "tera" { t.Fatalf("persistent hostname = %q, want tera", snapshot.Persistent.Network.HostName) } } func TestNetworkOperatingModeActuallySwitchesOOBAndSingleTopology(t *testing.T) { store := mock.NewStore(fixedClock) controller := &networkconfig.MockController{} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","gateway":"192.168.0.1"}]}`, "mock-admin") getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"operating_mode":"oob","oob":{"primary_interface":"Interface-1","fallback_interface":"Interface-2","health_check_target":"1.1.1.1","health_check_interval_seconds":10,"failure_threshold":3}}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/network/apply", "", "mock-admin") snapshot, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } if snapshot.Running.Network.OperatingMode != networkconfig.OperatingModeOOB || snapshot.Running.Network.Interfaces[0].Role != networkconfig.RoleExternal || snapshot.Running.Network.Interfaces[1].Role != networkconfig.RoleExternal { t.Fatalf("OOB running topology = %+v", snapshot.Running.Network) } if controller.Current.OOBPrimaryInterface != "Interface-1" || controller.Current.OOBFallbackInterface != "Interface-2" { t.Fatalf("OOB controller topology = %+v", controller.Current) } getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"operating_mode":"single"}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/network/apply", "", "mock-admin") snapshot, err = store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } interfaces := snapshot.Running.Network.Interfaces if snapshot.Running.Network.OperatingMode != networkconfig.OperatingModeSingle || interfaces[0].Role != networkconfig.RoleInternal || interfaces[1].Role != networkconfig.RoleExternal { t.Fatalf("Single running topology = %+v", snapshot.Running.Network) } } type disconnectingNetworkController struct { *networkconfig.MockController cancel context.CancelFunc } func (c *disconnectingNetworkController) Apply(ctx context.Context, config networkconfig.Config) (networkconfig.ApplyResult, error) { result, err := c.MockController.Apply(ctx, config) if err == nil && c.cancel != nil { c.cancel() } return result, err } func TestNetworkApplyCompletesAfterManagementRequestDisconnects(t *testing.T) { store := mock.NewStore(fixedClock) requestContext, cancelRequest := context.WithCancel(context.Background()) controller := &disconnectingNetworkController{MockController: &networkconfig.MockController{}, cancel: cancelRequest} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","gateway":"192.168.0.1"}]}`, "mock-admin") request := httptest.NewRequest(http.MethodPost, "/api/v1/network/apply", nil).WithContext(requestContext) request.Header.Set("X-Mock-Actor", "mock-admin") response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("network apply after disconnect status=%d body=%s", response.Code, response.Body.String()) } if controller.Pending != nil { t.Fatalf("network transaction was not confirmed after request cancellation: %+v", controller.Pending) } snapshot, err := store.ConfigExport(context.Background()) if err != nil { t.Fatal(err) } if snapshot.Persistent.Network.Interfaces[0].Gateway != "192.168.0.1" { t.Fatalf("persistent gateway=%q, want 192.168.0.1", snapshot.Persistent.Network.Interfaces[0].Gateway) } } func TestNetworkSaveApplyConfirmRunsWebAPIToSystemTransaction(t *testing.T) { store := mock.NewStore(fixedClock) controller := &networkconfig.MockController{} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller}) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","role":"internal","address":"192.168.0.223/24","gateway":"","weight":1},{"name":"Interface-2","role":"external","address":"192.168.78.55/24","gateway":"192.168.78.1","weight":2}]}`, "mock-admin") assertString(t, update, "result", "network_update_staged") applied := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save-and-apply", "", "mock-admin") transaction := objectValue(t, applied, "network_apply") assertString(t, transaction, "state", "pending_confirmation") transactionID := stringValue(t, transaction, "transaction_id") var external networkconfig.Interface for _, item := range controller.Current.Interfaces { if item.Name == "Interface-2" { external = item break } } if len(controller.Current.Interfaces) != 2 || external.Device != "eth1" || external.Role != networkconfig.RoleExternal || external.Weight != 2 { t.Fatalf("controller config = %+v", controller.Current) } confirmed := getJSONAs(t, handler, http.MethodPost, "/api/v1/network/confirm", fmt.Sprintf(`{"transaction_id":%q}`, transactionID), "mock-admin") assertString(t, confirmed, "result", "network.confirm") if controller.Pending != nil { t.Fatalf("network transaction still pending: %+v", controller.Pending) } events := getAuditEvents(t, handler) if len(events) != 3 { t.Fatalf("audit events = %#v, want update/apply/confirm", events) } assertString(t, events[2], "action", "network.confirm") } func TestNetworkApplyCarriesHostnameDHCPDNSAndIPv6ToController(t *testing.T) { store := mock.NewStore(fixedClock) controller := &networkconfig.MockController{} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller}) updateBody := `{ "host_name":"field-console", "advanced":{"mode":"single","bond_mode":"active-backup","bond_mii":75,"bridge_stp":true}, "ddns":{"enabled":false,"provider":"cloudflare","ipv4_enabled":true,"ipv6_enabled":false,"username":"field-user","ipv4_domain":"console.example.com"}, "interfaces":[{ "name":"Interface-1", "address_mode":"static", "address":"192.168.10.233/24", "gateway":"", "dns_servers":["8.8.8.8","1.1.1.1"], "ipv6_mode":"static", "ipv6_address":"2001:db8:10::233/64", "ipv6_gateway":"2001:db8:10::1", "ipv6_dns_servers":["2001:4860:4860::8888"] }] }` getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", updateBody, "mock-admin") applied := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") transaction := objectValue(t, applied, "network_apply") assertString(t, transaction, "state", "pending_confirmation") if controller.Current.HostName != "field-console" { t.Fatalf("controller hostname = %q", controller.Current.HostName) } if controller.Current.Advanced.BondMii != 75 || !controller.Current.Advanced.BridgeSTP { t.Fatalf("controller advanced settings = %+v", controller.Current.Advanced) } if controller.Current.DDNS.Provider != "cloudflare" || controller.Current.DDNS.Username != "field-user" || controller.Current.DDNS.IPv4Domain != "console.example.com" { t.Fatalf("controller DDNS settings = %+v", controller.Current.DDNS) } var first networkconfig.Interface for _, item := range controller.Current.Interfaces { if item.Name == "Interface-1" { first = item break } } if first.AddressMode != networkconfig.AddressModeStatic || first.Address != "192.168.10.233/24" { t.Fatalf("controller IPv4 = %+v", first) } if !reflect.DeepEqual(first.DNSServers, []string{"8.8.8.8", "1.1.1.1"}) { t.Fatalf("controller DNS = %#v", first.DNSServers) } if first.IPv6Mode != networkconfig.IPv6ModeStatic || first.IPv6Address != "2001:db8:10::233/64" || first.IPv6Gateway != "2001:db8:10::1" { t.Fatalf("controller IPv6 = %+v", first) } if !reflect.DeepEqual(first.IPv6DNSServers, []string{"2001:4860:4860::8888"}) { t.Fatalf("controller IPv6 DNS = %#v", first.IPv6DNSServers) } } func TestNetworkRollbackRestoresAPIRunningState(t *testing.T) { store := mock.NewStore(fixedClock) controller := &networkconfig.MockController{Current: networkconfig.Config{ HostName: "iconsole-server", OperatingMode: networkconfig.OperatingModeSingle, OOBPrimaryInterface: "Interface-1", OOBFallbackInterface: "wwan0", OOBHealthCheckTarget: "1.1.1.1", OOBHealthCheckSecond: 10, OOBFailureThreshold: 3, RollbackTimeoutSecond: networkconfig.DefaultRollbackSecond, Interfaces: []networkconfig.Interface{ {Name: "Interface-1", Device: "eth0", Role: networkconfig.RoleInternal, AddressMode: networkconfig.AddressModeStatic, Address: "192.168.0.223/24", IPv6Mode: networkconfig.IPv6ModeAuto, Weight: 1}, {Name: "Interface-2", Device: "eth1", Role: networkconfig.RoleExternal, AddressMode: networkconfig.AddressModeStatic, Address: "192.168.78.55/24", Gateway: "192.168.78.1", IPv6Mode: networkconfig.IPv6ModeAuto, Weight: 1}, }, }} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"host_name":"temporary-host","interfaces":[{"name":"Interface-1","address":"192.168.10.233/24"}]}`, "mock-admin") applied := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") transactionID := stringValue(t, objectValue(t, applied, "network_apply"), "transaction_id") rolledBack := getJSONAs(t, handler, http.MethodPost, "/api/v1/network/rollback", fmt.Sprintf(`{"transaction_id":%q}`, transactionID), "mock-admin") assertString(t, rolledBack, "result", "network.rollback") network := getJSONAs(t, handler, http.MethodGet, "/api/v1/network", "", "mock-admin") assertString(t, network, "host_name", "iconsole-server") assertString(t, firstNetworkInterface(t, network), "address", "192.168.0.223/24") status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") if numberValue(t, status, "pending_changes") != 0 { t.Fatalf("pending changes after rollback = %#v", status["pending_changes"]) } } func TestConfigCommandsReplaceReachablePendingNetworkTransaction(t *testing.T) { tests := []struct { name string path string wantNewPending bool }{ {name: "apply", path: "/api/v1/config/apply", wantNewPending: true}, {name: "save and apply", path: "/api/v1/config/save-and-apply", wantNewPending: true}, {name: "save and restart", path: "/api/v1/config/save-and-restart", wantNewPending: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { store := mock.NewStore(fixedClock) controller := &networkconfig.MockController{} handler := NewRouterWithOptions(store, RouterOptions{NetworkController: controller, SerialController: serial.MockController{}}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","role":"internal","address":"192.168.10.223/24","gateway":"","weight":1},{"name":"Interface-2","role":"external","address":"192.168.78.55/24","gateway":"192.168.78.1","weight":2}]}`, "mock-admin") firstApply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save-and-apply", "", "mock-admin") firstTransaction := objectValue(t, firstApply, "network_apply") assertString(t, firstTransaction, "state", "pending_confirmation") if controller.Pending == nil { t.Fatal("first network transaction is not pending") } getJSONAs(t, handler, http.MethodPatch, "/api/v1/network/running", `{"interfaces":[{"name":"Interface-1","role":"internal","address":"192.168.11.223/24","gateway":"","weight":1},{"name":"Interface-2","role":"external","address":"192.168.78.55/24","gateway":"192.168.78.1","weight":2}]}`, "mock-admin") getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"baud_rate":38400}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, test.path, "", "mock-admin") if test.wantNewPending && controller.Pending == nil { t.Fatal("replacement network transaction is not pending") } if !test.wantNewPending && controller.Pending != nil { t.Fatalf("network transaction still pending after save and restart: %+v", controller.Pending) } var appliedPrimary networkconfig.Interface for _, item := range controller.Current.Interfaces { if item.Name == "Interface-1" { appliedPrimary = item break } } if got := appliedPrimary.Address; got != "192.168.11.223/24" { t.Fatalf("applied network address = %q, want replacement address", got) } status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") if numberValue(t, status, "pending_changes") != 0 { t.Fatalf("pending changes = %#v, want none", status) } events := getAuditEvents(t, handler) foundRecoveryConfirmation := false for _, event := range events { if event["action"] == "network.confirm" && strings.Contains(fmt.Sprint(event["message"]), "before replacement") { foundRecoveryConfirmation = true break } } if !foundRecoveryConfirmation { t.Fatalf("audit events = %#v, want reachable replacement confirmation", events) } }) } } func TestTimeSettingsUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/time/settings", "", "mock-admin") assertString(t, before, "mode", "ntp") assertString(t, before, "timezone", "Asia/Seoul") update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/time/settings", `{"timezone":"UTC","ntp_servers":["time1.example.local","time2.example.local"]}`, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "time_settings_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/time/settings", "", "mock-admin") assertString(t, beforeApply, "timezone", "Asia/Seoul") exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingTime, ok := pending["time"].(map[string]any) if !ok { t.Fatalf("pending time shape = %T, want object", pending["time"]) } assertString(t, pendingTime, "timezone", "UTC") apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/time/settings", "", "mock-admin") assertString(t, afterApply, "timezone", "UTC") } func TestTimeSettingsValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/time/settings", strings.NewReader(`{"mode":"ntp","ntp_servers":[]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "time.settings.update") assertString(t, events[0], "target", "time") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "ntp_servers must include at least one server") } func TestManagementAccessUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/security/management-access", "", "mock-admin") if got, ok := before["https_enabled"].(bool); !ok || !got { t.Fatalf("https_enabled = %v, want true", before["https_enabled"]) } if got, ok := before["telnet_enabled"].(bool); !ok || got { t.Fatalf("telnet_enabled = %v, want false", before["telnet_enabled"]) } if got, ok := before["ssh_enabled"].(bool); !ok || got { t.Fatalf("ssh_enabled = %v, want false", before["ssh_enabled"]) } assertNumber(t, before, "http_port", 80) assertNumber(t, before, "https_port", 443) assertNumber(t, before, "ssh_port", 22) assertNumber(t, before, "telnet_port", 23) assertNumber(t, before, "idle_timeout_minutes", 15) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/security/management-access", `{"http_enabled":true,"http_port":8081,"https_enabled":true,"https_port":8443,"idle_timeout_minutes":30}`, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "management_access_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/security/management-access", "", "mock-admin") if got, ok := beforeApply["telnet_enabled"].(bool); !ok || got { t.Fatalf("telnet before apply = %v, want false", beforeApply["telnet_enabled"]) } exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingManagement, ok := pending["management"].(map[string]any) if !ok { t.Fatalf("pending management shape = %T, want object", pending["management"]) } if got, ok := pendingManagement["telnet_enabled"].(bool); !ok || got { t.Fatalf("pending telnet = %v, want false", pendingManagement["telnet_enabled"]) } assertNumber(t, pendingManagement, "http_port", 8081) assertNumber(t, pendingManagement, "https_port", 8443) assertNumber(t, pendingManagement, "ssh_port", 22) assertNumber(t, pendingManagement, "telnet_port", 23) apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/security/management-access", "", "mock-admin") if got, ok := afterApply["telnet_enabled"].(bool); !ok || got { t.Fatalf("telnet after apply = %v, want false", afterApply["telnet_enabled"]) } assertNumber(t, afterApply, "http_port", 8081) assertNumber(t, afterApply, "https_port", 8443) assertNumber(t, afterApply, "ssh_port", 22) assertNumber(t, afterApply, "telnet_port", 23) assertNumber(t, afterApply, "idle_timeout_minutes", 30) } func TestManagementAccessRejectsInvalidOrConflictingPorts(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, body := range []string{ `{"https_port":0}`, `{"ssh_enabled":true}`, `{"telnet_enabled":true}`, `{"http_enabled":true,"http_port":443,"https_enabled":true,"https_port":443}`, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/security/management-access", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d for %s; body: %s", response.Code, http.StatusBadRequest, body, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestManagementAccessRejectsDisablingRecoveryPaths(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/security/management-access", strings.NewReader(`{"https_enabled":false,"serial_console_enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestManagementAccessValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/security/management-access", strings.NewReader(`{"https_enabled":false,"serial_console_enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "security.management_access.update") assertString(t, events[0], "target", "management_access") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "management access must keep HTTPS or serial console enabled") } func TestTLSCertificateRequiresConfigPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/security/tls-certificate", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/security/tls-certificate", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/security/tls-certificate", strings.NewReader(`{"active_certificate_id":"operator-imported-2026"}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestTLSCertificateUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/security/tls-certificate", "", "mock-admin") assertString(t, before, "active_certificate_id", "factory-default") assertString(t, before, "source", "factory") if got, ok := before["private_key_stored"].(bool); !ok || !got { t.Fatalf("private_key_stored = %#v, want true", before["private_key_stored"]) } created := getJSONAs(t, handler, http.MethodPost, "/api/v1/security/tls-certificates", `{"certificate_id":"operator-imported-2026","common_name":"console.example.local"}`, "mock-admin") assertString(t, created, "certificate_id", "operator-imported-2026") assertNumber(t, created, "key_bits", 2048) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/security/tls-certificate", `{"active_certificate_id":"operator-imported-2026"}`, "mock-admin") assertConfigSummary(t, update, 1, 1, 1, false) exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingTLS, ok := pending["tls_certificate"].(map[string]any) if !ok { t.Fatalf("pending tls_certificate shape = %T, want object", pending["tls_certificate"]) } assertString(t, pendingTLS, "active_certificate_id", "operator-imported-2026") if strings.Contains(strings.ToLower(fmt.Sprintf("%#v", pendingTLS)), "private_key_pem") { t.Fatalf("tls certificate export exposes private key material: %#v", pendingTLS) } beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/security/tls-certificate", "", "mock-admin") assertString(t, beforeApply, "active_certificate_id", "factory-default") apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/security/tls-certificate", "", "mock-admin") assertString(t, afterApply, "active_certificate_id", "operator-imported-2026") assertString(t, afterApply, "source", "generated") events := getAuditEvents(t, handler) if len(events) != 3 { t.Fatalf("audit event count = %d, want 3", len(events)) } assertString(t, events[1], "action", "security.tls_certificate.update") assertString(t, events[1], "target", "tls_certificate") assertString(t, events[1], "result", "succeeded") } func TestTLSCertificateRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, body := range []string{ `{}`, `{"active_certificate_id":""}`, `{"active_certificate_id":"missing"}`, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/security/tls-certificate", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestTLSCertificateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/security/tls-certificate", strings.NewReader(`{"active_certificate_id":"missing"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "security.tls_certificate.update") assertString(t, events[0], "target", "tls_certificate") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "tls certificate active_certificate_id must reference an available certificate: TLS certificate does not exist") } func TestConfigExportContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet"}`, "mock-admin") assertConfigSummary(t, update, 1, 1, 1, false) exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") requireKeys(t, exported, "format_version", "generated_at", "summary", "running", "persistent", "pending") summary, ok := exported["summary"].(map[string]any) if !ok { t.Fatalf("summary shape = %T, want object", exported["summary"]) } assertNumber(t, summary, "pending_changes", 1) running, ok := exported["running"].(map[string]any) if !ok { t.Fatalf("running shape = %T, want object", exported["running"]) } requireKeys(t, running, "ports", "alarm_rules", "network", "tls_certificate") runningTLS, ok := running["tls_certificate"].(map[string]any) if !ok { t.Fatalf("running tls_certificate shape = %T, want object", running["tls_certificate"]) } assertString(t, runningTLS, "active_certificate_id", "factory-default") runningPorts, ok := running["ports"].([]any) if !ok || len(runningPorts) != 48 { t.Fatalf("running ports shape = %T length %d, want 48 ports", running["ports"], len(runningPorts)) } pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } requireKeys(t, pending, "ports", "alarm_rules", "network_interfaces") pendingPorts, ok := pending["ports"].([]any) if !ok || len(pendingPorts) != 1 { t.Fatalf("pending ports shape = %T length %d, want one port", pending["ports"], len(pendingPorts)) } firstPendingPort, ok := pendingPorts[0].(map[string]any) if !ok { t.Fatalf("pending port shape = %T, want object", pendingPorts[0]) } assertString(t, firstPendingPort, "channel", "CH01") assertString(t, firstPendingPort, "mode", "reverse_telnet") } func TestNetworkUpdateRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "empty", body: `{}`}, {name: "missing interface name", body: `{"interfaces":[{"address":"192.168.10.233/24"}]}`}, {name: "invalid address", body: `{"interfaces":[{"name":"Interface-1","address":"not-cidr"}]}`}, {name: "invalid gateway", body: `{"interfaces":[{"name":"Interface-1","gateway":"not-ip"}]}`}, {name: "no changes", body: `{"interfaces":[{"name":"Interface-1"}]}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/network/running", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/network/running", strings.NewReader(`{"interfaces":[{"name":"Interface-3","address":"192.168.10.233/24"}]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("unknown interface status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") } func TestNetworkUpdateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/network/running", strings.NewReader(`{"interfaces":[{"name":"Interface-1","address":"not-cidr"}]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "network.update") assertString(t, events[0], "target", "network") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "address must be CIDR notation") } func TestSNMPAgentRequiresConfigPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/snmp/agent", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/snmp/agent", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/snmp/agent", strings.NewReader(`{"enabled":true}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestSNMPAgentUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/snmp/agent", "", "mock-admin") assertString(t, before, "location", "Data center") if got, ok := before["enabled"].(bool); !ok || got { t.Fatalf("enabled = %v, want false", before["enabled"]) } body := `{"enabled":true,"location":"Rack 42","contact":"noc@example.local","v1_enabled":false,"v2c_enabled":false,"v3_enabled":true,"read_only_community":"tera-read-only","v3_users":[{"username":"snmp-admin","auth_protocol":"sha256","privacy_protocol":"aes","auth_key":"snmp-auth-passphrase","privacy_key":"snmp-privacy-passphrase","enabled":true}]}` update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/snmp/agent", body, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "snmp_agent_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/snmp/agent", "", "mock-admin") assertString(t, beforeApply, "location", "Data center") exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending := objectValue(t, exported, "pending") pendingSNMP := objectValue(t, pending, "snmp_agent") assertString(t, pendingSNMP, "location", "Rack 42") users := arrayValue(t, pendingSNMP, "v3_users") user := objectAt(t, users, 0) assertString(t, user, "username", "snmp-admin") if got, ok := user["auth_key_stored"].(bool); !ok || !got { t.Fatalf("auth_key_stored = %v, want true", user["auth_key_stored"]) } exportBytes, err := json.Marshal(exported) if err != nil { t.Fatalf("marshal export: %v", err) } exportString := string(exportBytes) for _, forbidden := range []string{"community_value", "auth_secret", "privacy_secret"} { if strings.Contains(exportString, forbidden) { t.Fatalf("config export contains forbidden SNMP agent secret field %q: %s", forbidden, exportString) } } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/snmp/agent", "", "mock-admin") assertString(t, afterApply, "location", "Rack 42") if got, ok := afterApply["enabled"].(bool); !ok || !got { t.Fatalf("enabled = %v, want true", afterApply["enabled"]) } events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, update, "audit_id")) assertString(t, events[0], "action", "snmp.agent.update") assertString(t, events[0], "target", "snmp_agent") assertString(t, events[0], "result", "succeeded") } func TestSNMPAgentRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "empty", body: `{}`}, {name: "no version", body: `{"enabled":true,"v1_enabled":false,"v2c_enabled":false,"v3_enabled":false}`}, {name: "community missing", body: `{"v2c_enabled":true,"read_only_community_stored":false}`}, {name: "v3 no enabled user", body: `{"enabled":true,"v2c_enabled":false,"v3_enabled":true,"v3_users":[{"username":"snmp-admin","auth_protocol":"sha256","privacy_protocol":"aes","auth_key_stored":true,"privacy_key_stored":true,"enabled":false}]}`}, {name: "bad auth protocol", body: `{"v3_users":[{"username":"snmp-admin","auth_protocol":"sha512","privacy_protocol":"none","enabled":false}]}`}, {name: "privacy without auth", body: `{"v3_users":[{"username":"snmp-admin","auth_protocol":"none","privacy_protocol":"aes","privacy_key_stored":true,"enabled":false}]}`}, {name: "enabled v3 missing key", body: `{"enabled":true,"v2c_enabled":false,"v3_enabled":true,"v3_users":[{"username":"snmp-admin","auth_protocol":"sha256","privacy_protocol":"aes","auth_key_stored":true,"privacy_key_stored":false,"enabled":true}]}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/snmp/agent", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } } func TestSNMPAgentValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/snmp/agent", strings.NewReader(`{}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "snmp.agent.update") assertString(t, events[0], "target", "snmp_agent") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "snmp agent update must include at least one field") } func TestSystemStatusRequiresSystemReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestServiceHealthRequiresSystemReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/system/services", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/system/services", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") services := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/system/services", "", "mock-admin") if len(services) == 0 { t.Fatalf("service health length = 0, want non-empty") } assertString(t, services[0], "service", "tera-api") assertString(t, services[0], "status", "ok") } func TestServiceHealthSupportsBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) services := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/system/services?limit=1", "", "mock-admin") if len(services) != 1 { t.Fatalf("service health count = %d, want 1", len(services)) } requireKeys(t, services[0], "service", "status", "detail", "last_checked_at") empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/system/services?limit=1&offset=1000", "", "mock-admin") if len(empty) != 0 { t.Fatalf("service health count = %d, want empty page", len(empty)) } } func TestServiceHealthRejectsInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/system/services?limit=501", "/api/v1/system/services?limit=-1", "/api/v1/system/services?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestSystemRebootRequiresSystemRebootPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/system/reboot", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/system/reboot", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "system.reboot") assertString(t, events[0], "result", "authentication_failed") assertString(t, events[1], "actor", "mock-viewer") assertString(t, events[1], "action", "system.reboot") assertString(t, events[1], "result", "authorization_denied") } func TestSystemRebootContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) operation := getJSONAs(t, handler, http.MethodPost, "/api/v1/system/reboot", "", "mock-admin") requireKeys(t, operation, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertString(t, operation, "state", "succeeded") assertNumber(t, operation, "progress_percent", 100) events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, operation, "audit_id")) assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "system.reboot") assertString(t, events[0], "target", "system") assertString(t, events[0], "result", "succeeded") } func TestFactoryResetRequiresFactoryResetPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/system/factory-reset", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/system/factory-reset", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestFactoryResetContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet","baud_rate":38400}`, "mock-admin") assertConfigSummary(t, update, 1, 1, 1, false) apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) operation := getJSONAs(t, handler, http.MethodPost, "/api/v1/system/factory-reset", `{"password":"mock-admin-secret","confirmed":true}`, "mock-admin") assertOperationPayload(t, operation) port := getPort(t, handler, "CH01") if port.Mode != "disabled" || port.BaudRate != 9600 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" { t.Fatalf("factory reset port = %#v, want default disabled operations and 9600", port) } status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") assertNumber(t, status, "pending_changes", 0) assertNumber(t, status, "running_revision", 3) assertNumber(t, status, "persistent_revision", 2) if got, ok := status["running_differs_from_persistent"].(bool); !ok || got { t.Fatalf("running_differs_from_persistent = %#v, want false", status["running_differs_from_persistent"]) } events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, operation, "audit_id")) assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "system.factory_reset") assertString(t, events[0], "target", "system") assertString(t, events[0], "result", "succeeded") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(`{"baud_rate":38400}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatal("write accepted after factory reset") } } func TestAlarmRuleUpdateRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/rules/auth_failures", strings.NewReader(`{"enabled":false}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "alarm.rule.update") assertString(t, events[0], "target", "auth_failures") assertString(t, events[0], "result", "authentication_failed") } func TestAlarmRuleUpdateRejectsUnauthorizedActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/rules/auth_failures", strings.NewReader(`{"enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-port-operator") assertString(t, events[0], "action", "alarm.rule.update") assertString(t, events[0], "target", "auth_failures") assertString(t, events[0], "result", "authorization_denied") } func TestAlarmRuleUpdateValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/rules/auth_failures", strings.NewReader(`{"trigger":" "}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", response.Code, http.StatusBadRequest) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.rule.update") assertString(t, events[0], "target", "auth_failures") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "trigger must not be empty") } func TestAlarmRuleUpdateMissingRuleRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/rules/missing_rule", strings.NewReader(`{"enabled":false}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status code = %d, want %d", response.Code, http.StatusNotFound) } assertErrorCode(t, response, "not_found") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.rule.update") assertString(t, events[0], "target", "missing_rule") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "alarm rule not found") } func TestAlarmRuleUpdateUsesConfigTransaction(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/alarms/rules/auth_failures", `{"enabled":false,"delivery":["web","syslog"]}`, "mock-admin") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getAlarmRule(t, handler, "auth_failures") if !beforeApply.Enabled { t.Fatalf("enabled before apply = false, want true") } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getAlarmRule(t, handler, "auth_failures") 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) } events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, update, "audit_id")) assertString(t, events[0], "action", "alarm.rule.update") assertString(t, events[0], "target", "auth_failures") assertString(t, events[0], "result", "succeeded") } func TestAlarmDeliveryPolicyRequiresAlarmPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/alarms/delivery-policy", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/alarms/delivery-policy", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/delivery-policy", strings.NewReader(`{"retry_count":5}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestAlarmDeliveryPolicyUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/delivery-policy", "", "mock-admin") assertNumber(t, before, "retry_count", 3) assertNumber(t, before, "retry_interval_seconds", 60) assertNumber(t, before, "pending_queue_limit", 100) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/alarms/delivery-policy", `{"retry_count":5,"retry_interval_seconds":120,"pending_queue_limit":250}`, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "alarm_delivery_policy_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/delivery-policy", "", "mock-admin") assertNumber(t, beforeApply, "retry_count", 3) exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingPolicy, ok := pending["alarm_delivery"].(map[string]any) if !ok { t.Fatalf("pending alarm_delivery shape = %T, want object", pending["alarm_delivery"]) } assertNumber(t, pendingPolicy, "retry_count", 5) assertNumber(t, pendingPolicy, "retry_interval_seconds", 120) assertNumber(t, pendingPolicy, "pending_queue_limit", 250) apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/delivery-policy", "", "mock-admin") assertNumber(t, afterApply, "retry_count", 5) assertNumber(t, afterApply, "retry_interval_seconds", 120) assertNumber(t, afterApply, "pending_queue_limit", 250) events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, update, "audit_id")) assertString(t, events[0], "action", "alarm.delivery_policy.update") assertString(t, events[0], "target", "alarm_delivery") assertString(t, events[0], "result", "succeeded") } func TestAlarmDeliveryPolicyRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "empty body", body: `{}`}, {name: "high retry count", body: `{"retry_count":11}`}, {name: "low retry interval", body: `{"retry_interval_seconds":9}`}, {name: "low queue limit", body: `{"pending_queue_limit":9}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/delivery-policy", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } } func TestAlarmDeliveryPolicyValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/delivery-policy", strings.NewReader(`{"retry_count":11}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.delivery_policy.update") assertString(t, events[0], "target", "alarm_delivery") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "retry_count must be between 0 and 10") } func TestAlarmDeliveryTestRequiresAlarmWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/alarms/delivery-test", strings.NewReader(`{"delivery":["web"]}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPost, "/api/v1/alarms/delivery-test", strings.NewReader(`{"delivery":["web"]}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestAlarmDeliveryTestReturnsOperationAndAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) operation := getJSONAs(t, handler, http.MethodPost, "/api/v1/alarms/delivery-test", `{"delivery":["web"],"message":"Operator verification"}`, "mock-admin") assertOperationPayload(t, operation) if !strings.Contains(stringValue(t, operation, "message"), "Operator verification") { t.Fatalf("operation message = %q, want requested message", stringValue(t, operation, "message")) } events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, operation, "audit_id")) assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.delivery.test") assertString(t, events[0], "target", "alarm_delivery") assertString(t, events[0], "result", "succeeded") } func TestAlarmDeliveryUnavailableReturnsServiceUnavailable(t *testing.T) { store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{ AlarmDeliveryController: alarmdelivery.UnavailableController{Reason: "tera-alarmd is offline"}, }) if err != nil { t.Fatal(err) } handler := NewRouter(store) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/alarms/delivery-test", strings.NewReader(`{"delivery":["web"]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusServiceUnavailable, response.Body.String()) } assertErrorCode(t, response, "alarm_delivery_unavailable") } func TestAlarmDeliveryTestRejectsUnavailableTarget(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/alarms/delivery-test", strings.NewReader(`{"delivery":["email"]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestAlarmDeliveryTestValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/alarms/delivery-test", strings.NewReader(`{"delivery":["email"]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.delivery.test") assertString(t, events[0], "target", "alarm_delivery") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "email delivery test requires enabled email notification with smtp_host, from, and to") } func TestEmailNotificationRequiresAlarmPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/alarms/email-settings", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/alarms/email-settings", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/email-settings", strings.NewReader(`{"enabled":true}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestEmailNotificationUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/email-settings", "", "mock-admin") assertString(t, before, "smtp_host", "smtp.example.local") if got, ok := before["enabled"].(bool); !ok || got { t.Fatalf("enabled = %v, want false", before["enabled"]) } body := `{"enabled":true,"smtp_host":"smtp.local","smtp_port":587,"starttls_enabled":true,"auth_enabled":true,"username":"mailer","password_stored":true,"from":"tera@example.local","to":["ops@example.local"],"cc":["audit@example.local"],"custom_title":"Tera alarm","custom_content":"Alarm: {{message}}"}` update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/alarms/email-settings", body, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "email_notification_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/email-settings", "", "mock-admin") assertString(t, beforeApply, "smtp_host", "smtp.example.local") exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending := objectValue(t, exported, "pending") pendingEmail := objectValue(t, pending, "email_notification") assertString(t, pendingEmail, "smtp_host", "smtp.local") assertString(t, pendingEmail, "username", "mailer") if got, ok := pendingEmail["password_stored"].(bool); !ok || !got { t.Fatalf("password_stored = %v, want true", pendingEmail["password_stored"]) } exportBytes, err := json.Marshal(exported) if err != nil { t.Fatalf("marshal export: %v", err) } if strings.Contains(string(exportBytes), "password_value") || strings.Contains(string(exportBytes), "smtp_password") { t.Fatalf("config export contains email password material: %s", string(exportBytes)) } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/email-settings", "", "mock-admin") assertString(t, afterApply, "smtp_host", "smtp.local") if got, ok := afterApply["enabled"].(bool); !ok || !got { t.Fatalf("enabled = %v, want true", afterApply["enabled"]) } to := arrayValue(t, afterApply, "to") if len(to) != 1 || to[0] != "ops@example.local" { t.Fatalf("to = %#v, want ops@example.local", to) } events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, update, "audit_id")) assertString(t, events[0], "action", "alarm.email.update") assertString(t, events[0], "target", "email_notification") assertString(t, events[0], "result", "succeeded") } func TestEmailNotificationRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "empty", body: `{}`}, {name: "bad port", body: `{"smtp_port":0}`}, {name: "bad from", body: `{"from":"not-email"}`}, {name: "enabled no recipient", body: `{"enabled":true,"to":[]}`}, {name: "auth no username", body: `{"auth_enabled":true,"username":"","password_stored":true}`}, {name: "auth no password", body: `{"auth_enabled":true,"username":"mailer","password_stored":false}`}, {name: "bad to", body: `{"to":["bad-address"]}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/email-settings", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } } func TestEmailNotificationValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/email-settings", strings.NewReader(`{"smtp_port":0}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.email.update") assertString(t, events[0], "target", "email_notification") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "email notification smtp_port must be between 1 and 65535") } func TestSNMPTrapTargetsRequireAlarmPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/alarms/snmp-trap-targets", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/alarms/snmp-trap-targets", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/snmp-trap-targets", strings.NewReader(`{"targets":[]}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestSNMPTrapTargetsUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/snmp-trap-targets", "", "mock-admin") targets := arrayValue(t, before, "targets") if len(targets) != 1 { t.Fatalf("initial target count = %d, want 1", len(targets)) } first := objectAt(t, targets, 0) assertString(t, first, "target_id", "snmp-primary") assertString(t, first, "host", "snmp.example.local") body := `{"targets":[{"target_id":"snmp-primary","host":"snmp.local","port":162,"version":"v3","username":"trap-user","auth_protocol":"sha1","privacy_protocol":"aes","engine_id":"tera-core-x48","credential":"snmp-trap-passphrase","privacy_key":"snmp-privacy-passphrase","enabled":true}]}` update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/alarms/snmp-trap-targets", body, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "snmp_trap_targets_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/snmp-trap-targets", "", "mock-admin") beforeApplyTargets := arrayValue(t, beforeApply, "targets") assertString(t, objectAt(t, beforeApplyTargets, 0), "host", "snmp.example.local") exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending := objectValue(t, exported, "pending") pendingSNMP := objectValue(t, pending, "snmp_trap_targets") pendingTargets := arrayValue(t, pendingSNMP, "targets") pendingFirst := objectAt(t, pendingTargets, 0) assertString(t, pendingFirst, "host", "snmp.local") assertString(t, pendingFirst, "version", "v3") if got, ok := pendingFirst["credential_stored"].(bool); !ok || !got { t.Fatalf("credential_stored = %v, want true", pendingFirst["credential_stored"]) } exportBytes, err := json.Marshal(exported) if err != nil { t.Fatalf("marshal export: %v", err) } exportString := string(exportBytes) for _, forbidden := range []string{"community_value", "auth_secret", "privacy_secret"} { if strings.Contains(exportString, forbidden) { t.Fatalf("config export contains forbidden SNMP credential field %q: %s", forbidden, exportString) } } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/alarms/snmp-trap-targets", "", "mock-admin") afterTargets := arrayValue(t, afterApply, "targets") afterFirst := objectAt(t, afterTargets, 0) assertString(t, afterFirst, "host", "snmp.local") if got, ok := afterFirst["enabled"].(bool); !ok || !got { t.Fatalf("enabled = %v, want true", afterFirst["enabled"]) } events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, update, "audit_id")) assertString(t, events[0], "action", "alarm.snmp_trap.update") assertString(t, events[0], "target", "snmp_trap_targets") assertString(t, events[0], "result", "succeeded") } func TestSNMPTrapTargetsRejectInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "empty", body: `{}`}, {name: "missing target id", body: `{"targets":[{"host":"snmp.local","port":162,"version":"v3","username":"trap-user","engine_id":"tera-core-x48","enabled":true}]}`}, {name: "enabled v3 without username", body: `{"targets":[{"target_id":"snmp-primary","host":"snmp.local","port":162,"version":"v3","engine_id":"tera-core-x48","enabled":true}]}`}, {name: "enabled without credential", body: `{"targets":[{"target_id":"snmp-primary","host":"snmp.local","port":162,"version":"v3","username":"trap-user","engine_id":"tera-core-x48","auth_protocol":"md5","credential_stored":false,"enabled":true}]}`}, {name: "bad version", body: `{"targets":[{"target_id":"snmp-primary","host":"snmp.local","port":162,"version":"v4","credential_stored":true,"enabled":false}]}`}, {name: "bad port", body: `{"targets":[{"target_id":"snmp-primary","host":"snmp.local","port":0,"version":"v3","username":"trap-user","credential_stored":true,"enabled":true}]}`}, {name: "duplicate id", body: `{"targets":[{"target_id":"snmp-primary","host":"one.local","port":162,"version":"v3","username":"trap-user","engine_id":"tera-core-x48","enabled":true},{"target_id":"SNMP-PRIMARY","host":"two.local","port":162,"version":"v2c","credential_stored":true,"enabled":false}]}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/snmp-trap-targets", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } } func TestSNMPTrapTargetsValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/alarms/snmp-trap-targets", strings.NewReader(`{"targets":[]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "alarm.snmp_trap.update") assertString(t, events[0], "target", "snmp_trap_targets") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "snmp trap target update must include at least one target") } func TestAuditLogRecordsMutatingCommands(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) portUpdate := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet"}`, "mock-admin") apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") restart := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH01/restart", "", "mock-admin") events := getAuditEvents(t, handler) if len(events) != 3 { t.Fatalf("audit event count = %d, want 3", len(events)) } requireKeys(t, events[0], "audit_id", "timestamp", "actor", "action", "target", "result", "message") assertString(t, events[0], "audit_id", stringValue(t, portUpdate, "audit_id")) assertString(t, events[0], "action", "port.update") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "succeeded") assertString(t, events[1], "audit_id", stringValue(t, apply, "audit_id")) assertString(t, events[1], "action", "config.apply") assertString(t, events[1], "target", "config") assertString(t, events[2], "audit_id", stringValue(t, restart, "audit_id")) assertString(t, events[2], "action", "port.restart") assertString(t, events[2], "target", "CH01") } func TestAuditLogRecordsConfigSaveCommands(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) save := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save", "", "mock-admin") updateForSaveApply := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse SSH"}`, "mock-admin") saveApply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save-and-apply", "", "mock-admin") updateForSaveRestart := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet"}`, "mock-admin") saveRestart := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save-and-restart", "", "mock-admin") assertOperationPayload(t, saveRestart) events := getAuditEvents(t, handler) if len(events) != 6 { t.Fatalf("audit event count = %d, want 6", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, save, "audit_id")) assertString(t, events[0], "action", "config.save") assertString(t, events[0], "target", "config") assertString(t, events[0], "result", "succeeded") assertString(t, events[1], "audit_id", stringValue(t, updateForSaveApply, "audit_id")) assertString(t, events[1], "action", "port.update") assertString(t, events[1], "target", "CH01") assertString(t, events[2], "audit_id", stringValue(t, saveApply, "audit_id")) assertString(t, events[2], "action", "config.save_and_apply") assertString(t, events[2], "target", "config") assertString(t, events[2], "result", "succeeded") assertString(t, events[3], "audit_id", stringValue(t, updateForSaveRestart, "audit_id")) assertString(t, events[3], "action", "port.update") assertString(t, events[3], "target", "CH01") assertString(t, events[4], "audit_id", stringValue(t, saveRestart, "audit_id")) assertString(t, events[4], "action", "config.save_and_restart") assertString(t, events[4], "target", "config") assertString(t, events[4], "result", "succeeded") assertString(t, events[5], "action", "system.reboot") assertString(t, events[5], "target", "system") assertString(t, events[5], "result", "succeeded") } func TestMutatingCommandRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(`{"mode":"Telnet"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "port.update") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "authentication_failed") } func TestMutatingCommandRejectsUnauthorizedActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(`{"mode":"Telnet"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-viewer") assertString(t, events[0], "action", "port.update") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "authorization_denied") } func TestMutatingCommandAuditUsesAuthenticatedActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) result := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet"}`, "mock-port-operator") requireKeys(t, result, "audit_id") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-port-operator") assertString(t, events[0], "action", "port.update") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "succeeded") assertString(t, events[0], "audit_id", stringValue(t, result, "audit_id")) } func TestMutatingCommandValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(`{"baud_rate":1}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", response.Code, http.StatusBadRequest) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-port-operator") assertString(t, events[0], "action", "port.update") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "baud_rate must be between 110 and 460800") } func TestMutatingCommandRejectsUnknownJSONFields(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(`{"mode":"telnet","unexpected":true}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-port-operator") assertString(t, events[0], "action", "port.update") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "invalid JSON body") } func TestDirectModeSessionRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/direct/CH01/sessions", strings.NewReader(`{"access":"viewer"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "direct.session.open") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "authentication_failed") } func TestDirectModeSessionRejectsUnknownJSONFields(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/direct/CH01/sessions", strings.NewReader(`{"access":"viewer","shell":"powershell"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", response.Code, http.StatusBadRequest) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "action", "direct.session.open") assertString(t, events[0], "result", "rejected") } func TestDirectModeSessionRejectsUnknownAccess(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/direct/CH01/sessions", strings.NewReader(`{"access":"shell"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", response.Code, http.StatusBadRequest) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "action", "direct.session.open") assertString(t, events[0], "result", "rejected") } func TestDirectModeViewerSessionContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/direct/CH01/sessions", `{"access":"viewer"}`, "mock-viewer") requireKeys(t, payload, "session_id", "channel", "actor", "access", "viewer_count", "max_viewers", "writer", "state", "message", "audit_id", "stream_url", "stream_protocol", "stream_ticket", "ticket_expires_at") assertString(t, payload, "channel", "CH01") assertString(t, payload, "actor", "mock-viewer") assertString(t, payload, "access", "viewer") assertString(t, payload, "state", "open") assertString(t, payload, "stream_protocol", "tera.direct.v1") if !strings.Contains(stringValue(t, payload, "stream_url"), "/api/v1/direct/CH01/sessions/") { t.Fatalf("stream_url = %q, want canonical CH01 session path", stringValue(t, payload, "stream_url")) } if stringValue(t, payload, "stream_ticket") == "" { t.Fatal("stream_ticket is empty") } assertNumber(t, payload, "viewer_count", 1) assertNumber(t, payload, "max_viewers", 4) events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, payload, "audit_id")) assertString(t, events[0], "action", "direct.session.open") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "succeeded") } func TestDirectModeWriterClaimRequiresWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/direct/CH01/writer", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-viewer") assertString(t, events[0], "action", "direct.writer.claim") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "authorization_denied") } func TestDirectModeWriterClaimContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/direct/CH01/writer", "", "mock-admin") requireKeys(t, payload, "session_id", "channel", "actor", "access", "viewer_count", "max_viewers", "writer", "state", "message", "audit_id") assertString(t, payload, "channel", "CH01") assertString(t, payload, "actor", "mock-admin") assertString(t, payload, "access", "writer") assertString(t, payload, "writer", "mock-admin") assertString(t, payload, "state", "open") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, payload, "audit_id")) assertString(t, events[0], "action", "direct.writer.claim") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "succeeded") } func TestDirectModeSessionCloseContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) opened := getJSONAs(t, handler, http.MethodPost, "/api/v1/direct/CH01/sessions", `{"access":"viewer"}`, "mock-viewer") sessionID := stringValue(t, opened, "session_id") closed := getJSONAs(t, handler, http.MethodDelete, "/api/v1/direct/CH01/sessions/"+sessionID, "", "mock-viewer") assertString(t, closed, "session_id", sessionID) assertString(t, closed, "channel", "CH01") assertString(t, closed, "actor", "mock-viewer") assertString(t, closed, "state", "closed") assertNumber(t, closed, "viewer_count", 0) events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[1], "action", "direct.session.close") assertString(t, events[1], "target", "CH01") assertString(t, events[1], "result", "succeeded") } func TestDirectModeSessionCloseRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) opened := getJSONAs(t, handler, http.MethodPost, "/api/v1/direct/CH01/sessions", `{"access":"viewer"}`, "mock-viewer") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/direct/CH01/sessions/"+stringValue(t, opened, "session_id"), nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[1], "action", "direct.session.close") assertString(t, events[1], "result", "authentication_failed") } func TestDirectModeSessionCloseRejectsAnotherActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) opened := getJSONAs(t, handler, http.MethodPost, "/api/v1/direct/CH01/sessions", `{"access":"viewer"}`, "mock-viewer") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/direct/CH01/sessions/"+stringValue(t, opened, "session_id"), nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[1], "action", "direct.session.close") assertString(t, events[1], "result", "authorization_denied") } type directPortRuntimeController struct { connected bool } func (c *directPortRuntimeController) States(context.Context) ([]serial.State, error) { return []serial.State{{Channel: "CH01", Available: true, ProfileApplied: true}}, nil } func (c *directPortRuntimeController) Apply(_ context.Context, _ []serial.ChannelConfig) ([]serial.State, error) { return c.States(context.Background()) } func (c *directPortRuntimeController) CRProbeStates(context.Context) ([]serial.CRProbeState, error) { state := serial.CRProbeStateNoResponse if c.connected { state = serial.CRProbeStateResponded } return []serial.CRProbeState{{ Channel: "CH01", Enabled: true, State: state, Up: c.connected, EverUp: c.connected, }}, nil } func TestDirectModeWriterSessionRejectsDisconnectedPort(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ SerialController: &directPortRuntimeController{}, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/direct/CH01/sessions", strings.NewReader(`{"access":"writer"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusConflict { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusConflict, response.Body.String()) } assertErrorCode(t, response, "port_not_connected") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "action", "direct.session.open") assertString(t, events[0], "target", "CH01") assertString(t, events[0], "result", "rejected") } func TestDirectModeWriterReleaseContract(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ SerialController: &directPortRuntimeController{connected: true}, }) opened := getJSONAs(t, handler, http.MethodPost, "/api/v1/direct/CH01/sessions", `{"access":"writer"}`, "mock-admin") if stringValue(t, opened, "writer") != "mock-admin" { t.Fatalf("opened writer = %q, want mock-admin", stringValue(t, opened, "writer")) } released := getJSONAs(t, handler, http.MethodDelete, "/api/v1/direct/CH01/writer", "", "mock-admin") assertString(t, released, "channel", "CH01") assertString(t, released, "actor", "mock-admin") assertString(t, released, "access", "viewer") assertString(t, released, "writer", "") assertString(t, released, "state", "open") events := getAuditEvents(t, handler) if len(events) != 2 { t.Fatalf("audit event count = %d, want 2", len(events)) } assertString(t, events[1], "action", "direct.writer.release") assertString(t, events[1], "target", "CH01") assertString(t, events[1], "result", "succeeded") } func TestDirectModeWriterReleaseRequiresWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodDelete, "/api/v1/direct/CH01/writer", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "action", "direct.writer.release") assertString(t, events[0], "result", "authorization_denied") } func TestPortLogSearchContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/port?channel=CH01&direction=RX&keyword=LOGIN&actor=admin&from=2026-06-15T23:00:00Z&to=2026-06-16T01:00:00Z", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) } var rows []map[string]any if err := json.Unmarshal(response.Body.Bytes(), &rows); err != nil { t.Fatalf("decode port logs: %v", err) } if len(rows) != 1 { t.Fatalf("port log count = %d, want 1", len(rows)) } requireKeys(t, rows[0], "timestamp", "channel", "direction", "actor", "message", "match") assertString(t, rows[0], "channel", "CH01") assertString(t, rows[0], "match", "keyword") } func TestSystemLogSearchContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) rows := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/system?category=status&severity=warning&keyword=failover&from=2026-06-15T23:00:00Z&to=2026-06-16T01:00:00Z", "", "mock-admin") if len(rows) != 1 { t.Fatalf("system log count = %d, want 1", len(rows)) } requireKeys(t, rows[0], "timestamp", "category", "severity", "source", "message") assertString(t, rows[0], "category", "status") assertString(t, rows[0], "severity", "warning") assertString(t, rows[0], "source", "network") } func TestSystemLogSearchSupportsBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) rows := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/system?limit=1&offset=1", "", "mock-admin") if len(rows) != 1 { t.Fatalf("system log count = %d, want 1", len(rows)) } assertString(t, rows[0], "severity", "warning") assertString(t, rows[0], "source", "network") rows = getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/system?limit=1&offset=5", "", "mock-admin") if len(rows) != 0 { t.Fatalf("system log count = %d, want empty page", len(rows)) } } func TestLogSearchRejectsInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/logs/audit?limit=501", "/api/v1/logs/audit?order=newest", "/api/v1/logs/audit?from=not-a-time", "/api/v1/logs/system?limit=-1", "/api/v1/logs/system?category=security", "/api/v1/logs/system?offset=bad", "/api/v1/logs/port?limit=501", "/api/v1/logs/port?direction=both", "/api/v1/logs/port?from=2026-06-17T00:00:00Z&to=2026-06-16T00:00:00Z", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestSystemLogSearchRequiresLogReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/system", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/logs/system", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestSystemLogSearchRejectsInvalidSeverity(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/system?severity=debug", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestPortLogSearchRequiresLogReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/port?channel=CH01", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/logs/port?channel=CH01", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestLogRetentionRequiresLogReadAndConfigWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/retention", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/logs/retention", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"port_days":180}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestLogRetentionUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/retention", "", "mock-admin") assertNumber(t, before, "port_days", 365) assertNumber(t, before, "protected_usage_warning_pct", 90) assertNumber(t, before, "keepalive_interval_minutes", 3) assertNumber(t, before, "all_port_status_interval_minutes", 30) assertNumber(t, before, "single_port_status_interval_minutes", 1) if before["local_system_enabled"] != true || before["remote_system_enabled"] != false { t.Fatalf("default log event destinations = %#v", before) } update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/logs/retention", `{"port_days":180,"protected_usage_warning_pct":85,"remote_system_enabled":true,"keepalive_interval_minutes":5,"all_port_status_interval_minutes":60,"single_port_status_realtime":true}`, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "log_retention_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/retention", "", "mock-admin") assertNumber(t, beforeApply, "port_days", 365) exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingLogs, ok := pending["logs"].(map[string]any) if !ok { t.Fatalf("pending logs shape = %T, want object", pending["logs"]) } assertNumber(t, pendingLogs, "port_days", 180) assertNumber(t, pendingLogs, "keepalive_interval_minutes", 5) if pendingLogs["remote_system_enabled"] != true || pendingLogs["single_port_status_realtime"] != true { t.Fatalf("pending log event settings = %#v", pendingLogs) } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/retention", "", "mock-admin") assertNumber(t, afterApply, "port_days", 180) assertNumber(t, afterApply, "protected_usage_warning_pct", 90) assertNumber(t, afterApply, "keepalive_interval_minutes", 5) assertNumber(t, afterApply, "all_port_status_interval_minutes", 60) if afterApply["remote_system_enabled"] != true || afterApply["single_port_status_realtime"] != true { t.Fatalf("applied log event settings = %#v", afterApply) } policy := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/policy", "", "mock-admin") assertNumber(t, policy, "link_poll_interval_seconds", 1) assertNumber(t, policy, "keepalive_interval_seconds", 300) assertNumber(t, policy, "snapshot_interval_seconds", 3600) } func TestLogEventIntervalValidation(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, body := range []string{ `{"keepalive_interval_minutes":4}`, `{"all_port_status_interval_minutes":0}`, `{"single_port_status_interval_minutes":1441}`, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("body %s status = %d, want %d", body, response.Code, http.StatusBadRequest) } } } func TestLogRetentionRejectsUnsafeProtectedLogDeletionPolicy(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"preserve_on_factory_reset":true}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestLogRetentionValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"preserve_on_factory_reset":true}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "log.retention.update") assertString(t, events[0], "target", "logs") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "preserve_on_factory_reset must be false: factory reset erases all logs") } func TestRemoteSyslogRequiresLogReadAndConfigWritePermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/logs/remote-syslog", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing read actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/logs/remote-syslog", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer read status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPatch, "/api/v1/logs/remote-syslog", strings.NewReader(`{"enabled":true,"host":"syslog.local"}`)) request.Header.Set("X-Mock-Actor", "mock-port-operator") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("operator write status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") } func TestRemoteSyslogUpdateStagesPendingConfig(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) before := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/remote-syslog", "", "mock-admin") if got, ok := before["enabled"].(bool); !ok || got { t.Fatalf("enabled = %v, want false", before["enabled"]) } assertString(t, before, "protocol", "udp") update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/logs/remote-syslog", `{"enabled":true,"host":"syslog.local","port":1514,"protocol":"tcp","facility":"local4"}`, "mock-admin") requireKeys(t, update, "result", "message", "audit_id", "config") assertString(t, update, "result", "remote_syslog_update_staged") assertConfigSummary(t, update, 1, 1, 1, false) beforeApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/remote-syslog", "", "mock-admin") if got, ok := beforeApply["enabled"].(bool); !ok || got { t.Fatalf("enabled before apply = %v, want false", beforeApply["enabled"]) } exported := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/export", "", "mock-admin") pending, ok := exported["pending"].(map[string]any) if !ok { t.Fatalf("pending shape = %T, want object", exported["pending"]) } pendingSyslog, ok := pending["remote_syslog"].(map[string]any) if !ok { t.Fatalf("pending remote_syslog shape = %T, want object", pending["remote_syslog"]) } assertString(t, pendingSyslog, "host", "syslog.local") assertString(t, pendingSyslog, "protocol", "tcp") apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/remote-syslog", "", "mock-admin") if got, ok := afterApply["enabled"].(bool); !ok || !got { t.Fatalf("enabled after apply = %v, want true", afterApply["enabled"]) } assertString(t, afterApply, "host", "syslog.local") assertNumber(t, afterApply, "port", 1514) } func TestRemoteSyslogRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, item := range []struct { name string body string }{ {name: "enabled without host", body: `{"enabled":true,"host":""}`}, {name: "invalid port", body: `{"port":70000}`}, {name: "invalid protocol", body: `{"protocol":"tls"}`}, {name: "invalid facility", body: `{"facility":"auth"}`}, {name: "invalid host character", body: `{"host":"bad host"}`}, } { t.Run(item.name, func(t *testing.T) { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/remote-syslog", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") }) } } func TestRemoteSyslogValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/remote-syslog", strings.NewReader(`{"protocol":"tls"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "log.remote_syslog.update") assertString(t, events[0], "target", "remote_syslog") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "remote syslog protocol must be udp or tcp") } func TestLogExportRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export", strings.NewReader(`{"log_type":"port","channel":"CH01"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "log.export") assertString(t, events[0], "target", "logs") assertString(t, events[0], "result", "authentication_failed") } func TestLogExportRejectsUnauthorizedActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export", strings.NewReader(`{"log_type":"port","channel":"CH01"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-viewer") assertString(t, events[0], "action", "log.export") assertString(t, events[0], "target", "logs") assertString(t, events[0], "result", "authorization_denied") } func TestLogExportContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/logs/export", `{"log_type":"port","channel":"CH01","keyword":"LOGIN","event_type":"traffic","direction":"rx","actor":"admin","from":"2026-06-15T23:00:00Z","to":"2026-06-16T01:00:00Z","max_rows":250}`, "mock-admin") assertOperationPayload(t, payload) if !strings.Contains(stringValue(t, payload, "message"), "max_rows=250") { t.Fatalf("message = %q, want max_rows detail", stringValue(t, payload, "message")) } result := objectValue(t, payload, "result") assertString(t, result, "log_type", "port") assertString(t, result, "channel", "CH01") assertString(t, result, "keyword", "LOGIN") assertString(t, result, "event_type", "traffic") assertString(t, result, "direction", "RX") assertString(t, result, "actor", "admin") assertString(t, result, "from", "2026-06-15T23:00:00Z") assertString(t, result, "to", "2026-06-16T01:00:00Z") assertNumber(t, result, "max_rows", 250) events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, payload, "audit_id")) assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "log.export") assertString(t, events[0], "target", "logs") assertString(t, events[0], "result", "succeeded") } func TestLogExportDownloadStreamsFilteredNDJSON(t *testing.T) { store := mock.NewStore(fixedClock) for _, event := range []domain.PortLogEvent{ {Channel: "CH01", EventType: "command", Direction: "RX", Actor: "admin/session-1", Command: "show LOGIN", ReturnValue: "ok", Message: "login matched"}, {Channel: "CH02", EventType: "command", Direction: "RX", Actor: "operator/session-2", Command: "show LOGIN", ReturnValue: "ok", Message: "other channel"}, {Channel: "CH01", EventType: "command", Direction: "TX", Actor: "operator/session-2", Command: "show LOGIN", ReturnValue: "ok", Message: "other direction"}, {Channel: "CH01", EventType: "traffic", Direction: "RX", Actor: "operator/session-2", Message: "LOGIN payload metadata"}, } { if _, err := store.RecordPortLog(context.Background(), event); err != nil { t.Fatal(err) } } handler := NewRouter(store) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export/download", strings.NewReader(`{"log_type":"port","channel":"CH01","keyword":"LOGIN","event_type":"command","direction":"RX","actor":"admin","from":"2026-06-15T23:00:00Z","to":"2026-06-16T01:00:00Z","max_rows":10}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) } if got := response.Header().Get("Content-Type"); got != "application/x-ndjson" { t.Fatalf("Content-Type = %q, want application/x-ndjson", got) } if got := response.Header().Get("Content-Disposition"); !strings.Contains(got, `attachment; filename="tera-port-logs-`) { t.Fatalf("Content-Disposition = %q", got) } lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") if len(lines) != 1 { t.Fatalf("export line count = %d, want 1; body: %s", len(lines), response.Body.String()) } var event domain.PortLogEvent if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { t.Fatal(err) } if event.Channel != "CH01" || event.Command != "show LOGIN" { t.Fatalf("exported event = %+v", event) } } func TestSystemLogExportDownloadFiltersSeverity(t *testing.T) { store := mock.NewStore(fixedClock) for _, event := range []domain.SystemLogEvent{ {Category: "status", Severity: "info", Source: "network", Message: "interface healthy"}, {Category: "status", Severity: "warning", Source: "network", Message: "interface degraded"}, {Category: "system", Severity: "critical", Source: "storage", Message: "storage exhausted"}, } { if _, err := store.RecordSystemLog(context.Background(), event); err != nil { t.Fatal(err) } } handler := NewRouter(store) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export/download", strings.NewReader(`{"log_type":"system","category":"status","severity":"warning","max_rows":10}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) } lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n") if len(lines) != 1 { t.Fatalf("export line count = %d, want 1; body: %s", len(lines), response.Body.String()) } var event domain.SystemLogEvent if err := json.Unmarshal([]byte(lines[0]), &event); err != nil { t.Fatal(err) } if event.Severity != "warning" || event.Message != "interface degraded" { t.Fatalf("exported event = %+v", event) } } func TestLogExportDownloadRejectsInvalidInputBeforeWritingAttachment(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export/download", strings.NewReader(`{"log_type":"debug"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } if got := response.Header().Get("Content-Disposition"); got != "" { t.Fatalf("Content-Disposition = %q, want empty", got) } assertErrorCode(t, response, "validation_failed") } func TestLogExportRejectsInvalidInput(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, body := range []string{ `{"log_type":"debug"}`, `{"log_type":"system","severity":"error"}`, `{"log_type":"port","severity":"warning"}`, `{"log_type":"system","category":"security"}`, `{"log_type":"port","category":"status"}`, `{"log_type":"port","channel":"CH99"}`, `{"log_type":"port","event_type":"snapshot"}`, `{"log_type":"port","direction":"inbound"}`, `{"log_type":"audit","event_type":"command"}`, `{"log_type":"system","keyword":"` + strings.Repeat("x", 129) + `"}`, `{"log_type":"system","actor":"admin"}`, `{"log_type":"audit","from":"2026-06-17T00:00:00Z","to":"2026-06-16T00:00:00Z"}`, `{"log_type":"audit","max_rows":10001}`, `{"log_type":"audit","max_rows":-1}`, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d for body %s; response: %s", response.Code, http.StatusBadRequest, body, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestLogExportValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/logs/export", strings.NewReader(`{"log_type":"debug"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "log.export") assertString(t, events[0], "target", "logs") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "log export log_type must be port, audit, system, or link") } func TestBrowserTimeSyncRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-browser", strings.NewReader(`{"browser_time":"2026-06-16T10:31:12+09:00","timezone":"Asia/Seoul"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "time.sync_browser") assertString(t, events[0], "target", "time") assertString(t, events[0], "result", "authentication_failed") } func TestBrowserTimeSyncRejectsUnauthorizedActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-browser", strings.NewReader(`{"browser_time":"2026-06-16T10:31:12+09:00","timezone":"Asia/Seoul"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-viewer") assertString(t, events[0], "action", "time.sync_browser") assertString(t, events[0], "target", "time") assertString(t, events[0], "result", "authorization_denied") } func TestBrowserTimeSyncRejectsInvalidTimestamp(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-browser", strings.NewReader(`{"browser_time":"2026-06-16 10:31:12","timezone":"Asia/Seoul"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestBrowserTimeSyncValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-browser", strings.NewReader(`{"timezone":"Asia/Seoul"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "time.sync_browser") assertString(t, events[0], "target", "time") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "browser_time must be an RFC3339 timestamp") } func TestBrowserTimeSyncContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/time/sync-browser", `{"browser_time":"2026-06-16T10:31:12+09:00","timezone":"Asia/Seoul"}`, "mock-admin") assertOperationPayload(t, payload) result := objectValue(t, payload, "result") assertString(t, result, "browser_time", "2026-06-16T01:31:12Z") assertString(t, result, "timezone", "Asia/Seoul") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, payload, "audit_id")) assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "time.sync_browser") assertString(t, events[0], "target", "time") assertString(t, events[0], "result", "succeeded") } func TestBrowserTimeSyncReturnsServiceUnavailableWhenTimedIsOffline(t *testing.T) { store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{ TimeController: systemtime.UnavailableController{Reason: "tera-timed is offline"}, }) if err != nil { t.Fatal(err) } handler := NewRouter(store) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-browser", strings.NewReader(`{"browser_time":"2026-06-16T10:31:12+09:00","timezone":"Asia/Seoul"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusServiceUnavailable, response.Body.String()) } assertErrorCode(t, response, "time_unavailable") } func TestNTPTimeSyncContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/time/sync-ntp", "", "mock-admin") assertOperationPayload(t, payload) result := objectValue(t, payload, "result") assertString(t, result, "applied_time", "2026-07-30T06:00:00Z") assertString(t, result, "timezone", "Asia/Seoul") assertStringArray(t, result, "ntp_servers", []string{"ntp.kriss.re.kr", "time.bora.net"}) events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "action", "time.sync_ntp") assertString(t, events[0], "target", "time") assertString(t, events[0], "result", "succeeded") } func TestNTPTimeSyncRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-ntp", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") } func TestNTPTimeSyncReturnsServiceUnavailableWhenTimedIsOffline(t *testing.T) { store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{ TimeController: systemtime.UnavailableController{Reason: "tera-timed is offline"}, }) if err != nil { t.Fatal(err) } handler := NewRouter(store) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/time/sync-ntp", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusServiceUnavailable, response.Body.String()) } assertErrorCode(t, response, "time_unavailable") } func TestDiagnosticsRequiresActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/network-test", strings.NewReader(`{"target":"192.168.0.1"}`)) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "anonymous") assertString(t, events[0], "action", "diagnostics.network_test") assertString(t, events[0], "target", "diagnostics") assertString(t, events[0], "result", "authentication_failed") } func TestDiagnosticsRejectsUnauthorizedActor(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/network-test", strings.NewReader(`{"target":"8.8.8.8"}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-viewer") assertString(t, events[0], "action", "diagnostics.network_test") assertString(t, events[0], "target", "diagnostics") assertString(t, events[0], "result", "authorization_denied") } func TestDiagnosticsRejectsMissingTarget(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/network-test", strings.NewReader(`{"target":""}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } func TestDiagnosticsValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/network-test", strings.NewReader(`{"target":""}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "diagnostics.network_test") assertString(t, events[0], "target", "diagnostics") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "diagnostic target must not be empty") } func TestDiagnosticsRejectsInvalidBounds(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, body := range []string{ `{"target":"8.8.8.8","timeout_seconds":301}`, `{"target":"8.8.8.8","timeout_seconds":-1}`, `{"target":"8.8.8.8","count":21}`, `{"target":"8.8.8.8","max_output_bytes":1023}`, `{"target":"8.8.8.8","max_output_bytes":1048577}`, `{"target":"-c"}`, } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/network-test", strings.NewReader(body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d for body %s; response: %s", response.Code, http.StatusBadRequest, body, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestTracerouteDiagnosticsAcceptsMaximumHopsAndUsesFixedTimeout(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/traceroute", strings.NewReader(`{"target":"192.0.2.10"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("traceroute status = %d, want 404", response.Code) } } func TestDiagnosticsContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/diagnostics/network-test", `{"target":"192.168.0.1","count":5,"timeout_seconds":10,"max_output_bytes":4096}`, "mock-admin") assertOperationPayload(t, payload) if !strings.Contains(stringValue(t, payload, "message"), "timeout_seconds=10") || !strings.Contains(stringValue(t, payload, "message"), "max_output_bytes=4096") { t.Fatalf("message = %q, want diagnostic bounds", stringValue(t, payload, "message")) } result := objectValue(t, payload, "result") assertString(t, result, "kind", "network_test") assertString(t, result, "target", "192.168.0.1") assertNumber(t, result, "count", 5) assertNumber(t, result, "timeout_seconds", 10) assertNumber(t, result, "max_output_bytes", 4096) lookup := getJSONAs(t, handler, http.MethodGet, "/api/v1/operations/"+stringValue(t, payload, "operation_id"), "", "mock-admin") lookupResult := objectValue(t, lookup, "result") assertString(t, lookupResult, "kind", "network_test") assertString(t, lookupResult, "target", "192.168.0.1") assertNumber(t, lookupResult, "count", 5) events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "audit_id", stringValue(t, payload, "audit_id")) assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "diagnostics.network_test") assertString(t, events[0], "target", "diagnostics") assertString(t, events[0], "result", "succeeded") } func TestTCPConnectionDiagnosticsContract(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/tcp-connection-test", strings.NewReader(`{"target":"192.0.2.10","port":443}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("TCP diagnostic status = %d, want 404", response.Code) } } func TestTCPConnectionDiagnosticsRejectsMissingPort(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/diagnostics/tcp-connection-test", strings.NewReader(`{"target":"192.0.2.10"}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404; body: %s", response.Code, response.Body.String()) } } func TestDiagnosticsProcessAdapterReturnsQueuedOperationAndCompletesAsync(t *testing.T) { reachable := true runner := diagnostics.RunnerFunc(func(_ context.Context, request diagnostics.Request) (diagnostics.Result, error) { return diagnostics.Result{ Kind: request.Kind, Target: request.Target, ExitCode: 0, Output: "reachable", OutputBytes: 9, Reachable: &reachable, }, nil }) store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{ DiagnosticsRunner: runner, DiagnosticsWorkers: 1, DiagnosticsQueue: 2, }) if err != nil { t.Fatal(err) } defer store.Close() handler := NewRouter(store) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/diagnostics/network-test", `{"target":"192.0.2.1","timeout_seconds":5,"max_output_bytes":4096}`, "mock-admin") assertString(t, payload, "state", "queued") operationID := stringValue(t, payload, "operation_id") 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 == "succeeded" { if operation.Result["adapter"] != "process" || operation.Result["reachable"] != true || operation.Result["output"] != "reachable" { t.Fatalf("operation result = %+v", operation.Result) } return } time.Sleep(10 * time.Millisecond) } t.Fatal("diagnostic operation did not complete") } func TestGreenLEDControlAPIIsRemoved(t *testing.T) { root := t.TempDir() for channel := 1; channel <= 48; channel++ { ledDir := filepath.Join(root, fmt.Sprintf("ch%02d-green-led-force", channel)) if err := os.MkdirAll(ledDir, 0o755); err != nil { t.Fatalf("create test LED directory: %v", err) } if err := os.WriteFile(filepath.Join(ledDir, "brightness"), []byte("0\n"), 0o644); err != nil { t.Fatalf("write test LED brightness: %v", err) } } handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ GreenLEDController: greenled.NewSysfsController(root), }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/hardware/green-leds", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("list green LEDs status = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } return var states []greenled.State if err := json.Unmarshal(response.Body.Bytes(), &states); err != nil { t.Fatalf("decode green LED list: %v", err) } if len(states) != 48 { t.Fatalf("green LED list length = %d, want 48", len(states)) } if states[0].Channel != "CH01" || states[0].I2CAddress != "0x20" || states[0].GPIOOffset != 0 || states[8].Channel != "CH09" || states[8].I2CAddress != "0x21" || states[8].GPIOOffset != 0 || states[47].Channel != "CH48" || states[47].I2CAddress != "0x25" || states[47].GPIOOffset != 7 { t.Fatalf("serial-channel LED mapping mismatch: first=%#v ninth=%#v last=%#v", states[0], states[8], states[47]) } forced := getJSONAs(t, handler, http.MethodPut, "/api/v1/hardware/green-leds/CH09", `{"force":true}`, "mock-admin") assertString(t, forced, "channel", "CH09") if value, ok := forced["forced"].(bool); !ok || !value { t.Fatalf("force response = %#v, want forced true", forced) } brightness, err := os.ReadFile(filepath.Join(root, "ch09-green-led-force", "brightness")) if err != nil || string(brightness) != "1\n" { t.Fatalf("CH09 brightness = %q, %v; want 1", brightness, err) } pulsed := getJSONAs(t, handler, http.MethodPost, "/api/v1/hardware/green-leds/CH09/pulse", `{"dwell_ms":100}`, "mock-admin") if value, ok := pulsed["forced"].(bool); !ok || value { t.Fatalf("pulse response = %#v, want released", pulsed) } brightness, err = os.ReadFile(filepath.Join(root, "ch09-green-led-force", "brightness")) if err != nil || string(brightness) != "0\n" { t.Fatalf("CH09 brightness after pulse = %q, %v; want 0", brightness, err) } response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodPut, "/api/v1/hardware/green-leds/CH09", strings.NewReader(`{"force":false}`)) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer force status = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") events := getAuditEvents(t, handler) if len(events) != 3 { t.Fatalf("audit event count = %d, want 3", len(events)) } assertString(t, events[0], "action", "hardware.green_led.force") assertString(t, events[0], "target", "CH09") assertString(t, events[1], "action", "hardware.green_led.pulse") assertString(t, events[2], "result", "authorization_denied") } func TestSupportBundleDiagnosticsReturnsManifest(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/diagnostics/support-bundle", `{"max_output_bytes":8192}`, "mock-admin") assertOperationPayload(t, payload) result := objectValue(t, payload, "result") assertString(t, result, "kind", "support_bundle") assertNumber(t, result, "max_output_bytes", 8192) assertString(t, result, "bundle_id", "support_bundle_20260616_000001") assertString(t, result, "artifact_path", "/var/lib/tera-api/diagnostics/support_bundle_20260616_000001.json") assertNumber(t, result, "retention_seconds", 86400) sections, ok := result["included_sections"].([]any) if !ok || len(sections) == 0 { t.Fatalf("included_sections shape = %T length %d, want non-empty array", result["included_sections"], len(sections)) } lookup := getJSONAs(t, handler, http.MethodGet, "/api/v1/operations/"+stringValue(t, payload, "operation_id"), "", "mock-admin") lookupResult := objectValue(t, lookup, "result") assertString(t, lookupResult, "bundle_id", "support_bundle_20260616_000001") } func TestSupportBundleDiagnosticsCreatesDownloadableArchive(t *testing.T) { manager, err := supportbundle.NewManager(supportbundle.Options{Directory: filepath.Join(t.TempDir(), "bundles")}) if err != nil { t.Fatal(err) } store, err := mock.NewStoreWithOptions(fixedClock, mock.StoreOptions{ SupportBundleService: manager, DiagnosticsWorkers: 1, DiagnosticsQueue: 2, }) if err != nil { t.Fatal(err) } defer store.Close() handler := NewRouter(store) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/diagnostics/support-bundle", `{}`, "mock-admin") operationID := stringValue(t, payload, "operation_id") var completed domain.Operation deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { completed, err = store.Operation(context.Background(), operationID) if err != nil { t.Fatal(err) } if completed.State == "succeeded" { break } time.Sleep(10 * time.Millisecond) } if completed.State != "succeeded" { t.Fatalf("support bundle operation = %+v", completed) } bundleID, _ := completed.Result["bundle_id"].(string) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/support-bundles/"+bundleID, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "application/zip" || !strings.HasPrefix(response.Body.String(), "PK") { t.Fatalf("download status=%d headers=%v body-prefix=%q", response.Code, response.Header(), response.Body.String()[:min(2, response.Body.Len())]) } if response.Header().Get("Cache-Control") != "no-store" || len(response.Header().Get("X-Checksum-SHA256")) != 64 { t.Fatalf("download security headers = %v", response.Header()) } unauthorized := httptest.NewRecorder() handler.ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/api/v1/diagnostics/support-bundles/"+bundleID, nil)) if unauthorized.Code != http.StatusUnauthorized { t.Fatalf("unauthorized download status = %d", unauthorized.Code) } } func TestOperationLookupRequiresOperationReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) restart := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH01/restart", "", "mock-admin") operationID := stringValue(t, restart, "operation_id") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/operations/"+operationID, nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/operations/"+operationID, nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") operation := getJSONAs(t, handler, http.MethodGet, "/api/v1/operations/"+operationID, "", "mock-admin") assertString(t, operation, "operation_id", operationID) assertOperationPayload(t, operation) } func TestOperationLookupRejectsEmptyOperationID(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/operations/", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusNotFound { t.Fatalf("empty operation id status code = %d, want %d; body: %s", response.Code, http.StatusNotFound, response.Body.String()) } assertErrorCode(t, response, "not_found") } func TestOperationListRequiresOperationReadPermission(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) restart := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH01/restart", "", "mock-admin") operationID := stringValue(t, restart, "operation_id") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/operations", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("missing actor status code = %d, want %d", response.Code, http.StatusUnauthorized) } assertErrorCode(t, response, "authentication_required") response = httptest.NewRecorder() request = httptest.NewRequest(http.MethodGet, "/api/v1/operations", nil) request.Header.Set("X-Mock-Actor", "mock-viewer") handler.ServeHTTP(response, request) if response.Code != http.StatusForbidden { t.Fatalf("viewer status code = %d, want %d", response.Code, http.StatusForbidden) } assertErrorCode(t, response, "authorization_denied") operations := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/operations", "", "mock-admin") if len(operations) != 1 { t.Fatalf("operation count = %d, want 1", len(operations)) } assertString(t, operations[0], "operation_id", operationID) assertOperationPayload(t, operations[0]) } func TestOperationListSupportsBoundedPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) first := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH01/restart", "", "mock-admin") second := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH02/restart", "", "mock-admin") third := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/CH03/restart", "", "mock-admin") operations := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/operations?limit=1&offset=1", "", "mock-admin") if len(operations) != 1 { t.Fatalf("operation count = %d, want 1", len(operations)) } assertString(t, operations[0], "operation_id", stringValue(t, second, "operation_id")) empty := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/operations?limit=1&offset=5", "", "mock-admin") if len(empty) != 0 { t.Fatalf("operation count = %d, want empty page", len(empty)) } all := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/operations?limit=3", "", "mock-admin") if len(all) != 3 { t.Fatalf("operation count = %d, want 3", len(all)) } assertString(t, all[0], "operation_id", stringValue(t, first, "operation_id")) assertString(t, all[2], "operation_id", stringValue(t, third, "operation_id")) } func TestOperationListRejectsInvalidPagination(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) for _, path := range []string{ "/api/v1/operations?limit=501", "/api/v1/operations?limit=-1", "/api/v1/operations?offset=bad", } { response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("%s status code = %d, want %d; body: %s", path, response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") } } func TestConfigTransactionState(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet","baud_rate":38400,"alarm_keyword":"PANIC"}`, "mock-admin") assertConfigSummary(t, update, 2, 1, 1, false) beforeApply := getPort(t, handler, "CH01") if beforeApply.Mode != "disabled" { t.Fatalf("mode before apply = %q, want disabled", beforeApply.Mode) } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 1, true) afterApply := getPort(t, handler, "CH01") if afterApply.Mode != "reverse_telnet" { t.Fatalf("mode after apply = %q, want reverse_telnet", afterApply.Mode) } if afterApply.BaudRate != 38400 { t.Fatalf("baud rate after apply = %d, want 38400", afterApply.BaudRate) } if afterApply.AlarmKeyword != "PANIC" { t.Fatalf("alarm keyword after apply = %q, want PANIC", afterApply.AlarmKeyword) } save := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save", "", "mock-admin") assertConfigSummary(t, save, 0, 2, 2, false) } func TestSaveWithoutApplyPersistsPendingButDoesNotChangeRunning(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"mode":"Reverse Telnet"}`, "mock-admin") assertConfigSummary(t, update, 1, 1, 1, false) save := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/save", "", "mock-admin") assertConfigSummary(t, save, 1, 1, 2, true) port := getPort(t, handler, "CH01") if port.Mode != "disabled" { t.Fatalf("running mode after save without apply = %q, want disabled", port.Mode) } apply := getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") assertConfigSummary(t, apply, 0, 2, 2, false) } func TestRestartPort(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/ports/CH01/restart", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status code = %d, want %d", response.Code, http.StatusOK) } var operation domain.Operation if err := json.Unmarshal(response.Body.Bytes(), &operation); err != nil { t.Fatalf("decode operation: %v", err) } if operation.State != "succeeded" { t.Fatalf("operation state = %q, want succeeded", operation.State) } if operation.AuditID == "" { t.Fatalf("expected audit id") } if operation.ProgressPercent != 100 { t.Fatalf("operation progress = %d, want 100", operation.ProgressPercent) } if operation.CreatedAt.IsZero() { t.Fatalf("expected operation created_at") } if operation.UpdatedAt.IsZero() { t.Fatalf("expected operation updated_at") } if operation.CompletedAt == nil || operation.CompletedAt.IsZero() { t.Fatalf("expected operation completed_at") } } func TestBulkRestartAppliesSelectedPortsAsOneHardwareBatch(t *testing.T) { controller := &serialControllerSpy{} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{SerialController: controller}) payload := getJSONAs(t, handler, http.MethodPost, "/api/v1/ports/restart", `{"channels":["CH12","ch01"]}`, "mock-admin") assertString(t, payload, "state", "succeeded") result := objectValue(t, payload, "result") assertNumber(t, result, "count", 2) assertStringArray(t, result, "channels", []string{"CH01", "CH12"}) if len(controller.applied) != 1 || len(controller.applied[0]) != 2 { t.Fatalf("serial apply batches = %#v, want one two-port batch", controller.applied) } if controller.applied[0][0].Channel != "CH12" || controller.applied[0][1].Channel != "CH01" { t.Fatalf("serial apply channels = %#v, want requested CH12/CH01", controller.applied[0]) } if len(controller.captures) != 1 || len(controller.captures[0]) != 2 { t.Fatalf("capture apply batches = %#v, want one two-port batch", controller.captures) } } func TestBulkRestartRejectsDuplicateBeforeHardwareApply(t *testing.T) { controller := &serialControllerSpy{} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{SerialController: controller}) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/ports/restart", strings.NewReader(`{"channels":["CH01","ch01"]}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", response.Code, response.Body.String()) } assertErrorCode(t, response, "validation_failed") if len(controller.applied) != 0 || len(controller.captures) != 0 { t.Fatalf("hardware was touched for invalid selection: apply=%#v capture=%#v", controller.applied, controller.captures) } } func TestApplyConfigRoutesChangedFullLineProfileThroughSerialController(t *testing.T) { controller := &serialControllerSpy{} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{SerialController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"baud_rate":38400,"data_bits":7,"parity":"even","stop_bits":2,"flow_control":"rtscts"}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") if len(controller.applied) != 1 { t.Fatalf("serial apply calls = %#v, want one", controller.applied) } if len(controller.applied[0]) != 1 || controller.applied[0][0] != (serial.ChannelConfig{ Channel: "CH01", BaudRate: 38400, DataBits: 7, Parity: "even", StopBits: 2, StopBitsMode: "2", FlowControl: "rtscts", RTSControl: "auto", DTRControl: "auto", }) { t.Fatalf("serial configs = %#v", controller.applied[0]) } port := getPort(t, handler, "CH01") if port.DataBits != 7 || port.Parity != "even" || port.StopBits != 2 || port.FlowControl != "rtscts" { t.Fatalf("running line profile = %#v", port) } } func TestPortUpdateRejectsInvalidLineProfiles(t *testing.T) { for _, item := range []struct { name string body string }{ {name: "unsupported baud", body: `{"baud_rate":12345}`}, {name: "data bits", body: `{"data_bits":9}`}, {name: "parity", body: `{"parity":"invalid"}`}, {name: "stop bits", body: `{"stop_bits":3}`}, {name: "flow control", body: `{"flow_control":"invalid"}`}, {name: "data log mode", body: `{"data_log_mode":"everything"}`}, {name: "removed legacy mode", body: `{"mode":"realcom"}`}, {name: "removed operation mode", body: `{"operation_1":{"mode":"realcom","port":950,"keep_alive_seconds":3,"timeout_ms":10000,"max_clients":4,"authentication":"local","newline":"cr"}}`}, {name: "normal operation host", body: `{"operation_1":{"mode":"ssh","port":22,"keep_alive_seconds":3,"timeout_ms":10000,"max_clients":4,"authentication":"local","newline":"cr"}}`}, {name: "embedded password auth", body: `{"operation_1":{"mode":"reverse_ssh","port":10001,"keep_alive_seconds":3,"timeout_ms":10000,"max_clients":4,"authentication":"password","newline":"cr"}}`}, {name: "operation client ceiling", body: `{"operation_1":{"mode":"reverse_telnet","port":2301,"keep_alive_seconds":3,"timeout_ms":10000,"max_clients":5,"authentication":"local","newline":"cr"}}`}, {name: "operation shortcut conflict", body: `{"operation_1":{"mode":"reverse_telnet","port":2301,"keep_alive_seconds":3,"timeout_ms":10000,"max_clients":4,"authentication":"local","newline":"cr","control_shortcut":"^T","switch_shortcut":"^T","quit_shortcut":"^Q"}}`}, } { t.Run(item.name, func(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports/CH01", strings.NewReader(item.body)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", response.Code, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit events = %d, want 1", len(events)) } assertString(t, events[0], "action", "port.update") assertString(t, events[0], "result", "rejected") }) } } func TestPortOperationPolicyUsesReverseModesManagedAuthAndFixedTimeouts(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{ "data_package":{"mode":"delimiter","length":2048,"timeout_ms":25,"delimiter_process":"remove","delimiter_1":13,"delimiter_2":10}, "operation_1":{"mode":"reverse_telnet","port":2301,"keep_alive_seconds":7,"timeout_ms":250,"max_clients":2,"authentication":"radius","newline":"cr","control_shortcut":"^]","switch_shortcut":"^t","quit_shortcut":"^q","break_shortcut":"^b","interrupt_shortcut":"^x"} }`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") port := getPort(t, handler, "CH01") if port.Operation1.Mode != "reverse_telnet" || port.Operation1.Authentication != "radius" || port.Operation1.TimeoutMilliseconds != 10000 || port.Operation1.InactivityTimeoutMilliseconds != 0 { t.Fatalf("operation policy = %#v", port.Operation1) } if port.Operation1.ControlShortcut != "^]" || port.Operation1.SwitchShortcut != "^T" || port.Operation1.QuitShortcut != "^Q" || port.Operation1.BreakShortcut != "^B" || port.Operation1.InterruptShortcut != "^X" { t.Fatalf("operation shortcuts = %#v", port.Operation1) } if port.DataPackage.Mode != "disabled" { t.Fatalf("data package = %#v, want disabled", port.DataPackage) } } func TestPortBulkUpdateStagesCompleteOriginalWebSettingsAtomically(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) body := `{ "channels":["CH01","CH12"], "update":{ "alias":"Core switch console", "baud_rate":460800,"data_bits":5,"parity":"mark","stop_bits":1,"stop_bits_mode":"1.5", "flow_control":"xonxoff","rts_control":"on","dtr_control":"off","ignore_jammed":true, "data_log":{"read":true,"send":false,"show_timestamp":true,"show_direction":true,"show_username":true,"show_ip_address":true,"show_port":true,"format":"ascii_hex","read_trigger":{"length":80,"timeout_ms":50,"process":"keep","character_1":13,"character_2":10},"send_trigger":{"length":0,"timeout_ms":0,"process":"keep","character_1":-1,"character_2":-1}}, "redirect_to":["CH02","CH03"] } }` update := getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports", body, "mock-admin") assertConfigSummary(t, update, 2, 1, 1, false) getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") for _, channel := range []string{"CH01", "CH12"} { port := getPort(t, handler, channel) if port.Alias != "Core switch console" || port.BaudRate != 460800 || port.Parity != "mark" || port.StopBitsMode != "1.5" || port.FlowControl != "xonxoff" { t.Fatalf("%s line settings = %#v", channel, port) } if !port.IgnoreJammed || port.RTSControl != "on" || port.DTRControl != "off" || port.DataPackage.Mode != "disabled" || port.DataLog.Format != "ascii_hex" { t.Fatalf("%s package/log settings = %#v", channel, port) } if port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" || len(port.RedirectTo) != 2 { t.Fatalf("%s operation settings = %#v", channel, port) } } } func TestPortBulkUpdateRejectsDuplicateTargetsWithoutStaging(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/ports", strings.NewReader(`{"channels":["CH01","ch01"],"update":{"baud_rate":9600}}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400; body: %s", response.Code, response.Body.String()) } status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") if numberValue(t, status, "pending_changes") != 0 { t.Fatalf("pending changes = %#v, want none", status) } } func TestApplyConfigLeavesPendingBaudWhenSerialControllerUnavailable(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ SerialController: serial.UnavailableController{Reason: "validated mapping is not installed"}, }) getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"baud_rate":38400}`, "mock-admin") response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/config/apply", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusServiceUnavailable { t.Fatalf("status = %d, want %d; body: %s", response.Code, http.StatusServiceUnavailable, response.Body.String()) } assertErrorCode(t, response, "hardware_unavailable") port := getPort(t, handler, "CH01") if port.BaudRate != 9600 { t.Fatalf("running baud rate = %d, want unchanged 9600", port.BaudRate) } status := getJSONAs(t, handler, http.MethodGet, "/api/v1/config/status", "", "mock-admin") assertNumber(t, status, "pending_changes", 1) } type serialControllerSpy struct { applied [][]serial.ChannelConfig captures [][]serial.CaptureConfig busyFor int } func (s *serialControllerSpy) States(context.Context) ([]serial.State, error) { return nil, nil } func (s *serialControllerSpy) Apply(_ context.Context, configs []serial.ChannelConfig) ([]serial.State, error) { copyOfConfigs := append([]serial.ChannelConfig(nil), configs...) s.applied = append(s.applied, copyOfConfigs) if len(s.applied) <= s.busyFor { return nil, serial.ErrStreamLeaseBusy } return nil, nil } func (s *serialControllerSpy) ApplyCapture(_ context.Context, configs []serial.CaptureConfig) error { s.captures = append(s.captures, append([]serial.CaptureConfig(nil), configs...)) return nil } func TestApplyConfigRetriesTransientCRProbeLease(t *testing.T) { controller := &serialControllerSpy{busyFor: 2} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{SerialController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH16", `{"baud_rate":38400}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") if len(controller.applied) != 3 { t.Fatalf("serial apply calls = %d, want two busy attempts and one success", len(controller.applied)) } if port := getPort(t, handler, "CH16"); port.BaudRate != 38400 { t.Fatalf("CH16 baud rate = %d, want 38400", port.BaudRate) } } func TestApplyConfigRoutesDataLogModeThroughSerialController(t *testing.T) { controller := &serialControllerSpy{} handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{SerialController: controller}) getJSONAs(t, handler, http.MethodPatch, "/api/v1/ports/CH01", `{"data_log_mode":"rx"}`, "mock-admin") getJSONAs(t, handler, http.MethodPost, "/api/v1/config/apply", "", "mock-admin") if len(controller.applied) != 0 { t.Fatalf("data-log-only update changed termios: %#v", controller.applied) } if len(controller.captures) != 1 || len(controller.captures[0]) != 1 { t.Fatalf("capture configs = %#v", controller.captures) } capture := controller.captures[0][0] if capture.Channel != "CH01" || capture.Mode != "rx" || !capture.DataLog.Read || capture.DataLog.Send || capture.DataLog.Format != "ascii" || capture.DataPackage.Mode != "disabled" { t.Fatalf("capture config = %#v", capture) } if port := getPort(t, handler, "CH01"); port.DataLogMode != "rx" { t.Fatalf("running data log mode = %q, want rx", port.DataLogMode) } } func TestRestartPortValidationFailureRecordsRejectedAudit(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPost, "/api/v1/ports/CH99/restart", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d; body: %s", response.Code, http.StatusBadRequest, response.Body.String()) } assertErrorCode(t, response, "validation_failed") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit event count = %d, want 1", len(events)) } assertString(t, events[0], "actor", "mock-admin") assertString(t, events[0], "action", "port.restart") assertString(t, events[0], "target", "CH99") assertString(t, events[0], "result", "rejected") assertString(t, events[0], "message", "channel must be CH01 through CH48") } func TestInvalidChannel(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/ports/CH99", nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status code = %d, want %d", response.Code, http.StatusBadRequest) } } func TestCORSHeaders(t *testing.T) { handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{ CORSAllowedOrigins: []string{"*"}, }) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodOptions, "/api/v1/system/status", nil) handler.ServeHTTP(response, request) if response.Code != http.StatusNoContent { t.Fatalf("status code = %d, want %d", response.Code, http.StatusNoContent) } if got := response.Header().Get("Access-Control-Allow-Origin"); got != "*" { t.Fatalf("Access-Control-Allow-Origin = %q, want *", got) } if got := response.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, "X-Mock-Actor") { t.Fatalf("Access-Control-Allow-Headers = %q, want X-Mock-Actor", got) } } func getJSON(t *testing.T, handler http.Handler, method, path, body string) map[string]any { t.Helper() return getJSONAs(t, handler, method, path, body, "") } func getJSONAs(t *testing.T, handler http.Handler, method, path, body, actor string) map[string]any { t.Helper() response := httptest.NewRecorder() request := httptest.NewRequest(method, path, strings.NewReader(body)) if actor != "" { request.Header.Set("X-Mock-Actor", actor) } handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("%s %s status code = %d, want %d; body: %s", method, path, response.Code, http.StatusOK, response.Body.String()) } var payload map[string]any if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("decode %s %s: %v", method, path, err) } return payload } func getJSONWithBearer(t *testing.T, handler http.Handler, method, path, body, token string) map[string]any { t.Helper() response := httptest.NewRecorder() request := httptest.NewRequest(method, path, strings.NewReader(body)) if token != "" { request.Header.Set("Authorization", "Bearer "+token) } handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("%s %s status code = %d, want %d; body: %s", method, path, response.Code, http.StatusOK, response.Body.String()) } var payload map[string]any if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("decode %s %s: %v", method, path, err) } return payload } func assertErrorCode(t *testing.T, response *httptest.ResponseRecorder, want string) { t.Helper() var payload domain.ErrorResponse if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("decode error response: %v", err) } if payload.Error.Code != want { t.Fatalf("error code = %q, want %q", payload.Error.Code, want) } } func getJSONArray(t *testing.T, handler http.Handler, method, path, body string) []map[string]any { t.Helper() return getJSONArrayAs(t, handler, method, path, body, "") } func getAuditEvents(t *testing.T, handler http.Handler) []map[string]any { t.Helper() return getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/audit", "", "mock-admin") } func getJSONArrayAs(t *testing.T, handler http.Handler, method, path, body, actor string) []map[string]any { t.Helper() response := httptest.NewRecorder() request := httptest.NewRequest(method, path, strings.NewReader(body)) if actor != "" { request.Header.Set("X-Mock-Actor", actor) } handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("%s %s status code = %d, want %d; body: %s", method, path, response.Code, http.StatusOK, response.Body.String()) } var payload []map[string]any if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("decode %s %s: %v", method, path, err) } return payload } func requireKeys(t *testing.T, payload map[string]any, keys ...string) { t.Helper() for _, key := range keys { if _, ok := payload[key]; !ok { t.Fatalf("missing key %q in payload %#v", key, payload) } } } func assertString(t *testing.T, payload map[string]any, key, want string) { t.Helper() got := stringValue(t, payload, key) if got != want { t.Fatalf("%s = %q, want %q", key, got, want) } } func stringValue(t *testing.T, payload map[string]any, key string) string { t.Helper() got, ok := payload[key].(string) if !ok { t.Fatalf("%s shape = %T, want string", key, payload[key]) } return got } func getPort(t *testing.T, handler http.Handler, channel string) domain.Port { t.Helper() response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/ports/"+channel, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("GET port status code = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) } var port domain.Port if err := json.Unmarshal(response.Body.Bytes(), &port); err != nil { t.Fatalf("decode port: %v", err) } return port } func getAlarmRule(t *testing.T, handler http.Handler, ruleID string) domain.AlarmRule { t.Helper() response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodGet, "/api/v1/alarms/rules/"+ruleID, nil) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("GET alarm rule status code = %d, want %d; body: %s", response.Code, http.StatusOK, response.Body.String()) } var rule domain.AlarmRule if err := json.Unmarshal(response.Body.Bytes(), &rule); err != nil { t.Fatalf("decode alarm rule: %v", err) } return rule } func firstNetworkInterface(t *testing.T, payload map[string]any) map[string]any { t.Helper() interfaces, ok := payload["interfaces"].([]any) if !ok || len(interfaces) == 0 { t.Fatalf("interfaces shape = %T length %d, want non-empty array", payload["interfaces"], len(interfaces)) } first, ok := interfaces[0].(map[string]any) if !ok { t.Fatalf("interface shape = %T, want object", interfaces[0]) } return first } func assertConfigSummary(t *testing.T, payload map[string]any, pending, runningRevision, persistentRevision int, runningDiffers bool) { t.Helper() config, ok := payload["config"].(map[string]any) if !ok { t.Fatalf("config shape = %T, want object in payload %#v", payload["config"], payload) } assertNumber(t, config, "pending_changes", pending) assertNumber(t, config, "running_revision", runningRevision) assertNumber(t, config, "persistent_revision", persistentRevision) if got, ok := config["running_differs_from_persistent"].(bool); !ok || got != runningDiffers { t.Fatalf("running_differs_from_persistent = %v, want %v", config["running_differs_from_persistent"], runningDiffers) } } func assertNumber(t *testing.T, payload map[string]any, key string, want int) { t.Helper() got := numberValue(t, payload, key) if int(got) != want { t.Fatalf("%s = %d, want %d", key, int(got), want) } } func assertStringArray(t *testing.T, payload map[string]any, key string, want []string) { t.Helper() values := arrayValue(t, payload, key) if len(values) != len(want) { t.Fatalf("%s length = %d, want %d: %#v", key, len(values), len(want), values) } for i, value := range values { got, ok := value.(string) if !ok { t.Fatalf("%s[%d] shape = %T, want string", key, i, value) } if got != want[i] { t.Fatalf("%s[%d] = %q, want %q", key, i, got, want[i]) } } } func numberValue(t *testing.T, payload map[string]any, key string) float64 { t.Helper() got, ok := payload[key].(float64) if !ok { t.Fatalf("%s shape = %T, want number", key, payload[key]) } return got } func objectValue(t *testing.T, payload map[string]any, key string) map[string]any { t.Helper() got, ok := payload[key].(map[string]any) if !ok { t.Fatalf("%s shape = %T, want object", key, payload[key]) } return got } func arrayValue(t *testing.T, payload map[string]any, key string) []any { t.Helper() got, ok := payload[key].([]any) if !ok { t.Fatalf("%s shape = %T, want array", key, payload[key]) } return got } func objectAt(t *testing.T, values []any, index int) map[string]any { t.Helper() if index < 0 || index >= len(values) { t.Fatalf("index %d outside array length %d", index, len(values)) } got, ok := values[index].(map[string]any) if !ok { t.Fatalf("array item %d shape = %T, want object", index, values[index]) } return got } func assertOperationPayload(t *testing.T, payload map[string]any) { t.Helper() requireKeys(t, payload, "operation_id", "state", "message", "audit_id", "progress_percent", "created_at", "updated_at", "completed_at") assertString(t, payload, "state", "succeeded") assertNumber(t, payload, "progress_percent", 100) assertTimestampString(t, payload, "created_at") assertTimestampString(t, payload, "updated_at") assertTimestampString(t, payload, "completed_at") } func assertTimestampString(t *testing.T, payload map[string]any, key string) { t.Helper() value := stringValue(t, payload, key) if _, err := time.Parse(time.RFC3339Nano, value); err != nil { t.Fatalf("%s = %q, want RFC3339 timestamp: %v", key, value, err) } } func assertPermissions(t *testing.T, payload map[string]any, want []string) { t.Helper() got, ok := payload["permissions"].([]any) if !ok { t.Fatalf("permissions shape = %T, want array", payload["permissions"]) } if len(got) != len(want) { t.Fatalf("permissions length = %d, want %d: %#v", len(got), len(want), got) } for index, wantPermission := range want { if got[index] != wantPermission { t.Fatalf("permissions[%d] = %q, want %q", index, got[index], wantPermission) } } } func containsPermission(t *testing.T, permissions []map[string]any, want string) bool { t.Helper() for _, permission := range permissions { if got, ok := permission["permission"].(string); ok && got == want { return true } } return false } func findObjectByString(t *testing.T, rows []map[string]any, key, value string) map[string]any { t.Helper() for _, row := range rows { if got, ok := row[key].(string); ok && got == value { return row } } t.Fatalf("object with %s=%q not found in %#v", key, value, rows) return nil } func containsStringValue(values []any, want string) bool { for _, value := range values { if value == want { return true } } return false } func TestLogPolicyAndLinkLogContracts(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) policy := getJSONAs(t, handler, http.MethodGet, "/api/v1/logs/policy", "", "mock-admin") assertNumber(t, policy, "max_retention_days", 365) if policy["command_result_capture"] != true || policy["physical_link_logging"] != true || policy["periodic_status_logging"] != true { t.Fatalf("log policy capture flags = %#v", policy) } assertNumber(t, policy, "link_poll_interval_seconds", 60) assertNumber(t, policy, "keepalive_interval_seconds", 180) assertNumber(t, policy, "snapshot_interval_seconds", 1800) if policy["at_rest_encryption"] != false || policy["raw_uart_payload_capture"] != true || policy["direct_mode_payload_capture"] != true { t.Fatalf("log policy payload flags = %#v", policy) } assertNumber(t, policy, "max_payload_bytes", 4096) links := getJSONArrayAs(t, handler, http.MethodGet, "/api/v1/logs/links?kind=ethernet&resource=Interface-1&state=down&event_type=transition&keyword=carrier&from=2026-06-15T23:55:00Z&to=2026-06-16T00:00:00Z", "", "mock-admin") if len(links) != 1 { t.Fatalf("link events = %#v", links) } assertString(t, links[0], "resource", "Interface-1") assertString(t, links[0], "event_type", "transition") if links[0]["alarm_triggered"] != true { t.Fatalf("link alarm flag = %#v", links[0]["alarm_triggered"]) } } func TestWebCommandAuditCapturesSurfaceCommandAndReturnValue(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) getJSONAs(t, handler, http.MethodPatch, "/api/v1/logs/retention", `{"port_days":180}`, "mock-admin") events := getAuditEvents(t, handler) if len(events) != 1 { t.Fatalf("audit events = %#v", events) } assertString(t, events[0], "surface", "web_api") assertString(t, events[0], "command", "PATCH /api/v1/logs/retention") assertString(t, events[0], "return_value", "log retention update staged in pending changes") if events[0]["redacted"] != false { t.Fatalf("redacted = %#v, want false", events[0]["redacted"]) } } func TestLogRetentionRejectsMoreThanOneYearThroughAPI(t *testing.T) { handler := NewRouter(mock.NewStore(fixedClock)) response := httptest.NewRecorder() request := httptest.NewRequest(http.MethodPatch, "/api/v1/logs/retention", strings.NewReader(`{"port_days":366}`)) request.Header.Set("X-Mock-Actor", "mock-admin") handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "between 1 and 365") { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) } } func fixedClock() time.Time { return time.Date(2026, 6, 16, 0, 0, 0, 0, time.UTC) } // Password/session contract tests explicitly disable CAPTCHA; captcha_test.go covers challenge enforcement. func newPasswordAuthTestStore(t *testing.T, clock func() time.Time) *mock.Store { t.Helper() path := filepath.Join(t.TempDir(), "config.json") store, err := mock.NewStoreWithConfigFile(clock, path) if err != nil { t.Fatal(err) } disabled := false if _, err := store.UpdateManagementAccess(context.Background(), domain.ManagementAccessUpdate{CaptchaEnabled: &disabled}); err != nil { t.Fatal(err) } if _, err := store.ApplyConfig(context.Background()); err != nil { t.Fatal(err) } store.Close() reopened, err := mock.NewStoreWithConfigFile(clock, path) if err != nil { t.Fatal(err) } t.Cleanup(reopened.Close) return reopened }