| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | |
| 7 | "github.com/carlosframework/platform/internal/infraview" |
| 8 | ) |
| 9 | |
| 10 | // TestNoNodeSitsOnAnEdgePath is the invariant the alarm column broke: a |
| 11 | // node that is not an endpoint of an edge must not sit ON that edge's |
| 12 | // path. Nodes paint after edges, so a node standing in the middle of a |
| 13 | // line hides it and the picture reads as a connection that does not |
| 14 | // exist — an IP appearing to reach its box THROUGH an alarm. |
| 15 | // |
| 16 | // An adjacent-layer edge is drawn as three segments (out, across, in), so |
| 17 | // the check is the two horizontal runs at the endpoints' own y values plus |
| 18 | // the vertical hop between them. A LAYER-SKIPPING edge is drawn as five, |
| 19 | // around a lane below every node — see edgeSegments, which mirrors |
| 20 | // RenderArchSVG exactly. Mirroring matters: a test that modelled the |
| 21 | // three-segment path for both would be checking geometry the renderer does |
| 22 | // not emit, and would pass while the picture lied. |
| 23 | func TestNoNodeSitsOnAnEdgePath(t *testing.T) { |
| 24 | // Per picture: a node in the other diagram cannot obscure this one's |
| 25 | // lines, and coordinates only mean anything inside one picture. |
| 26 | for name, est := range eachBand(BuildArch(archFixture())) { |
| 27 | assertNoNodeOnAnEdge(t, name, est) |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | // TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips is the case #9 caught in |
| 32 | // a browser and this file's fixture did not have: an edge that skips a |
| 33 | // layer, with an unrelated node sitting in the layer between its ends, on |
| 34 | // the same row. |
| 35 | // |
| 36 | // With the plain three-segment route the line turned at the midpoint, which |
| 37 | // for such an edge is INSIDE the skipped column, so it ran under that node |
| 38 | // and out the other side. Nodes paint after edges, so the top row read as one |
| 39 | // continuous chain — `carloku.com -> au.oncarlos.com -> 13.55.0.44 -> 10 |
| 40 | // inbound-smtp… -> carlos-flagship-au -> kass -> bucket` — of which two hops |
| 41 | // are fiction. |
| 42 | // |
| 43 | // #9's own edge was ip -> box past an `external` column; #390 folded |
| 44 | // external into the address column, so that pair is adjacent now. The skip |
| 45 | // that remains is record -> box: a record whose value is an address the |
| 46 | // machine holds directly rather than through an allocated IP (recordTarget), |
| 47 | // which points at the box from two columns away, past whatever ip or |
| 48 | // external node shares its row. |
| 49 | func TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips(t *testing.T) { |
| 50 | est := ArchBand{} |
| 51 | est.Nodes = []Node{ |
| 52 | {ID: "record:au", Kind: "record", Title: "au.oncarlos.com"}, |
| 53 | {ID: "ext:mx", Kind: "external", Title: "10 inbound-smtp.eu-west-1.amazonaws.com"}, |
| 54 | {ID: "box:au", Kind: "box", Title: "carlos-flagship-au"}, |
| 55 | } |
| 56 | // The one edge, skipping the address column the MX node occupies. |
| 57 | est.Edges = []Edge{{From: "record:au", To: "box:au", Kind: "resolves"}} |
| 58 | layout(&est) |
| 59 | assertNoNodeOnAnEdge(t, "skip", est) |
| 60 | |
| 61 | // And it is the skip routing that is drawn, not the plain one. |
| 62 | out := string(RenderArchSVG(est)) |
| 63 | if !strings.Contains(out, "archedge-skip") { |
| 64 | t.Errorf("a layer-skipping edge must be routed around, not straight through:\n%s", out) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // seg is one axis-aligned run: `at` is the fixed coordinate, a..b the span. |
| 69 | type seg struct{ at, a, b int } |
| 70 | |
| 71 | // edgeSegments is RenderArchSVG's routing, in the same two branches and with |
| 72 | // the same numbers. Kept beside the renderer's own code deliberately: the |
| 73 | // value of this whole file is that it checks what is DRAWN. |
| 74 | func edgeSegments(v ArchBand, from, to Node) (horizontal, vertical []seg) { |
| 75 | x1, y1 := from.X+from.W, from.Y+from.H/2 |
| 76 | x2, y2 := to.X, to.Y+to.H/2 |
| 77 | if archLayer[to.Kind]-archLayer[from.Kind] > 1 { |
| 78 | lane := v.Height - padding/2 |
| 79 | return []seg{ |
| 80 | {at: y1, a: x1, b: x1 + gapX/2}, |
| 81 | {at: lane, a: x1 + gapX/2, b: x2 - gapX/2}, |
| 82 | {at: y2, a: x2 - gapX/2, b: x2}, |
| 83 | }, []seg{ |
| 84 | {at: x1 + gapX/2, a: y1, b: lane}, |
| 85 | {at: x2 - gapX/2, a: lane, b: y2}, |
| 86 | } |
| 87 | } |
| 88 | mid := (x1 + x2) / 2 |
| 89 | return []seg{{at: y1, a: x1, b: mid}, {at: y2, a: mid, b: x2}}, |
| 90 | []seg{{at: mid, a: y1, b: y2}} |
| 91 | } |
| 92 | |
| 93 | // assertNoNodeOnAnEdge checks every drawn edge against every node that is not |
| 94 | // one of its endpoints. name says which picture, since every band is checked |
| 95 | // separately and their coordinates are unrelated. |
| 96 | func assertNoNodeOnAnEdge(t *testing.T, name string, v ArchBand) { |
| 97 | t.Helper() |
| 98 | pos := map[string]Node{} |
| 99 | for _, n := range v.Nodes { |
| 100 | pos[n.ID] = n |
| 101 | } |
| 102 | for _, e := range v.Edges { |
| 103 | from, to := pos[e.From], pos[e.To] |
| 104 | hs, vs := edgeSegments(v, from, to) |
| 105 | for _, n := range v.Nodes { |
| 106 | if n.ID == e.From || n.ID == e.To { |
| 107 | continue |
| 108 | } |
| 109 | for _, h := range hs { |
| 110 | if segmentHitsNode(h.a, h.b, h.at, n) { |
| 111 | t.Errorf("%s: edge %s -> %s runs horizontally through node %s (%d,%d %dx%d)", |
| 112 | name, e.From, e.To, n.ID, n.X, n.Y, n.W, n.H) |
| 113 | } |
| 114 | } |
| 115 | for _, vv := range vs { |
| 116 | if verticalHitsNode(vv.at, vv.a, vv.b, n) { |
| 117 | t.Errorf("%s: edge %s -> %s runs vertically through node %s (%d,%d %dx%d)", |
| 118 | name, e.From, e.To, n.ID, n.X, n.Y, n.W, n.H) |
| 119 | } |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func segmentHitsNode(xa, xb, y int, n Node) bool { |
| 126 | if xa > xb { |
| 127 | xa, xb = xb, xa |
| 128 | } |
| 129 | return y > n.Y && y < n.Y+n.H && xb > n.X && xa < n.X+n.W |
| 130 | } |
| 131 | |
| 132 | func verticalHitsNode(x, ya, yb int, n Node) bool { |
| 133 | if ya > yb { |
| 134 | ya, yb = yb, ya |
| 135 | } |
| 136 | return x > n.X && x < n.X+n.W && yb > n.Y && ya < n.Y+n.H |
| 137 | } |
| 138 | |
| 139 | // TestAlarmsRideTheNodeTheyWatch: an alarm is a property OF the thing it |
| 140 | // watches, so it is drawn on that node rather than as a node of its own in |
| 141 | // the flow. A firing alarm must be distinguishable from a healthy one by |
| 142 | // something other than its wording. |
| 143 | func TestAlarmsRideTheNodeTheyWatch(t *testing.T) { |
| 144 | in := archFixture() |
| 145 | in.Inv.Resources = append(in.Inv.Resources, |
| 146 | infraview.Resource{Provider: "aws", Kind: infraview.KindAlarm, NativeType: "cloudwatch:alarm", |
| 147 | ID: "arn:alarm/ok", Name: "carlos-eu-west-1-auto-recover", Region: "eu-west-1", |
| 148 | Attrs: map[string]string{"state": "OK", "metric": "StatusCheckFailed_System", "target": "i-123"}}, |
| 149 | infraview.Resource{Provider: "aws", Kind: infraview.KindAlarm, NativeType: "cloudwatch:alarm", |
| 150 | ID: "arn:alarm/firing", Name: "carlos-eu-west-1-cpu", Region: "eu-west-1", |
| 151 | Attrs: map[string]string{"state": "ALARM", "metric": "CPUUtilization", "target": "i-123"}}, |
| 152 | ) |
| 153 | v := BuildArch(in) |
| 154 | |
| 155 | if n := nodeByID(v, "alarm:arn:alarm/ok"); n != nil { |
| 156 | t.Fatalf("an alarm must not be drawn as its own node in the flow: %+v", n) |
| 157 | } |
| 158 | box := nodeByID(v, "box:carlos-eu-west-1") |
| 159 | if box == nil { |
| 160 | t.Fatal("no box node") |
| 161 | } |
| 162 | if len(box.Alarms) != 2 { |
| 163 | t.Fatalf("box carries %d alarms, want 2: %+v", len(box.Alarms), box.Alarms) |
| 164 | } |
| 165 | var firing, ok bool |
| 166 | for _, a := range box.Alarms { |
| 167 | if a.Name == "carlos-eu-west-1-cpu" && a.Firing { |
| 168 | firing = true |
| 169 | } |
| 170 | if a.Name == "carlos-eu-west-1-auto-recover" && !a.Firing { |
| 171 | ok = true |
| 172 | } |
| 173 | } |
| 174 | if !firing || !ok { |
| 175 | t.Errorf("firing state not carried per alarm: %+v", box.Alarms) |
| 176 | } |
| 177 | |
| 178 | out := renderBoth(v) |
| 179 | if !strings.Contains(out, "archalarm-firing") { |
| 180 | t.Error("a firing alarm must render differently from a healthy one") |
| 181 | } |
| 182 | if !strings.Contains(out, "carlos-eu-west-1-cpu ALARM") { |
| 183 | t.Error("the alarm's name and state must be reachable on the node (hover title)") |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // TestSVGTextIsFittedToItsBox: nothing may bleed past the rect it belongs |
| 188 | // to. The full string stays reachable as the node's hover title, so |
| 189 | // truncation costs nothing but the overflow. |
| 190 | func TestSVGTextIsFittedToItsBox(t *testing.T) { |
| 191 | in := archFixture() |
| 192 | v := BuildArch(in) |
| 193 | long := "console, kass, keymail, platform-site, woodstar, status, carloku-site" |
| 194 | // A box's apps line is the longest string on the page and the one most |
| 195 | // likely to bleed. Written into the BAND's own node slice — Nodes() is a |
| 196 | // fresh copy per call, so mutating what it returns would change nothing |
| 197 | // and this test would pass by drawing the untouched fixture. |
| 198 | touched := false |
| 199 | for bi := range v.CARLOS.Bands { |
| 200 | for i := range v.CARLOS.Bands[bi].Nodes { |
| 201 | if v.CARLOS.Bands[bi].Nodes[i].Kind != "box" { |
| 202 | continue |
| 203 | } |
| 204 | v.CARLOS.Bands[bi].Nodes[i].Extra = []string{ |
| 205 | long, "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3", |
| 206 | } |
| 207 | touched = true |
| 208 | } |
| 209 | } |
| 210 | if !touched { |
| 211 | t.Fatal("no box node to widen — the fixture stopped exercising this") |
| 212 | } |
| 213 | out := renderBoth(v) |
| 214 | if strings.Contains(out, ">"+long+"<") { |
| 215 | t.Error("a title wider than its rect must be truncated, not drawn past the box") |
| 216 | } |
| 217 | if !strings.Contains(out, "…") { |
| 218 | t.Error("truncated text must show an ellipsis") |
| 219 | } |
| 220 | if !strings.Contains(out, long) { |
| 221 | t.Error("the full string must survive as the node's hover title") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // TestByoBoxIsNotPriced: the platform does not pay for a box it does not |
| 226 | // own, so a bring-your-own machine gets no monthly figure and no |
| 227 | // "priced by the default spec" chip — a price there would be a claim about |
| 228 | // someone else's bill. |
| 229 | func TestByoBoxIsNotPriced(t *testing.T) { |
| 230 | v := BuildArch(archFixture()) |
| 231 | for _, c := range v.Boxes { |
| 232 | if c.CloudID != "" { |
| 233 | continue |
| 234 | } |
| 235 | if c.USDMonth != 0 { |
| 236 | t.Errorf("BYO box %s priced at %v", c.Label, c.USDMonth) |
| 237 | } |
| 238 | for _, d := range c.Drift { |
| 239 | if strings.Contains(d, "default spec") { |
| 240 | t.Errorf("BYO box %s carries a pricing-drift chip: %q", c.Label, d) |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |