| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "crypto/ed25519" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "reflect" |
| 8 | "runtime" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostactors" |
| 14 | "github.com/carlosframework/platform/internal/hostfloor" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/version" |
| 17 | ) |
| 18 | |
| 19 | func TestHostConsoleAdmissionAfterFloorPublication(t *testing.T) { |
| 20 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 21 | build := version.Build |
| 22 | version.Build = "build123" |
| 23 | t.Cleanup(func() { version.Build = build }) |
| 24 | for _, fault := range []string{"none", "missing", "unsigned", "wrong-signer", "wrong-bucket", "wrong-id", "wrong-enrollment", "wrong-compute", "unready", "stale", "report-enrollment", "guard-error"} { |
| 25 | t.Run(fault, func(t *testing.T) { |
| 26 | ctx := t.Context() |
| 27 | s, d, _ := activationFixture(t) |
| 28 | s.cfg.HostConsoleID, s.cfg.ReleasePub, s.cfg.HostClaim = "console-2", d.PublicKey, true |
| 29 | signer := release.RootSigner(s.cfg.Key) |
| 30 | before, err := d.Read(ctx) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | if before.Roster.Publication != "published" { |
| 35 | t.Fatal("fixture has no published floor") |
| 36 | } |
| 37 | c := hostactors.Console{Enrollment: strings.Repeat("c", 32), InstanceID: s.cfg.HostConsoleID, Namespaces: []string{"platform"}, DeploymentBucket: d.Bucket} |
| 38 | invited := c |
| 39 | bucket := d.Bucket |
| 40 | switch fault { |
| 41 | case "wrong-bucket": |
| 42 | bucket = "other" |
| 43 | case "wrong-id": |
| 44 | invited.InstanceID = "other" |
| 45 | case "wrong-enrollment": |
| 46 | invited.Enrollment = "invalid" |
| 47 | case "wrong-compute": |
| 48 | invited.Compute = &hostactors.ComputeBinding{Box: "unknown", Enrollment: strings.Repeat("a", 32)} |
| 49 | } |
| 50 | invitation := hostactors.ConsoleInvitation{V: 1, Bucket: bucket, Console: invited} |
| 51 | invitationSigner := signer |
| 52 | if fault == "wrong-signer" { |
| 53 | _, key, err := ed25519.GenerateKey(nil) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | invitationSigner = release.RootSigner(key) |
| 58 | } |
| 59 | wire, err := release.SignRootEnvelope(invitationSigner, invitation) |
| 60 | if fault == "unsigned" { |
| 61 | wire, err = json.Marshal(invitation) |
| 62 | } |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if fault != "missing" { |
| 67 | if err := release.CreateExact(ctx, d.Store, "control/host-console-invitations/"+c.InstanceID+".json", wire); err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | } |
| 71 | platform := runtime.GOOS + "-" + runtime.GOARCH |
| 72 | hash := strings.Repeat("d", 64) |
| 73 | rel := release.SystemRelease{Version: version.Build, ReleaseTag: "v1.2.3", MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{platform: {Key: release.SystemArtifactKey(version.Build, platform), SHA256: hash}}, ConsoleArtifacts: map[string]release.SystemArtifact{platform: {Key: "bootstrap/console/build123/carlos-" + platform, SHA256: hash}}} |
| 74 | if _, err := release.PromoteSystem(ctx, d.Store, signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | wire, _, err = d.Store.Get(ctx, release.SystemPointerKey("router", "stable")) |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if err := release.PublishSystemIdentity(ctx, d.Store, d.PublicKey, wire); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | next := hostactors.Clone(before.Roster) |
| 85 | next.Consoles[c.InstanceID] = c |
| 86 | if _, err := d.Replace(ctx, signer, before, next); err == nil { |
| 87 | t.Fatal("invitation admitted console without a report") |
| 88 | } |
| 89 | called := false |
| 90 | report, err := s.hostReport(ctx, func() (hostfloor.Running, error) { |
| 91 | called = true |
| 92 | if fault == "guard-error" { |
| 93 | return hostfloor.Running{}, hostfloor.ErrFloor |
| 94 | } |
| 95 | return hostfloor.Running{ReleaseTag: rel.ReleaseTag, Hash: hash, Protocol: 1, BootID: "boot", FloorSHA256: before.Roster.FloorSHA256, GuardReady: fault != "unready"}, nil |
| 96 | }) |
| 97 | refused := fault == "missing" || fault == "unsigned" || fault == "wrong-signer" || fault == "wrong-bucket" || fault == "wrong-id" || fault == "wrong-enrollment" || fault == "wrong-compute" |
| 98 | if refused || fault == "guard-error" { |
| 99 | if err == nil || (refused && called) { |
| 100 | t.Fatalf("invalid invitation or guard accepted: called=%v, err=%v", called, err) |
| 101 | } |
| 102 | return |
| 103 | } |
| 104 | if err != nil { |
| 105 | t.Fatal("invited console cannot report before admission", err) |
| 106 | } |
| 107 | if report.Actor != c.InstanceID || report.Enrollment != c.Enrollment || report.Component != "console" || report.ProcessID == "" { |
| 108 | t.Fatal("incorrect reporting identity", report) |
| 109 | } |
| 110 | unchanged, err := d.Read(ctx) |
| 111 | if err != nil || unchanged.ETag != before.ETag { |
| 112 | t.Fatal("reporting changed membership", err) |
| 113 | } |
| 114 | switch fault { |
| 115 | case "stale": |
| 116 | report.GeneratedAt = time.Now().Add(-time.Hour) |
| 117 | case "report-enrollment": |
| 118 | report.Enrollment = strings.Repeat("b", 32) |
| 119 | } |
| 120 | raw, err := json.Marshal(report) |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if err := release.CreateExact(ctx, d.Store, "status/consoles/"+c.InstanceID+".json", raw); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | admittedAt := time.Now() |
| 128 | after, err := d.Replace(ctx, signer, before, next) |
| 129 | if fault != "none" { |
| 130 | if err == nil { |
| 131 | t.Fatal("invalid report admitted console") |
| 132 | } |
| 133 | return |
| 134 | } |
| 135 | if err != nil { |
| 136 | t.Fatal("admission after reporting failed", err) |
| 137 | } |
| 138 | enrolled := after.Roster.Consoles[c.InstanceID] |
| 139 | if enrolled.EnrolledAt.Before(admittedAt) || enrolled.EnrolledAt.After(time.Now()) { |
| 140 | t.Fatal("admission did not assign enrollment time", enrolled.EnrolledAt) |
| 141 | } |
| 142 | c.EnrolledAt = enrolled.EnrolledAt |
| 143 | if !reflect.DeepEqual(enrolled, c) { |
| 144 | t.Fatal("admission changed the invited identity", enrolled) |
| 145 | } |
| 146 | if err := d.Store.Delete(ctx, "control/host-console-invitations/"+c.InstanceID+".json"); err != nil { |
| 147 | t.Fatal(err) |
| 148 | } |
| 149 | _, err = s.hostReport(ctx, func() (hostfloor.Running, error) { return hostfloor.Running{}, hostfloor.ErrFloor }) |
| 150 | if !errors.Is(err, hostfloor.ErrFloor) { |
| 151 | t.Fatal("admitted console still requires invitation", err) |
| 152 | } |
| 153 | }) |
| 154 | } |
| 155 | } |
| 156 | |