carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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
15func 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.
22func 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.
38func 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.
50func 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.
65func 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.
79func 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".
92func 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.
106func 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.
114func 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.
120func 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
131func 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.
142func 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.
162func 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.
188func 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.
210func 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.
226func 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.
242func 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.
252func 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.
274func 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