| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | "time" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | func movePipe(chans ...release.PipelineChannel) *release.Pipeline { |
| 16 | return &release.Pipeline{Schema: 1, Channels: chans} |
| 17 | } |
| 18 | |
| 19 | // The bypass the union exists to close, and the reason guarding only the |
| 20 | // DESTINATION is not enough: the guarded channel is the one being LEFT, and |
| 21 | // the destination carries no rules at all. |
| 22 | func TestMoveGateTakesRulesFromTheChannelBeingLeft(t *testing.T) { |
| 23 | p := movePipe( |
| 24 | release.PipelineChannel{Name: "edge"}, |
| 25 | release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}, |
| 26 | ) |
| 27 | passkey, approvals := moveGate(p, "production", "edge") |
| 28 | if approvals != 2 { |
| 29 | t.Fatalf("approvals = %d, want 2 — moving OFF a guarded channel serves its URL unreviewed code", approvals) |
| 30 | } |
| 31 | if passkey { |
| 32 | t.Fatalf("passkey = true, want false — neither channel asks for one") |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // And the other half: guarding only the source would let a route be walked |
| 37 | // ONTO a channel whose policy the mover never met. |
| 38 | func TestMoveGateTakesRulesFromTheChannelBeingJoined(t *testing.T) { |
| 39 | p := movePipe( |
| 40 | release.PipelineChannel{Name: "edge"}, |
| 41 | release.PipelineChannel{Name: "production", Rules: release.ChannelRules{Passkey: true}}, |
| 42 | ) |
| 43 | passkey, approvals := moveGate(p, "edge", "production") |
| 44 | if !passkey || approvals != 0 { |
| 45 | t.Fatalf("passkey=%v approvals=%d, want true/0", passkey, approvals) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | // Each rule at its strictest, across both ends and both approval fields. |
| 50 | func TestMoveGateTakesTheMaximumAcrossBothEndsAndBothRules(t *testing.T) { |
| 51 | p := movePipe( |
| 52 | release.PipelineChannel{Name: "a", Rules: release.ChannelRules{ChangeApprovals: 3}}, |
| 53 | release.PipelineChannel{Name: "b", Rules: release.ChannelRules{PromoteApprovals: 1}}, |
| 54 | ) |
| 55 | if _, approvals := moveGate(p, "a", "b"); approvals != 3 { |
| 56 | t.Fatalf("approvals = %d, want 3", approvals) |
| 57 | } |
| 58 | if _, approvals := moveGate(p, "b", "a"); approvals != 3 { |
| 59 | t.Fatalf("approvals = %d (reversed), want 3 — the gate is symmetric", approvals) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | // A legacy app has no pipeline: the stable RUNG keeps the console's standing |
| 64 | // unconditional passkey, in BOTH directions, and nothing else is gated. |
| 65 | func TestMoveGateLegacyStableNeedsPasskeyBothDirections(t *testing.T) { |
| 66 | if passkey, _ := moveGate(nil, "edge", "stable"); !passkey { |
| 67 | t.Fatal("moving ONTO stable must need a passkey") |
| 68 | } |
| 69 | if passkey, _ := moveGate(nil, "stable", "edge"); !passkey { |
| 70 | t.Fatal("moving OFF stable must need a passkey") |
| 71 | } |
| 72 | if passkey, approvals := moveGate(nil, "edge", "beta"); passkey || approvals != 0 { |
| 73 | t.Fatalf("edge→beta on a legacy app must be free, got %v/%d", passkey, approvals) |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | // canary/* is reserved platform namespace, outside every pipeline: always |
| 78 | // allowed, never gated, even when the app's real channels are strict. |
| 79 | func TestMoveGateCanaryIsUngated(t *testing.T) { |
| 80 | p := movePipe(release.PipelineChannel{Name: "edge", Rules: release.ChannelRules{Passkey: true, PromoteApprovals: 2}}) |
| 81 | if passkey, approvals := moveGate(p, "canary/x", "canary/y"); passkey || approvals != 0 { |
| 82 | t.Fatalf("canary→canary must be ungated, got %v/%d", passkey, approvals) |
| 83 | } |
| 84 | // But a canary END does not launder the OTHER end's rules. |
| 85 | if passkey, approvals := moveGate(p, "canary/x", "edge"); !passkey || approvals != 2 { |
| 86 | t.Fatalf("canary→edge must still meet edge's rules, got %v/%d", passkey, approvals) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // A pipelined app whose channel names sit outside the legacy vocabulary must |
| 91 | // not pick up legacy rungs by accident: "production" is not "stable". |
| 92 | func TestMoveGatePipelinedNameIsNotALegacyRung(t *testing.T) { |
| 93 | p := movePipe( |
| 94 | release.PipelineChannel{Name: "edge"}, |
| 95 | release.PipelineChannel{Name: "stable"}, // named like the rung, no rules declared |
| 96 | ) |
| 97 | if passkey, approvals := moveGate(p, "edge", "stable"); passkey || approvals != 0 { |
| 98 | t.Fatalf("a pipelined app governs its OWN channels: got %v/%d, want free", passkey, approvals) |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | // A route sitting on a channel the app's pipeline does NOT name — a legacy |
| 103 | // route on stable, on an app that has since declared a pipeline without it — |
| 104 | // keeps the legacy rung's protection. Anything else would make "declare a |
| 105 | // pipeline that omits stable" a way to move production routes for free. |
| 106 | func TestMoveGateChannelOutsideThePipelineKeepsItsLegacyRung(t *testing.T) { |
| 107 | p := movePipe(release.PipelineChannel{Name: "edge"}, release.PipelineChannel{Name: "production"}) |
| 108 | if passkey, _ := moveGate(p, "stable", "edge"); !passkey { |
| 109 | t.Fatal("a route on stable, outside the pipeline, must still need a passkey to move off it") |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // channelURL is the move door for one host. |
| 114 | func channelURL(fx *consoleFixture, acct, app, host string) string { |
| 115 | return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/instances/" + host + "/channel" |
| 116 | } |
| 117 | |
| 118 | // recordOn puts one instance record in the bucket, on the named channel — |
| 119 | // fixture state standing in for whatever created the route. |
| 120 | func recordOn(t *testing.T, fx *consoleFixture, app, host, channel string) { |
| 121 | t.Helper() |
| 122 | rec := provision.Record{ |
| 123 | Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: app, |
| 124 | Account: release.OpsAccount, Channel: channel, CreatedAt: time.Now().UTC(), |
| 125 | } |
| 126 | if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func recordChannel(t *testing.T, fx *consoleFixture, app, host string) string { |
| 132 | t.Helper() |
| 133 | rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host) |
| 134 | if err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | return rec.Channel |
| 138 | } |
| 139 | |
| 140 | // A legacy app, neither end on stable: the move just lands, and the fleet is |
| 141 | // told once. |
| 142 | func TestAPIInstanceChannelLegacyMoveLands(t *testing.T) { |
| 143 | fx, member := instanceFixture(t, "code-chan-move") |
| 144 | const host = "web.go.tito.io" |
| 145 | recordOn(t, fx, "titogo", host, "edge") |
| 146 | before := serial(t, fx) |
| 147 | |
| 148 | status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"beta"}`) |
| 149 | if status != http.StatusOK { |
| 150 | t.Fatalf("move = %d %s, want 200", status, out) |
| 151 | } |
| 152 | if got := recordChannel(t, fx, "titogo", host); got != "beta" { |
| 153 | t.Fatalf("record channel = %q, want beta", got) |
| 154 | } |
| 155 | if got := serial(t, fx); got != before+1 { |
| 156 | t.Fatalf("serial = %d, want exactly one bump from %d", got, before) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // Either end on the stable rung is the console's standing passkey refusal, |
| 161 | // and it names where to finish. |
| 162 | func TestAPIInstanceChannelLegacyStableNeedsPasskey(t *testing.T) { |
| 163 | fx, member := instanceFixture(t, "code-chan-stable") |
| 164 | const host = "web.go.tito.io" |
| 165 | recordOn(t, fx, "titogo", host, "edge") |
| 166 | |
| 167 | status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"stable"}`) |
| 168 | if status != http.StatusForbidden { |
| 169 | t.Fatalf("move onto stable = %d %s, want 403", status, out) |
| 170 | } |
| 171 | if !strings.Contains(out, "console_url") { |
| 172 | t.Fatalf("403 body = %s, want it to name where to finish", out) |
| 173 | } |
| 174 | if got := recordChannel(t, fx, "titogo", host); got != "edge" { |
| 175 | t.Fatalf("record moved anyway: %q", got) |
| 176 | } |
| 177 | |
| 178 | // And OFF stable, which is the direction a naive gate forgets. |
| 179 | recordOn(t, fx, "titogo", "other.go.tito.io", "stable") |
| 180 | if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "other.go.tito.io"), |
| 181 | member, `{"channel":"edge"}`); status != http.StatusForbidden { |
| 182 | t.Fatalf("move OFF stable = %d %s, want 403", status, out) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | // The bypass, at the door: moving OFF a guarded channel onto an unguarded |
| 187 | // one must meet the guarded channel's quorum, and must not write yet. |
| 188 | func TestAPIInstanceChannelOffGuardedChannelProposes(t *testing.T) { |
| 189 | fx, member := instanceFixture(t, "code-chan-guarded") |
| 190 | const host = "web.go.tito.io" |
| 191 | recordOn(t, fx, "titogo", host, "production") |
| 192 | setPipeline(t, fx, "titogo", |
| 193 | release.PipelineChannel{Name: "edge"}, |
| 194 | release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 2}}) |
| 195 | |
| 196 | status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`) |
| 197 | if status != http.StatusAccepted { |
| 198 | t.Fatalf("move off a guarded channel = %d %s, want 202 (proposed)", status, out) |
| 199 | } |
| 200 | if got := recordChannel(t, fx, "titogo", host); got != "production" { |
| 201 | t.Fatalf("record channel = %q — a proposal must not have written", got) |
| 202 | } |
| 203 | if !strings.Contains(out, `"needed":2`) || !strings.Contains(out, `"result":"proposed"`) { |
| 204 | t.Fatalf("proposal body = %s, want needed:2 and result:proposed", out) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // A pipelined app may not reach a channel its pipeline left out, and the |
| 209 | // refusal lists what it does have. |
| 210 | func TestAPIInstanceChannelUnknownChannelNamesTheApps(t *testing.T) { |
| 211 | fx, member := instanceFixture(t, "code-chan-unknown") |
| 212 | const host = "web.go.tito.io" |
| 213 | recordOn(t, fx, "titogo", host, "edge") |
| 214 | setPipeline(t, fx, "titogo", release.PipelineChannel{Name: "edge"}) |
| 215 | |
| 216 | status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"production"}`) |
| 217 | if status != http.StatusConflict { |
| 218 | t.Fatalf("unknown channel = %d %s, want 409", status, out) |
| 219 | } |
| 220 | if !strings.Contains(out, "edge") { |
| 221 | t.Fatalf("refusal = %s, want it to list the app's channels", out) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // A no-op must not send the whole fleet converging for nothing. |
| 226 | func TestAPIInstanceChannelNoOpDoesNotBumpTheSerial(t *testing.T) { |
| 227 | fx, member := instanceFixture(t, "code-chan-noop") |
| 228 | const host = "web.go.tito.io" |
| 229 | recordOn(t, fx, "titogo", host, "edge") |
| 230 | before := serial(t, fx) |
| 231 | |
| 232 | if status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), |
| 233 | member, `{"channel":"edge"}`); status != http.StatusOK { |
| 234 | t.Fatalf("no-op move = %d %s, want 200", status, out) |
| 235 | } |
| 236 | if got := serial(t, fx); got != before { |
| 237 | t.Fatalf("serial = %d, want it unchanged at %d", got, before) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | // A host with no record is the family's 404, not an existence oracle. |
| 242 | func TestAPIInstanceChannelUnknownHostIs404(t *testing.T) { |
| 243 | fx, member := instanceFixture(t, "code-chan-404") |
| 244 | if status, _, _ := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "nope.go.tito.io"), |
| 245 | member, `{"channel":"edge"}`); status != http.StatusNotFound { |
| 246 | t.Fatalf("unknown host = %d, want 404", status) |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // An app token is a credential the app itself holds: it must not be able to |
| 251 | // walk its own route onto another channel. |
| 252 | func TestAPIInstanceChannelRefusesAppToken(t *testing.T) { |
| 253 | fx, _ := instanceFixture(t, "code-chan-apptok") |
| 254 | const host = "web.go.tito.io" |
| 255 | recordOn(t, fx, "titogo", host, "edge") |
| 256 | appTok, err := fx.st.NewAppToken(OpsAccountID, "titogo", "alice@keymail.dev", "signup", time.Now()) |
| 257 | if err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), appTok, `{"channel":"beta"}`) |
| 261 | if status != http.StatusForbidden { |
| 262 | t.Fatalf("app token = %d %s, want 403 from the role gate", status, out) |
| 263 | } |
| 264 | if !strings.Contains(out, "needs a person") { |
| 265 | t.Fatalf("app-token refusal = %s, want the role gate's own sentence", out) |
| 266 | } |
| 267 | if got := recordChannel(t, fx, "titogo", host); got != "edge" { |
| 268 | t.Fatalf("record channel = %q, want edge", got) |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | // The pin: a confirmed move writes, and a record that moved underneath the |
| 273 | // proposal refuses instead. |
| 274 | func TestExecutePendingRouteChannelWritesAndPins(t *testing.T) { |
| 275 | fx, member := instanceFixture(t, "code-chan-exec") |
| 276 | const host = "web.go.tito.io" |
| 277 | recordOn(t, fx, "titogo", host, "production") |
| 278 | setPipeline(t, fx, "titogo", |
| 279 | release.PipelineChannel{Name: "edge"}, |
| 280 | release.PipelineChannel{Name: "production", Rules: release.ChannelRules{PromoteApprovals: 1}}) |
| 281 | |
| 282 | status, _, out := rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", host), member, `{"channel":"edge"}`) |
| 283 | if status != http.StatusAccepted { |
| 284 | t.Fatalf("propose = %d %s, want 202", status, out) |
| 285 | } |
| 286 | var body map[string]any |
| 287 | if err := json.Unmarshal([]byte(out), &body); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | id, _ := body["pending_id"].(string) |
| 291 | pc, err := fx.st.PendingChange(id, time.Now()) |
| 292 | if err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | acct, err := fx.srv.store.AccountBySqid(release.OpsAccount) |
| 296 | if err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | |
| 300 | // The summary a confirmer reads names BOTH ends — what the URL serves |
| 301 | // now matters as much as what it would serve after. |
| 302 | if got := pendingSummary(*pc); got != "move "+host+" from production to edge" { |
| 303 | t.Fatalf("summary = %q", got) |
| 304 | } |
| 305 | |
| 306 | // Stale first: the record moves underneath the proposal. |
| 307 | rec, etag, err := provision.GetRecordETag(context.Background(), fx.rel, release.OpsAccount, "titogo", host) |
| 308 | if err != nil { |
| 309 | t.Fatal(err) |
| 310 | } |
| 311 | rec.Channel = "beta" |
| 312 | if err := provision.UpdateRecord(context.Background(), fx.rel, *rec, etag); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc); err == nil || |
| 316 | !strings.Contains(err.Error(), "re-propose") { |
| 317 | t.Fatalf("stale execute err = %v, want the stale-proposal refusal", err) |
| 318 | } |
| 319 | if got := recordChannel(t, fx, "titogo", host); got != "beta" { |
| 320 | t.Fatalf("a refused execution wrote anyway: %q", got) |
| 321 | } |
| 322 | |
| 323 | // Now a proposal whose pin still holds. |
| 324 | recordOn(t, fx, "titogo", "two.go.tito.io", "production") |
| 325 | status, _, out = rawReq(t, http.MethodPost, channelURL(fx, "bab", "titogo", "two.go.tito.io"), member, `{"channel":"edge"}`) |
| 326 | if status != http.StatusAccepted { |
| 327 | t.Fatalf("second propose = %d %s", status, out) |
| 328 | } |
| 329 | if err := json.Unmarshal([]byte(out), &body); err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | id, _ = body["pending_id"].(string) |
| 333 | pc2, err := fx.st.PendingChange(id, time.Now()) |
| 334 | if err != nil { |
| 335 | t.Fatal(err) |
| 336 | } |
| 337 | if _, err := fx.srv.executePendingRouteChannel(context.Background(), acct, "titogo", "alice@keymail.dev", pc2); err != nil { |
| 338 | t.Fatalf("execute: %v", err) |
| 339 | } |
| 340 | if got := recordChannel(t, fx, "titogo", "two.go.tito.io"); got != "edge" { |
| 341 | t.Fatalf("record channel = %q, want edge", got) |
| 342 | } |
| 343 | } |
| 344 | |