| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "net/url" |
| 6 | "reflect" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/registry" |
| 12 | "github.com/carlosframework/platform/internal/status" |
| 13 | ) |
| 14 | |
| 15 | func TestInstanceGroupingPaginatesWithoutLoss(t *testing.T) { |
| 16 | rows := []InstanceRow{ |
| 17 | {Host: "a.example.com", Channel: "stable", HealthWord: "Running"}, |
| 18 | {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"}, |
| 19 | {Host: "c.example.com", Channel: "stable", HealthWord: "Running"}, |
| 20 | {Host: "d.example.com", Channel: "edge", HealthWord: "Running"}, |
| 21 | } |
| 22 | q := instanceQuery{Group: true} |
| 23 | var got []string |
| 24 | for i := 0; i < 4; i++ { |
| 25 | p := pageInstanceRows(rows, q, 1) |
| 26 | for _, r := range p.Rows { |
| 27 | got = append(got, r.Host) |
| 28 | } |
| 29 | if p.NextCursor == "" { |
| 30 | break |
| 31 | } |
| 32 | q.Cursor = p.NextCursor |
| 33 | u, err := url.Parse(p.NextURL) |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if u.Query().Get("group") != "channel" { |
| 38 | t.Fatal(p.NextURL) |
| 39 | } |
| 40 | } |
| 41 | want := []string{"b.example.com", "d.example.com", "a.example.com", "c.example.com"} |
| 42 | if !reflect.DeepEqual(got, want) { |
| 43 | t.Fatalf("got %v want %v", got, want) |
| 44 | } |
| 45 | p := pageInstanceRows(rows, instanceQuery{Cursor: q.Cursor}, 50) |
| 46 | if len(p.Rows) != 4 { |
| 47 | t.Fatal("changing grouping must reset a cursor from the other order") |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestInstanceRowsExcludePlatformAliases(t *testing.T) { |
| 52 | routes := []RouteView{ |
| 53 | {Host: "app.example", Account: "ours", Kind: "instance"}, |
| 54 | {Host: "static.example", Account: "ours", Kind: "static"}, |
| 55 | {Host: "alias.example", Account: "ours", Kind: "static", Platform: true}, |
| 56 | {Host: "foreign.example", Account: "theirs", Kind: "instance"}, |
| 57 | } |
| 58 | owned := ownedInstanceRoutes(routes, "ours") |
| 59 | if len(owned) != 2 || owned[0].Host != "app.example" || owned[1].Host != "static.example" { |
| 60 | t.Fatalf("owned instance routes = %+v", owned) |
| 61 | } |
| 62 | rows := assembleInstanceRows(&AppView{Routes: routes}, &Account{Sqid: "ours"}, nil) |
| 63 | if len(rows) != 2 || rows[0].Host != "app.example" || rows[1].Host != "static.example" { |
| 64 | t.Fatalf("instance rows = %+v", rows) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | func TestInstanceChannelFilterAndLinks(t *testing.T) { |
| 69 | rows := []InstanceRow{{Host: "a.example.com", Channel: "canary/a+b", HealthWord: "Running"}, {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"}} |
| 70 | q := instanceQuery{Channel: "canary/a+b", Group: true} |
| 71 | p := pageInstanceRows(rows, q, 50) |
| 72 | if p.Total != 1 || p.Rows[0].Host != "a.example.com" { |
| 73 | t.Fatalf("%+v", p) |
| 74 | } |
| 75 | for _, l := range p.Links { |
| 76 | u, e := url.Parse(l.URL) |
| 77 | if e != nil { |
| 78 | t.Fatal(e) |
| 79 | } |
| 80 | if u.Query().Get("channel") != q.Channel || u.Query().Get("group") != "channel" { |
| 81 | t.Fatal(l.URL) |
| 82 | } |
| 83 | } |
| 84 | q.Channel = "missing" |
| 85 | if p := pageInstanceRows(rows, q, 50); p.Total != 0 { |
| 86 | t.Fatalf("unmatched channel widened: %+v", p) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestInstanceGroupsKeepTargetSeparate(t *testing.T) { |
| 91 | rows := []InstanceRow{{Host: "a.example.com", Channel: "edge", Version: "old"}, {Host: "b.example.com", Channel: "edge", Version: "new"}} |
| 92 | groups := groupInstanceRows(rows[:1], []ChannelView{{Name: "edge", Version: "new"}}, rows) |
| 93 | if len(groups) != 1 || groups[0].Total != 2 || groups[0].TargetVersion != "new" || groups[0].Rows[0].Version != "old" { |
| 94 | t.Fatalf("%+v", groups) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestInstanceGroupCountsRespectActiveFilters(t *testing.T) { |
| 99 | rows := []InstanceRow{ |
| 100 | {Host: "a.example.com", Channel: "edge", HealthWord: "Running"}, |
| 101 | {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"}, |
| 102 | {Host: "c.example.com", Channel: "stable", HealthWord: "Running"}, |
| 103 | } |
| 104 | p := pageInstanceRows(rows, instanceQuery{Group: true, Channel: "edge", Health: "Running"}, 1) |
| 105 | groups := groupInstanceRows(p.Rows, nil, p.Matching) |
| 106 | if len(groups) != 1 || groups[0].Channel != "edge" || groups[0].Total != 1 || len(groups[0].Rows) != 1 { |
| 107 | t.Fatalf("groups = %+v; page = %+v", groups, p) |
| 108 | } |
| 109 | if p.Counts["Running"] != 2 || p.Counts["Asleep"] != 1 { |
| 110 | t.Fatalf("global health counts changed with filters: %+v", p.Counts) |
| 111 | } |
| 112 | } |
| 113 | |
| 114 | func TestOwnedInstanceRoutesDoesNotPassOtherAccountsToSelfReport(t *testing.T) { |
| 115 | routes := []RouteView{{Host: "mine.example.com", Account: "bab"}, {Host: "theirs.example.com", Account: "bac"}} |
| 116 | got := ownedInstanceRoutes(routes, "bab") |
| 117 | if len(got) != 1 || got[0].Host != "mine.example.com" { |
| 118 | t.Fatalf("%+v", got) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | func TestInstanceDetailOwnsRouteAndAliases(t *testing.T) { |
| 123 | fx := newConsoleFixture(t) |
| 124 | c := signInPaul(t, fx) |
| 125 | reg, err := registry.Open(fx.regPath) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | t.Cleanup(func() { reg.Close() }) |
| 130 | if err := reg.Upsert(registry.Route{Host: "foreign.example.com", App: "kass", Account: "bac", Kind: "instance", Version: "foreign-version"}); err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if err := reg.UpsertAlias(registry.RouteAlias{Alias: "alias.example.com", Host: "kass.carlosapp.dev", Account: fx.sqid, App: "kass"}); err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | if err := reg.UpsertAlias(registry.RouteAlias{Alias: "foreign-alias.example.com", Host: "kass.carlosapp.dev", Account: "bac", App: "kass"}); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | status, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev?channel=stable&group=channel")) |
| 140 | if status != http.StatusOK { |
| 141 | t.Fatalf("%d: %s", status, body) |
| 142 | } |
| 143 | for _, want := range []string{"kass.carlosapp.dev", "alias.example.com", `/instances?channel=stable&group=channel`, `href="https://kass.carlosapp.dev"`} { |
| 144 | if !strings.Contains(body, want) { |
| 145 | t.Errorf("missing %q", want) |
| 146 | } |
| 147 | } |
| 148 | for _, bad := range []string{"foreign-version", "foreign-alias.example.com", "stamp its unit"} { |
| 149 | if strings.Contains(body, bad) { |
| 150 | t.Errorf("leaked %q", bad) |
| 151 | } |
| 152 | } |
| 153 | for _, host := range []string{"foreign.example.com", "otherapp.carlosapp.dev", "missing.example.com"} { |
| 154 | status, _, body := get(t, c, fx.u("/apps/kass/instances/"+host)) |
| 155 | if status != http.StatusNotFound || body != "404 page not found\n" { |
| 156 | t.Fatalf("%s = %d %q", host, status, body) |
| 157 | } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | func TestInstanceDetailRejectsPlatformAlias(t *testing.T) { |
| 162 | fx := newConsoleFixture(t) |
| 163 | c := signInPaul(t, fx) |
| 164 | fx.addRoute(t, registry.Route{Host: "platform-alias.example.com", App: "kass", Account: fx.sqid, Kind: "static", Platform: true}) |
| 165 | |
| 166 | status, _, body := get(t, c, fx.u("/apps/kass/instances/platform-alias.example.com")) |
| 167 | if status != http.StatusNotFound || body != "404 page not found\n" { |
| 168 | t.Fatalf("platform alias detail = %d %q", status, body) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | func TestInstanceListLinksToDetailAndKeepsHealthShort(t *testing.T) { |
| 173 | fx := newConsoleFixture(t) |
| 174 | c := signInPaul(t, fx) |
| 175 | status, _, body := get(t, c, fx.u("/apps/kass/instances?group=channel&channel=stable")) |
| 176 | if status != http.StatusOK { |
| 177 | t.Fatalf("%d %s", status, body) |
| 178 | } |
| 179 | if !strings.Contains(body, `/instances/kass.carlosapp.dev?channel=stable&group=channel`) { |
| 180 | t.Fatal("missing filtered detail link") |
| 181 | } |
| 182 | if !strings.Contains(body, `href="https://kass.carlosapp.dev"`) { |
| 183 | t.Fatal("missing separate external link") |
| 184 | } |
| 185 | if strings.Contains(body, "stamp its unit") { |
| 186 | t.Fatal("health cell still carries infrastructure instructions") |
| 187 | } |
| 188 | if !strings.Contains(body, `class="instance-group"`) { |
| 189 | t.Fatal("missing grouped table") |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func TestInstanceListKeepsUnknownSelectedChannelVisible(t *testing.T) { |
| 194 | fx := newConsoleFixture(t) |
| 195 | c := signInPaul(t, fx) |
| 196 | status, _, body := get(t, c, fx.u("/apps/kass/instances?channel=retired")) |
| 197 | if status != http.StatusOK { |
| 198 | t.Fatalf("%d %s", status, body) |
| 199 | } |
| 200 | if !strings.Contains(body, `<option value="retired" selected>retired</option>`) { |
| 201 | t.Fatalf("selected unknown channel is invisible: %s", body) |
| 202 | } |
| 203 | if strings.Contains(body, `<option value="" selected>`) { |
| 204 | t.Fatal("unknown channel made the selector appear unfiltered") |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func TestInstanceDetailRendersFlashBeforeNavigation(t *testing.T) { |
| 209 | fx := newConsoleFixture(t) |
| 210 | c := signInPaul(t, fx) |
| 211 | u, err := url.Parse(fx.u("/apps/kass/instances/kass.carlosapp.dev")) |
| 212 | if err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | c.Jar.SetCookies(u, []*http.Cookie{{Name: flashCookie, Value: url.QueryEscape("err|flash marker"), Path: "/"}}) |
| 216 | status, _, body := get(t, c, u.String()) |
| 217 | if status != http.StatusOK { |
| 218 | t.Fatalf("%d %s", status, body) |
| 219 | } |
| 220 | flash := strings.Index(body, `<p class="banner error">flash marker</p>`) |
| 221 | nav := strings.Index(body, `<nav class="tabs"`) |
| 222 | if flash < 0 || nav < 0 || flash > nav { |
| 223 | t.Fatalf("flash=%d nav=%d", flash, nav) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // TestInstanceDetailShowsLayoutWord pins R10: the instance page's Layout |
| 228 | // fact shows the WORD a box's report gives this host, never the reason — |
| 229 | // proven here with a foreign row, whose reason names paths a member has no |
| 230 | // business seeing. |
| 231 | func TestInstanceDetailShowsLayoutWord(t *testing.T) { |
| 232 | fx := newConsoleFixture(t) |
| 233 | c := signInPaul(t, fx) |
| 234 | seedLayoutBox(t, fx.rel, "box-a", time.Now(), &status.LayoutReport{ |
| 235 | Mode: "nested", |
| 236 | Rows: map[string]status.RowLayout{ |
| 237 | "kass.carlosapp.dev": {Layout: "foreign", Reason: "db \"/secret/path\" is not the flat or nested default"}, |
| 238 | }, |
| 239 | }) |
| 240 | httpStatus, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev")) |
| 241 | if httpStatus != http.StatusOK { |
| 242 | t.Fatalf("%d: %s", httpStatus, body) |
| 243 | } |
| 244 | if !strings.Contains(body, "<dt>Layout</dt><dd>foreign</dd>") { |
| 245 | t.Errorf("missing the layout fact showing the word: %s", body) |
| 246 | } |
| 247 | if strings.Contains(body, "/secret/path") { |
| 248 | t.Errorf("the reason leaked onto the page: %s", body) |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | // TestInstanceDetailShowsLayoutUnknownWithNoReport: a box that has never |
| 253 | // reported this host's layout (predates layout reporting, or has armed no |
| 254 | // pass) must not silently omit the fact — the approved fallback text says |
| 255 | // so, byte for byte. |
| 256 | func TestInstanceDetailShowsLayoutUnknownWithNoReport(t *testing.T) { |
| 257 | fx := newConsoleFixture(t) |
| 258 | c := signInPaul(t, fx) |
| 259 | httpStatus, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev")) |
| 260 | if httpStatus != http.StatusOK { |
| 261 | t.Fatalf("%d: %s", httpStatus, body) |
| 262 | } |
| 263 | if !strings.Contains(body, "<dt>Layout</dt><dd>not reported yet</dd>") { |
| 264 | t.Errorf("missing the unknown-layout fallback: %s", body) |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // The nil-Releases shape is not separately tested here: appPage's own |
| 269 | // Assemble already dereferences cfg.Releases (assembleChannels, view.go) |
| 270 | // before handleAppInstance's layout read ever runs, so the whole instance |
| 271 | // page panics on that configuration regardless of this feature — a |
| 272 | // pre-existing gap in Assemble (R10: "do not touch"), not one this task |
| 273 | // introduces. The `s.cfg.Releases != nil` guard above is still correct |
| 274 | // defensive code for the day Assemble stops assuming a bucket. |
| 275 | |