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

301 lines
12 KiB

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"tera-clone/services/tera-api/internal/adapters/mock"
"tera-clone/services/tera-api/internal/directstream"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/portservice"
"tera-clone/services/tera-api/internal/serial"
"tera-clone/services/tera-api/internal/sessionhealth"
)
type runtimeSerialController struct{}
func (runtimeSerialController) States(context.Context) ([]serial.State, error) {
states := make([]serial.State, 0, serial.ChannelCount)
for index := 1; index <= serial.ChannelCount; index++ {
settings := &serial.LineSettings{BaudRate: 9600, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"}
states = append(states, serial.State{
Channel: fmt.Sprintf("CH%02d", index), Device: fmt.Sprintf("/dev/ttyWCH%d", index+3),
HardwareProfile: "fet3568j-c", MappingVersion: "test-v1", Available: true,
ProfileApplied: true, LineSettings: settings,
})
}
return states, nil
}
func (runtimeSerialController) LinkStates(context.Context) ([]serial.LinkState, error) {
links := make([]serial.LinkState, 0, serial.ChannelCount)
for index := 1; index <= serial.ChannelCount; index++ {
links = append(links, serial.LinkState{
Channel: fmt.Sprintf("CH%02d", index), State: "unknown", Signal: "unknown",
Message: "physical DCD/DSR signal is not board-validated",
})
}
return links, nil
}
func (runtimeSerialController) CRProbeStates(context.Context) ([]serial.CRProbeState, error) {
confirmedAt := fixedClock().UTC()
return []serial.CRProbeState{{
Channel: "CH01", Enabled: true, State: serial.CRProbeStateResponded,
Method: "cr", ResponseBytes: 7, ConsecutiveResponses: 1, Up: true, EverUp: true,
LastConfirmedAt: &confirmedAt, Message: "remote serial endpoint response confirmed",
}, {
Channel: "CH08", Enabled: true, State: serial.CRProbeStateResponded,
Method: "cr", ResponseBytes: 7, ConsecutiveResponses: 2, Up: true, EverUp: true,
LastConfirmedAt: &confirmedAt, Message: "remote serial endpoint response confirmed",
}, {
Channel: "CH20", Enabled: true, State: serial.CRProbeStateResponded,
Method: "cr", ResponseBytes: 7, ConsecutiveResponses: 1, Up: true, EverUp: true,
LastConfirmedAt: &confirmedAt, Message: "remote serial endpoint response confirmed",
}}, nil
}
func (runtimeSerialController) Apply(context.Context, []serial.ChannelConfig) ([]serial.State, error) {
return nil, nil
}
type runtimeSessionController struct{}
func (runtimeSessionController) Status(context.Context) (sessionhealth.Status, error) {
return sessionhealth.Status{
Status: "ok",
Broker: directstream.BrokerSnapshot{
TotalAttachments: 2, ActiveChannels: 1,
Channels: []directstream.ChannelSnapshot{{
Channel: "CH01", AttachmentCount: 2, MaxAttachments: 4,
WriterSessionID: "session-real", QueuedBytes: 7,
}},
},
Serial: serial.StreamSnapshot{
ActiveLeases: 1,
Channels: []serial.StreamChannelSnapshot{{Channel: "CH01", Active: true, RXBytes: 11, TXBytes: 13}},
},
}, nil
}
type runtimePortServiceController struct{}
func (runtimePortServiceController) Apply(context.Context, []domain.Port) error { return nil }
func (runtimePortServiceController) Snapshot() portservice.Snapshot {
return portservice.Snapshot{Services: []portservice.ServiceSnapshot{{
Channel: "CH20", Operation: 1, Mode: "reverse_ssh", Port: 2423,
State: "listening", ActiveClients: 1,
}}}
}
type countingRuntimeSerialController struct {
runtimeSerialController
mu sync.Mutex
stateCalls int
}
func (c *countingRuntimeSerialController) States(ctx context.Context) ([]serial.State, error) {
c.mu.Lock()
c.stateCalls++
c.mu.Unlock()
return c.runtimeSerialController.States(ctx)
}
func (c *countingRuntimeSerialController) calls() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.stateCalls
}
func TestPortAndSystemStatusUseActualSerialRuntime(t *testing.T) {
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{
SerialController: runtimeSerialController{}, SessionController: runtimeSessionController{},
})
portsResponse := httptest.NewRecorder()
portsRequest := httptest.NewRequest(http.MethodGet, "/api/v1/ports?limit=48", nil)
portsRequest.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(portsResponse, portsRequest)
if portsResponse.Code != http.StatusOK {
t.Fatalf("ports status=%d body=%s", portsResponse.Code, portsResponse.Body.String())
}
var ports []domain.Port
if err := json.Unmarshal(portsResponse.Body.Bytes(), &ports); err != nil {
t.Fatal(err)
}
if len(ports) != serial.ChannelCount || ports[0].Status != "session" || ports[0].ConnectedUsers != 2 || ports[0].Writer != "session-real" {
t.Fatalf("CH01 = %#v", ports[0])
}
if ports[0].Runtime == nil || !ports[0].Runtime.ProfileApplied || ports[0].Runtime.RXBytes != 11 || ports[0].Runtime.TXBytes != 13 || ports[0].Runtime.LinkAvailable {
t.Fatalf("CH01 runtime = %#v", ports[0].Runtime)
}
if ports[1].Status != "unknown" {
t.Fatalf("CH02 status = %q, want unknown", ports[1].Status)
}
if ports[7].Runtime == nil || ports[7].Runtime.CRProbeState != serial.CRProbeStateResponded || ports[7].Runtime.CRProbeBytes != 7 ||
!ports[7].Runtime.CRProbeUp || !ports[7].Runtime.CRProbeEverUp || ports[7].Runtime.CRProbeResponses != 2 || ports[7].Runtime.CRProbeConfirmedAt == nil {
t.Fatalf("CH08 CR runtime = %#v", ports[7].Runtime)
}
statusResponse := httptest.NewRecorder()
statusRequest := httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil)
statusRequest.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(statusResponse, statusRequest)
var status domain.SystemStatus
if err := json.Unmarshal(statusResponse.Body.Bytes(), &status); err != nil {
t.Fatal(err)
}
if status.SerialPortCount != serial.ChannelCount || status.ActiveSessions != 2 {
t.Fatalf("system status = %#v", status)
}
servicesResponse := httptest.NewRecorder()
servicesRequest := httptest.NewRequest(http.MethodGet, "/api/v1/system/services?limit=100", nil)
servicesRequest.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(servicesResponse, servicesRequest)
var services []domain.ServiceHealth
if err := json.Unmarshal(servicesResponse.Body.Bytes(), &services); err != nil {
t.Fatal(err)
}
statusByService := make(map[string]string, len(services))
for _, service := range services {
statusByService[service.Service] = service.Status
}
if statusByService["tera-seriald"] != "ok" || statusByService["tera-sessiond"] != "ok" || statusByService["tera-hwd"] != "degraded" {
t.Fatalf("service statuses = %#v", statusByService)
}
}
func TestSerialRuntimeSnapshotIsSharedForShortBurst(t *testing.T) {
controller := &countingRuntimeSerialController{}
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{
SerialController: controller, SessionController: runtimeSessionController{},
})
requestPorts := func() {
t.Helper()
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/ports?limit=48", nil)
request.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("ports status=%d body=%s", response.Code, response.Body.String())
}
}
requestPorts()
requestPorts()
if calls := controller.calls(); calls != 1 {
t.Fatalf("serial States calls=%d, want 1 for a short request burst", calls)
}
time.Sleep(runtimeStatusCacheTTL + 50*time.Millisecond)
requestPorts()
if calls := controller.calls(); calls != 2 {
t.Fatalf("serial States calls=%d, want 2 after cache expiry", calls)
}
}
func TestReverseSSHClientMarksPortAsSession(t *testing.T) {
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{
SerialController: runtimeSerialController{}, SessionController: runtimeSessionController{},
PortServiceController: runtimePortServiceController{},
})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/ports?limit=48", nil)
request.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("ports status=%d body=%s", response.Code, response.Body.String())
}
var ports []domain.Port
if err := json.Unmarshal(response.Body.Bytes(), &ports); err != nil {
t.Fatal(err)
}
port := ports[19]
if port.Channel != "CH20" || port.Status != "session" || port.ConnectedUsers != 1 {
t.Fatalf("CH20 = %#v", port)
}
if port.Runtime == nil || !port.Runtime.SessionActive || len(port.Runtime.Operations) != 1 || port.Runtime.Operations[0].ActiveClients != 1 {
t.Fatalf("CH20 runtime = %#v", port.Runtime)
}
}
func TestSharedBrokerDoesNotCountOperationClientsTwice(t *testing.T) {
router := &Router{portServices: runtimePortServiceController{}}
runtime := serialRuntimeSnapshot{}
runtime.session.Broker.SharedPortSessions = true
runtime.session.Broker.Channels = []directstream.ChannelSnapshot{{Channel: "CH20", AttachmentCount: 3, MaxAttachments: 4}}
ports := router.overlayPortRuntime([]domain.Port{{Channel: "CH20"}}, runtime)
if ports[0].ConnectedUsers != 3 {
t.Fatalf("connected users = %d", ports[0].ConnectedUsers)
}
}
func TestPortRuntimeStatusEndpointReturnsCompactLiveState(t *testing.T) {
handler := NewRouterWithOptions(mock.NewStore(fixedClock), RouterOptions{
SerialController: runtimeSerialController{}, SessionController: runtimeSessionController{},
PortServiceController: runtimePortServiceController{},
})
response := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/ports/runtime-status", nil)
request.Header.Set("X-Mock-Actor", "mock-admin")
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("runtime status=%d body=%s", response.Code, response.Body.String())
}
var ports []compactPortRuntimeStatus
if err := json.Unmarshal(response.Body.Bytes(), &ports); err != nil {
t.Fatal(err)
}
if len(ports) != serial.ChannelCount {
t.Fatalf("ports=%d, want %d", len(ports), serial.ChannelCount)
}
if ports[0].Channel != "CH01" || ports[0].Status != "session" || !ports[0].Runtime.SessionActive {
t.Fatalf("CH01 = %#v", ports[0])
}
if ports[7].Status != "up" || !ports[7].Runtime.CRProbeUp {
t.Fatalf("CH08 = %#v", ports[7])
}
if ports[19].Status != "session" || ports[19].ConnectedUsers != 1 {
t.Fatalf("CH20 = %#v", ports[19])
}
if bytes := response.Body.Bytes(); len(bytes) >= 30000 || string(bytes) == "" {
t.Fatalf("compact response size=%d", len(bytes))
}
if strings.Contains(response.Body.String(), `"operation_1"`) || strings.Contains(response.Body.String(), `"baud_rate"`) {
t.Fatalf("compact response contains port configuration: %s", response.Body.String())
}
}
func TestRuntimePortStatusRequiresUpBeforeDownAndPrioritizesSession(t *testing.T) {
tests := []struct {
name string
runtime *domain.PortRuntime
want string
}{
{name: "boot", runtime: &domain.PortRuntime{}, want: "unknown"},
{name: "unconfirmed miss", runtime: &domain.PortRuntime{CRProbeState: serial.CRProbeStateNoResponse}, want: "unknown"},
{name: "confirmed response", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeUp: true, CRProbeEverUp: true}, want: "up"},
{name: "no response after confirmed up", runtime: &domain.PortRuntime{CRProbeEnabled: true, CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "down"},
{name: "no RX timestamp", runtime: &domain.PortRuntime{RXAgeMillis: 0}, want: "unknown"},
{name: "confirmed hardware down", runtime: &domain.PortRuntime{LinkAvailable: true, LinkEverUp: true, LinkState: "down", CRProbeEnabled: true, CRProbeUp: true}, want: "down"},
{name: "unknown session is not promoted", runtime: &domain.PortRuntime{SessionActive: true}, want: "unknown"},
{name: "active session", runtime: &domain.PortRuntime{SessionActive: true, CRProbeEverUp: true, CRProbeState: serial.CRProbeStateNoResponse}, want: "session"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := runtimePortStatus(test.runtime); got != test.want {
t.Fatalf("runtimePortStatus()=%q, want %q", got, test.want)
}
})
}
}