| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/appconfig" |
| 13 | ) |
| 14 | |
| 15 | func TestAPISwapSetsAndEchoesThePolicy(t *testing.T) { |
| 16 | fx := newConsoleFixture(t) |
| 17 | token := cliToken(t, fx, "paul@keymail.dev", "code-swap") |
| 18 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| 19 | |
| 20 | code, _, body := rawReq(t, http.MethodPut, u, token, `{"swap":"hold","swap_concurrency":2}`) |
| 21 | if code != http.StatusOK { |
| 22 | t.Fatalf("set = %d %s", code, body) |
| 23 | } |
| 24 | var out struct { |
| 25 | Swap string `json:"swap"` |
| 26 | Concurrency int `json:"swap_concurrency"` |
| 27 | } |
| 28 | if err := json.Unmarshal([]byte(body), &out); err != nil || out.Swap != "hold" || out.Concurrency != 2 { |
| 29 | t.Fatalf("echo = %s (%v), want hold/2", body, err) |
| 30 | } |
| 31 | got, err := appconfig.GetSwapPolicy(context.Background(), fx.rel, fx.sqid, "kass") |
| 32 | if err != nil || got != (appconfig.SwapPolicy{Swap: "hold", Concurrency: 2}) { |
| 33 | t.Fatalf("stored = %+v, %v", got, err) |
| 34 | } |
| 35 | if n := serial(t, fx); n != 1 { |
| 36 | t.Fatalf("serial = %d, want 1: boxes act on the serial", n) |
| 37 | } |
| 38 | code, _, body = rawReq(t, http.MethodPut, u, token, `{"swap_concurrency":1}`) |
| 39 | if code != http.StatusOK || json.Unmarshal([]byte(body), &out) != nil || out.Swap != "hold" || out.Concurrency != 1 { |
| 40 | t.Fatalf("concurrency only = %d %s, want hold/1", code, body) |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | func TestAPISwapRefusesBeforeWriting(t *testing.T) { |
| 45 | fx := newConsoleFixture(t) |
| 46 | token := cliToken(t, fx, "paul@keymail.dev", "code-swap-bad") |
| 47 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| 48 | for _, bad := range []string{`{}`, `{"swap":"fast"}`, `{"swap":""}`, `{"swap_concurrency":0}`, |
| 49 | `{"swap_concurrency":-1}`, `{"swap_concurrency":1.5}`, `{"swap":"hold","swap_concurrency":0}`, |
| 50 | // A misspelt key is refused by name, not dropped: without the strict |
| 51 | // reader this body would read as "nothing to set" at best, and as a |
| 52 | // half-applied request at worst. |
| 53 | `{"swap_conurrency":3}`, `{"swap":"hold","swap_conurrency":3}`} { |
| 54 | if code, _, body := rawReq(t, http.MethodPut, u, token, bad); code != http.StatusBadRequest { |
| 55 | t.Errorf("%s = %d %s, want 400", bad, code, body) |
| 56 | } |
| 57 | } |
| 58 | if _, _, err := fx.rel.Get(context.Background(), appconfig.Key(fx.sqid, "kass")); !errors.Is(err, os.ErrNotExist) { |
| 59 | t.Fatalf("a refused request wrote config.json: %v", err) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestAPISwapNamesAMisspeltKey(t *testing.T) { |
| 64 | fx := newConsoleFixture(t) |
| 65 | token := cliToken(t, fx, "paul@keymail.dev", "code-swap-typo") |
| 66 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| 67 | code, _, body := rawReq(t, http.MethodPut, u, token, `{"swap":"hold","swap_conurrency":3}`) |
| 68 | if code != http.StatusBadRequest || !strings.Contains(body, "swap_conurrency") { |
| 69 | t.Fatalf("typo = %d %s, want a 400 naming swap_conurrency", code, body) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // Astra plan 6 R1 #9: the swap door's rate-limit answer is its own marked |
| 74 | // draft (zdd.api.swap-limited), not the sibling doors' em-dash sentence. |
| 75 | func TestAPISwapRateLimitIsAMarkedDraftWithNoEmDash(t *testing.T) { |
| 76 | fx := newConsoleFixture(t) |
| 77 | token := cliToken(t, fx, "paul@keymail.dev", "code-swap-limited") |
| 78 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/kass/swap" |
| 79 | for i := 0; i < cliWriteBurst+3; i++ { |
| 80 | code, _, body := rawReq(t, http.MethodPut, u, token, `{"swap":"overlap"}`) |
| 81 | if code != http.StatusTooManyRequests { |
| 82 | continue |
| 83 | } |
| 84 | var out struct{ Error string } |
| 85 | if err := json.Unmarshal([]byte(body), &out); err != nil || out.Error != "too many requests. wait a minute and try again" { |
| 86 | t.Fatalf("429 = %s (%v)", body, err) |
| 87 | } |
| 88 | return |
| 89 | } |
| 90 | t.Fatalf("the swap door was never rate-limited in %d attempts", cliWriteBurst+3) |
| 91 | } |
| 92 | |
| 93 | // Astra plan 6 R1 #9: the Deploys card's Save button is new copy. It is a |
| 94 | // literal in the template, so the pinned copy record names the template for it |
| 95 | // (zdd.settings.save), and this keeps the button inside the Deploys card. |
| 96 | func TestDeploysCardSaveButtonIsInTheCard(t *testing.T) { |
| 97 | raw, err := os.ReadFile("templates/app_settings.html") |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | _, card, ok := strings.Cut(string(raw), "{{.SwapCopy.Heading}}") |
| 102 | if !ok { |
| 103 | t.Fatal("no Deploys card in app_settings.html") |
| 104 | } |
| 105 | card, _, _ = strings.Cut(card, "</form>") |
| 106 | if !strings.Contains(card, `<button type="submit" class="btn-sm">Save</button>`) { |
| 107 | t.Fatalf("the Deploys card has no Save button:\n%s", card) |
| 108 | } |
| 109 | } |
| 110 | |