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.

312 lines
11 KiB

package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"tera-clone/services/tera-api/internal/diagnostics"
"tera-clone/services/tera-api/internal/domain"
"tera-clone/services/tera-api/internal/security/password"
"tera-clone/services/tera-api/internal/serial"
"tera-clone/services/tera-api/internal/supportbundle"
)
type serialRuntimeStoreStub struct {
snapshot domain.ConfigExport
err error
}
func (s serialRuntimeStoreStub) ConfigExport(context.Context) (domain.ConfigExport, error) {
return s.snapshot, s.err
}
type serialRuntimeControllerStub struct {
line []serial.ChannelConfig
capture []serial.CaptureConfig
lineErr error
}
func (*serialRuntimeControllerStub) States(context.Context) ([]serial.State, error) { return nil, nil }
func (s *serialRuntimeControllerStub) Apply(_ context.Context, configs []serial.ChannelConfig) ([]serial.State, error) {
s.line = append([]serial.ChannelConfig(nil), configs...)
return nil, s.lineErr
}
func (s *serialRuntimeControllerStub) ApplyCapture(_ context.Context, configs []serial.CaptureConfig) error {
s.capture = append([]serial.CaptureConfig(nil), configs...)
return nil
}
func TestReconcileSerialRuntimeAppliesAllLineAndCaptureProfiles(t *testing.T) {
ports := make([]domain.Port, 0, serial.ChannelCount)
for index := 1; index <= serial.ChannelCount; index++ {
ports = append(ports, domain.Port{
Channel: serialChannel(index), BaudRate: 115200, DataBits: 8,
Parity: "none", StopBits: 1, FlowControl: "none", DataLogMode: "rx_tx",
})
}
controller := &serialRuntimeControllerStub{}
err := reconcileSerialRuntime(context.Background(), serialRuntimeStoreStub{
snapshot: domain.ConfigExport{Running: domain.ConfigExportState{Ports: ports}},
}, controller)
if err != nil {
t.Fatal(err)
}
if len(controller.line) != serial.ChannelCount || len(controller.capture) != serial.ChannelCount {
t.Fatalf("line=%d capture=%d", len(controller.line), len(controller.capture))
}
if controller.line[0].Channel != "CH01" || controller.line[0].BaudRate != 115200 || controller.capture[0].Mode != "rx_tx" {
t.Fatalf("CH01 line=%#v capture=%#v", controller.line[0], controller.capture[0])
}
}
func TestReconcileSerialRuntimeRejectsIncompleteSnapshotAndPropagatesApplyFailure(t *testing.T) {
if err := reconcileSerialRuntime(context.Background(), serialRuntimeStoreStub{}, &serialRuntimeControllerStub{}); err == nil {
t.Fatal("incomplete serial snapshot must fail")
}
ports := make([]domain.Port, serial.ChannelCount)
for index := range ports {
ports[index] = domain.Port{Channel: serialChannel(index + 1), BaudRate: 9600, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none", DataLogMode: "rx_tx"}
}
want := errors.New("line apply failed")
err := reconcileSerialRuntime(context.Background(), serialRuntimeStoreStub{snapshot: domain.ConfigExport{Running: domain.ConfigExportState{Ports: ports}}}, &serialRuntimeControllerStub{lineErr: want})
if !errors.Is(err, want) {
t.Fatalf("apply error = %v", err)
}
}
func TestSerialRuntimeHealthyRequiresAllVerifiedProfiles(t *testing.T) {
states := make([]serial.State, 0, serial.ChannelCount)
for index := 1; index <= serial.ChannelCount; index++ {
settings := &serial.LineSettings{BaudRate: 115200, DataBits: 8, Parity: "none", StopBits: 1, FlowControl: "none"}
states = append(states, serial.State{Channel: serialChannel(index), Available: true, ProfileApplied: true, LineSettings: settings})
}
if !serialRuntimeHealthy(states, nil) {
t.Fatal("complete verified profiles should be healthy")
}
states[0].ProfileApplied = false
if serialRuntimeHealthy(states, nil) {
t.Fatal("a missing applied profile must require reconciliation")
}
}
func serialChannel(index int) string {
return fmt.Sprintf("CH%02d", index)
}
func TestGetenvDurationSeconds(t *testing.T) {
t.Setenv("TERA_TEST_TIMEOUT_SECONDS", "7")
got := getenvDurationSeconds("TERA_TEST_TIMEOUT_SECONDS", 5*time.Second)
if got != 7*time.Second {
t.Fatalf("duration = %s, want 7s", got)
}
}
func TestGetenvDurationSecondsFallsBackForInvalidValues(t *testing.T) {
for _, value := range []string{"", "0", "-1", "abc"} {
t.Setenv("TERA_TEST_TIMEOUT_SECONDS", value)
got := getenvDurationSeconds("TERA_TEST_TIMEOUT_SECONDS", 5*time.Second)
if got != 5*time.Second {
t.Fatalf("duration for %q = %s, want 5s", value, got)
}
}
}
func TestMinimumPasswordLengthEnvironment(t *testing.T) {
t.Setenv("TERA_API_MIN_PASSWORD_LENGTH", "8")
if got := getenvInt("TERA_API_MIN_PASSWORD_LENGTH", 12); got != 8 {
t.Fatalf("minimum password length = %d, want 8", got)
}
}
func TestGetenvBool(t *testing.T) {
for _, item := range []struct {
value string
want bool
}{
{value: "true", want: true},
{value: "1", want: true},
{value: "yes", want: true},
{value: "on", want: true},
{value: "false", want: false},
{value: "0", want: false},
{value: "no", want: false},
{value: "off", want: false},
} {
t.Setenv("TERA_TEST_BOOL", item.value)
if got := getenvBool("TERA_TEST_BOOL", !item.want); got != item.want {
t.Fatalf("bool for %q = %v, want %v", item.value, got, item.want)
}
}
}
func TestGetenvBoolFallsBackForMissingOrInvalidValues(t *testing.T) {
for _, value := range []string{"", "maybe"} {
t.Setenv("TERA_TEST_BOOL", value)
if got := getenvBool("TERA_TEST_BOOL", true); !got {
t.Fatalf("bool for %q = false, want fallback true", value)
}
}
}
func TestListenAddrConfigUsesTargetEnvironmentName(t *testing.T) {
t.Setenv("TERA_API_ADDR", "")
t.Setenv("TERA_API_LISTEN_ADDR", "0.0.0.0:8080")
if got := listenAddrConfig(); got != "0.0.0.0:8080" {
t.Fatalf("listen addr = %q, want target env value", got)
}
}
func TestListenAddrConfigKeepsLegacyEnvironmentName(t *testing.T) {
t.Setenv("TERA_API_ADDR", "127.0.0.1:18080")
t.Setenv("TERA_API_LISTEN_ADDR", "0.0.0.0:8080")
if got := listenAddrConfig(); got != "127.0.0.1:18080" {
t.Fatalf("listen addr = %q, want legacy env value", got)
}
}
func TestAllowMockActorsConfigDefaultsToDisabled(t *testing.T) {
t.Setenv("TERA_API_ALLOW_MOCK_ACTORS", "")
if allowMockActorsConfig() {
t.Fatalf("mock actors should be disabled unless explicitly enabled")
}
}
func TestAllowMockActorsConfigCanBeEnabled(t *testing.T) {
t.Setenv("TERA_API_ALLOW_MOCK_ACTORS", "true")
if !allowMockActorsConfig() {
t.Fatalf("mock actors should be enabled when explicitly configured")
}
}
func TestDiagnosticsRunnerConfigIsExplicitAndRejectsRelativePaths(t *testing.T) {
runner, err := diagnosticsRunnerConfig(false)
if err != nil || runner != nil {
t.Fatalf("disabled diagnostics runner = %#v, %v", runner, err)
}
t.Setenv("TERA_API_DIAGNOSTICS_PING_PATH", "relative-ping")
if _, err := diagnosticsRunnerConfig(true); err == nil {
t.Fatal("diagnosticsRunnerConfig accepted a relative executable path")
}
t.Setenv("TERA_API_DIAGNOSTICS_PING_PATH", "/bin/ping")
t.Setenv("TERA_API_DIAGNOSTICS_TRACEROUTE_PATH", "/usr/bin/traceroute")
runner, err = diagnosticsRunnerConfig(true)
if err != nil {
t.Fatal(err)
}
if _, ok := runner.(*diagnostics.ProcessRunner); !ok {
t.Fatalf("runner type = %T, want *diagnostics.ProcessRunner", runner)
}
}
func TestSupportBundleServiceConfigIsExplicitAndBounded(t *testing.T) {
service, err := supportBundleServiceConfig("")
if err != nil || service != nil {
t.Fatalf("disabled support bundle service = %#v, %v", service, err)
}
if _, err := supportBundleServiceConfig("relative/bundles"); err == nil {
t.Fatal("support bundle service accepted a relative directory")
}
t.Setenv("TERA_API_SUPPORT_BUNDLE_MAX_ARTIFACTS", "2")
t.Setenv("TERA_API_SUPPORT_BUNDLE_MAX_BYTES", "65536")
t.Setenv("TERA_API_SUPPORT_BUNDLE_MAX_SECTION_BYTES", "4096")
service, err = supportBundleServiceConfig(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, ok := service.(*supportbundle.Manager); !ok {
t.Fatalf("support bundle service type = %T, want *supportbundle.Manager", service)
}
}
func TestPasswordKDFConfigDefaultsToArgon2id(t *testing.T) {
kdf, registry, err := passwordKDFConfig("")
if err != nil {
t.Fatalf("password KDF config: %v", err)
}
if kdf.Algorithm() != password.AlgorithmArgon2id {
t.Fatalf("kdf algorithm = %q, want argon2id", kdf.Algorithm())
}
if !registry.Supports(password.AlgorithmArgon2id) {
t.Fatalf("registry should support argon2id")
}
if registry.Supports(password.AlgorithmPBKDF2SHA256) {
t.Fatalf("default registry should not enable pbkdf2")
}
}
func TestPasswordKDFConfigSupportsFIPSCompatiblePBKDF2(t *testing.T) {
for _, mode := range []string{password.AlgorithmPBKDF2SHA256, "pbkdf2", "fips"} {
kdf, registry, err := passwordKDFConfig(mode)
if err != nil {
t.Fatalf("password KDF config for %q: %v", mode, err)
}
if kdf.Algorithm() != password.AlgorithmPBKDF2SHA256 {
t.Fatalf("kdf algorithm for %q = %q, want pbkdf2-hmac-sha256", mode, kdf.Algorithm())
}
if !registry.Supports(password.AlgorithmPBKDF2SHA256) {
t.Fatalf("registry for %q should support pbkdf2", mode)
}
if registry.Supports(password.AlgorithmArgon2id) {
t.Fatalf("fips-compatible registry for %q should not enable argon2id", mode)
}
}
}
func TestPasswordKDFConfigRejectsUnknownMode(t *testing.T) {
if _, _, err := passwordKDFConfig("scrypt"); err == nil {
t.Fatalf("unknown password KDF should fail")
}
}
func TestUnnotifiedKernelBootOnlyClaimsNewBootID(t *testing.T) {
directory := t.TempDir()
bootPath := filepath.Join(directory, "boot-id")
markerPath := filepath.Join(directory, "marker")
if err := os.WriteFile(bootPath, []byte("boot-a\n"), 0o600); err != nil {
t.Fatal(err)
}
bootID, unnotified, err := unnotifiedKernelBoot(bootPath, markerPath)
if err != nil || bootID != "boot-a" || !unnotified {
t.Fatalf("first detection = %q, %v, %v", bootID, unnotified, err)
}
if err := os.WriteFile(markerPath, []byte("boot-a\n"), 0o600); err != nil {
t.Fatal(err)
}
_, unnotified, err = unnotifiedKernelBoot(bootPath, markerPath)
if err != nil || unnotified {
t.Fatalf("same-boot detection = %v, %v", unnotified, err)
}
if err := os.WriteFile(bootPath, []byte("boot-b\n"), 0o600); err != nil {
t.Fatal(err)
}
bootID, unnotified, err = unnotifiedKernelBoot(bootPath, markerPath)
if err != nil || bootID != "boot-b" || !unnotified {
t.Fatalf("new-boot detection = %q, %v, %v", bootID, unnotified, err)
}
}
func TestUnnotifiedKernelBootRejectsInvalidOrUnreadableState(t *testing.T) {
directory := t.TempDir()
bootPath := filepath.Join(directory, "boot-id")
markerPath := filepath.Join(directory, "marker")
if _, _, err := unnotifiedKernelBoot(bootPath, markerPath); err == nil {
t.Fatal("missing boot id was accepted")
}
if err := os.WriteFile(bootPath, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := unnotifiedKernelBoot(bootPath, markerPath); err == nil {
t.Fatal("empty boot id was accepted")
}
}