| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "net/http" |
| 6 | "sync/atomic" |
| 7 | "testing" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/appconfig" |
| 10 | "github.com/carlosframework/platform/internal/objstore" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // racingRunner is a claims-on AppMutationRunner whose first permit for action |
| 15 | // lets a concurrent writer land before the callback runs. |
| 16 | type racingRunner struct { |
| 17 | release.Store |
| 18 | action string |
| 19 | race func(context.Context) |
| 20 | raced atomic.Bool |
| 21 | puts atomic.Int64 |
| 22 | } |
| 23 | |
| 24 | func (r *racingRunner) RunAppMutation(ctx context.Context, account, app, action string, f func(context.Context) error) error { |
| 25 | if action == r.action && r.raced.CompareAndSwap(false, true) { |
| 26 | r.race(ctx) |
| 27 | } |
| 28 | return f(ctx) |
| 29 | } |
| 30 | |
| 31 | func (r *racingRunner) Put(ctx context.Context, key string, body []byte, etag string) (string, error) { |
| 32 | if r.raced.Load() { |
| 33 | r.puts.Add(1) |
| 34 | } |
| 35 | return r.Store.Put(ctx, key, body, etag) |
| 36 | } |
| 37 | |
| 38 | func rotationRaceFixture(t *testing.T, code string) (*consoleFixture, string, string) { |
| 39 | t.Helper() |
| 40 | fx, _, token, priv := storeArmed(t, code) |
| 41 | grantStore(t, fx, token) |
| 42 | fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| 43 | enableDeploymentScanning(t, fx) |
| 44 | grantScanning(t, fx, token, "titogo") |
| 45 | return fx, token, priv |
| 46 | } |
| 47 | |
| 48 | func editRecord(t *testing.T, fx *consoleFixture, edit func(*objstore.Record)) { |
| 49 | t.Helper() |
| 50 | ctx := context.Background() |
| 51 | rec, etag, err := objstore.Get(ctx, fx.rel, fx.sqid, "titogo") |
| 52 | if err != nil || rec == nil { |
| 53 | t.Fatalf("read record: %v %v", rec, err) |
| 54 | } |
| 55 | edit(rec) |
| 56 | if _, err := objstore.Put(ctx, fx.rel, fx.sqid, "titogo", rec, etag); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | func revokeBehindTheDoor(t *testing.T, fx *consoleFixture) { |
| 62 | t.Helper() |
| 63 | editRecord(t, fx, func(rec *objstore.Record) { |
| 64 | rec.Scanning.Enabled = nil |
| 65 | rec.Scanning.Enforce = nil |
| 66 | }) |
| 67 | if _, err := appconfig.Apply(context.Background(), fx.rel, fx.sqid, "titogo", nil, |
| 68 | []string{objstore.DefaultEnvPrefix + "_SCAN_CALLBACK_SECRET"}); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestDeploymentRotateDeliversTheGenerationHeldUnderThePermit(t *testing.T) { |
| 74 | fx, _, priv := rotationRaceFixture(t, "code-rotate-race-gen") |
| 75 | fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "race-master", testWebhookMaster) |
| 76 | staleKeyID := storedRecord(t, fx, "titogo").Scanning.WebhookKeyID |
| 77 | runner := &racingRunner{Store: fx.rel, action: "scan-webhook-rotate", race: func(context.Context) { |
| 78 | editRecord(t, fx, func(rec *objstore.Record) { rec.Scanning.WebhookGen++ }) |
| 79 | }} |
| 80 | fx.srv.cfg.Releases = runner |
| 81 | pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test") |
| 82 | |
| 83 | remaining, changed, err := fx.srv.rotateWebhookBudget(context.Background(), pub) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if remaining != 0 || changed != 1 { |
| 88 | t.Fatalf("rotation = remaining %d changed %d, want 0/1", remaining, changed) |
| 89 | } |
| 90 | rec := storedRecord(t, fx, "titogo") |
| 91 | if rec.Scanning.WebhookKeyID == staleKeyID || rec.Scanning.WebhookKeyID != fx.srv.cfg.CallbackSigner.CurrentKeyID() { |
| 92 | t.Fatalf("webhook_key_id = %q, want current", rec.Scanning.WebhookKeyID) |
| 93 | } |
| 94 | want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen)) |
| 95 | if got := callbackSecret(t, fx, priv, "titogo"); got != want { |
| 96 | t.Fatal("delivered callback secret does not verify against the recorded generation") |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | func TestDeploymentRotateSkipsWorkFinishedBeforeThePermit(t *testing.T) { |
| 101 | fx, _, priv := rotationRaceFixture(t, "code-rotate-race-done") |
| 102 | fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "race-master", testWebhookMaster) |
| 103 | pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test") |
| 104 | var runner *racingRunner |
| 105 | runner = &racingRunner{Store: fx.rel, action: "scan-webhook-rotate", race: func(ctx context.Context) { |
| 106 | cur := storedRecord(t, fx, "titogo") |
| 107 | gen := cur.Scanning.WebhookGen + 1 |
| 108 | if err := fx.srv.deliverCallbackSecret(ctx, pub, fx.sqid, "titogo", cur, gen); err != nil { |
| 109 | t.Fatal(err) |
| 110 | } |
| 111 | manifest, err := appconfig.Get(ctx, fx.rel, fx.sqid, "titogo") |
| 112 | if err != nil { |
| 113 | t.Fatal(err) |
| 114 | } |
| 115 | editRecord(t, fx, func(rec *objstore.Record) { |
| 116 | rec.Scanning.WebhookGen = gen |
| 117 | rec.Scanning.WebhookKeyID = fx.srv.cfg.CallbackSigner.CurrentKeyID() |
| 118 | rec.Scanning.WebhookConfigHash = scanSecretHash(manifest.Vars[rec.Declared.EnvPrefix+"_SCAN_CALLBACK_SECRET"]) |
| 119 | }) |
| 120 | runner.puts.Store(0) |
| 121 | }} |
| 122 | fx.srv.cfg.Releases = runner |
| 123 | |
| 124 | remaining, changed, err := fx.srv.rotateWebhookBudget(context.Background(), pub) |
| 125 | if err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if remaining != 0 || changed != 0 || runner.puts.Load() != 0 { |
| 129 | t.Fatalf("rotation = remaining %d changed %d puts %d, want 0/0/0", remaining, changed, runner.puts.Load()) |
| 130 | } |
| 131 | rec := storedRecord(t, fx, "titogo") |
| 132 | want := string(fx.srv.cfg.CallbackSigner.DerivedKey(fx.sqid, "titogo", rec.Scanning.WebhookGen)) |
| 133 | if got := callbackSecret(t, fx, priv, "titogo"); got != want { |
| 134 | t.Fatal("rotation overwrote the concurrent rotation's callback secret") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestDeploymentRotateLeavesARevokedAppAlone(t *testing.T) { |
| 139 | fx, _, _ := rotationRaceFixture(t, "code-rotate-race-revoke") |
| 140 | fx.srv.cfg.CallbackSigner = callbackTestSigner(t, "race-master", testWebhookMaster) |
| 141 | before := storedRecord(t, fx, "titogo").Scanning.WebhookKeyID |
| 142 | runner := &racingRunner{Store: fx.rel, action: "scan-webhook-rotate", race: func(context.Context) { |
| 143 | revokeBehindTheDoor(t, fx) |
| 144 | }} |
| 145 | fx.srv.cfg.Releases = runner |
| 146 | pub, _ := appconfig.ParsePublicKey(fx.srv.cfg.SecretsPub, "test") |
| 147 | |
| 148 | remaining, changed, err := fx.srv.rotateWebhookBudget(context.Background(), pub) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if remaining != 0 || changed != 0 || runner.puts.Load() != 0 { |
| 153 | t.Fatalf("rotation = remaining %d changed %d puts %d, want 0/0/0", remaining, changed, runner.puts.Load()) |
| 154 | } |
| 155 | if _, ok := appVars(t, fx, "titogo")[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]; ok { |
| 156 | t.Fatal("rotation delivered a callback secret to a revoked app") |
| 157 | } |
| 158 | rec := storedRecord(t, fx, "titogo") |
| 159 | if rec.Scanning.Enabled != nil || rec.Scanning.WebhookKeyID != before { |
| 160 | t.Fatalf("rotation rewrote a revoked scanning block: %+v", rec.Scanning) |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | func TestPerAppRotateRefusesARevokeLandedBeforeThePermit(t *testing.T) { |
| 165 | fx, token, _ := rotationRaceFixture(t, "code-rotate-race-app-revoke") |
| 166 | before := storedRecord(t, fx, "titogo").Scanning.WebhookGen |
| 167 | runner := &racingRunner{Store: fx.rel, action: "store-scan-callback-rotate", race: func(context.Context) { |
| 168 | revokeBehindTheDoor(t, fx) |
| 169 | }} |
| 170 | fx.srv.cfg.Releases = runner |
| 171 | |
| 172 | s, o := postJSON(t, scanURL(fx, "titogo")+"/callback-key/rotate", map[string]any{}, token) |
| 173 | if s != http.StatusConflict || o["error"] != "scanning is not enabled on this store — run `carlos store scan grant` first" { |
| 174 | t.Fatalf("per-app rotate after revoke = %d %v, want 409", s, o) |
| 175 | } |
| 176 | if runner.puts.Load() != 0 { |
| 177 | t.Fatalf("per-app rotate after revoke wrote %d objects, want none", runner.puts.Load()) |
| 178 | } |
| 179 | if _, ok := appVars(t, fx, "titogo")[objstore.DefaultEnvPrefix+"_SCAN_CALLBACK_SECRET"]; ok { |
| 180 | t.Fatal("per-app rotate delivered a callback secret to a revoked app") |
| 181 | } |
| 182 | if got := storedRecord(t, fx, "titogo").Scanning.WebhookGen; got != before { |
| 183 | t.Fatalf("webhook_gen = %d, want unchanged %d", got, before) |
| 184 | } |
| 185 | } |
| 186 | |