| 1 | package console |
| 2 | |
| 3 | // The account-level Fleet tab (accountfleet.go): GET /a/{sqid}/fleet, a |
| 4 | // MEMBER-readable page that answers "where does this account's work run?" in |
| 5 | // two halves — the shared platform fleet, presented purely from the public |
| 6 | // status/fleet.json aggregate, and the account's own fleets from the |
| 7 | // registry. The properties that matter, in the order they'd hurt if lost: |
| 8 | // the page never 500s because one of its three independent reads had a bad |
| 9 | // moment (the loadFleetPage/overview.go posture), it never shows another |
| 10 | // account's fleets, and the shared card's "Active" claim tracks placement — |
| 11 | // an account whose every route sits on its own fleet must not be told its |
| 12 | // apps run on the shared one. |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "errors" |
| 17 | "net/http" |
| 18 | "path/filepath" |
| 19 | "strings" |
| 20 | "testing" |
| 21 | "time" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/registry" |
| 24 | "github.com/carlosframework/platform/internal/status" |
| 25 | ) |
| 26 | |
| 27 | // seedSharedBoxes publishes a status/fleet.json carrying one box per region |
| 28 | // name given, through the same BuildFleet/PublishFleet path production uses |
| 29 | // (seedFleetJSON's shape, with the regions the tab renders as chips). |
| 30 | func seedSharedBoxes(t *testing.T, fx *consoleFixture, regions ...string) { |
| 31 | t.Helper() |
| 32 | ctx := context.Background() |
| 33 | for i, region := range regions { |
| 34 | if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| 35 | GeneratedAt: time.Now(), |
| 36 | Label: []string{"ada-stargazing", "grace-moonlit", "alan-seaside"}[i], |
| 37 | Region: region, |
| 38 | }); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | } |
| 42 | reg, err := registry.Open(fx.regPath) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | routes, err := reg.List() |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | f, err := status.BuildFleet(ctx, fx.rel, routes, time.Now(), 5*time.Minute) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | if err := status.PublishFleet(ctx, fx.rel, f); err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | // fixtureAccount is the account value the assemble-level tests pass in — the |
| 60 | // same {ID, Sqid} pair the gated handler would hand it. |
| 61 | func fixtureAccount(fx *consoleFixture) *Account { |
| 62 | return &Account{ID: OpsAccountID, Sqid: fx.sqid, Name: "ops"} |
| 63 | } |
| 64 | |
| 65 | // TestAccountFleetTabMemberReadable: the tab is a READ, so a plain member |
| 66 | // sees it exactly as the owner does; a non-member gets the account gate's |
| 67 | // byte-identical 404, the same answer an unrouted URL gives. |
| 68 | func TestAccountFleetTabMemberReadable(t *testing.T) { |
| 69 | fx, token := fleetFixture(t, "code-acctfleet-gate") |
| 70 | if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| 71 | map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| 72 | t.Fatalf("create fleet = %d %v", st, body) |
| 73 | } |
| 74 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | |
| 78 | owner := newBrowser(t) |
| 79 | signUp(t, fx, owner, "code-acctfleet-owner") |
| 80 | st, _, body := get(t, owner, fx.u("/fleet")) |
| 81 | if st != http.StatusOK { |
| 82 | t.Fatalf("owner GET /fleet = %d, want 200:\n%s", st, body) |
| 83 | } |
| 84 | for _, want := range []string{"Shared fleet", "Your fleets", "acme", "Bring your own fleet"} { |
| 85 | if !strings.Contains(body, want) { |
| 86 | t.Fatalf("owner fleet tab missing %q in:\n%s", want, body) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | fx.setUser("sam@keymail.dev") |
| 91 | member := newBrowser(t) |
| 92 | signUp(t, fx, member, "code-acctfleet-member") |
| 93 | st, _, memberBody := get(t, member, fx.u("/fleet")) |
| 94 | if st != http.StatusOK { |
| 95 | t.Fatalf("member GET /fleet = %d, want 200:\n%s", st, memberBody) |
| 96 | } |
| 97 | for _, want := range []string{"Shared fleet", "acme"} { |
| 98 | if !strings.Contains(memberBody, want) { |
| 99 | t.Fatalf("member fleet tab missing %q in:\n%s", want, memberBody) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | fx.setUser("bob@keymail.dev") |
| 104 | stranger := newBrowser(t) |
| 105 | signUp(t, fx, stranger, "code-acctfleet-bob") |
| 106 | nonMemberStatus, _, nonMemberBody := get(t, stranger, fx.u("/fleet")) |
| 107 | ghostStatus, _, ghostBody := get(t, stranger, fx.ts.URL+"/definitely-not-a-page") |
| 108 | if nonMemberStatus != http.StatusNotFound || nonMemberStatus != ghostStatus { |
| 109 | t.Fatalf("non-member fleet tab = %d, unrouted page = %d; want both 404", nonMemberStatus, ghostStatus) |
| 110 | } |
| 111 | if nonMemberBody != ghostBody { |
| 112 | t.Fatalf("non-member fleet tab body differs from an unrouted page's:\n%q\n%q", nonMemberBody, ghostBody) |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // TestAccountFleetTabNavLink: the tab is reachable — every account page |
| 117 | // carries the Fleet nav entry, and it marks itself current on this one. |
| 118 | func TestAccountFleetTabNavLink(t *testing.T) { |
| 119 | fx := newConsoleFixture(t) |
| 120 | c := signInPaul(t, fx) |
| 121 | |
| 122 | // Nav LINKS carry the preferred segment — the account's name, "ops" for |
| 123 | // the fixture's built-in account — while forms keep posting to the sqid |
| 124 | // path (accountPage: Nav vs Base). |
| 125 | st, _, apps := get(t, c, fx.u("/")) |
| 126 | if st != http.StatusOK { |
| 127 | t.Fatalf("account home = %d:\n%s", st, apps) |
| 128 | } |
| 129 | if !strings.Contains(apps, `href="/a/ops/fleet"`) { |
| 130 | t.Fatalf("account home nav has no Fleet link:\n%s", apps) |
| 131 | } |
| 132 | st, _, body := get(t, c, fx.u("/fleet")) |
| 133 | if st != http.StatusOK { |
| 134 | t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| 135 | } |
| 136 | if !strings.Contains(body, `href="/a/ops/fleet" aria-current="page"`) { |
| 137 | t.Fatalf("Fleet tab does not mark its own nav entry current:\n%s", body) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // TestAccountFleetSharedCardFromFleetJSON: the shared card is a PRESENTATION |
| 142 | // of the public aggregate — box count and the distinct regions, deduped and |
| 143 | // sorted, straight off status/fleet.json. |
| 144 | func TestAccountFleetSharedCardFromFleetJSON(t *testing.T) { |
| 145 | fx := newConsoleFixture(t) |
| 146 | seedSharedBoxes(t, fx, "UK · London", "EU · Ireland") |
| 147 | |
| 148 | card, _, _, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx)) |
| 149 | if warn != "" { |
| 150 | t.Fatalf("warn = %q, want none on a healthy read", warn) |
| 151 | } |
| 152 | if card.Boxes != 2 { |
| 153 | t.Fatalf("card.Boxes = %d, want 2", card.Boxes) |
| 154 | } |
| 155 | if len(card.Regions) != 2 || card.Regions[0] != "EU · Ireland" || card.Regions[1] != "UK · London" { |
| 156 | t.Fatalf("card.Regions = %v, want both regions, sorted", card.Regions) |
| 157 | } |
| 158 | |
| 159 | // And they reach the page. |
| 160 | c := signInPaul(t, fx) |
| 161 | st, _, body := get(t, c, fx.u("/fleet")) |
| 162 | if st != http.StatusOK { |
| 163 | t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| 164 | } |
| 165 | for _, want := range []string{"EU · Ireland", "UK · London", "2 box"} { |
| 166 | if !strings.Contains(body, want) { |
| 167 | t.Fatalf("fleet tab missing %q in:\n%s", want, body) |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // TestAccountFleetActiveFlipsWithPlacement: "Active" is a claim about THIS |
| 173 | // account's routes — true while any of its apps still runs on the shared |
| 174 | // fleet, false once every one of them is placed on a fleet of its own. An |
| 175 | // account told "your apps run here" when they do not is the failure this |
| 176 | // test exists to prevent. |
| 177 | func TestAccountFleetActiveFlipsWithPlacement(t *testing.T) { |
| 178 | fx, token := fleetFixture(t, "code-acctfleet-active") |
| 179 | reg := openTestReg(t, fx) |
| 180 | if err := reg.Upsert(registry.Route{ |
| 181 | Host: "kass.example.dev", Kind: "instance", App: "kass", Account: fx.sqid, |
| 182 | }); err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | |
| 186 | acct := fixtureAccount(fx) |
| 187 | card, _, _, _ := fx.srv.assembleAccountFleet(context.Background(), acct) |
| 188 | if !card.Active || card.PlatformApps != 1 { |
| 189 | t.Fatalf("unplaced route: Active=%v PlatformApps=%d, want true/1", card.Active, card.PlatformApps) |
| 190 | } |
| 191 | |
| 192 | if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| 193 | map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| 194 | t.Fatalf("create fleet = %d %v", st, body) |
| 195 | } |
| 196 | if st, body := postJSON(t, placeURL(fx, "kass"), map[string]string{"target": "fleet/acme"}, token); st != http.StatusOK { |
| 197 | t.Fatalf("place = %d %v, want 200", st, body) |
| 198 | } |
| 199 | |
| 200 | card, own, _, _ := fx.srv.assembleAccountFleet(context.Background(), acct) |
| 201 | if card.Active || card.PlatformApps != 0 { |
| 202 | t.Fatalf("every route placed: Active=%v PlatformApps=%d, want false/0", card.Active, card.PlatformApps) |
| 203 | } |
| 204 | if len(own) != 1 || own[0].Apps != 1 { |
| 205 | t.Fatalf("own fleets = %+v, want acme carrying the one placed app", own) |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // TestAccountFleetListsOnlyOwnFleets: fleet names are one flat cross-account |
| 210 | // namespace (fleets.go's package doc), so the index MUST filter by owner — |
| 211 | // a fleet belonging to another account must not appear here any more than it |
| 212 | // resolves on the detail page. |
| 213 | func TestAccountFleetListsOnlyOwnFleets(t *testing.T) { |
| 214 | fx, token := fleetFixture(t, "code-acctfleet-own") |
| 215 | reg := openTestReg(t, fx) |
| 216 | if _, err := reg.CreateFleet(registry.Fleet{Name: "stranger", Account: "zzz", Bucket: "not-yours"}); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | for _, name := range []string{"acme", "beta"} { |
| 220 | if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| 221 | map[string]string{"name": name, "bucket": name + "-data"}, token); st != http.StatusOK { |
| 222 | t.Fatalf("create fleet %s = %d %v", name, st, body) |
| 223 | } |
| 224 | } |
| 225 | if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| 226 | map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| 227 | t.Fatalf("add-box = %d %v", st, body) |
| 228 | } |
| 229 | writeFleetHeartbeat(t, fx, "acme", status.Box{GeneratedAt: time.Now(), Label: "pi-1"}) |
| 230 | |
| 231 | _, own, ownKnown, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx)) |
| 232 | if warn != "" { |
| 233 | t.Fatalf("warn = %q, want none", warn) |
| 234 | } |
| 235 | if !ownKnown { |
| 236 | t.Fatalf("fleetsKnown = false on a healthy registry read") |
| 237 | } |
| 238 | if len(own) != 2 || own[0].Name != "acme" || own[1].Name != "beta" { |
| 239 | t.Fatalf("own fleets = %+v, want exactly acme and beta", own) |
| 240 | } |
| 241 | if own[0].Boxes != 1 || own[0].Reachable != 1 { |
| 242 | t.Fatalf("acme = %+v, want 1 box, 1 reachable", own[0]) |
| 243 | } |
| 244 | if own[1].Boxes != 0 || own[1].Reachable != 0 { |
| 245 | t.Fatalf("beta = %+v, want no boxes", own[1]) |
| 246 | } |
| 247 | |
| 248 | c := signInPaul(t, fx) |
| 249 | st, _, body := get(t, c, fx.u("/fleet")) |
| 250 | if st != http.StatusOK { |
| 251 | t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| 252 | } |
| 253 | if strings.Contains(body, "stranger") { |
| 254 | t.Fatalf("another account's fleet leaked onto the tab:\n%s", body) |
| 255 | } |
| 256 | for _, want := range []string{`href="/a/ops/fleets/acme"`, `href="/a/ops/fleets/beta"`} { |
| 257 | if !strings.Contains(body, want) { |
| 258 | t.Fatalf("fleet tab missing link %q in:\n%s", want, body) |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestAccountFleetDegradesWithoutSnapshot: a deployment whose status-lease |
| 264 | // holder has never published status/fleet.json still serves the tab — the |
| 265 | // shared card renders its empty state, and the account's own fleets (a |
| 266 | // completely independent read) are unaffected. |
| 267 | func TestAccountFleetDegradesWithoutSnapshot(t *testing.T) { |
| 268 | fx, token := fleetFixture(t, "code-acctfleet-nosnapshot") |
| 269 | if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| 270 | map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| 271 | t.Fatalf("create fleet = %d %v", st, body) |
| 272 | } |
| 273 | |
| 274 | c := signInPaul(t, fx) |
| 275 | st, _, body := get(t, c, fx.u("/fleet")) |
| 276 | if st != http.StatusOK { |
| 277 | t.Fatalf("GET /fleet with no published snapshot = %d, want 200 (degrade, don't 500):\n%s", st, body) |
| 278 | } |
| 279 | if !strings.Contains(body, "No shared boxes are reporting right now.") { |
| 280 | t.Fatalf("missing the shared card's empty state:\n%s", body) |
| 281 | } |
| 282 | if !strings.Contains(body, "acme") { |
| 283 | t.Fatalf("the account's own fleets (an independent read) went missing too:\n%s", body) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // TestAccountFleetUnreadableRegistryClaimsNothing: one failed registry read |
| 288 | // takes out BOTH registry-fed sections, and neither may fall through to its |
| 289 | // zero value and make a positive claim from it — not the card ("none of your |
| 290 | // apps run here", about routes it never looked at) and not the index ("no |
| 291 | // fleets of your own yet", about a list it never got). "Could not check" and |
| 292 | // "checked, and no" are different sentences in both places. |
| 293 | func TestAccountFleetUnreadableRegistryClaimsNothing(t *testing.T) { |
| 294 | fx := newConsoleFixture(t) |
| 295 | seedSharedBoxes(t, fx, "EU · Ireland") |
| 296 | c := signInPaul(t, fx) |
| 297 | // A path whose parent directory does not exist: registry.Open fails, and |
| 298 | // a failed open is never cached (openRegistry, view.go). |
| 299 | fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "gone", "registry.db") |
| 300 | |
| 301 | st, _, body := get(t, c, fx.u("/fleet")) |
| 302 | if st != http.StatusOK { |
| 303 | t.Fatalf("GET /fleet with an unreadable registry = %d, want 200 (degrade, don't 500):\n%s", st, body) |
| 304 | } |
| 305 | if strings.Contains(body, "None of your apps run on the shared fleet") || |
| 306 | strings.Contains(body, "Not used") { |
| 307 | t.Fatalf("an unread registry rendered as a positive \"not used\" verdict:\n%s", body) |
| 308 | } |
| 309 | if !strings.Contains(body, "Whether your apps run here could not be read just now.") { |
| 310 | t.Fatalf("missing the honest unknown-placement state:\n%s", body) |
| 311 | } |
| 312 | if strings.Contains(body, "No fleets of your own yet") { |
| 313 | t.Fatalf("an unread registry rendered as a positive \"you have no fleets\" verdict:\n%s", body) |
| 314 | } |
| 315 | if !strings.Contains(body, "Your fleets could not be read just now.") { |
| 316 | t.Fatalf("missing the honest unknown-fleets state:\n%s", body) |
| 317 | } |
| 318 | // The half that DID read is still shown. |
| 319 | if !strings.Contains(body, "EU · Ireland") { |
| 320 | t.Fatalf("the shared card's own (successful) read went missing:\n%s", body) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | // TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned is the positive half of |
| 325 | // the test above: a HEALTHY registry that genuinely holds no fleets for this |
| 326 | // account does get the flat "none" wording — the honest-unknown copy must not |
| 327 | // have swallowed the ordinary empty state along with the lie. |
| 328 | func TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned(t *testing.T) { |
| 329 | fx := newConsoleFixture(t) |
| 330 | c := signInPaul(t, fx) |
| 331 | |
| 332 | st, _, body := get(t, c, fx.u("/fleet")) |
| 333 | if st != http.StatusOK { |
| 334 | t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| 335 | } |
| 336 | if !strings.Contains(body, "No fleets of your own yet.") { |
| 337 | t.Fatalf("an account with no fleets and a healthy registry lost its empty state:\n%s", body) |
| 338 | } |
| 339 | if strings.Contains(body, "could not be read just now") { |
| 340 | t.Fatalf("a healthy read rendered as unknown:\n%s", body) |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | // TestFleetBYOPageMemberReadable: the BYO instructions page is member- |
| 345 | // readable, the same gate as the Fleet tab itself — a member deciding |
| 346 | // whether to bring a box needs to read the recipe as much as the owner |
| 347 | // does. It must carry the exact commands and env var an operator will |
| 348 | // actually type/set, and the Hardening section the checklist lives under. |
| 349 | func TestFleetBYOPageMemberReadable(t *testing.T) { |
| 350 | fx := newConsoleFixture(t) |
| 351 | c := signInPaul(t, fx) |
| 352 | |
| 353 | st, _, body := get(t, c, fx.u("/fleet/byo")) |
| 354 | if st != http.StatusOK { |
| 355 | t.Fatalf("GET /fleet/byo = %d, want 200:\n%s", st, body) |
| 356 | } |
| 357 | for _, want := range []string{"carlos fleets create", "CARLOS_TUNNEL_NAME", "Hardening"} { |
| 358 | if !strings.Contains(body, want) { |
| 359 | t.Fatalf("BYO page missing %q in:\n%s", want, body) |
| 360 | } |
| 361 | } |
| 362 | // The primary env contract is bucketless: no platform-bucket var or |
| 363 | // credential belongs there (the box reaches releases/heartbeats through |
| 364 | // the console's edge with its tunnel token). CARLOS_DEPLOYMENT_BUCKET |
| 365 | // still appears, but only inside the demoted legacy-direct-S3 note. |
| 366 | if !strings.Contains(body, "Legacy direct-S3") { |
| 367 | t.Fatalf("BYO page missing the legacy direct-S3 note in:\n%s", body) |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost |
| 372 | // the shared card, never the page — the fleets index is registry-side and |
| 373 | // keeps rendering, with a flash that says which half is unknown. |
| 374 | func TestAccountFleetDegradesOnStoreFailure(t *testing.T) { |
| 375 | fx, token := fleetFixture(t, "code-acctfleet-storefail") |
| 376 | if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| 377 | map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| 378 | t.Fatalf("create fleet = %d %v", st, body) |
| 379 | } |
| 380 | seedSharedBoxes(t, fx, "EU · Ireland") |
| 381 | |
| 382 | c := signInPaul(t, fx) |
| 383 | // Only the aggregate itself is unreadable: the per-fleet heartbeat reads |
| 384 | // still work, so a degraded shared card is the ONLY thing this costs. |
| 385 | fx.srv.cfg.Releases = errOnKeyStore{Store: fx.rel, |
| 386 | key: "status/fleet.json", err: errors.New("bucket having a moment")} |
| 387 | |
| 388 | st, _, body := get(t, c, fx.u("/fleet")) |
| 389 | if st != http.StatusOK { |
| 390 | t.Fatalf("GET /fleet with an unreadable store = %d, want 200 (degrade, don't 500):\n%s", st, body) |
| 391 | } |
| 392 | if !strings.Contains(body, "acme") { |
| 393 | t.Fatalf("the registry-side fleets index went missing on a BUCKET failure:\n%s", body) |
| 394 | } |
| 395 | if !strings.Contains(body, "Could not read the shared fleet") { |
| 396 | t.Fatalf("a failed shared-fleet read rendered as if nothing were wrong:\n%s", body) |
| 397 | } |
| 398 | if strings.Contains(body, "EU · Ireland") { |
| 399 | t.Fatalf("a failed read still showed the last-known regions:\n%s", body) |
| 400 | } |
| 401 | } |
| 402 | |