| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/dnsrec" |
| 13 | "github.com/carlosframework/platform/internal/hostops" |
| 14 | "github.com/carlosframework/platform/internal/mail" |
| 15 | "github.com/carlosframework/platform/internal/objstore" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | func TestClaimPurgeStoreInventoryRefusesBeforeEffects(t *testing.T) { |
| 20 | for _, tc := range []struct{ name, key, body string }{ |
| 21 | {"malformed key", "stores/bad/nested.json", `{"schema":1,"app":"titogo"}`}, |
| 22 | {"unsupported schema", "stores/prod.json", `{"schema":2,"app":"titogo","environment":"prod"}`}, |
| 23 | {"identity mismatch", "stores/prod.json", `{"schema":1,"app":"other","environment":"prod"}`}, |
| 24 | {"default unsupported schema", "store.json", `{"schema":2,"app":"titogo"}`}, |
| 25 | {"default identity mismatch", "store.json", `{"schema":1,"app":"other"}`}, |
| 26 | } { |
| 27 | for _, stage := range []string{"request", "teardown"} { |
| 28 | t.Run(tc.name+"/"+stage, func(t *testing.T) { |
| 29 | cx := newClaimCase(t) |
| 30 | cx.purgeWorld(t) |
| 31 | p := cx.srv.cfg.ClaimPurge |
| 32 | ctx := t.Context() |
| 33 | const id = "strict-store-purge" |
| 34 | appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}] |
| 35 | var h *hostops.OperationHandle |
| 36 | if stage == "teardown" { |
| 37 | got, err := p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner) |
| 38 | if got != id || err != nil && !errors.Is(err, hostops.ErrPending) { |
| 39 | t.Fatalf("request: %s %v", got, err) |
| 40 | } |
| 41 | var err2 error |
| 42 | h, err2 = p.Authority.Control.ResumeExclusive(ctx, "bab", "titogo", appID, id, hostops.OpPurging, p.Owner) |
| 43 | if err2 != nil { |
| 44 | t.Fatal(err2) |
| 45 | } |
| 46 | j, _, err := hostops.ReadPurgeJournal(ctx, cx.rel, "bab", "titogo", id, appID) |
| 47 | if err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | j.Phase = "claimed" // Stage fixture: host barriers are tested separately. |
| 51 | key, _ := hostops.PurgeKey("bab", "titogo") |
| 52 | putMailJSON(t, cx.rel, key, j) |
| 53 | } |
| 54 | key := release.AppPrefix("bab", "titogo") + tc.key |
| 55 | if _, err := cx.rel.Put(ctx, key, []byte(tc.body), ""); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | mark := cx.rec.mark() |
| 59 | var err error |
| 60 | if stage == "request" { |
| 61 | _, err = p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner) |
| 62 | journal, _ := hostops.PurgeKey("bab", "titogo") |
| 63 | if _, _, e := cx.rel.Get(ctx, journal); !errors.Is(e, os.ErrNotExist) { |
| 64 | t.Errorf("invalid inventory reserved purge: %v", e) |
| 65 | } |
| 66 | } else { |
| 67 | err = p.stage(ctx, h, "teardown", func(ctx context.Context) error { |
| 68 | scope := mail.ClaimMailScope{Account: "bab", App: "titogo", AppID: appID, Operation: id} |
| 69 | return mail.WithClaimMailResource(mail.WithClaimMailScope(ctx, scope), cx.srv.claimMailStore(), func(held context.Context) error { |
| 70 | return p.teardown(held, h, "bab", "titogo", id, appID) |
| 71 | }) |
| 72 | }) |
| 73 | } |
| 74 | if err == nil || errors.Is(err, hostops.ErrPending) { |
| 75 | t.Errorf("invalid inventory was not refused: %v", err) |
| 76 | } |
| 77 | for _, e := range cx.rec.since(mark) { |
| 78 | if !e.Control && (e.Unscoped || len(e.Scopes) > 0) { |
| 79 | t.Errorf("effect before validation: %v", e) |
| 80 | } |
| 81 | } |
| 82 | if _, _, e := cx.rel.Get(ctx, key); e != nil { |
| 83 | t.Errorf("invalid estate record removed: %v", e) |
| 84 | } |
| 85 | }) |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestClaimStoreReadersValidateEnabledMode(t *testing.T) { |
| 91 | cx := newClaimCase(t) |
| 92 | for _, tc := range []struct{ name, body string }{ |
| 93 | {"schema", `{"schema":2,"app":"titogo"}`}, |
| 94 | {"identity", `{"schema":1,"app":"other"}`}, |
| 95 | } { |
| 96 | t.Run(tc.name, func(t *testing.T) { |
| 97 | key := objstore.Key("bab", "titogo") |
| 98 | _, tag, _ := cx.rel.Get(t.Context(), key) |
| 99 | if _, err := cx.rel.Put(t.Context(), key, []byte(tc.body), tag); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | if _, _, err := objstore.GetIn(t.Context(), cx.srv.cfg.Releases, "bab", "titogo", ""); err == nil { |
| 103 | t.Fatal("enabled reader accepted invalid record") |
| 104 | } |
| 105 | if _, _, err := objstore.GetIn(t.Context(), cx.rel, "bab", "titogo", ""); err != nil { |
| 106 | t.Fatalf("legacy reader changed: %v", err) |
| 107 | } |
| 108 | mark := cx.rec.mark() |
| 109 | if err := objstore.TeardownIn(t.Context(), cx.srv.cfg.Releases, cx.srv.cfg.StoreProvisioner, nil, "bab", "titogo", ""); err == nil { |
| 110 | t.Fatal("teardown accepted invalid record") |
| 111 | } |
| 112 | for _, e := range cx.rec.since(mark) { |
| 113 | if !e.Control { |
| 114 | t.Errorf("teardown effect: %v", e) |
| 115 | } |
| 116 | } |
| 117 | }) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func TestClaimBackendLegacyDomainClaims(t *testing.T) { |
| 122 | for _, schema := range []int{0, 1, -1, 2} { |
| 123 | for _, bad := range []string{"", "domain", "account", "app"} { |
| 124 | t.Run(string(rune('a'+schema+1))+"/"+bad, func(t *testing.T) { |
| 125 | raw := release.NewDirStore(t.TempDir()) |
| 126 | ctx := mailGuardPermits(t, raw, hostops.AppScope{Account: "bab", App: "titogo"}) |
| 127 | w := claimTestWriter(t, raw) |
| 128 | lease, err := w.AcquireMailGate(ctx, hostops.MailGateOwner{Console: "c", Enrollment: "e", Controller: "k"}, hostops.MailGateScope{}) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | ctx = hostops.WithMailGateLease(ctx, lease) |
| 133 | const domain = "legacy.example.com" |
| 134 | c := mail.DomainClaim{Schema: schema, Domain: domain, Account: "bab", App: "titogo", CreatedAt: time.Now()} |
| 135 | switch bad { |
| 136 | case "domain": |
| 137 | c.Domain = "other.example.com" |
| 138 | case "account": |
| 139 | c.Account = "" |
| 140 | case "app": |
| 141 | c.App = "../other" |
| 142 | } |
| 143 | putMailJSON(t, raw, mail.DomainClaimKey(domain), c) |
| 144 | mem := mail.NewMemProvisioner("1") |
| 145 | dns := dnsrec.NewMemWriter() |
| 146 | g := backendGuard{data: raw, control: w} |
| 147 | p := newClaimMailProvisioner(mem, g) |
| 148 | d := newClaimDNSWriter(dns, g) |
| 149 | _, sesErr := p.EnsureIdentity(ctx, "eu-west-1", domain, "", false) |
| 150 | dnsErr := d.EnsureRecord(ctx, dnsrec.Record{Name: "_dmarc." + domain, Type: "TXT", Values: []string{"v=DMARC1; p=none"}}) |
| 151 | valid := (schema == 0 || schema == 1) && bad == "" |
| 152 | if valid { |
| 153 | if sesErr != nil || dnsErr != nil { |
| 154 | t.Fatalf("legacy supported claim: SES=%v DNS=%v", sesErr, dnsErr) |
| 155 | } |
| 156 | } else { |
| 157 | if sesErr == nil || dnsErr == nil { |
| 158 | t.Fatalf("invalid claim accepted: SES=%v DNS=%v", sesErr, dnsErr) |
| 159 | } |
| 160 | if len(mem.Identities) != 0 || len(dns.Records) != 0 { |
| 161 | t.Fatal("invalid claim reached backend") |
| 162 | } |
| 163 | } |
| 164 | }) |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // The same worker fixture checks actual HTTP delivery, not just its SQL receipt. |
| 170 | func TestClaimMailPushCountsOutboundEffects(t *testing.T) { |
| 171 | cx := newClaimCase(t) |
| 172 | door := claimFixtures["worker StartMailEventPush"].door |
| 173 | door.setup(t, cx) |
| 174 | ref := door.apps[0] |
| 175 | restore := cx.foreign(t, ref) |
| 176 | w := cx.measure(t, door) |
| 177 | if problems := claimForeignProblems(ref, door.apps, w); len(problems) > 0 { |
| 178 | t.Errorf("foreign delivery: %v", problems) |
| 179 | } |
| 180 | restore() |
| 181 | w = cx.measure(t, door) |
| 182 | sends := 0 |
| 183 | for _, e := range w.events { |
| 184 | if e.Surface == "http" && e.Method == http.MethodPost { |
| 185 | sends++ |
| 186 | } |
| 187 | } |
| 188 | if sends != 2 { |
| 189 | t.Fatalf("open worker sent %d requests, want 2", sends) |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | type claimDeliveryTransport struct { |
| 194 | call func(*http.Request) (*http.Response, error) |
| 195 | } |
| 196 | |
| 197 | func (tr claimDeliveryTransport) RoundTrip(r *http.Request) (*http.Response, error) { |
| 198 | return tr.call(r) |
| 199 | } |
| 200 | |
| 201 | func TestClaimMailPushHoldsAdmissionThroughDelivery(t *testing.T) { |
| 202 | for _, ambiguous := range []bool{false, true} { |
| 203 | t.Run(fmt.Sprint(ambiguous), func(t *testing.T) { |
| 204 | cx := newClaimCase(t) |
| 205 | door := claimFixtures["worker StartMailEventPush"].door |
| 206 | door.setup(t, cx) |
| 207 | appID := cx.appIDs[door.apps[0]] |
| 208 | calls := 0 |
| 209 | old := mailPushClient.Transport |
| 210 | mailPushClient.Transport = claimDeliveryTransport{call: func(r *http.Request) (*http.Response, error) { |
| 211 | calls++ |
| 212 | if err := hostops.RequireAppMutation(r.Context(), cx.rel, "bab", "titogo", appID); err != nil { |
| 213 | t.Errorf("outbound call lacks permit: %v", err) |
| 214 | } |
| 215 | a, err := hostops.ReadCanonicalAdmission(r.Context(), cx.rel, "bab", "titogo") |
| 216 | if err != nil || a.Value.Op == nil || a.Value.Op.Intent["action"] != "mail-event-delivery" { |
| 217 | t.Errorf("delivery admission: %+v %v", a.Value, err) |
| 218 | } |
| 219 | _, err = cx.srv.cfg.ClaimAuthority.Control.BeginExclusive(r.Context(), "bab", "titogo", appID, hostops.AppOperation{ID: "competing", Kind: hostops.OpPurging, AppID: appID, Phase: "reserved"}, claimTestOwner) |
| 220 | if err == nil { |
| 221 | t.Error("purge entered while HTTP delivery was in flight") |
| 222 | } |
| 223 | if ambiguous { |
| 224 | return nil, errors.New("lost response") |
| 225 | } |
| 226 | return old.RoundTrip(r) |
| 227 | }} |
| 228 | t.Cleanup(func() { mailPushClient.Transport = old }) |
| 229 | if err := cx.srv.mailPushPass(t.Context(), time.Now); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | a, err := hostops.ReadCanonicalAdmission(t.Context(), cx.rel, "bab", "titogo") |
| 233 | if err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | if ambiguous { |
| 237 | if calls != 1 || a.Value.Op == nil || a.Value.State != "closed" { |
| 238 | t.Fatalf("ambiguous call released: calls=%d admission=%+v", calls, a.Value) |
| 239 | } |
| 240 | } else { |
| 241 | if calls != 2 || a.Value.Op != nil || a.Value.State != "open" { |
| 242 | t.Fatalf("joined delivery unsettled: calls=%d admission=%+v", calls, a.Value) |
| 243 | } |
| 244 | rows, err := cx.st.dueMailEventDeliveriesFor(t.Context(), "bab", "titogo", time.Now(), mailPushPerApp) |
| 245 | if err != nil || len(rows) != 0 { |
| 246 | t.Fatalf("delivery did not settle SQL: %v %v", rows, err) |
| 247 | } |
| 248 | } |
| 249 | }) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | type claimStoreInventoryFault struct { |
| 254 | release.Store |
| 255 | list []string |
| 256 | listErr, errorRead error |
| 257 | } |
| 258 | |
| 259 | func (s claimStoreInventoryFault) List(context.Context, string) ([]string, error) { |
| 260 | return s.list, s.listErr |
| 261 | } |
| 262 | func (s claimStoreInventoryFault) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 263 | if s.errorRead != nil { |
| 264 | return nil, "", s.errorRead |
| 265 | } |
| 266 | return s.Store.Get(ctx, key) |
| 267 | } |
| 268 | func TestClaimStoreInventoryCompleteListing(t *testing.T) { |
| 269 | raw := release.NewDirStore(t.TempDir()) |
| 270 | key := objstore.KeyIn("bab", "titogo", "prod") |
| 271 | putMailJSON(t, raw, key, objstore.Record{Schema: 1, App: "titogo", Environment: "prod"}) |
| 272 | sentinel := errors.New("partial listing") |
| 273 | for _, tc := range []struct { |
| 274 | name string |
| 275 | keys []string |
| 276 | listErr, readErr error |
| 277 | want bool |
| 278 | }{ |
| 279 | {"valid duplicate", []string{key, key}, nil, nil, true}, |
| 280 | {"partial", []string{key}, sentinel, nil, false}, |
| 281 | {"missing listed", []string{key}, nil, os.ErrNotExist, false}, |
| 282 | {"failed read", []string{key}, nil, sentinel, false}, |
| 283 | {"foreign prefix", []string{"apps/zed/other/stores/prod.json"}, nil, nil, false}, |
| 284 | {"stray", []string{key, "apps/bab/titogo/stores/notes.txt"}, nil, nil, false}, |
| 285 | } { |
| 286 | t.Run(tc.name, func(t *testing.T) { |
| 287 | s := claimStoreInventoryFault{Store: raw, list: tc.keys, listErr: tc.listErr, errorRead: tc.readErr} |
| 288 | got, err := objstore.StrictStoreEnvironments(t.Context(), s, "bab", "titogo") |
| 289 | if tc.want { |
| 290 | if err != nil || len(got) != 1 || got[0] != "prod" { |
| 291 | t.Fatalf("%v %v", got, err) |
| 292 | } |
| 293 | } else if err == nil || got != nil { |
| 294 | t.Fatalf("incomplete inventory accepted: %v %v", got, err) |
| 295 | } |
| 296 | }) |
| 297 | } |
| 298 | } |
| 299 | |