package console
// The action confirm interstitial (confirm.go). The properties under test are
// the ones the file's own doc comment calls load-bearing: the card describes a
// request that would otherwise proceed, it never renders for someone the owner
// 404 is meant to turn away, and it sits strictly BETWEEN that 404 and the
// passkey — so an owner sees the card first and is asked for the passkey only
// on the confirmed replay.
//
// Every card test also lands the confirmed POST. A card that renders but whose
// resubmit does not carry the action through is a dead end, and asserting only
// on the markup would not notice.
import (
"context"
"errors"
"net/http"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
// confirmCard asserts the response IS a confirm card — 200, the titled
// heading, the confirmed=1 marker the resubmit turns on, the CSRF token
// carried through, and a submit button — and hands the body back for the
// card-specific assertions. Everything here is what makes the card a working
// resubmit rather than a screenshot of one.
func confirmCard(t *testing.T, status int, body, title, csrf string) string {
t.Helper()
if status != http.StatusOK {
t.Fatalf("confirm step = %d, want a 200 card:\n%s", status, body)
}
if !strings.Contains(body, title) {
t.Fatalf("card is not titled %q:\n%s", title, body)
}
if !strings.Contains(body, ` `) {
t.Fatalf("card carries no confirmed=1 marker, so its button cannot land the action:\n%s", body)
}
if !strings.Contains(body, ` `) {
t.Fatalf("card carries no csrf field, so its resubmit would be refused:\n%s", body)
}
if !strings.Contains(body, ``,
` `,
} {
if !strings.Contains(card, want) {
t.Fatalf("card does not carry %s:\n%s", want, card)
}
}
if !strings.Contains(card, "Promote v3") {
t.Fatalf("the card's button does not name the promotion:\n%s", card)
}
// A card is a question, not a deferral: beta does not exist yet.
if _, err := release.Channel(context.Background(), fx.rel, fx.pub,
release.OpsAccount, "kass", "beta"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("beta = %v after an UNconfirmed promote, want it still absent", err)
}
form.Set("confirmed", "1")
status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), form)
if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("confirmed promote = %d -> %q, want 303 to the app page:\n%s", status, loc, body)
}
if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
t.Fatalf("beta = %s after a confirmed promote, want v3", p.Version)
}
if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Promoted v3 to beta.") {
t.Fatalf("no promote flash:\n%s", page)
}
}
// The stable-target card is the one with a real choice on it: bake is off for
// this app, so the card offers to turn it on. And the card is strictly for
// owners — a member probing the stable rung meets the byte-identical 404 with
// no card in it, because a card would concede the action exists for somebody.
func TestConfirmStablePromoteOffersBakeAndHidesFromMembers(t *testing.T) {
fx := newConsoleFixture(t)
shipVersion(t, fx, "kass", "v3")
seedChannel(t, fx, "kass", "beta", "v3") // stable is entered from beta
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"),
url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
// The card comes BEFORE the passkey: an owner is asked "do you mean it?"
// first, and only the confirmed replay meets requireFreshAuth.
if strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("the owner was sent to Keymail before the card was shown (-> %q)", loc)
}
card := confirmCard(t, status, body, "Promote v3 to stable?", csrf)
if !strings.Contains(card, `name="enable_bake"`) {
t.Fatalf("the stable card carries no enable_bake choice for a bake-off app:\n%s", card)
}
if !strings.Contains(card, "puts v3 in front of everyone") {
t.Fatalf("the stable card does not warn what stable means:\n%s", card)
}
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
t.Fatalf("stable moved to %s on an unconfirmed post", p.Version)
}
// A member: the byte-identical 404, and not a card in sight.
if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
t.Fatal(err)
}
fx.setUser("sam@keymail.dev")
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-confirm-stable")
mcsrf := sessionToken(t, fx, cm)
status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/promote"),
url.Values{"csrf": {mcsrf}, "channel": {"stable"}, "version": {"v3"}})
if status != http.StatusNotFound {
t.Fatalf("member promoting to stable = %d, want 404", status)
}
notACard(t, memberBody)
strangerBody := strangerRefusal(t, fx, "code-zoe-confirm-stable", fx.u("/apps/kass/promote"),
url.Values{"channel": {"stable"}, "version": {"v3"}})
if memberBody != strangerBody {
t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
}
if memberBody != "404 page not found\n" {
t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody)
}
}
// TestConfirmPromoteCardIsPipelineAware: the HoldFor sweep (release
// pipelines v2, Task 12) — the confirm card must never tell a member
// something the boxes will not do. A pipelined channel's bake comes from
// ITS OWN window, on a name the legacy rung table has never heard of; the
// legacy enable_bake checkbox — which only ever flips the production flag a
// pipelined app's boxes ignore (spec §3) — has nothing to offer here and
// must not appear; and the passkey note follows the channel's OWN rule
// rather than the legacy "stable" name.
func TestConfirmPromoteCardIsPipelineAware(t *testing.T) {
fx, c, csrf := memberFixture(t) // paul owns, kim is a member
pipelinedApp(t, fx, "widget", "abc1234",
release.PipelineChannel{Name: "edge"},
release.PipelineChannel{Name: "production", Bake: "24h", Rules: release.ChannelRules{Passkey: true}})
// Any member — not just the owner — reaches the card: the pipeline's own
// rules govern this channel, not the legacy stable-is-owner-only gate.
status, _, body := postForm(t, c, fx.u("/apps/widget/promote"),
url.Values{"csrf": {csrf}, "channel": {"production"}, "version": {"abc1234"}})
card := confirmCard(t, status, body, "Promote abc1234 to production?", csrf)
if !strings.Contains(card, "waits ~24h on production") {
t.Fatalf("card does not carry the pipeline's OWN 24h window:\n%s", card)
}
if strings.Contains(card, `name="enable_bake"`) {
t.Fatalf("a pipelined channel's card offered the legacy enable_bake checkbox, which its boxes ignore:\n%s", card)
}
if !strings.Contains(card, "really you with your passkey") {
t.Fatalf("channel production carries a passkey rule but the card said nothing about it:\n%s", card)
}
// An unruled channel of the same pipeline (edge: no bake, no rules)
// carries neither line — nothing to wait for, nothing to confirm.
status, _, body = postForm(t, c, fx.u("/apps/widget/promote"),
url.Values{"csrf": {csrf}, "channel": {"edge"}, "version": {"abc1234"}})
card = confirmCard(t, status, body, "Promote abc1234 to edge?", csrf)
if strings.Contains(card, "safety delay") {
t.Fatalf("edge has no bake window in this pipeline, but the card mentions one:\n%s", card)
}
if strings.Contains(card, "your passkey") {
t.Fatalf("edge carries no passkey rule, but the card promised one:\n%s", card)
}
}
// The rollback card names BOTH versions — a rollback card that cannot say
// where it is going has no business asking anyone to confirm it — and the
// confirmed post re-points the channel.
func TestConfirmRollbackCardShowsBothVersions(t *testing.T) {
fx := newConsoleFixture(t)
// stable: v1 (fixture) then v2, so the pointer has a history to go back to.
seedChannel(t, fx, "kass", "stable", "v2")
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
form := url.Values{"csrf": {csrf}, "channel": {"stable"}}
status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form)
card := confirmCard(t, status, body, "Roll back stable to v1?", csrf)
if !strings.Contains(card, "stable serves v2 now.") {
t.Fatalf("the card does not say where the channel is now:\n%s", card)
}
if !strings.Contains(card, "Roll back to v1") {
t.Fatalf("the card's button does not name the target version:\n%s", card)
}
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
t.Fatalf("stable = %s on an unconfirmed rollback, want it untouched at v2", p.Version)
}
form.Set("confirmed", "1")
// stable is gated (owner + step-up, mirroring promoteGate's legacy path
// — Task 8/9 review), so the confirmed post walks the passkey round trip.
if status, _, body := postGated(t, fx, c, fx.u("/apps/kass/rollback"), form); status != http.StatusSeeOther {
t.Fatalf("confirmed rollback = %d:\n%s", status, body)
}
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
t.Fatalf("stable = %s after a confirmed rollback, want v1", p.Version)
}
if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Rolled back stable to v1.") {
t.Fatalf("no rollback flash:\n%s", page)
}
}
// Fast-track is the whole gate order in one handler: member → 404 with no
// card; owner → card BEFORE any Keymail round trip; confirmed owner → the
// passkey, and only then the waiver.
func TestConfirmFastTrackCardComesBeforeStepUp(t *testing.T) {
fx := newConsoleFixture(t)
if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
t.Fatal(err)
}
fx.setUser("sam@keymail.dev")
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-confirm-ft")
mcsrf := sessionToken(t, fx, cm)
status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/fasttrack"),
url.Values{"csrf": {mcsrf}, "channel": {"stable"}})
if status != http.StatusNotFound {
t.Fatalf("member fast-tracking = %d, want 404", status)
}
notACard(t, memberBody)
if memberBody != "404 page not found\n" {
t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody)
}
fx.setUser("paul@keymail.dev")
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
form := url.Values{"csrf": {csrf}, "channel": {"stable"}}
status, loc, body := postForm(t, c, fx.u("/apps/kass/fasttrack"), form)
if strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("the owner met the passkey before the card (-> %q) — confirm asks \"do you mean it?\" first", loc)
}
card := confirmCard(t, status, body, "Skip the safety delay for v1 on stable?", csrf)
if !strings.Contains(card, "The safety delay is the window where a new build proves itself") {
t.Fatalf("the fast-track card carries no warning:\n%s", card)
}
if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
t.Fatal("the window was waived by merely asking")
}
// The confirmed replay is where the passkey is demanded — and where it lands.
form.Set("confirmed", "1")
status, loc, _ = postForm(t, c, fx.u("/apps/kass/fasttrack"), form)
if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("confirmed fast-track = %d -> %q, want a redirect into Keymail", status, loc)
}
if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
t.Fatal("the window was waived with no passkey confirmation")
}
if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther {
t.Fatalf("resumed fast-track = %d:\n%s", status, body)
}
if p := fx.channelVersion(t, "kass", "stable"); !p.FastTrack {
t.Fatalf("pointer = %+v after the confirmed, stepped-up waiver, want fast_track set", p)
}
}
// The retire card states exactly the two facts the handler established before
// asking — the canary exists, and nothing follows it — so neither a canary
// with a host on it nor a name that was never a canary gets one. The confirmed
// post deletes the pointer.
func TestConfirmRetireCanaryCardAndDeletion(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
// No such canary: a refusal, never a card describing a deletion that
// could not happen.
status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {csrf}, "name": {"nope"}})
if status != http.StatusSeeOther {
t.Fatalf("retire of a missing canary = %d, want the flash redirect:\n%s", status, body)
}
notACard(t, body)
// The fixture's canary/x exists and nothing follows it: the card.
form := url.Values{"csrf": {csrf}, "name": {"x"}}
status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"), form)
card := confirmCard(t, status, body, "Retire canary x?", csrf)
for _, want := range []string{
"Deletes channel canary/x",
"No host follows it",
` `,
} {
if !strings.Contains(card, want) {
t.Fatalf("the retire card is missing %q:\n%s", want, card)
}
}
if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
t.Fatalf("canary/x = %+v after an unconfirmed retire, want it untouched", p)
}
form.Set("confirmed", "1")
if status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), form); status != http.StatusSeeOther {
t.Fatalf("confirmed retire = %d:\n%s", status, body)
}
if _, err := release.Channel(context.Background(), fx.rel, fx.pub,
release.OpsAccount, "kass", "canary/x"); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("canary/x = %v after a confirmed retire, want it gone", err)
}
// A canary with a follower never reaches the card at all — the refusal
// fires first, and a card here would be describing a deletion the handler
// has already decided against.
seedChannel(t, fx, "kass", "canary/y", "v2")
fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
Version: "v2", App: "kass", Channel: "canary/y", Addr: "127.0.0.1:9021"})
status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {csrf}, "name": {"y"}})
if status != http.StatusSeeOther {
t.Fatalf("retire of a followed canary = %d, want the flash redirect:\n%s", status, body)
}
notACard(t, body)
if p := fx.channelVersion(t, "kass", "canary/y"); p.Version != "v2" {
t.Fatalf("canary/y = %+v, want the followed canary untouched", p)
}
if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page,
"still has a host (rc.kass.carlosapp.dev) following it") {
t.Fatalf("no follower refusal flash:\n%s", page)
}
}