| 1 | package console |
| 2 | |
| 3 | // The action confirm interstitial (confirm.go). The properties under test are |
| 4 | // the ones the file's own doc comment calls load-bearing: the card describes a |
| 5 | // request that would otherwise proceed, it never renders for someone the owner |
| 6 | // 404 is meant to turn away, and it sits strictly BETWEEN that 404 and the |
| 7 | // passkey — so an owner sees the card first and is asked for the passkey only |
| 8 | // on the confirmed replay. |
| 9 | // |
| 10 | // Every card test also lands the confirmed POST. A card that renders but whose |
| 11 | // resubmit does not carry the action through is a dead end, and asserting only |
| 12 | // on the markup would not notice. |
| 13 | |
| 14 | import ( |
| 15 | "context" |
| 16 | "errors" |
| 17 | "net/http" |
| 18 | "net/url" |
| 19 | "os" |
| 20 | "strings" |
| 21 | "testing" |
| 22 | "time" |
| 23 | |
| 24 | "github.com/carlosframework/platform/internal/registry" |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | // confirmCard asserts the response IS a confirm card — 200, the titled |
| 29 | // heading, the confirmed=1 marker the resubmit turns on, the CSRF token |
| 30 | // carried through, and a submit button — and hands the body back for the |
| 31 | // card-specific assertions. Everything here is what makes the card a working |
| 32 | // resubmit rather than a screenshot of one. |
| 33 | func confirmCard(t *testing.T, status int, body, title, csrf string) string { |
| 34 | t.Helper() |
| 35 | if status != http.StatusOK { |
| 36 | t.Fatalf("confirm step = %d, want a 200 card:\n%s", status, body) |
| 37 | } |
| 38 | if !strings.Contains(body, title) { |
| 39 | t.Fatalf("card is not titled %q:\n%s", title, body) |
| 40 | } |
| 41 | if !strings.Contains(body, `<input type="hidden" name="confirmed" value="1">`) { |
| 42 | t.Fatalf("card carries no confirmed=1 marker, so its button cannot land the action:\n%s", body) |
| 43 | } |
| 44 | if !strings.Contains(body, `<input type="hidden" name="csrf" value="`+csrf+`">`) { |
| 45 | t.Fatalf("card carries no csrf field, so its resubmit would be refused:\n%s", body) |
| 46 | } |
| 47 | if !strings.Contains(body, `<button type="submit"`) { |
| 48 | t.Fatalf("card has no submit button:\n%s", body) |
| 49 | } |
| 50 | return body |
| 51 | } |
| 52 | |
| 53 | // notACard is the negative: whatever this response is, it must not be an |
| 54 | // interstitial. Used wherever a card would concede that an action exists for |
| 55 | // somebody (the owner-only 404s) or that it would otherwise proceed (the |
| 56 | // cheap refusals). |
| 57 | func notACard(t *testing.T, body string) { |
| 58 | t.Helper() |
| 59 | if strings.Contains(body, `name="confirmed"`) || strings.Contains(body, "confirm-card") { |
| 60 | t.Fatalf("a confirm card was rendered where none may be:\n%s", body) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // The ordinary promote: the unconfirmed POST is answered with the card and |
| 65 | // changes nothing; the same POST plus confirmed=1 moves the channel. |
| 66 | func TestConfirmPromoteCardThenLands(t *testing.T) { |
| 67 | fx := newConsoleFixture(t) |
| 68 | shipVersion(t, fx, "kass", "v3") |
| 69 | seedChannel(t, fx, "kass", "edge", "v3") // beta is entered from edge |
| 70 | c := signInPaul(t, fx) |
| 71 | csrf := sessionToken(t, fx, c) |
| 72 | form := url.Values{"csrf": {csrf}, "channel": {"beta"}, "version": {"v3"}} |
| 73 | |
| 74 | status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), form) |
| 75 | card := confirmCard(t, status, body, "Promote v3 to beta?", csrf) |
| 76 | // The card carries what the resubmit needs to be the SAME request. |
| 77 | for _, want := range []string{ |
| 78 | `<input type="hidden" name="channel" value="beta">`, |
| 79 | `<input type="hidden" name="version" value="v3">`, |
| 80 | } { |
| 81 | if !strings.Contains(card, want) { |
| 82 | t.Fatalf("card does not carry %s:\n%s", want, card) |
| 83 | } |
| 84 | } |
| 85 | if !strings.Contains(card, "Promote v3") { |
| 86 | t.Fatalf("the card's button does not name the promotion:\n%s", card) |
| 87 | } |
| 88 | // A card is a question, not a deferral: beta does not exist yet. |
| 89 | if _, err := release.Channel(context.Background(), fx.rel, fx.pub, |
| 90 | release.OpsAccount, "kass", "beta"); !errors.Is(err, os.ErrNotExist) { |
| 91 | t.Fatalf("beta = %v after an UNconfirmed promote, want it still absent", err) |
| 92 | } |
| 93 | |
| 94 | form.Set("confirmed", "1") |
| 95 | status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), form) |
| 96 | if status != http.StatusSeeOther || loc != fx.path("/apps/kass") { |
| 97 | t.Fatalf("confirmed promote = %d -> %q, want 303 to the app page:\n%s", status, loc, body) |
| 98 | } |
| 99 | if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| 100 | t.Fatalf("beta = %s after a confirmed promote, want v3", p.Version) |
| 101 | } |
| 102 | if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Promoted v3 to beta.") { |
| 103 | t.Fatalf("no promote flash:\n%s", page) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | // The stable-target card is the one with a real choice on it: bake is off for |
| 108 | // this app, so the card offers to turn it on. And the card is strictly for |
| 109 | // owners — a member probing the stable rung meets the byte-identical 404 with |
| 110 | // no card in it, because a card would concede the action exists for somebody. |
| 111 | func TestConfirmStablePromoteOffersBakeAndHidesFromMembers(t *testing.T) { |
| 112 | fx := newConsoleFixture(t) |
| 113 | shipVersion(t, fx, "kass", "v3") |
| 114 | seedChannel(t, fx, "kass", "beta", "v3") // stable is entered from beta |
| 115 | c := signInPaul(t, fx) |
| 116 | csrf := sessionToken(t, fx, c) |
| 117 | |
| 118 | status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), |
| 119 | url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}}) |
| 120 | // The card comes BEFORE the passkey: an owner is asked "do you mean it?" |
| 121 | // first, and only the confirmed replay meets requireFreshAuth. |
| 122 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 123 | t.Fatalf("the owner was sent to Keymail before the card was shown (-> %q)", loc) |
| 124 | } |
| 125 | card := confirmCard(t, status, body, "Promote v3 to stable?", csrf) |
| 126 | if !strings.Contains(card, `name="enable_bake"`) { |
| 127 | t.Fatalf("the stable card carries no enable_bake choice for a bake-off app:\n%s", card) |
| 128 | } |
| 129 | if !strings.Contains(card, "puts v3 in front of everyone") { |
| 130 | t.Fatalf("the stable card does not warn what stable means:\n%s", card) |
| 131 | } |
| 132 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { |
| 133 | t.Fatalf("stable moved to %s on an unconfirmed post", p.Version) |
| 134 | } |
| 135 | |
| 136 | // A member: the byte-identical 404, and not a card in sight. |
| 137 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 138 | t.Fatal(err) |
| 139 | } |
| 140 | fx.setUser("sam@keymail.dev") |
| 141 | cm := newBrowser(t) |
| 142 | signUp(t, fx, cm, "code-sam-confirm-stable") |
| 143 | mcsrf := sessionToken(t, fx, cm) |
| 144 | status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/promote"), |
| 145 | url.Values{"csrf": {mcsrf}, "channel": {"stable"}, "version": {"v3"}}) |
| 146 | if status != http.StatusNotFound { |
| 147 | t.Fatalf("member promoting to stable = %d, want 404", status) |
| 148 | } |
| 149 | notACard(t, memberBody) |
| 150 | strangerBody := strangerRefusal(t, fx, "code-zoe-confirm-stable", fx.u("/apps/kass/promote"), |
| 151 | url.Values{"channel": {"stable"}, "version": {"v3"}}) |
| 152 | if memberBody != strangerBody { |
| 153 | t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| 154 | } |
| 155 | if memberBody != "404 page not found\n" { |
| 156 | t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // TestConfirmPromoteCardIsPipelineAware: the HoldFor sweep (release |
| 161 | // pipelines v2, Task 12) — the confirm card must never tell a member |
| 162 | // something the boxes will not do. A pipelined channel's bake comes from |
| 163 | // ITS OWN window, on a name the legacy rung table has never heard of; the |
| 164 | // legacy enable_bake checkbox — which only ever flips the production flag a |
| 165 | // pipelined app's boxes ignore (spec §3) — has nothing to offer here and |
| 166 | // must not appear; and the passkey note follows the channel's OWN rule |
| 167 | // rather than the legacy "stable" name. |
| 168 | func TestConfirmPromoteCardIsPipelineAware(t *testing.T) { |
| 169 | fx, c, csrf := memberFixture(t) // paul owns, kim is a member |
| 170 | pipelinedApp(t, fx, "widget", "abc1234", |
| 171 | release.PipelineChannel{Name: "edge"}, |
| 172 | release.PipelineChannel{Name: "production", Bake: "24h", Rules: release.ChannelRules{Passkey: true}}) |
| 173 | |
| 174 | // Any member — not just the owner — reaches the card: the pipeline's own |
| 175 | // rules govern this channel, not the legacy stable-is-owner-only gate. |
| 176 | status, _, body := postForm(t, c, fx.u("/apps/widget/promote"), |
| 177 | url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}}) |
| 178 | card := confirmCard(t, status, body, "Promote abc1234 to production?", csrf) |
| 179 | if !strings.Contains(card, "waits ~24h on production") { |
| 180 | t.Fatalf("card does not carry the pipeline's OWN 24h window:\n%s", card) |
| 181 | } |
| 182 | if strings.Contains(card, `name="enable_bake"`) { |
| 183 | t.Fatalf("a pipelined channel's card offered the legacy enable_bake checkbox, which its boxes ignore:\n%s", card) |
| 184 | } |
| 185 | if !strings.Contains(card, "really you with your passkey") { |
| 186 | t.Fatalf("channel production carries a passkey rule but the card said nothing about it:\n%s", card) |
| 187 | } |
| 188 | |
| 189 | // An unruled channel of the same pipeline (edge: no bake, no rules) |
| 190 | // carries neither line — nothing to wait for, nothing to confirm. |
| 191 | status, _, body = postForm(t, c, fx.u("/apps/widget/promote"), |
| 192 | url.Values{"csrf": {csrf}, "channel": {"edge"}, "version": {"abc1234"}}) |
| 193 | card = confirmCard(t, status, body, "Promote abc1234 to edge?", csrf) |
| 194 | if strings.Contains(card, "safety delay") { |
| 195 | t.Fatalf("edge has no bake window in this pipeline, but the card mentions one:\n%s", card) |
| 196 | } |
| 197 | if strings.Contains(card, "your passkey") { |
| 198 | t.Fatalf("edge carries no passkey rule, but the card promised one:\n%s", card) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | // The rollback card names BOTH versions — a rollback card that cannot say |
| 203 | // where it is going has no business asking anyone to confirm it — and the |
| 204 | // confirmed post re-points the channel. |
| 205 | func TestConfirmRollbackCardShowsBothVersions(t *testing.T) { |
| 206 | fx := newConsoleFixture(t) |
| 207 | // stable: v1 (fixture) then v2, so the pointer has a history to go back to. |
| 208 | seedChannel(t, fx, "kass", "stable", "v2") |
| 209 | c := signInPaul(t, fx) |
| 210 | csrf := sessionToken(t, fx, c) |
| 211 | form := url.Values{"csrf": {csrf}, "channel": {"stable"}} |
| 212 | |
| 213 | status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form) |
| 214 | card := confirmCard(t, status, body, "Roll back stable to v1?", csrf) |
| 215 | if !strings.Contains(card, "stable serves v2 now.") { |
| 216 | t.Fatalf("the card does not say where the channel is now:\n%s", card) |
| 217 | } |
| 218 | if !strings.Contains(card, "Roll back to v1") { |
| 219 | t.Fatalf("the card's button does not name the target version:\n%s", card) |
| 220 | } |
| 221 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" { |
| 222 | t.Fatalf("stable = %s on an unconfirmed rollback, want it untouched at v2", p.Version) |
| 223 | } |
| 224 | |
| 225 | form.Set("confirmed", "1") |
| 226 | // stable is gated (owner + step-up, mirroring promoteGate's legacy path |
| 227 | // — Task 8/9 review), so the confirmed post walks the passkey round trip. |
| 228 | if status, _, body := postGated(t, fx, c, fx.u("/apps/kass/rollback"), form); status != http.StatusSeeOther { |
| 229 | t.Fatalf("confirmed rollback = %d:\n%s", status, body) |
| 230 | } |
| 231 | if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" { |
| 232 | t.Fatalf("stable = %s after a confirmed rollback, want v1", p.Version) |
| 233 | } |
| 234 | if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Rolled back stable to v1.") { |
| 235 | t.Fatalf("no rollback flash:\n%s", page) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // Fast-track is the whole gate order in one handler: member → 404 with no |
| 240 | // card; owner → card BEFORE any Keymail round trip; confirmed owner → the |
| 241 | // passkey, and only then the waiver. |
| 242 | func TestConfirmFastTrackCardComesBeforeStepUp(t *testing.T) { |
| 243 | fx := newConsoleFixture(t) |
| 244 | if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | fx.setUser("sam@keymail.dev") |
| 248 | cm := newBrowser(t) |
| 249 | signUp(t, fx, cm, "code-sam-confirm-ft") |
| 250 | mcsrf := sessionToken(t, fx, cm) |
| 251 | status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/fasttrack"), |
| 252 | url.Values{"csrf": {mcsrf}, "channel": {"stable"}}) |
| 253 | if status != http.StatusNotFound { |
| 254 | t.Fatalf("member fast-tracking = %d, want 404", status) |
| 255 | } |
| 256 | notACard(t, memberBody) |
| 257 | if memberBody != "404 page not found\n" { |
| 258 | t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody) |
| 259 | } |
| 260 | |
| 261 | fx.setUser("paul@keymail.dev") |
| 262 | c := signInPaul(t, fx) |
| 263 | csrf := sessionToken(t, fx, c) |
| 264 | form := url.Values{"csrf": {csrf}, "channel": {"stable"}} |
| 265 | |
| 266 | status, loc, body := postForm(t, c, fx.u("/apps/kass/fasttrack"), form) |
| 267 | if strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 268 | t.Fatalf("the owner met the passkey before the card (-> %q) — confirm asks \"do you mean it?\" first", loc) |
| 269 | } |
| 270 | card := confirmCard(t, status, body, "Skip the safety delay for v1 on stable?", csrf) |
| 271 | if !strings.Contains(card, "The safety delay is the window where a new build proves itself") { |
| 272 | t.Fatalf("the fast-track card carries no warning:\n%s", card) |
| 273 | } |
| 274 | if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| 275 | t.Fatal("the window was waived by merely asking") |
| 276 | } |
| 277 | |
| 278 | // The confirmed replay is where the passkey is demanded — and where it lands. |
| 279 | form.Set("confirmed", "1") |
| 280 | status, loc, _ = postForm(t, c, fx.u("/apps/kass/fasttrack"), form) |
| 281 | if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| 282 | t.Fatalf("confirmed fast-track = %d -> %q, want a redirect into Keymail", status, loc) |
| 283 | } |
| 284 | if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| 285 | t.Fatal("the window was waived with no passkey confirmation") |
| 286 | } |
| 287 | if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| 288 | t.Fatalf("resumed fast-track = %d:\n%s", status, body) |
| 289 | } |
| 290 | if p := fx.channelVersion(t, "kass", "stable"); !p.FastTrack { |
| 291 | t.Fatalf("pointer = %+v after the confirmed, stepped-up waiver, want fast_track set", p) |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // The retire card states exactly the two facts the handler established before |
| 296 | // asking — the canary exists, and nothing follows it — so neither a canary |
| 297 | // with a host on it nor a name that was never a canary gets one. The confirmed |
| 298 | // post deletes the pointer. |
| 299 | func TestConfirmRetireCanaryCardAndDeletion(t *testing.T) { |
| 300 | fx := newConsoleFixture(t) |
| 301 | c := signInPaul(t, fx) |
| 302 | csrf := sessionToken(t, fx, c) |
| 303 | |
| 304 | // No such canary: a refusal, never a card describing a deletion that |
| 305 | // could not happen. |
| 306 | status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), |
| 307 | url.Values{"csrf": {csrf}, "name": {"nope"}}) |
| 308 | if status != http.StatusSeeOther { |
| 309 | t.Fatalf("retire of a missing canary = %d, want the flash redirect:\n%s", status, body) |
| 310 | } |
| 311 | notACard(t, body) |
| 312 | |
| 313 | // The fixture's canary/x exists and nothing follows it: the card. |
| 314 | form := url.Values{"csrf": {csrf}, "name": {"x"}} |
| 315 | status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"), form) |
| 316 | card := confirmCard(t, status, body, "Retire canary x?", csrf) |
| 317 | for _, want := range []string{ |
| 318 | "Deletes channel canary/x", |
| 319 | "No host follows it", |
| 320 | `<input type="hidden" name="name" value="x">`, |
| 321 | } { |
| 322 | if !strings.Contains(card, want) { |
| 323 | t.Fatalf("the retire card is missing %q:\n%s", want, card) |
| 324 | } |
| 325 | } |
| 326 | if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" { |
| 327 | t.Fatalf("canary/x = %+v after an unconfirmed retire, want it untouched", p) |
| 328 | } |
| 329 | |
| 330 | form.Set("confirmed", "1") |
| 331 | if status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), form); status != http.StatusSeeOther { |
| 332 | t.Fatalf("confirmed retire = %d:\n%s", status, body) |
| 333 | } |
| 334 | if _, err := release.Channel(context.Background(), fx.rel, fx.pub, |
| 335 | release.OpsAccount, "kass", "canary/x"); !errors.Is(err, os.ErrNotExist) { |
| 336 | t.Fatalf("canary/x = %v after a confirmed retire, want it gone", err) |
| 337 | } |
| 338 | |
| 339 | // A canary with a follower never reaches the card at all — the refusal |
| 340 | // fires first, and a card here would be describing a deletion the handler |
| 341 | // has already decided against. |
| 342 | seedChannel(t, fx, "kass", "canary/y", "v2") |
| 343 | fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready", |
| 344 | Version: "v2", App: "kass", Channel: "canary/y", Addr: "127.0.0.1:9021"}) |
| 345 | status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"), |
| 346 | url.Values{"csrf": {csrf}, "name": {"y"}}) |
| 347 | if status != http.StatusSeeOther { |
| 348 | t.Fatalf("retire of a followed canary = %d, want the flash redirect:\n%s", status, body) |
| 349 | } |
| 350 | notACard(t, body) |
| 351 | if p := fx.channelVersion(t, "kass", "canary/y"); p.Version != "v2" { |
| 352 | t.Fatalf("canary/y = %+v, want the followed canary untouched", p) |
| 353 | } |
| 354 | if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, |
| 355 | "still has a host (rc.kass.carlosapp.dev) following it") { |
| 356 | t.Fatalf("no follower refusal flash:\n%s", page) |
| 357 | } |
| 358 | } |
| 359 | |