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

7276 lines
295 KiB

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 TestStartupGateAllowsLoginAndReadsButBlocksCommands(t *testing.T) {
ready := false
handler := NewRouterWithOptions(mock.NewStore(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.StatusOK {
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"))
}
ready = true
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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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(mock.NewStore(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", "", "mock-admin")
assertOperationPayload(t, operation)
port := getPort(t, handler, "CH01")
if port.Mode != "disabled" || port.BaudRate != 115200 || port.Operation1.Mode != "disabled" || port.Operation2.Mode != "disabled" {
t.Fatalf("factory reset port = %#v, want default disabled operations and 115200", 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) != 3 {
t.Fatalf("audit event count = %d, want 3", len(events))
}
assertString(t, events[2], "audit_id", stringValue(t, operation, "audit_id"))
assertString(t, events[2], "actor", "mock-admin")
assertString(t, events[2], "action", "system.factory_reset")
assertString(t, events[2], "target", "system")
assertString(t, events[2], "result", "succeeded")
}
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", 80)
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", 85)
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":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 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":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", "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 remain true for protected 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 != 115200 {
t.Fatalf("running baud rate = %d, want unchanged 115200", 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":9600}`, "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 != 9600 {
t.Fatalf("CH16 baud rate = %d, want 9600", 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)
}