| new file mode 100644 |
| index 000000000..1a85ecec4 |
| --- /dev/null |
| +++ b/internal/e2e/selfserve/selfserve_forward_test.go |
| @@ -0,0 +1,352 @@ |
| +//go:build e2e |
| + |
| +package selfserve |
| + |
| +import ( |
| + "encoding/json" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/host" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The self-serve move through the member's doors (self-serve moves plan 2 |
| +// Task 10): the request, the poll, the cut and the cancel over HTTP with |
| +// the owner's move-capable token, the job on the console's pass and the |
| +// bridge's two real boxes doing the work. |
| + |
| +// TestSelfServeForwardMove: the member asks for the move to uk, polls it |
| +// preparing then ready, and cuts it; the job runs it through every phase |
| +// and the poll reads cutting with the offline time, then none with the |
| +// move's outcome: done from ie to uk. The record is in uk with no move and |
| +// last_move done with the job's receipt, the open record names the move, |
| +// the reservations are released, and the move's events end in exactly one |
| +// terminal event, every one delivered. |
| +func TestSelfServeForwardMove(t *testing.T) { |
| + w := newMemberWorld(t) |
| + id := w.prepare() |
| + if v := w.view(); v.State != "preparing" || v.ID != id || v.EstimateSeconds <= 0 || v.Target != host.E2ETargetRegion { |
| + t.Fatalf("the poll after the request: %+v", v) |
| + } |
| + w.watch = id |
| + w.phase(id, "ready", 60) |
| + if v := w.view(); v.State != "ready" || v.ID != id { |
| + t.Fatalf("the poll at ready: %+v", v) |
| + } |
| + if code, out := w.cut(id); code != http.StatusAccepted || parseView(t, out).State != "cutting" { |
| + t.Fatalf("the cut = %d %s", code, out) |
| + } |
| + sawOffline := false |
| + r := w.run(300, "done", id, func(r *console.SelfServeMove) bool { |
| + if r.Outcome == "" { |
| + if v := w.view(); v.State == "cutting" && v.OfflineAt > 0 { |
| + sawOffline = true |
| + } |
| + } |
| + return r.Outcome != "" && !r.ReleasePending && r.EffectsDone |
| + }) |
| + rec := w.record() |
| + switch { |
| + case r.Outcome != "done" || r.Warning != "": |
| + t.Fatalf("finished %s warning %q", describe(r), r.Warning) |
| + case !sawOffline: |
| + t.Fatal("the poll never read cutting with the offline time") |
| + case rec.Move != nil || rec.Region != host.E2ETargetRegion || rec.LastMove == nil || rec.LastMove.ID != id || |
| + rec.LastMove.Outcome != provision.OfflineOutcomeDone || rec.LastMove.Request == "": |
| + t.Fatalf("the record: move %+v region %q last %+v", rec.Move, rec.Region, rec.LastMove) |
| + case len(w.reservations()) != 0: |
| + t.Fatalf("reservations left: %v", w.reservations()) |
| + } |
| + if o := w.openRecord(id); o.MoveID != id || o.To != host.E2ETargetRegion || o.Incarnation != rec.Incarnation { |
| + t.Fatalf("the open record %+v", o) |
| + } |
| + v := w.view() |
| + if v.State != "none" || v.Last == nil || v.Last.ID != id || v.Last.Outcome != "done" || v.Last.From != host.E2EHomeRegion || v.Last.To != host.E2ETargetRegion || v.Last.Warning != "" { |
| + t.Fatalf("the poll after the move: %+v %+v", v, v.Last) |
| + } |
| + w.deliverEvents() |
| + evs, err := w.cst.SelfServeEvents(id) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + terminal := 0 |
| + for _, e := range evs { |
| + if !e.Emitted { |
| + t.Fatalf("event %+v not delivered", e) |
| + } |
| + if e.Phase == "done" { |
| + terminal++ |
| + } |
| + } |
| + if terminal != 1 { |
| + t.Fatalf("%d terminal events in %d", terminal, len(evs)) |
| + } |
| +} |
| + |
| +// deliverEvents ticks until every event is delivered (the terminal event |
| +// is the last tick's, so the next delivers it). |
| +func (w *jobWorld) deliverEvents() { |
| + w.t.Helper() |
| + for i := 0; i < 3; i++ { |
| + if left, err := w.cst.UnemittedSelfServeEvents(10); err == nil && len(left) == 0 { |
| + return |
| + } |
| + w.tick() |
| + } |
| +} |
| + |
| +// TestSelfServeCancelInReady: the member cancels a ready move through the |
| +// cancel door: 202 with the cancellation's request time; a cut naming the |
| +// move after it is refused "the move was cancelled"; the job finishes it |
| +// aborted "the move was cancelled", lossless (the record home with no |
| +// move, last_move aborted with no offline time, the reservations released, |
| +// the source reopened at the cancel), and the poll reads none with that |
| +// outcome. The cancel door's own table and races are Task 5's |
| +// (TestMutationsNameTheMoveAndRace); the abort in every phase before the |
| +// cut is TestAbortBeforeOfflineIsLossless. |
| +func TestSelfServeCancelInReady(t *testing.T) { |
| + w := newMemberWorld(t) |
| + region := w.record().Region |
| + id := w.readyByDoor() |
| + code, out := w.cancelDoor(id) |
| + if code != http.StatusAccepted { |
| + t.Fatalf("the cancel = %d %s", code, out) |
| + } |
| + if v := parseView(t, out); v.ID != id || v.Abort == nil || v.Abort.RequestedAt == 0 { |
| + t.Fatalf("the cancel answered %s", out) |
| + } |
| + if code, out := w.cut(id); code != http.StatusConflict || errorOf(out) != "the move was cancelled" { |
| + t.Fatalf("the cut after the cancel = %d %s", code, out) |
| + } |
| + r := w.finished(id, 60) |
| + w.requireLossless(r, region) |
| + rec := w.record() |
| + switch { |
| + case r.OutcomeReason != "the move was cancelled" || !r.SourceReopenedAt.Equal(r.AbortRequestedAt): |
| + t.Fatalf("finished %s, reopened %v, asked %v", describe(r), r.SourceReopenedAt, r.AbortRequestedAt) |
| + case rec.LastMove.ID != id || rec.LastMove.OfflineS != 0 || rec.LastMove.Request == "": |
| + t.Fatalf("last_move %+v", rec.LastMove) |
| + } |
| + v := w.view() |
| + if v.State != "none" || v.Last == nil || v.Last.ID != id || v.Last.Outcome != "aborted" || v.Last.Reason != "the move was cancelled" { |
| + t.Fatalf("the poll after the cancel: %+v %+v", v, v.Last) |
| + } |
| +} |
| + |
| +// requireAbortedView is the member's poll after an abort: none, with the |
| +// move's outcome aborted and its reason. |
| +func (w *jobWorld) requireAbortedView(id, reason string) { |
| + w.t.Helper() |
| + v := w.view() |
| + if v.State != "none" || v.Last == nil || v.Last.ID != id || v.Last.Outcome != "aborted" || v.Last.Reason != reason { |
| + w.t.Fatalf("the poll after the abort: %+v %+v; want aborted %q", v, v.Last, reason) |
| + } |
| +} |
| + |
| +// TestSelfServeAbortByFailedPrecheck: the move is staged and in its |
| +// prechecks when the real target box comes to hold data for the instance |
| +// (an env file in its data directory); the target's precheck report says |
| +// so and the job aborts with the target's fact, before the cut: the record |
| +// never goes offline (last_move aborted, no offline time), the source |
| +// never stopped, and the poll reads the reason. The precheck's book |
| +// comparison is TestPrechecksCompareTheBook. |
| +func TestSelfServeAbortByFailedPrecheck(t *testing.T) { |
| + w := newMemberWorld(t) |
| + region := w.record().Region |
| + id := w.prepare() |
| + w.watch = id |
| + w.phase(id, "prechecks", 60) |
| + env := filepath.Join(w.e.Box(host.E2ETargetBox).DataDir(), host.E2EHost+".env") |
| + if err := os.WriteFile(env, []byte("A=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + r := w.finished(id, 40) |
| + want := "the target box already holds data for shop.example.com: " + env |
| + if r.OutcomeReason != want { |
| + t.Fatalf("finished %s; want %q", describe(r), want) |
| + } |
| + w.requireLossless(r, region) |
| + if rec := w.record(); rec.LastMove.OfflineS != 0 || !r.SourceReopenedAt.Equal(r.AbortRequestedAt) || w.hits["start:write"] != 0 { |
| + t.Fatalf("the move went offline: last %+v, reopened %v asked %v, %d starts", rec.LastMove, r.SourceReopenedAt, r.AbortRequestedAt, w.hits["start:write"]) |
| + } |
| + w.requireAbortedView(id, want) |
| +} |
| + |
| +// TestSelfServeAbortByStopDeadline: after the cut the source box stops |
| +// answering, so its stop is never confirmed; past the stop's deadline the |
| +// job aborts naming it ("could not determine the stop on ie-box"), and |
| +// once the box is back the abort completes lossless and the poll reads it. |
| +func TestSelfServeAbortByStopDeadline(t *testing.T) { |
| + w := newMemberWorld(t) |
| + region := w.record().Region |
| + id := w.cutByDoor() |
| + w.run(100, "stopping", id, func(r *console.SelfServeMove) bool { return r.Phase == "stopping" || r.Outcome != "" }) |
| + src := w.e.Box(host.E2ESourceBox) |
| + src.Down() |
| + w.tick() |
| + r := w.row(id) |
| + if r.Phase != "stopping" { |
| + t.Fatalf("the row left stopping with the source down: %s", describe(r)) |
| + } |
| + w.pastDeadline(r) |
| + w.consoleUntil(4, "the abort", func() bool { return !w.row(id).AbortRequestedAt.IsZero() }) |
| + const want = "could not determine the stop on ie-box" |
| + if r = w.row(id); r.OutcomeReason != want { |
| + t.Fatalf("aborted %s; want %q", describe(r), want) |
| + } |
| + src.Up() |
| + w.requireLossless(w.done(id), region) |
| + w.requireAbortedView(id, want) |
| +} |
| + |
| +// TestSelfServeAbortByLandingFailure: the sealed archive in the target's |
| +// bucket is damaged after the flip and before the target lands it; the |
| +// real target box reports its landing failed ("the archive does not |
| +// verify") and the job aborts with the box's own detail: the sealer lifts |
| +// the tombstone, the source reopens, the abort ends lossless and the poll |
| +// reads the detail. |
| +func TestSelfServeAbortByLandingFailure(t *testing.T) { |
| + w := newMemberWorld(t) |
| + region := w.record().Region |
| + id := w.cutByDoor() |
| + damaged := false |
| + w.e.Fault(host.FaultPass+host.E2ETargetBox, func() { |
| + if rec := w.record(); damaged || rec.Move == nil || rec.Move.State != provision.OfflineLanding { |
| + return |
| + } |
| + damaged = true |
| + w.damageArchive(id) |
| + }) |
| + r := w.run(200, "the abort", id, func(r *console.SelfServeMove) bool { return !r.AbortRequestedAt.IsZero() || r.Outcome != "" }) |
| + w.e.Fault(host.FaultPass+host.E2ETargetBox, nil) |
| + if !damaged { |
| + t.Fatalf("the move never reached landing: %s", describe(r)) |
| + } |
| + // The target's report as the job decided on it (the box rewrites it |
| + // once it reverts). |
| + tgt, ok := w.report(id, moves.OfflineRoleTarget, host.E2ETargetBox) |
| + if !ok || tgt.Phase != moves.OfflinePhaseFailed { |
| + t.Fatalf("the target's report %+v", tgt) |
| + } |
| + const want = "the archive does not verify" |
| + if r.OutcomeReason != want || moves.FailedDetail(tgt) != want { |
| + t.Fatalf("aborted %s; the target's detail %q", describe(r), moves.FailedDetail(tgt)) |
| + } |
| + r = w.done(id) |
| + if r.OutcomeReason != want || w.hits["open:write"] != 0 { |
| + t.Fatalf("finished %s, %d opens", describe(r), w.hits["open:write"]) |
| + } |
| + w.requireLossless(r, region) |
| + w.requireAbortedView(id, want) |
| +} |
| + |
| +// damageArchive flips a byte of the move's sealed archive in the target's |
| +// data bucket, over the bridge's S3 endpoint. |
| +func (w *jobWorld) damageArchive(id string) { |
| + w.t.Helper() |
| + st, err := release.NewS3StoreIn(w.ctx, w.e.Box(host.E2ETargetBox).DataBucket(), "eu-west-2") |
| + if err != nil { |
| + w.t.Fatal(err) |
| + } |
| + key := moves.OfflineArchivePrefix(host.E2EHost, id) + "files.sealed" |
| + body, etag, err := st.Get(w.ctx, key) |
| + if err != nil || len(body) == 0 { |
| + w.t.Fatalf("the sealed archive %s: %d bytes, %v", key, len(body), err) |
| + } |
| + body[len(body)/2] ^= 0xff |
| + if _, err := st.Put(w.ctx, key, body, etag); err != nil { |
| + w.t.Fatal(err) |
| + } |
| +} |
| + |
| +// TestSelfServeAbortByOpenCheckRefusal: the target has landed and the job |
| +// is to open it, but the open is refused. Another move's open record in |
| +// the way is refused at once: the job aborts "another move's open record |
| +// is in the way". A landing report the open check does not accept (an |
| +// item not OK, the target then silent) is waited on and never opened, and |
| +// past the open's deadline aborts "could not determine the open on |
| +// uk-box". Either way the open is never written, the abort ends lossless |
| +// and the poll reads the reason. |
| +func TestSelfServeAbortByOpenCheckRefusal(t *testing.T) { |
| + for _, c := range []struct { |
| + name, want string |
| + refuse func(w *jobWorld, id string) |
| + }{ |
| + {"another move's open record", "another move's open record is in the way", func(w *jobWorld, id string) { |
| + body, _ := json.Marshal(moves.OpenRecord{MoveID: id, Host: host.E2EHost, Account: w.acct, App: host.E2EApp, |
| + Incarnation: w.record().Incarnation + 1, From: "", To: host.E2ETargetRegion, OpenedAt: time.Now().UTC()}) |
| + if _, err := w.raw.Put(w.ctx, moves.OpenRecordKey(host.E2EHost, id), body, ""); err != nil { |
| + w.t.Fatal(err) |
| + } |
| + }}, |
| + {"a landing the open check refuses", "could not determine the open on uk-box", func(w *jobWorld, id string) { |
| + w.e.Box(host.E2ETargetBox).Down() |
| + w.doctor(id, moves.OfflineRoleTarget, host.E2ETargetBox, func(r *moves.OfflineReport) { |
| + for k, c := range r.Items { |
| + c.OK, c.Reason = false, "waits" |
| + r.Items[k] = c |
| + break |
| + } |
| + }) |
| + w.tick() |
| + if r := w.row(id); r.Phase != "landed" || w.hits["open:write"] != 0 { |
| + w.t.Fatalf("the refused open moved: %s, %d writes", describe(r), w.hits["open:write"]) |
| + } |
| + w.pastDeadline(w.row(id)) |
| + }}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + w := newMemberWorld(t) |
| + region := w.record().Region |
| + id := w.cutByDoor() |
| + // At landed the job's open is the next tick's. |
| + if r := w.run(200, "landed", id, func(r *console.SelfServeMove) bool { return r.Phase == "landed" || r.Outcome != "" }); r.Phase != "landed" { |
| + t.Fatalf("the move is %s", describe(r)) |
| + } |
| + c.refuse(w, id) |
| + r := w.done(id) |
| + if r.OutcomeReason != c.want || w.hits["open:write"] != 0 { |
| + t.Fatalf("finished %s, %d opens written; want %q", describe(r), w.hits["open:write"], c.want) |
| + } |
| + w.requireLossless(r, region) |
| + w.requireAbortedView(id, c.want) |
| + }) |
| + } |
| +} |
| + |
| +// TestSelfServePostOpenFailureIsDoneWithAWarning: after the open the |
| +// account never answers its health probes; the health window runs out and |
| +// the move is done all the same (the instance is open in uk; nothing is |
| +// undone after the open), with the warning, which the poll reads with the |
| +// outcome. The paging and the window's precedence are |
| +// TestHealthFailureFinishesWithAWarningAndPages and |
| +// TestHealthExpiryTakesPrecedence. |
| +func TestSelfServePostOpenFailureIsDoneWithAWarning(t *testing.T) { |
| + w := newMemberWorld(t) |
| + w.probe = func() (bool, string) { return false, "the probe did not get an answer" } |
| + id := w.cutByDoor() |
| + r := w.run(200, "health", id, func(r *console.SelfServeMove) bool { return r.Phase == "health" || r.Outcome != "" }) |
| + w.pastDeadline(r) |
| + r = w.done(id) |
| + const warning = "the account did not answer on uk within 4 minutes" |
| + rec := w.record() |
| + switch { |
| + case r.Outcome != "done" || r.Warning != warning || !r.AbortRequestedAt.IsZero(): |
| + t.Fatalf("finished %s warning %q", describe(r), r.Warning) |
| + case rec.Move != nil || rec.Region != host.E2ETargetRegion || rec.LastMove == nil || rec.LastMove.Outcome != provision.OfflineOutcomeDone: |
| + t.Fatalf("the record: move %+v region %q last %+v", rec.Move, rec.Region, rec.LastMove) |
| + case len(w.reservations()) != 0: |
| + t.Fatalf("reservations left: %v", w.reservations()) |
| + } |
| + v := w.view() |
| + if v.State != "none" || v.Last == nil || v.Last.ID != id || v.Last.Outcome != "done" || v.Last.Warning != warning { |
| + t.Fatalf("the poll after the move: %+v %+v", v, v.Last) |
| + } |
| +} |