| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/infraview" |
| 9 | ) |
| 10 | |
| 11 | // The same fleet must draw the same picture every time. A diagram that |
| 12 | // reshuffles between refreshes cannot be read for change, which is most of |
| 13 | // what an operator opens it for. |
| 14 | func TestLayoutIsDeterministic(t *testing.T) { |
| 15 | a := renderBoth(BuildArch(archFixture())) |
| 16 | b := renderBoth(BuildArch(archFixture())) |
| 17 | if a != b { |
| 18 | t.Fatal("two builds of the same input produced different SVG") |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | // renderBoth is the whole page's drawing: every band of the CARLOS estate |
| 23 | // and every band of the account's other one, in page order. Determinism is a |
| 24 | // property of the PAGE, so every test about it renders all of it — a split |
| 25 | // that made one picture stable by letting another reshuffle would be no |
| 26 | // improvement on one that reshuffled. It also covers the band ORDER, which |
| 27 | // is part of what a reader would see move. |
| 28 | func renderBoth(v ArchView) string { |
| 29 | return renderEstate(v.CARLOS) + "\n" + renderEstate(v.Rest) |
| 30 | } |
| 31 | |
| 32 | // renderEstate is one estate's drawing: every band of it, in page order. |
| 33 | func renderEstate(e ArchEstate) string { |
| 34 | var out []string |
| 35 | for _, b := range e.Bands { |
| 36 | out = append(out, b.Label+"\n"+string(RenderArchSVG(b))) |
| 37 | } |
| 38 | return strings.Join(out, "\n") |
| 39 | } |
| 40 | |
| 41 | // eachBand names every picture on the page — one per estate per region — so |
| 42 | // a test that must hold of all of them says which one failed. It is the |
| 43 | // unit those tests want: a band is what gets laid out, sized and drawn, and |
| 44 | // the properties they assert (nothing overlaps, nothing sits on an edge, |
| 45 | // the viewBox is positive) are properties of one picture. |
| 46 | func eachBand(v ArchView) map[string]ArchBand { |
| 47 | out := map[string]ArchBand{} |
| 48 | for name, e := range map[string]ArchEstate{"CARLOS": v.CARLOS, "rest of the account": v.Rest} { |
| 49 | for _, b := range e.Bands { |
| 50 | out[name+" / "+b.Label] = b |
| 51 | } |
| 52 | } |
| 53 | return out |
| 54 | } |
| 55 | |
| 56 | // Layer order is the story one band tells: a DNS name enters at the left, |
| 57 | // resolves to an address, and reaches the box that answers for it. Four |
| 58 | // columns, because four is what fits the page (archLayer). A record |
| 59 | // pointing outside the estate must sit to the RIGHT of the record that |
| 60 | // names it — the diagram's headline unjoined case reads left-to-right like |
| 61 | // everything else. |
| 62 | func TestLayoutOrdersLayers(t *testing.T) { |
| 63 | in := archFixture() |
| 64 | in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{ |
| 65 | Kind: infraview.KindDNSRecord, ID: "r2", Name: "mail.carloku.com", |
| 66 | Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "203.0.113.9"}, |
| 67 | }) |
| 68 | v := BuildArch(in) |
| 69 | x := map[string]int{} |
| 70 | for _, n := range archNodes(v) { |
| 71 | x[n.ID] = n.X |
| 72 | } |
| 73 | if !(x["record:r1"] < x["ip:eipalloc-1"] && x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"]) { |
| 74 | t.Fatalf("layers out of order: %+v", x) |
| 75 | } |
| 76 | // And the whole band fits the console's 64rem main column, which is the |
| 77 | // entire reason the columns were folded down to four. A picture wider |
| 78 | // than this is one the operator has to drag. |
| 79 | for name, b := range eachBand(v) { |
| 80 | if b.Width > 1024 { |
| 81 | t.Errorf("%s: band is %d units wide — it must fit the 1024px main column without scrolling sideways", name, b.Width) |
| 82 | } |
| 83 | } |
| 84 | if !(x["record:r2"] < x["external:203.0.113.9"]) { |
| 85 | t.Fatalf("external must sit right of the record that points at it: %+v", x) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // No node may overlap another: the layout is the only thing standing |
| 90 | // between a growing fleet and an unreadable page. |
| 91 | func TestLayoutDoesNotOverlap(t *testing.T) { |
| 92 | v := BuildArch(archFixture()) |
| 93 | // Per picture: two nodes in DIFFERENT diagrams sharing a coordinate is |
| 94 | // not an overlap, it is two diagrams. |
| 95 | for name, est := range eachBand(v) { |
| 96 | for i := range est.Nodes { |
| 97 | for j := i + 1; j < len(est.Nodes); j++ { |
| 98 | a, b := est.Nodes[i], est.Nodes[j] |
| 99 | if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { |
| 100 | t.Fatalf("%s: %s overlaps %s", name, a.ID, b.ID) |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The SVG must scale without script and must not carry raw user strings |
| 108 | // unescaped. |
| 109 | func TestSVGIsSelfContainedAndEscaped(t *testing.T) { |
| 110 | in := archFixture() |
| 111 | in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>` |
| 112 | in.Hosts[0].Label = in.Fleet.Boxes[0].Label |
| 113 | out := renderBoth(BuildArch(in)) |
| 114 | if !strings.Contains(out, "viewBox=") { |
| 115 | t.Error("SVG must carry a viewBox so it scales with no JS") |
| 116 | } |
| 117 | if strings.Contains(out, "<script") { |
| 118 | t.Error("a box label must never reach the SVG unescaped") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // A deployment with no boxes and no inventory yet — an ungranted console on |
| 123 | // day one — must still draw a valid, positive viewBox. This is the FIRST |
| 124 | // page an operator sees after this ships, not a contrived corner case. |
| 125 | func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) { |
| 126 | v := BuildArch(ArchInput{}) |
| 127 | for name, est := range eachBand(v) { |
| 128 | if len(est.Nodes) != 0 { |
| 129 | t.Fatalf("%s: expected to draw no nodes at all, got %d", name, len(est.Nodes)) |
| 130 | } |
| 131 | if est.Width <= 0 || est.Height <= 0 { |
| 132 | t.Fatalf("%s: Width/Height must be positive even with zero nodes: %dx%d", name, est.Width, est.Height) |
| 133 | } |
| 134 | } |
| 135 | out := renderBoth(v) |
| 136 | if strings.Count(out, "viewBox=") != 2 { |
| 137 | t.Error("both SVGs must carry a viewBox even when the graph is empty") |
| 138 | } |
| 139 | if strings.Contains(out, `viewBox="0 0 -`) { |
| 140 | t.Error("viewBox must never carry a negative dimension") |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // The SVG carries an intrinsic size, not a viewBox alone. Without width and |
| 145 | // height a browser has nothing to size the element by, so it fills its |
| 146 | // container and scales the drawing down to fit: a full estate is ~1918 units |
| 147 | // wide against a ~1024px column, which renders 13px node titles at ~7px and |
| 148 | // never overflows, so the wrapper's scrollbar never appears either. The |
| 149 | // labels ARE the content of this diagram, so the size attributes are what |
| 150 | // make it a diagram rather than a texture. |
| 151 | func TestSVGCarriesIntrinsicSize(t *testing.T) { |
| 152 | v := BuildArch(archFixture()) |
| 153 | out := renderBoth(v) |
| 154 | for name, est := range eachBand(v) { |
| 155 | if want := fmt.Sprintf(`width="%d" height="%d"`, est.Width, est.Height); !strings.Contains(out, want) { |
| 156 | t.Errorf("%s: SVG must carry %s so it renders 1:1 and .archwrap scrolls; got:\n%s", name, want, out) |
| 157 | } |
| 158 | if !strings.Contains(out, fmt.Sprintf(`viewBox="0 0 %d %d"`, est.Width, est.Height)) { |
| 159 | t.Errorf("%s: the viewBox must still match the intrinsic size", name) |
| 160 | } |
| 161 | } |
| 162 | // Deterministic with the attributes as it was without them. |
| 163 | if out != renderBoth(BuildArch(archFixture())) { |
| 164 | t.Error("two renders of the same input differ") |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // archAlarmFixture is the flagship's real shape for this: one auto-recover |
| 169 | // alarm per box, plus one alarm naming a machine the estate read did not |
| 170 | // return. |
| 171 | func archAlarmFixture() ArchInput { |
| 172 | in := archFixture() |
| 173 | in.Inv.Resources = append(in.Inv.Resources, |
| 174 | infraview.Resource{Kind: infraview.KindAlarm, ID: "a-1", Name: "auto-recover-eu-west-1", |
| 175 | Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"}}, |
| 176 | infraview.Resource{Kind: infraview.KindAlarm, ID: "a-2", Name: "auto-recover-mystery", |
| 177 | Attrs: map[string]string{"target": "i-999", "state": "ALARM", "metric": "StatusCheckFailed"}}, |
| 178 | ) |
| 179 | return in |
| 180 | } |
| 181 | |
| 182 | // An alarm-bearing estate stays clean: the alarms ride the nodes they |
| 183 | // watch instead of standing in the flow (which is what put them under the |
| 184 | // IP-to-box edges), and adding them changes neither the overlap-free |
| 185 | // property nor determinism. |
| 186 | func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) { |
| 187 | v := BuildArch(archAlarmFixture()) |
| 188 | |
| 189 | nodes := archNodes(v) |
| 190 | for _, n := range nodes { |
| 191 | if n.Kind == "alarm" { |
| 192 | t.Fatalf("an alarm must not be a node in the flow: %+v", n) |
| 193 | } |
| 194 | } |
| 195 | worn := map[string]int{} |
| 196 | for _, n := range nodes { |
| 197 | worn[n.ID] = len(n.Alarms) |
| 198 | } |
| 199 | // One in each picture: the box's alarm is CARLOS, the alarm on a machine |
| 200 | // no box reports belongs with the rest of the account. |
| 201 | for _, id := range []string{"box:carlos-eu-west-1", "machine:i-999"} { |
| 202 | if worn[id] == 0 { |
| 203 | t.Errorf("%s should wear the alarm that watches it: %+v", id, nodes) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | for name, est := range eachBand(v) { |
| 208 | for i := range est.Nodes { |
| 209 | for j := i + 1; j < len(est.Nodes); j++ { |
| 210 | a, b := est.Nodes[i], est.Nodes[j] |
| 211 | if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { |
| 212 | t.Fatalf("%s: %s overlaps %s", name, a.ID, b.ID) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | if renderBoth(v) != renderBoth(BuildArch(archAlarmFixture())) { |
| 218 | t.Error("two builds of an alarm-bearing estate produced different SVG") |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | // The ordering contract inside bandsFrom: satellites are dropped, THEN the |
| 223 | // bands are decided, THEN each is laid out, and only then are its dangling |
| 224 | // edges pruned. Get it wrong and the page carries edges to nodes nobody |
| 225 | // draws, leaving the invariant to rest entirely on RenderArchSVG's skip. |
| 226 | // Two halves: the steps compose in that order, and a built view is already |
| 227 | // clean when it ships. |
| 228 | func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) { |
| 229 | g := archGraph{ |
| 230 | Nodes: []Node{ |
| 231 | {ID: "box:a", Kind: "box", Title: "a"}, |
| 232 | {ID: "identity:role", Kind: "identity", Title: "carlos-box-role"}, |
| 233 | }, |
| 234 | Edges: []Edge{{From: "box:a", To: "identity:role", Kind: "runs"}}, |
| 235 | } |
| 236 | bands := bandsFrom(g, map[string]string{"box:a": "eu-west-1"}) |
| 237 | if len(bands) != 1 || bands[0].Region != "eu-west-1" { |
| 238 | t.Fatalf("bands = %+v, want one eu-west-1 band", bands) |
| 239 | } |
| 240 | for _, n := range bands[0].Nodes { |
| 241 | if n.ID == "identity:role" { |
| 242 | t.Fatal("an identity is table-only; it must be dropped before it is drawn") |
| 243 | } |
| 244 | } |
| 245 | if len(bands[0].Edges) != 0 { |
| 246 | t.Errorf("edges = %+v, want none — the only edge pointed at a dropped node", bands[0].Edges) |
| 247 | } |
| 248 | |
| 249 | // And on a real build carrying both drawn alarms and a partial estate: |
| 250 | // pruning the shipped view again must change nothing, because BuildArch |
| 251 | // already pruned AFTER laying out. |
| 252 | in := archAlarmFixture() |
| 253 | in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{ |
| 254 | Kind: infraview.KindAlarm, ID: "a-gone", |
| 255 | Attrs: map[string]string{"target": "i-terminated", "state": "OK"}, |
| 256 | }) |
| 257 | built := BuildArch(in) |
| 258 | for name, est := range eachBand(built) { |
| 259 | before := len(est.Edges) |
| 260 | pruneDanglingEdges(&est) |
| 261 | if len(est.Edges) != before { |
| 262 | t.Errorf("%s: a shipped view still had %d dangling edges in it", name, before-len(est.Edges)) |
| 263 | } |
| 264 | } |
| 265 | } |
| 266 | |