| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/plans" |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | ) |
| 15 | |
| 16 | // atRunningCap turns plans on and leaves the fixture's ops account with |
| 17 | // exactly capN running things against a cap of capN (capN >= the free |
| 18 | // plan's 3). |
| 19 | func atRunningCap(t *testing.T, fx *consoleFixture, capN int) *Account { |
| 20 | t.Helper() |
| 21 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 22 | acct, err := fx.st.AccountBySqid(fx.sqid) |
| 23 | if err != nil || acct == nil { |
| 24 | t.Fatalf("ops account: %v", err) |
| 25 | } |
| 26 | for i := 0; ; i++ { |
| 27 | n, err := fx.srv.runningThings(context.Background(), acct) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | if n > capN { |
| 32 | capN = n |
| 33 | } |
| 34 | if n == capN { |
| 35 | break |
| 36 | } |
| 37 | capsApp(t, fx, acct, fmt.Sprintf("filler%02d", i)) |
| 38 | } |
| 39 | if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: capN}, "test", "ops@example.com", planNow); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | return acct |
| 43 | } |
| 44 | |
| 45 | func raiseRunningCap(t *testing.T, fx *consoleFixture, acct *Account, by int) { |
| 46 | t.Helper() |
| 47 | n, err := fx.srv.runningThings(context.Background(), acct) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: n + by}, "test", "ops@example.com", planNow); err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestCreateAppRefusedAtTheRunningThingsCap(t *testing.T) { |
| 57 | fx := newConsoleFixture(t) |
| 58 | acct := atRunningCap(t, fx, 3) |
| 59 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-create") |
| 60 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "onemore"}, token) |
| 61 | if status != http.StatusConflict || out["code"] != "cap" || out["error"] == nil { |
| 62 | t.Fatalf("CLI create at the cap = %d %v, want 409 cap", status, out) |
| 63 | } |
| 64 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-create-web") |
| 65 | if st, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"webmore"}}); st != http.StatusSeeOther { |
| 66 | t.Fatalf("browser create at the cap = %d, want the flash redirect", st) |
| 67 | } |
| 68 | for _, app := range []string{"onemore", "webmore"} { |
| 69 | if has, _ := fx.st.HasAppIn(acct.ID, app); has { |
| 70 | t.Fatalf("%s was created past the cap", app) |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestCreateAppBelowTheCapStillWorks(t *testing.T) { |
| 76 | fx := newConsoleFixture(t) |
| 77 | acct := atRunningCap(t, fx, 3) |
| 78 | raiseRunningCap(t, fx, acct, 1) |
| 79 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-below") |
| 80 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "fits"}, token); status != http.StatusOK { |
| 81 | t.Fatalf("create below the cap = %d %v", status, out) |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | func TestTemporaryAppCountsTowardRunningThings(t *testing.T) { |
| 86 | fx := newConsoleFixture(t) |
| 87 | fx.srv.cfg.TemporaryApps = true |
| 88 | atRunningCap(t, fx, 3) |
| 89 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-temp") |
| 90 | status, out := createTemporary(t, fx, token, "tmp-capcheck", 3600) |
| 91 | if status != http.StatusConflict || out["code"] != "cap" { |
| 92 | t.Fatalf("temporary create at the running cap = %d %v, want 409 cap", status, out) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | func TestRestoreRefusedAtTheRunningThingsCap(t *testing.T) { |
| 97 | fx := newConsoleFixture(t) |
| 98 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-restore") |
| 99 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/delete", map[string]string{}, token); status != http.StatusOK { |
| 100 | t.Fatalf("delete kass = %d %v", status, out) |
| 101 | } |
| 102 | atRunningCap(t, fx, 3) |
| 103 | status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/kass/restore", map[string]string{}, token) |
| 104 | if status != http.StatusConflict || out["code"] != "cap" { |
| 105 | t.Fatalf("CLI restore at the cap = %d %v, want 409 cap", status, out) |
| 106 | } |
| 107 | c, csrf := signInAs(t, fx, "paul@keymail.dev", "code-cap-restore-web") |
| 108 | if st, _, _ := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}); st != http.StatusSeeOther { |
| 109 | t.Fatalf("browser restore at the cap = %d, want the flash redirect", st) |
| 110 | } |
| 111 | if markerOf(t, fx, "kass") == nil { |
| 112 | t.Fatal("a refused restore removed the trash marker") |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | func TestRunningThingsCapIsExactUnderConcurrentCreates(t *testing.T) { |
| 117 | fx := newConsoleFixture(t) |
| 118 | acct := atRunningCap(t, fx, 3) |
| 119 | raiseRunningCap(t, fx, acct, 1) |
| 120 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-race") |
| 121 | var mu sync.Mutex |
| 122 | created := 0 |
| 123 | var wg sync.WaitGroup |
| 124 | for i := range 8 { |
| 125 | wg.Add(1) |
| 126 | go func() { |
| 127 | defer wg.Done() |
| 128 | status, _ := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": fmt.Sprintf("race%d", i)}, token) |
| 129 | if status == http.StatusOK { |
| 130 | mu.Lock() |
| 131 | created++ |
| 132 | mu.Unlock() |
| 133 | } |
| 134 | }() |
| 135 | } |
| 136 | wg.Wait() |
| 137 | if created != 1 { |
| 138 | t.Fatalf("%d creates succeeded for one free slot, want exactly 1", created) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestCreateAppWithoutACatalogUnchanged(t *testing.T) { |
| 143 | fx := newConsoleFixture(t) |
| 144 | acct, _ := fx.st.AccountBySqid(fx.sqid) |
| 145 | for i := range 5 { |
| 146 | capsApp(t, fx, acct, fmt.Sprintf("many%d", i)) |
| 147 | } |
| 148 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-nocat") |
| 149 | if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "stillfine"}, token); status != http.StatusOK { |
| 150 | t.Fatalf("create with no catalog = %d %v", status, out) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | // sponsorCustomDomains keeps the cap as atRunningCap left it and adds custom |
| 155 | // domains, so a first-route attach meets the running-things cap rather than |
| 156 | // the custom-domain gate. |
| 157 | func sponsorCustomDomains(t *testing.T, fx *consoleFixture, acct *Account) { |
| 158 | t.Helper() |
| 159 | row, err := fx.st.AccountPlan(acct.ID) |
| 160 | if err != nil || row == nil || row.Sponsored == nil { |
| 161 | t.Fatalf("plan row: %+v, %v", row, err) |
| 162 | } |
| 163 | sp := *row.Sponsored |
| 164 | sp.CustomDomains = true |
| 165 | if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &sp, "test", "ops@example.com", planNow); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestInstanceCreateRefusedAtTheRunningThingsCap(t *testing.T) { |
| 171 | fx, token := instanceFixture(t, "code-cap-instance") |
| 172 | enableInstances(t, fx, "titogo", titoManifest()) |
| 173 | atRunningCap(t, fx, 3) |
| 174 | status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`) |
| 175 | if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) { |
| 176 | t.Fatalf("instance create at the cap = %d %s, want 409 cap", status, out) |
| 177 | } |
| 178 | if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io"); err == nil { |
| 179 | t.Fatal("an instance record was written past the cap") |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | func TestFirstRouteAttachRefusedAtTheRunningThingsCap(t *testing.T) { |
| 184 | fx, token := instanceFixture(t, "code-cap-firstroute") |
| 185 | shipVersion(t, fx, "titogo", "v1") |
| 186 | seedChannel(t, fx, "titogo", "canary/live", "v1") |
| 187 | acct := atRunningCap(t, fx, 3) |
| 188 | sponsorCustomDomains(t, fx, acct) |
| 189 | status, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), token, `{"host":"shop.example.com","kind":"static"}`) |
| 190 | if status != http.StatusConflict { |
| 191 | t.Fatalf("first-route attach at the cap = %d %s, want 409", status, out) |
| 192 | } |
| 193 | if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "shop.example.com"); err == nil { |
| 194 | t.Fatal("a first-route record was written past the cap") |
| 195 | } |
| 196 | } |
| 197 | |
| 198 | func TestSidecarCreateRefusedAtTheSidecarCap(t *testing.T) { |
| 199 | fx, token := instanceFixture(t, "code-cap-sidecar") |
| 200 | enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| 201 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 202 | acct, _ := fx.st.AccountBySqid("bab") |
| 203 | capsSidecar(t, fx, acct, "titogo", "first") |
| 204 | status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`) |
| 205 | if status != http.StatusConflict || !strings.Contains(out, `"code":"cap"`) { |
| 206 | t.Fatalf("second sidecar on free = %d %s, want 409 cap", status, out) |
| 207 | } |
| 208 | if _, _, err := fx.rel.Get(context.Background(), provision.SidecarRecordKey("bab", "titogo", "bot")); err == nil { |
| 209 | t.Fatal("a sidecar record was written past the cap") |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | func TestInstanceAndSidecarCreateWithoutACatalogUnchanged(t *testing.T) { |
| 214 | fx, token := instanceFixture(t, "code-cap-inst-nocat") |
| 215 | enableInstances(t, fx, "titogo", titoManifest()) |
| 216 | if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, `{"host":"acme.go.tito.io","region":"ie"}`); status != http.StatusCreated { |
| 217 | t.Fatalf("instance create with no catalog = %d %s", status, out) |
| 218 | } |
| 219 | enableSidecars(t, fx, "titogo", titoSidecarManifest()) |
| 220 | acct, _ := fx.st.AccountBySqid("bab") |
| 221 | capsSidecar(t, fx, acct, "titogo", "first") |
| 222 | if status, _, out := rawReq(t, http.MethodPost, sidecarsURL(fx, "bab", "titogo"), token, `{"name":"bot","region":"ie"}`); status != http.StatusCreated { |
| 223 | t.Fatalf("sidecar create with no catalog = %d %s", status, out) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | func accountCount(t *testing.T, fx *consoleFixture) int { |
| 228 | t.Helper() |
| 229 | all, err := fx.st.Accounts() |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | return len(all) |
| 234 | } |
| 235 | |
| 236 | func TestNewAccountRefusedForASecondFreeAccount(t *testing.T) { |
| 237 | fx := newConsoleFixture(t) |
| 238 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 239 | before := accountCount(t, fx) |
| 240 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct") |
| 241 | status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token) |
| 242 | if status != http.StatusConflict || out["code"] != "cap" { |
| 243 | t.Fatalf("CLI second free account = %d %v, want 409 cap", status, out) |
| 244 | } |
| 245 | c := signInPaul(t, fx) |
| 246 | st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {sessionToken(t, fx, c)}, "name": {"thirdtry"}}) |
| 247 | if st != http.StatusSeeOther || loc != "/accounts/new" { |
| 248 | t.Fatalf("browser second free account = %d -> %q, want the flash redirect back to /accounts/new", st, loc) |
| 249 | } |
| 250 | if after := accountCount(t, fx); after != before { |
| 251 | t.Fatalf("accounts %d -> %d: one was minted past the cap", before, after) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | func TestFirstAccountIsNeverRefused(t *testing.T) { |
| 256 | fx := newConsoleFixture(t) |
| 257 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 258 | signInAs(t, fx, "newcomer@keymail.dev", "code-cap-first") |
| 259 | accts, err := fx.st.AccountsFor("newcomer@keymail.dev") |
| 260 | if err != nil || len(accts) != 1 { |
| 261 | t.Fatalf("first sign-in with plans on: %d accounts, %v; want the auto-minted one", len(accts), err) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func TestNewAccountAllowedWhenTheOtherIsPaid(t *testing.T) { |
| 266 | fx := newConsoleFixture(t) |
| 267 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 268 | ops, _ := fx.st.AccountBySqid(fx.sqid) |
| 269 | if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-paid") |
| 273 | if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token); status != http.StatusOK { |
| 274 | t.Fatalf("a first free account beside a paid one = %d %v", status, out) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | func TestNewAccountWithoutACatalogUnchanged(t *testing.T) { |
| 279 | fx := newConsoleFixture(t) |
| 280 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-nocat") |
| 281 | for _, name := range []string{"one", "two"} { |
| 282 | if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": name}, token); status != http.StatusOK { |
| 283 | t.Fatalf("account %s with no catalog = %d %v", name, status, out) |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestFleetCreateRefusedOnAPlanWithoutFleets(t *testing.T) { |
| 289 | fx, token := fleetFixture(t, "code-cap-fleet") |
| 290 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 291 | status, out := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token) |
| 292 | if status != http.StatusConflict || out["code"] != "cap" { |
| 293 | t.Fatalf("fleet create on free = %d %v, want 409 cap", status, out) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | func TestCustomDomainRefusedOnAFreePlan(t *testing.T) { |
| 298 | fx, member := instanceFixture(t, "code-cap-domain") |
| 299 | shipVersion(t, fx, "titogo", "v1") |
| 300 | seedChannel(t, fx, "titogo", "canary/live", "v1") |
| 301 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 302 | for name, body := range map[string]string{ |
| 303 | "alias": `{"host":"tickets.example.com","route":"go.tito.io"}`, |
| 304 | "first route": `{"host":"fresh.example.com","kind":"static"}`, |
| 305 | } { |
| 306 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body) |
| 307 | if code != http.StatusConflict { |
| 308 | t.Errorf("%s on free = %d %s, want 409", name, code, out) |
| 309 | } |
| 310 | } |
| 311 | claimAbsent(t, fx, "tickets.example.com") |
| 312 | claimAbsent(t, fx, "fresh.example.com") |
| 313 | } |
| 314 | |
| 315 | func TestFleetsAndDomainsWithoutACatalogUnchanged(t *testing.T) { |
| 316 | fx, member := instanceFixture(t, "code-cap-domain-nocat") |
| 317 | if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, `{"host":"tickets.example.com","route":"go.tito.io"}`); code != http.StatusOK { |
| 318 | t.Fatalf("alias attach with no catalog = %d %s", code, out) |
| 319 | } |
| 320 | fx2, token := fleetFixture(t, "code-cap-fleet-nocat") |
| 321 | if status, out := postJSON(t, fleetsURL(fx2, fx2.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token); status != http.StatusOK { |
| 322 | t.Fatalf("fleet create with no catalog = %d %v", status, out) |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | // Review Focus 5: an address under the deployment's apps domain is the |
| 327 | // account's own platform address, not a custom domain, so free plans keep it. |
| 328 | func TestPlatformHostnamesStayFreeOnFreePlans(t *testing.T) { |
| 329 | fx, member := instanceFixture(t, "code-cap-platform") |
| 330 | fx.srv.cfg.AppsDomain = "apps.example.com" |
| 331 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 332 | acct, _ := fx.st.AccountBySqid("bab") |
| 333 | if fx.srv.customDomain("shop.bab.apps.example.com", acct.Sqid) { |
| 334 | t.Fatal("an address under the apps domain was treated as a custom domain") |
| 335 | } |
| 336 | if !fx.srv.customDomain("shop.example.com", acct.Sqid) { |
| 337 | t.Fatal("a customer domain was not treated as a custom domain") |
| 338 | } |
| 339 | if ref := fx.srv.customDomainRefusal(acct, "shop.bab.apps.example.com"); ref != nil { |
| 340 | t.Fatalf("free plan refused its own platform address: %+v", ref) |
| 341 | } |
| 342 | _ = member |
| 343 | } |
| 344 | |
| 345 | // Astra plans-3a finding 2: concurrent creates by one person who owns only |
| 346 | // paid accounts must not mint more free accounts than the allowance. |
| 347 | func TestFreeAccountCapIsExactUnderConcurrentCreates(t *testing.T) { |
| 348 | fx := newConsoleFixture(t) |
| 349 | fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| 350 | ops, _ := fx.st.AccountBySqid(fx.sqid) |
| 351 | if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil { |
| 352 | t.Fatal(err) |
| 353 | } |
| 354 | token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-race") |
| 355 | var mu sync.Mutex |
| 356 | created := 0 |
| 357 | var wg sync.WaitGroup |
| 358 | for i := range 6 { |
| 359 | wg.Add(1) |
| 360 | go func() { |
| 361 | defer wg.Done() |
| 362 | status, _ := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": fmt.Sprintf("race%d", i)}, token) |
| 363 | if status == http.StatusOK { |
| 364 | mu.Lock() |
| 365 | created++ |
| 366 | mu.Unlock() |
| 367 | } |
| 368 | }() |
| 369 | } |
| 370 | wg.Wait() |
| 371 | if created != 1 { |
| 372 | t.Fatalf("%d free accounts minted concurrently, want exactly 1", created) |
| 373 | } |
| 374 | } |
| 375 | |