package main // printRestartNotice's three states (discussion #16). The assertions are on // STRUCTURE — which hosts are named, which remedy is offered, whether // anything is said at all — never on the exact sentences, which are reviewed // copy and change without changing what the notice means. import ( "bytes" "strings" "testing" "github.com/carlosframework/platform/internal/appconfig" ) func TestPrintRestartNoticeSaysNothingWhenNoClaimWasMade(t *testing.T) { var b bytes.Buffer printRestartNotice(&b, "relay", nil) if b.Len() != 0 { t.Fatalf("printed %q for a nil answer, want silence — an older console said nothing, which is not a claim that nothing needs restarting", b.String()) } } func TestPrintRestartNoticeSaysNothingWhenNothingPending(t *testing.T) { var b bytes.Buffer printRestartNotice(&b, "relay", &restartAnswer{known: true}) if b.Len() != 0 { t.Fatalf("printed %q when the answer is a genuine none", b.String()) } } // The one that matters: a console that LOOKED and could not tell must say // so, because staying quiet here is the failure being fixed. func TestPrintRestartNoticeSpeaksUpWhenItCouldNotTell(t *testing.T) { var b bytes.Buffer printRestartNotice(&b, "relay", &restartAnswer{known: false}) if b.Len() == 0 { t.Fatal("printed nothing for known:false — 'could not check' must not render as 'all clear'") } } func TestPrintRestartNoticeNamesEachHost(t *testing.T) { var b bytes.Buffer printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{ Hosts: []appconfig.RestartHost{ {Host: "one.example.com"}, {Host: "two.example.com"}, }, }}) got := b.String() for _, want := range []string{"one.example.com", "two.example.com"} { if !strings.Contains(got, want) { t.Fatalf("output %q does not name %q", got, want) } } // One line per host, plus the single trailing scope caveat. if n := strings.Count(got, "\n"); n != 3 { t.Fatalf("output has %d lines, want one per host plus one caveat:\n%s", n, got) } if n := strings.Count(got, restartCopyScope); n != 1 { t.Fatalf("scope caveat appears %d times, want exactly once:\n%s", n, got) } } // The remedy split. A route whose unit carlos knows gets pointed at a // command; a route without one must NOT be, because `carlos restart` is a // documented no-op for that shape and sending someone to run it would be a // second silent failure answering the first. func TestPrintRestartNoticeOffersTheRightRemedy(t *testing.T) { var withUnit bytes.Buffer printRestartNotice(&withUnit, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{ Hosts: []appconfig.RestartHost{{Host: "console.example.com", Unit: "console.service"}}, }}) if !strings.Contains(withUnit.String(), "console.service") { t.Fatalf("output %q does not name the unit that can be cycled", withUnit.String()) } var plain bytes.Buffer printRestartNotice(&plain, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{ Hosts: []appconfig.RestartHost{{Host: "connect.example.com"}}, }}) if strings.Contains(plain.String(), "carlos restart") { t.Fatalf("output %q offers `carlos restart` for a route it cannot cycle", plain.String()) } } func TestPrintRestartNoticeNamesUnresolvedHosts(t *testing.T) { var b bytes.Buffer printRestartNotice(&b, "relay", &restartAnswer{known: true, pending: appconfig.RestartPending{ Unresolved: []string{"ghost.example.com"}, }}) if !strings.Contains(b.String(), "ghost.example.com") { t.Fatalf("output %q does not name the host it could not resolve", b.String()) } } // checkNoEmptyValues: the report's aside, on the CLI side of the seal. This // is the only gate in direct mode, and it fires before the network // round-trip that fetches the sealing key. func TestCheckNoEmptyValues(t *testing.T) { if err := checkNoEmptyValues(map[string]string{"A": "1", "B": "2"}); err != nil { t.Fatalf("refused a pair of real values: %v", err) } err := checkNoEmptyValues(map[string]string{"CLIENT_ID": "", "CLIENT_SECRET": "", "FINE": "v"}) if err == nil { t.Fatal("accepted empty values") } // Every offending key, not just the first — a mistyped `op read` field // usually spoils several at once, and Go's map order would otherwise // name a different one each run. for _, want := range []string{"CLIENT_ID", "CLIENT_SECRET"} { if !strings.Contains(err.Error(), want) { t.Fatalf("error %q does not name %q", err, want) } } if strings.Contains(err.Error(), "FINE") { t.Fatalf("error %q names a key that was fine", err) } // Sorted, so the message is the same on every run. if i, j := strings.Index(err.Error(), "CLIENT_ID"), strings.Index(err.Error(), "CLIENT_SECRET"); i > j { t.Fatalf("keys are not sorted in %q", err) } } // restartFromAPI's nil-preservation is the wire half of the same three // states: a console that omits the field must not be turned into one that // claims there is nothing to do. func TestRestartFromAPIPreservesNil(t *testing.T) { if got := restartFromAPI(nil); got != nil { t.Fatalf("restartFromAPI(nil) = %+v, want nil", got) } got := restartFromAPI(&apiConfigRestart{ Known: true, Hosts: []apiConfigRestartHost{{Host: "h.example.com", Unit: "u.service"}}, }) if got == nil || !got.known { t.Fatalf("restartFromAPI dropped a present answer: %+v", got) } if len(got.pending.Hosts) != 1 || got.pending.Hosts[0].Unit != "u.service" { t.Fatalf("hosts = %+v, want the unit carried through", got.pending.Hosts) } }