| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "testing" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // TestAPIBuildProbeSetsAndClearsOnAnExistingApp is the whole point of the |
| 11 | // door. Until it existed, SetAppBuildProbe had exactly one caller — the claim |
| 12 | // door — so an app created without the flag could never acquire a probe, and |
| 13 | // the fix for "deploy reported live over the old binary" was unreachable by |
| 14 | // every app that already had the problem (discussion #13). |
| 15 | func TestAPIBuildProbeSetsAndClearsOnAnExistingApp(t *testing.T) { |
| 16 | fx := newConsoleFixture(t) |
| 17 | token := cliToken(t, fx, "paul@keymail.dev", "code-buildprobe") |
| 18 | if _, err := fx.st.CreateAppIn(OpsAccountID, "docs", "paul@keymail.dev", time.Now()); err != nil { |
| 19 | t.Fatalf("create app: %v", err) |
| 20 | } |
| 21 | |
| 22 | probeOf := func() string { |
| 23 | t.Helper() |
| 24 | _, probe, err := fx.st.AppFlags(OpsAccountID, "docs") |
| 25 | if err != nil { |
| 26 | t.Fatalf("AppFlags: %v", err) |
| 27 | } |
| 28 | return probe |
| 29 | } |
| 30 | if got := probeOf(); got != "" { |
| 31 | t.Fatalf("a fresh app already carries a probe: %q", got) |
| 32 | } |
| 33 | |
| 34 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/docs/build-probe" |
| 35 | status, _, body := rawReq(t, http.MethodPost, u, token, `{"build_probe":"/api/version"}`) |
| 36 | if status != http.StatusOK { |
| 37 | t.Fatalf("set = %d %q", status, body) |
| 38 | } |
| 39 | var out struct { |
| 40 | BuildProbe string `json:"build_probe"` |
| 41 | } |
| 42 | if err := json.Unmarshal([]byte(body), &out); err != nil { |
| 43 | t.Fatalf("unreadable: %v: %s", err, body) |
| 44 | } |
| 45 | // The echo is the contract: this is the field the deploy watch trusts |
| 46 | // instead of the header, so a write that silently failed has to be a loud |
| 47 | // error rather than a quiet lie. Response and store both. |
| 48 | if out.BuildProbe != "/api/version" { |
| 49 | t.Errorf("echo = %q, want /api/version", out.BuildProbe) |
| 50 | } |
| 51 | if got := probeOf(); got != "/api/version" { |
| 52 | t.Errorf("stored = %q, want /api/version", got) |
| 53 | } |
| 54 | |
| 55 | // "" clears back to header-watching — a legal value of this field, not a |
| 56 | // missing one. |
| 57 | status, _, body = rawReq(t, http.MethodPost, u, token, `{"build_probe":""}`) |
| 58 | if status != http.StatusOK { |
| 59 | t.Fatalf("clear = %d %q", status, body) |
| 60 | } |
| 61 | if got := probeOf(); got != "" { |
| 62 | t.Errorf("after clear, stored = %q, want empty", got) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // TestAPIBuildProbeRefusesABadShape: the watch builds "https://" + host + |
| 67 | // probe, so a probe not starting with "/" turns the poll into a request to a |
| 68 | // machine of somebody else's choosing, which would then report ITS build as |
| 69 | // this deploy's. Refused at the write door, not only where it is read. |
| 70 | func TestAPIBuildProbeRefusesABadShape(t *testing.T) { |
| 71 | fx := newConsoleFixture(t) |
| 72 | token := cliToken(t, fx, "paul@keymail.dev", "code-buildprobebad") |
| 73 | if _, err := fx.st.CreateAppIn(OpsAccountID, "docs", "paul@keymail.dev", time.Now()); err != nil { |
| 74 | t.Fatalf("create app: %v", err) |
| 75 | } |
| 76 | |
| 77 | u := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/docs/build-probe" |
| 78 | for _, bad := range []string{"@evil.example/x", "api/version", "/x?y=1", "https://evil.example/x"} { |
| 79 | payload, _ := json.Marshal(map[string]string{"build_probe": bad}) |
| 80 | status, _, body := rawReq(t, http.MethodPost, u, token, string(payload)) |
| 81 | if status != http.StatusBadRequest { |
| 82 | t.Errorf("probe %q = %d %q, want 400", bad, status, body) |
| 83 | } |
| 84 | } |
| 85 | if _, probe, _ := fx.st.AppFlags(OpsAccountID, "docs"); probe != "" { |
| 86 | t.Errorf("a refused probe still landed: %q", probe) |
| 87 | } |
| 88 | } |
| 89 | |