mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
279 lines
8.9 KiB
Go
279 lines
8.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func overrideProvisionResultPath(t *testing.T) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), provisionResultFileName)
|
|
old := provisionResultPath
|
|
provisionResultPath = func() string { return path }
|
|
t.Cleanup(func() { provisionResultPath = old })
|
|
return path
|
|
}
|
|
|
|
func TestProvisionCodesMapToOneStageAndInRangeExit(t *testing.T) {
|
|
stageRanges := map[provisionStage][2]int{
|
|
provisionStageBootstrap: {30, 39},
|
|
provisionStageListener: {40, 49},
|
|
provisionStageService: {50, 59},
|
|
}
|
|
reservedExits := map[int]string{
|
|
statusExitRunning: "ctrld status running",
|
|
statusExitStopped: "ctrld status stopped",
|
|
statusExitUnknown: "ctrld status unknown",
|
|
statusExitNotReady: "ctrld status not ready",
|
|
deactivationPinInvalidExitCode: "deactivation pin invalid",
|
|
}
|
|
seenExits := make(map[int]provisionFailureCode)
|
|
for _, code := range allProvisionFailureCodes {
|
|
stage, ok := provisionStageForCode[code]
|
|
if !ok {
|
|
t.Fatalf("code %s has no stage", code)
|
|
}
|
|
exit, ok := provisionExitCodeForCode[code]
|
|
if !ok {
|
|
t.Fatalf("code %s has no exit code", code)
|
|
}
|
|
r := stageRanges[stage]
|
|
if exit < r[0] || exit > r[1] {
|
|
t.Errorf("code %s exit %d outside stage %s range %v", code, exit, stage, r)
|
|
}
|
|
if owner, ok := reservedExits[exit]; ok {
|
|
t.Errorf("code %s exit %d collides with %s", code, exit, owner)
|
|
}
|
|
if prev, dup := seenExits[exit]; dup {
|
|
t.Errorf("codes %s and %s share exit %d", prev, code, exit)
|
|
}
|
|
seenExits[exit] = code
|
|
}
|
|
if len(allProvisionFailureCodes) != 8 {
|
|
t.Errorf("expected 8 codes, got %d", len(allProvisionFailureCodes))
|
|
}
|
|
}
|
|
|
|
func TestNewProvisionResultRedactsSecrets(t *testing.T) {
|
|
token := "org-secret-token-12345"
|
|
cdUIDValue := "abcdef123456"
|
|
attempts := []provisionBindAttempt{
|
|
{Addr: "127.0.0.1:53", Proto: "udp", OSError: "bind failed for " + token},
|
|
}
|
|
r := newProvisionResult(
|
|
provisionCodeListenerBindFailed,
|
|
"could not bind, token="+token+" uid="+cdUIDValue,
|
|
attempts,
|
|
token, cdUIDValue,
|
|
)
|
|
raw, err := json.Marshal(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, secret := range []string{token, cdUIDValue} {
|
|
if strings.Contains(string(raw), secret) {
|
|
t.Errorf("serialized result contains secret %q: %s", secret, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewProvisionResultBoundsDetail(t *testing.T) {
|
|
long := strings.Repeat("x", 1000)
|
|
var attempts []provisionBindAttempt
|
|
for i := 0; i < 50; i++ {
|
|
attempts = append(attempts, provisionBindAttempt{Addr: long, Proto: "udp", OSError: long})
|
|
}
|
|
r := newProvisionResult(provisionCodeListenerBindFailed, long, attempts)
|
|
if got := len(r.Detail.Attempts); got > maxProvisionBindAttempts {
|
|
t.Errorf("attempts not capped: %d > %d", got, maxProvisionBindAttempts)
|
|
}
|
|
if len(r.Message) > maxProvisionStringLen {
|
|
t.Errorf("message not capped: %d", len(r.Message))
|
|
}
|
|
for _, a := range r.Detail.Attempts {
|
|
if len(a.Addr) > maxProvisionStringLen || len(a.OSError) > maxProvisionStringLen {
|
|
t.Error("attempt fields not capped")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProvisionResultFields(t *testing.T) {
|
|
r := newProvisionResult(provisionCodeAPIRejected, "the API rejected this configuration", nil)
|
|
if r.Version != 1 {
|
|
t.Errorf("version = %d, want 1", r.Version)
|
|
}
|
|
if r.Stage != string(provisionStageBootstrap) {
|
|
t.Errorf("stage = %q, want bootstrap", r.Stage)
|
|
}
|
|
if r.ExitCode != provisionExitCodeForCode[provisionCodeAPIRejected] {
|
|
t.Errorf("exit = %d", r.ExitCode)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, r.Timestamp); err != nil {
|
|
t.Errorf("timestamp %q not RFC3339: %v", r.Timestamp, err)
|
|
}
|
|
if r.Detail != nil {
|
|
t.Error("nil attempts should give nil detail")
|
|
}
|
|
}
|
|
|
|
func TestProvisionResultTrusted(t *testing.T) {
|
|
good := newProvisionResult(provisionCodeListenerBindFailed, "x", nil)
|
|
if !provisionResultTrusted(good) {
|
|
t.Error("constructor-built result must be trusted")
|
|
}
|
|
bogusCode := newProvisionResult(provisionCodeListenerBindFailed, "x", nil)
|
|
bogusCode.Code = "TOTALLY_MADE_UP"
|
|
if provisionResultTrusted(bogusCode) {
|
|
t.Error("unknown code must not be trusted")
|
|
}
|
|
wrongExit := newProvisionResult(provisionCodeListenerBindFailed, "x", nil)
|
|
wrongExit.ExitCode = 126
|
|
if provisionResultTrusted(wrongExit) {
|
|
t.Error("exit code not matching the contract must not be trusted")
|
|
}
|
|
wrongStage := newProvisionResult(provisionCodeListenerBindFailed, "x", nil)
|
|
wrongStage.Stage = string(provisionStageService)
|
|
if provisionResultTrusted(wrongStage) {
|
|
t.Error("stage not matching the code must not be trusted")
|
|
}
|
|
}
|
|
|
|
func TestNewProvisionResultTruncatesOnRuneBoundary(t *testing.T) {
|
|
msg := strings.Repeat("é", maxProvisionStringLen) // 2 bytes per rune
|
|
r := newProvisionResult(provisionCodeListenerBindFailed, msg, nil)
|
|
if len(r.Message) > maxProvisionStringLen {
|
|
t.Errorf("message not capped: %d bytes", len(r.Message))
|
|
}
|
|
if !utf8.ValidString(r.Message) {
|
|
t.Error("truncation split a multi-byte rune")
|
|
}
|
|
}
|
|
|
|
func TestFailureCodeDocTableMatchesConstants(t *testing.T) {
|
|
buf, err := os.ReadFile(filepath.Join("..", "..", "docs", "provisioning-failure-codes.md"))
|
|
if os.IsNotExist(err) {
|
|
// The Windows CI runner executes prebuilt test binaries outside the
|
|
// repo; the sync guarantee is still enforced on runners with a checkout.
|
|
t.Skip("failure-code doc not available in this test environment")
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("could not read the failure-code doc: %v", err)
|
|
}
|
|
doc := string(buf)
|
|
rows := 0
|
|
for _, line := range strings.Split(doc, "\n") {
|
|
if strings.HasPrefix(line, "| `") {
|
|
rows++
|
|
}
|
|
}
|
|
if rows != len(allProvisionFailureCodes) {
|
|
t.Errorf("doc table has %d code rows, want %d", rows, len(allProvisionFailureCodes))
|
|
}
|
|
for _, code := range allProvisionFailureCodes {
|
|
row := "| `" + string(code) + "` | " + string(provisionStageForCode[code]) + " | " + strconv.Itoa(provisionExitCodeForCode[code]) + " |"
|
|
if !strings.Contains(doc, row) {
|
|
t.Errorf("doc table missing row for %s (want prefix %q)", code, row)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProvisionFailureLineFormat(t *testing.T) {
|
|
r := newProvisionResult(provisionCodeListenerBindFailed, "could not find available listen ip and port", nil)
|
|
want := "provisioning failed: stage=listener code=LISTENER_BIND_FAILED (exit 41)"
|
|
if got := r.failureLine(); got != want {
|
|
t.Errorf("failureLine() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestProvisionResultRoundTrip(t *testing.T) {
|
|
overrideProvisionResultPath(t)
|
|
in := newProvisionResult(provisionCodeServiceStartFailed, "service failed to start", nil)
|
|
if err := writeProvisionResult(in); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Code != in.Code || out.Stage != in.Stage || out.ExitCode != in.ExitCode || out.Message != in.Message {
|
|
t.Errorf("round trip mismatch: in=%+v out=%+v", in, out)
|
|
}
|
|
}
|
|
|
|
func TestWriteProvisionResultOverwritesAtomically(t *testing.T) {
|
|
path := overrideProvisionResultPath(t)
|
|
first := newProvisionResult(provisionCodeAPIUnreachable, "first", nil)
|
|
if err := writeProvisionResult(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second := newProvisionResult(provisionCodeListenerBindFailed, "second", nil)
|
|
if err := writeProvisionResult(second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Code != string(provisionCodeListenerBindFailed) || out.Message != "second" {
|
|
t.Errorf("overwrite failed: %+v", out)
|
|
}
|
|
entries, err := os.ReadDir(filepath.Dir(path))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Errorf("temp files left behind: %v", entries)
|
|
}
|
|
}
|
|
|
|
func TestClearProvisionResult(t *testing.T) {
|
|
path := overrideProvisionResultPath(t)
|
|
clearProvisionResult() // missing file must not panic or error loudly
|
|
if err := writeProvisionResult(newProvisionResult(provisionCodeAPIUnreachable, "x", nil)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clearProvisionResult()
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Errorf("result file still present after clear: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReadProvisionResultMissing(t *testing.T) {
|
|
overrideProvisionResultPath(t)
|
|
if _, err := readProvisionResult(); err == nil {
|
|
t.Error("expected error reading missing result file")
|
|
}
|
|
}
|
|
|
|
func TestFailProvisionWritesLogsNotifiesAndExits(t *testing.T) {
|
|
overrideProvisionResultPath(t)
|
|
exitCode := -1
|
|
oldExit := provisionExit
|
|
provisionExit = func(code int) { exitCode = code }
|
|
t.Cleanup(func() { provisionExit = oldExit })
|
|
|
|
notified := false
|
|
r := newProvisionResult(provisionCodeListenerBindFailed, "no listen addr", nil)
|
|
failProvision(r, func() { notified = true })
|
|
|
|
if !notified {
|
|
t.Error("notify func not called")
|
|
}
|
|
if exitCode != provisionExitCodeForCode[provisionCodeListenerBindFailed] {
|
|
t.Errorf("exit code = %d", exitCode)
|
|
}
|
|
out, err := readProvisionResult()
|
|
if err != nil {
|
|
t.Fatalf("result not persisted: %v", err)
|
|
}
|
|
if out.Code != string(provisionCodeListenerBindFailed) {
|
|
t.Errorf("persisted code = %q", out.Code)
|
|
}
|
|
}
|