`status --json` emitted no JSON at all when a single node was unresolvable. A blocked node is not on the private network, and a mesh whose hub is that node has no hub — which came back through the reading as a refusal, so `status` printed nothing and `status --json` put multi-line prose on stderr and not one byte on stdout. A machine-readable interface that stops being machine-readable exactly when something is wrong is one nobody can build an alarm on. Why a machine cannot be worked out is read as data now, per machine, through the same whoResolves the private network is built from — so this and the network agree about who could not be resolved rather than deciding it twice. The private network failing to compute is kept as a note beside it instead of ending the read: it is almost always a consequence of those same refusals, and every question that does not depend on it is still answered. It reaches all three ways of saying it, from the one reading: the text form leads with it because a machine here is in none of the answers below, the JSON carries `unresolved` (always a list, never null) and `network`, and the page has a section of its own. That also closes a silent success. A machine that resolves to nothing has nothing computed for it, so there is nothing to compare it against and nothing it can be behind — it appeared in no answer at all, and `status` reported a mesh where nothing could be sent anywhere as "all doing what they were told". statusAsJSON takes the whole reading now rather than a growing argument list, which is what let an answer be added to the text form and forgotten here. The two are one function's output in two shapes and must not be able to differ about what was asked. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
142 lines
5.0 KiB
Go
142 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-control/internal/inventory"
|
|
)
|
|
|
|
// The shape something other than a person reads.
|
|
//
|
|
// Hard to change once anything is built against it, so it stays close to what the domain already
|
|
// calls things and carries no summary field that would have to be kept true.
|
|
|
|
func statusOf(t *testing.T, wrong []inventory.Doing, nodes, quiet []inventory.Node,
|
|
behind map[string][]string, sources map[string]inventory.Source) map[string]any {
|
|
t.Helper()
|
|
body, err := statusAsJSON(answers{
|
|
wrong: wrong, nodes: nodes, quiet: quiet, behind: behind, sources: sources})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal(body, &parsed); err != nil {
|
|
t.Fatalf("what a board would read is not JSON: %v", err)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func TestRefusedAndFailedStayDistinctAllTheWayOut(t *testing.T) {
|
|
// They are fixed in different places, so one word for both would send half the readers of a
|
|
// page to the wrong one.
|
|
got := statusOf(t,
|
|
[]inventory.Doing{
|
|
{Node: "one", Outcome: inventory.OutcomeRefused, Refused: "a file needs a path"},
|
|
{Node: "two", Outcome: inventory.OutcomeFailed, Applied: 3,
|
|
Failed: []inventory.FailedResource{{ID: "shell.pkg", Error: "target not found"}}},
|
|
},
|
|
[]inventory.Node{{Name: "one"}, {Name: "two"}}, nil, nil, nil)
|
|
|
|
wrong, _ := got["wrong"].([]any)
|
|
if len(wrong) != 2 {
|
|
t.Fatalf("got %v", got["wrong"])
|
|
}
|
|
first, _ := wrong[0].(map[string]any)
|
|
if first["outcome"] != inventory.OutcomeRefused || first["refused"] == nil {
|
|
t.Fatalf("a refusal did not survive: %v", first)
|
|
}
|
|
second, _ := wrong[1].(map[string]any)
|
|
if second["outcome"] != inventory.OutcomeFailed {
|
|
t.Fatalf("a failure did not survive: %v", second)
|
|
}
|
|
if second["applied"] != float64(3) {
|
|
// "Three of eight" and "none of eight" are different machines.
|
|
t.Fatalf("what did apply was not carried: %v", second)
|
|
}
|
|
}
|
|
|
|
func TestAMachineThatNeverSpokeSaysSoByOmission(t *testing.T) {
|
|
// Never heard from and quiet for a while are different situations, and a zero time would read
|
|
// as a date in 1970 on any page that formatted it.
|
|
got := statusOf(t, nil,
|
|
[]inventory.Node{{Name: "silent"}, {Name: "away", LastSeen: time.Now().Add(-3 * time.Hour)}},
|
|
[]inventory.Node{{Name: "silent"}, {Name: "away", LastSeen: time.Now().Add(-3 * time.Hour)}},
|
|
nil, nil)
|
|
|
|
quiet, _ := got["quiet"].([]any)
|
|
if len(quiet) != 2 {
|
|
t.Fatalf("got %v", got["quiet"])
|
|
}
|
|
never, _ := quiet[0].(map[string]any)
|
|
if _, said := never["lastSeen"]; said {
|
|
t.Fatalf("a machine that never spoke carries a time: %v", never)
|
|
}
|
|
away, _ := quiet[1].(map[string]any)
|
|
if _, said := away["lastSeen"]; !said {
|
|
t.Fatalf("a machine that has been quiet carries no time: %v", away)
|
|
}
|
|
}
|
|
|
|
func TestNothingWrongIsAnEmptyListRatherThanNothing(t *testing.T) {
|
|
// A page distinguishing "no machines are wrong" from "this field is missing" would have to
|
|
// handle both, and null is the one that gets forgotten.
|
|
got := statusOf(t, nil, []inventory.Node{{Name: "a", LastSeen: time.Now()}}, nil, nil, nil)
|
|
for _, key := range []string{"wrong", "quiet", "behind"} {
|
|
list, ok := got[key].([]any)
|
|
if !ok {
|
|
t.Fatalf("%q is %T, not a list", key, got[key])
|
|
}
|
|
if len(list) != 0 {
|
|
t.Fatalf("%q is not empty: %v", key, list)
|
|
}
|
|
}
|
|
// And how many machines there are, so a reader can tell "none wrong" from "none at all".
|
|
if got["machines"] != float64(1) {
|
|
t.Fatalf("got %v", got["machines"])
|
|
}
|
|
}
|
|
|
|
func TestWhatIsBehindNamesTheMachinesRunningTheOldOne(t *testing.T) {
|
|
// A module being out of date is a fact about the catalogue; machines running the old one is
|
|
// the thing with consequences.
|
|
got := statusOf(t, nil, nil, nil,
|
|
map[string][]string{"shell": {"workstation", "laptop"}},
|
|
map[string]inventory.Source{"shell": {BuiltFrom: "aaaaaaa1", Head: "bbbbbbb2"}})
|
|
|
|
behind, _ := got["behind"].([]any)
|
|
if len(behind) != 1 {
|
|
t.Fatalf("got %v", got["behind"])
|
|
}
|
|
row, _ := behind[0].(map[string]any)
|
|
if row["module"] != "shell" || row["builtFrom"] != "aaaaaaa1" || row["head"] != "bbbbbbb2" {
|
|
t.Fatalf("got %v", row)
|
|
}
|
|
on, _ := row["on"].([]any)
|
|
if len(on) != 2 {
|
|
t.Fatalf("the machines running the old one are not named: %v", row)
|
|
}
|
|
}
|
|
|
|
func TestNoSecretIsInWhatABoardReads(t *testing.T) {
|
|
// Everything here comes from the mesh's own records, which hold no readable secret — but a
|
|
// shape a page is built against is exactly where one would eventually be added for
|
|
// convenience, so this says it out loud.
|
|
body, err := statusAsJSON(answers{
|
|
wrong: []inventory.Doing{{Node: "a", Outcome: inventory.OutcomeRefused,
|
|
Refused: "resource \"x\": a file needs a path"}},
|
|
nodes: []inventory.Node{{Name: "a"}},
|
|
refused: map[string]string{"a": "nothing provides \"database\", wanted by web"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, word := range []string{"password", "secret", "sealed", "credential", "token"} {
|
|
if strings.Contains(strings.ToLower(string(body)), word) {
|
|
t.Fatalf("what a board reads carries a %q field:\n%s", word, body)
|
|
}
|
|
}
|
|
}
|