package console import ( "context" "encoding/json" "net/http" "strings" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) func movePipe(chans ...release.PipelineChannel) *release.Pipeline { return &release.Pipeline{Schema: 1, Channels: chans} } // The bypass the union exists to close, and the reason guarding only the // DESTINATION is not enough: the guarded channel is the one being LEFT, and // the destination carries no rules at all. func TestMoveGateTakesRulesFromTheChannelBeingLeft(t *testing.T) { p := movePipe( release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}, ) passkey, approvals := moveGate(p, "production", "edge") if approvals != 2 { t.Fatalf("approvals = %d, want 2 — moving OFF a guarded channel serves its URL unreviewed code", approvals) } if passkey { t.Fatalf("passkey = true, want false — neither channel asks for one") } } // And the other half: guarding only the source would let a route be walked // ONTO a channel whose policy the mover never met. func TestMoveGateTakesRulesFromTheChannelBeingJoined(t *testing.T) { p := movePipe( release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}, ) passkey, approvals := moveGate(p, "edge", "production") if !passkey || approvals != 0 { t.Fatalf("passkey=%v approvals=%d, want true/0", passkey, approvals) } } // Each rule at its strictest, across both ends and both approval fields. func TestMoveGateTakesTheMaximumAcrossBothEndsAndBothRules(t *testing.T) { p := movePipe( release.PipelineChannel{Name: "a", Rules: release.ChannelRules{ChangeApprovals: 3}}, release.PipelineChannel{Name: "b", Rules: release.ChannelRules{PromoteApprovals: 1}}, ) if _, approvals := moveGate(p, "a", "b"); approvals != 3 { t.Fatalf("approvals = %d, want 3", approvals) } if _, approvals := moveGate(p, "b", "a"); approvals != 3 { t.Fatalf("approvals = %d (reversed), want 3 — the gate is symmetric", approvals) } } // A legacy app has no pipeline: the stable RUNG keeps the console's standing // unconditional passkey, in BOTH directions, and nothing else is gated. func TestMoveGateLegacyStableNeedsPasskeyBothDirections(t *testing.T) { if passkey, _ := moveGate(nil, "edge", "stable"); !passkey { t.Fatal("moving ONTO stable must need a passkey") } if passkey, _ := moveGate(nil, "stable", "edge"); !passkey { t.Fatal("moving OFF stable must need a passkey") } if passkey, approvals := moveGate(nil, "edge", "beta"); passkey || approvals != 0 { t.Fatalf("edge→beta on a legacy app must be free, got %v/%d", passkey, approvals) } } // canary/* is reserved platform namespace, outside every pipeline: always // allowed, never gated, even when the app's real channels are strict. func TestMoveGateCanaryIsUngated(t *testing.T) { p := movePipe(release.PipelineChannel{Name: "edge", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2}}) if passkey, approvals := moveGate(p, "canary/x", "canary/y"); passkey || approvals != 0 { t.Fatalf("canary→canary must be ungated, got %v/%d", passkey, approvals) } // But a canary END does not launder the OTHER end's rules. if passkey, approvals := moveGate(p, "canary/x", "edge"); !passkey || approvals != 2 { t.Fatalf("canary→edge must still meet edge's rules, got %v/%d", passkey, approvals) } } // A pipelined app whose channel names sit outside the legacy vocabulary must // not pick up legacy rungs by accident: "production" is not "stable". func TestMoveGatePipelinedNameIsNotALegacyRung(t *testing.T) { p := movePipe( release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "stable"}, // named like the rung, no rules declared ) if passkey, approvals := moveGate(p, "edge", "stable"); passkey || approvals != 0 { t.Fatalf("a pipelined app governs its OWN channels: got %v/%d, want free", passkey, approvals) } } // A route sitting on a channel the app's pipeline does NOT name — a legacy // route on stable, on an app that has since declared a pipeline without it — // keeps the legacy rung's protection. Anything else would make "declare a // pipeline that omits stable" a way to move production routes for free. func TestMoveGateChannelOutsideThePipelineKeepsItsLegacyRung(t *testing.T) { p := movePipe(release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production"}) if passkey, _ := moveGate(p, "stable", "edge"); !passkey { t.Fatal("a route on stable, outside the pipeline, must still need a passkey to move off it") } } // channelURL is the move door for one host. func channelURL(fx *consoleFixture, acct, app, host string) string { return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances/" + host + "/channel" } // recordOn puts one instance record in the bucket, on the named channel — // fixture state standing in for whatever created the route. func recordOn(t *testing.T, fx *consoleFixture, app, host, channel string) { t.Helper() rec := provision.Record{ Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: app, Account: release.OpsAccount, Channel: channel, CreatedAt: time.Now().UTC(), } if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { t.Fatal(err) } } func recordChannel(t *testing.T, fx *consoleFixture, app, host string) string { t.Helper() rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host) if err != nil { t.Fatal(err) } return rec.Channel } // A legacy app, neither end on stable: the move just lands, and the fleet is // told once. func TestAPIInstanceChannelLegacyMoveLands(t *testing.T) { fx, member := instanceFixture(t, "code-chan-move") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "edge") before := serial(t, fx) status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"beta"}`) if status != http.StatusOK { t.Fatalf("move = %d %s, want 200", status, out) } if got := recordChannel(t, fx, "titogo", host); got != "beta" { t.Fatalf("record channel = %q, want beta", got) } if got := serial(t, fx); got != before+1 { t.Fatalf("serial = %d, want exactly one bump from %d", got, before) } } // Either end on the stable rung is the console's standing passkey refusal, // and it names where to finish. func TestAPIInstanceChannelLegacyStableNeedsPasskey(t *testing.T) { fx, member := instanceFixture(t, "code-chan-stable") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "edge") status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"stable"}`) if status != http.StatusForbidden { t.Fatalf("move onto stable = %d %s, want 403", status, out) } if !strings.Contains(out, "console_url") { t.Fatalf("403 body = %s, want it to name where to finish", out) } if got := recordChannel(t, fx, "titogo", host); got != "edge" { t.Fatalf("record moved anyway: %q", got) } // And OFF stable, which is the direction a naive gate forgets. recordOn(t, fx, "titogo", "other.go.tito.io", "stable") if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "other.go.tito.io"), member, `{"channel":"edge"}`); status != http.StatusForbidden { t.Fatalf("move OFF stable = %d %s, want 403", status, out) } } // The bypass, at the door: moving OFF a guarded channel onto an unguarded // one must meet the guarded channel's quorum, and must not write yet. func TestAPIInstanceChannelOffGuardedChannelProposes(t *testing.T) { fx, member := instanceFixture(t, "code-chan-guarded") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "production") setPipeline(t, fx, "titogo", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`) if status != http.StatusAccepted { t.Fatalf("move off a guarded channel = %d %s, want 202 (proposed)", status, out) } if got := recordChannel(t, fx, "titogo", host); got != "production" { t.Fatalf("record channel = %q — a proposal must not have written", got) } if !strings.Contains(out, `"needed":2`) || !strings.Contains(out, `"result":"proposed"`) { t.Fatalf("proposal body = %s, want needed:2 and result:proposed", out) } } // A pipelined app may not reach a channel its pipeline left out, and the // refusal lists what it does have. func TestAPIInstanceChannelUnknownChannelNamesTheApps(t *testing.T) { fx, member := instanceFixture(t, "code-chan-unknown") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "edge") setPipeline(t, fx, "titogo", release.PipelineChannel{Name: "edge"}) status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"production"}`) if status != http.StatusConflict { t.Fatalf("unknown channel = %d %s, want 409", status, out) } if !strings.Contains(out, "edge") { t.Fatalf("refusal = %s, want it to list the app's channels", out) } } // A no-op must not send the whole fleet converging for nothing. func TestAPIInstanceChannelNoOpDoesNotBumpTheSerial(t *testing.T) { fx, member := instanceFixture(t, "code-chan-noop") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "edge") before := serial(t, fx) if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`); status != http.StatusOK { t.Fatalf("no-op move = %d %s, want 200", status, out) } if got := serial(t, fx); got != before { t.Fatalf("serial = %d, want it unchanged at %d", got, before) } } // A host with no record is the family's 404, not an existence oracle. func TestAPIInstanceChannelUnknownHostIs404(t *testing.T) { fx, member := instanceFixture(t, "code-chan-404") if status, _, _ := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "nope.go.tito.io"), member, `{"channel":"edge"}`); status != http.StatusNotFound { t.Fatalf("unknown host = %d, want 404", status) } } // An app token is a credential the app itself holds: it must not be able to // walk its own route onto another channel. func TestAPIInstanceChannelRefusesAppToken(t *testing.T) { fx, _ := instanceFixture(t, "code-chan-apptok") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "edge") appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) if err != nil { t.Fatal(err) } status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), appTok, `{"channel":"beta"}`) if status != http.StatusForbidden { t.Fatalf("app token = %d %s, want 403 from the role gate", status, out) } if !strings.Contains(out, "needs a person") { t.Fatalf("app-token refusal = %s, want the role gate's own sentence", out) } if got := recordChannel(t, fx, "titogo", host); got != "edge" { t.Fatalf("record channel = %q, want edge", got) } } // The pin: a confirmed move writes, and a record that moved underneath the // proposal refuses instead. func TestExecutePendingRouteChannelWritesAndPins(t *testing.T) { fx, member := instanceFixture(t, "code-chan-exec") const host = "web.go.tito.io" recordOn(t, fx, "titogo", host, "production") setPipeline(t, fx, "titogo", release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}}) status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`) if status != http.StatusAccepted { t.Fatalf("propose = %d %s, want 202", status, out) } var body map[string]any if err := json.Unmarshal([]byte(out), &body); err != nil { t.Fatal(err) } id, _ := body["pending_id"].(string) pc, err := fx.st.PendingChange(id, time.Now()) if err != nil { t.Fatal(err) } acct, err := fx.srv.store.AccountBySqid(release.OpsAccount) if err != nil { t.Fatal(err) } // The summary a confirmer reads names BOTH ends — what the URL serves // now matters as much as what it would serve after. if got := pendingSummary(*pc); got != "move "+host+" from production to edge" { t.Fatalf("summary = %q", got) } // Stale first: the record moves underneath the proposal. rec, etag, err := provision.GetRecordETag(context.Background(), fx.rel, release.OpsAccount, "titogo", host) if err != nil { t.Fatal(err) } rec.Channel = "beta" if err := provision.UpdateRecord(context.Background(), fx.rel, *rec, etag); err != nil { t.Fatal(err) } if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc); err == nil || !strings.Contains(err.Error(), "re-propose") { t.Fatalf("stale execute err = %v, want the stale-proposal refusal", err) } if got := recordChannel(t, fx, "titogo", host); got != "beta" { t.Fatalf("a refused execution wrote anyway: %q", got) } // Now a proposal whose pin still holds. recordOn(t, fx, "titogo", "two.go.tito.io", "production") status, _, out = rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "two.go.tito.io"), member, `{"channel":"edge"}`) if status != http.StatusAccepted { t.Fatalf("second propose = %d %s", status, out) } if err := json.Unmarshal([]byte(out), &body); err != nil { t.Fatal(err) } id, _ = body["pending_id"].(string) pc2, err := fx.st.PendingChange(id, time.Now()) if err != nil { t.Fatal(err) } if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc2); err != nil { t.Fatalf("execute: %v", err) } if got := recordChannel(t, fx, "titogo", "two.go.tito.io"); got != "edge" { t.Fatalf("record channel = %q, want edge", got) } }