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