| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/hostops" |
| 11 | "github.com/carlosframework/platform/internal/mail" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // mailGuardPermits seeds an open admission for each app and returns a |
| 16 | // context holding a live exclusive app-mutation permit for every one of |
| 17 | // them: the shape a mail admission's context has. |
| 18 | func mailGuardPermits(t *testing.T, raw release.Store, apps ...hostops.AppScope) context.Context { |
| 19 | t.Helper() |
| 20 | ctx := t.Context() |
| 21 | w := claimTestWriter(t, raw) |
| 22 | for i, a := range apps { |
| 23 | appID := "id-" + a.Account + "-" + a.App |
| 24 | seedClaimAdmission(t, raw, a.Account, a.App, hostops.AppAdmission{V: 1, AppID: appID, Revision: 1, Epoch: 1, State: "open", |
| 25 | Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}) |
| 26 | op := "mail-op-" + string(rune('a'+i)) |
| 27 | h, err := w.BeginExclusive(ctx, a.Account, a.App, appID, hostops.AppOperation{ID: op, Kind: hostops.OpAppMutation, AppID: appID, Phase: "issued", Intent: map[string]string{"action": "mail"}}, claimTestOwner) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | next, _, err := h.Runner(time.Now).Issue(ctx, hostops.Effect{ID: op, Resource: "mail-gate:test", Action: "mail"}) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | ctx = next |
| 36 | } |
| 37 | return ctx |
| 38 | } |
| 39 | |
| 40 | func putMailJSON(t *testing.T, s release.Store, key string, v any) { |
| 41 | t.Helper() |
| 42 | body, err := json.Marshal(v) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | _, tag, _ := s.Get(context.Background(), key) |
| 47 | if _, err = s.Put(context.Background(), key, body, tag); err != nil { |
| 48 | t.Fatal(err) |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func putMailClaim(t *testing.T, s release.Store, c mail.DomainClaim) { |
| 53 | t.Helper() |
| 54 | if c.Schema == 0 { |
| 55 | c.Schema = 1 |
| 56 | } |
| 57 | if c.CreatedAt.IsZero() { |
| 58 | c.CreatedAt = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC) |
| 59 | } |
| 60 | putMailJSON(t, s, mail.DomainClaimKey(c.Domain), c) |
| 61 | } |
| 62 | |
| 63 | func scopesEqual(got []hostops.AppScope, want ...hostops.AppScope) bool { |
| 64 | if len(got) != len(want) { |
| 65 | return false |
| 66 | } |
| 67 | set := map[hostops.AppScope]bool{} |
| 68 | for _, g := range got { |
| 69 | set[g] = true |
| 70 | } |
| 71 | for _, w := range want { |
| 72 | if !set[w] { |
| 73 | return false |
| 74 | } |
| 75 | } |
| 76 | return true |
| 77 | } |
| 78 | |
| 79 | // The invariant failure this closes: EnsureDomain calls EnsureIdentity after |
| 80 | // claimDomain and BEFORE the account domain record exists. The identity is |
| 81 | // bound to the account's create-only global claim, resolved to the claim's |
| 82 | // app, and is never accepted on a record alone or on a foreign claim. |
| 83 | func TestMailGuardBindsNewIdentityToDomainClaim(t *testing.T) { |
| 84 | raw := release.NewDirStore(t.TempDir()) |
| 85 | titogo := hostops.AppScope{Account: "bab", App: "titogo"} |
| 86 | ctx := mailGuardPermits(t, raw, titogo) |
| 87 | g := backendGuard{data: raw} |
| 88 | const domain = "titogo.bab.oncarlos.com" |
| 89 | |
| 90 | // No claim and no record: nothing proves the domain. |
| 91 | if _, err := g.domainMembers(ctx, domain, false, domainCreate, "eu-west-1"); !errors.Is(err, errBackendUnresolved) { |
| 92 | t.Fatalf("unclaimed domain: %v", err) |
| 93 | } |
| 94 | |
| 95 | // A record alone (account data) never proves a NEW identity. |
| 96 | putMailJSON(t, raw, mail.DomainKey("bab", "loose.example.com"), mail.DomainRecord{Domain: "loose.example.com", Apps: []string{"titogo"}}) |
| 97 | if _, err := g.domainMembers(ctx, "loose.example.com", false, domainCreate, "eu-west-1"); !errors.Is(err, errBackendUnproven) { |
| 98 | t.Fatalf("record without claim admitted a new identity: %v", err) |
| 99 | } |
| 100 | // ...but still resolves the effects written after the record. |
| 101 | if scopes, err := g.domainMembers(ctx, "loose.example.com", false, domainTouch, ""); err != nil || !scopesEqual(scopes, titogo) { |
| 102 | t.Fatalf("record members for a bind: %v %v", scopes, err) |
| 103 | } |
| 104 | |
| 105 | // The claim exists, the record does not yet: EnsureIdentity resolves. |
| 106 | putMailClaim(t, raw, mail.DomainClaim{Domain: domain, Account: "bab", App: "titogo"}) |
| 107 | scopes, err := g.domainMembers(ctx, domain, false, domainCreate, "eu-west-1") |
| 108 | if err != nil || !scopesEqual(scopes, titogo) { |
| 109 | t.Fatalf("claimed domain before its record: %v %v", scopes, err) |
| 110 | } |
| 111 | // Its DKIM, MAIL FROM and DMARC names resolve to the same owner. |
| 112 | for _, name := range []string{"tok1._domainkey." + domain, mail.MailFromDomain(domain, "eu-west-1"), "_dmarc." + domain} { |
| 113 | if scopes, err := g.domainMembers(ctx, name, true, domainCreate, ""); err != nil || !scopesEqual(scopes, titogo) { |
| 114 | t.Fatalf("publish %s: %v %v", name, scopes, err) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // DeleteIdentity needs the claim's own created-in-region stamp. |
| 119 | if _, err := g.domainMembers(ctx, domain, false, domainDestroy, "eu-west-1"); !errors.Is(err, errBackendUnproven) { |
| 120 | t.Fatalf("delete of an identity the claim never created: %v", err) |
| 121 | } |
| 122 | putMailClaim(t, raw, mail.DomainClaim{Domain: domain, Account: "bab", App: "titogo", IdentityRegions: []string{"eu-west-1"}}) |
| 123 | if _, err := g.domainMembers(ctx, domain, false, domainDestroy, "eu-west-1"); err != nil { |
| 124 | t.Fatalf("delete of the claim's own identity: %v", err) |
| 125 | } |
| 126 | if _, err := g.domainMembers(ctx, domain, false, domainDestroy, "us-east-1"); !errors.Is(err, errBackendUnproven) { |
| 127 | t.Fatalf("delete in a region the claim never created: %v", err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // A claim held by an account with no permit here owns the name, whatever a |
| 132 | // permitted account's record or a shorter suffix says. |
| 133 | func TestMailGuardRefusesForeignClaim(t *testing.T) { |
| 134 | raw := release.NewDirStore(t.TempDir()) |
| 135 | ctx := mailGuardPermits(t, raw, hostops.AppScope{Account: "bab", App: "shop"}) |
| 136 | g := backendGuard{data: raw} |
| 137 | |
| 138 | // bab holds a (legacy) record for victim.com, but zed holds the claim. |
| 139 | putMailJSON(t, raw, mail.DomainKey("bab", "victim.com"), mail.DomainRecord{Domain: "victim.com", Apps: []string{"shop"}}) |
| 140 | putMailClaim(t, raw, mail.DomainClaim{Domain: "victim.com", Account: "zed", App: "web", IdentityRegions: []string{"eu-west-1"}}) |
| 141 | for _, use := range []domainUse{domainTouch, domainCreate, domainDestroy} { |
| 142 | if _, err := g.domainMembers(ctx, "victim.com", false, use, "eu-west-1"); !errors.Is(err, errBackendUnproven) { |
| 143 | t.Fatalf("use %d on a foreign claim: %v", use, err) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | // bab owns example.com; zed claimed sub.example.com. A DNS name under |
| 148 | // sub.example.com is zed's, not bab's parent domain's. |
| 149 | putMailClaim(t, raw, mail.DomainClaim{Domain: "example.com", Account: "bab", App: "shop"}) |
| 150 | putMailJSON(t, raw, mail.DomainKey("bab", "example.com"), mail.DomainRecord{Domain: "example.com", Apps: []string{"shop"}}) |
| 151 | putMailClaim(t, raw, mail.DomainClaim{Domain: "sub.example.com", Account: "zed", App: "web"}) |
| 152 | if _, err := g.domainMembers(ctx, "tok._domainkey.sub.example.com", true, domainCreate, ""); !errors.Is(err, errBackendUnproven) { |
| 153 | t.Fatalf("DNS under another account's claimed subdomain: %v", err) |
| 154 | } |
| 155 | if scopes, err := g.domainMembers(ctx, "tok._domainkey.example.com", true, domainCreate, ""); err != nil || !scopesEqual(scopes, hostops.AppScope{Account: "bab", App: "shop"}) { |
| 156 | t.Fatalf("own domain DNS: %v %v", scopes, err) |
| 157 | } |
| 158 | |
| 159 | // No permit at all: nothing resolves. |
| 160 | if _, err := g.domainMembers(t.Context(), "example.com", false, domainTouch, ""); !errors.Is(err, errBackendUnresolved) { |
| 161 | t.Fatalf("no permits: %v", err) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | // The persisted members (record and claim app) and the proposed member all |
| 166 | // resolve, so a sibling's re-run needs the first claimer's permit too. |
| 167 | func TestMailGuardDomainScopeIsComplete(t *testing.T) { |
| 168 | raw := release.NewDirStore(t.TempDir()) |
| 169 | first := hostops.AppScope{Account: "bab", App: "first"} |
| 170 | second := hostops.AppScope{Account: "bab", App: "second"} |
| 171 | ctx := mailGuardPermits(t, raw, first, second) |
| 172 | g := backendGuard{data: raw} |
| 173 | putMailClaim(t, raw, mail.DomainClaim{Domain: "shared.com", Account: "bab", App: "first"}) |
| 174 | putMailJSON(t, raw, mail.DomainKey("bab", "shared.com"), mail.DomainRecord{Domain: "shared.com", Apps: []string{"second"}}) |
| 175 | scopes, err := g.domainMembers(ctx, "shared.com", false, domainCreate, "eu-west-1") |
| 176 | if err != nil || !scopesEqual(scopes, first, second) { |
| 177 | t.Fatalf("complete scope: %v %v", scopes, err) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // Rev 5 P2: main passes "" to SetIdentityConfigSet to unbind an identity when |
| 182 | // SNS is disabled. The wrapper authorizes the domain and lets the empty |
| 183 | // destination through; a named set still needs the account's own set name, |
| 184 | // and an unproven domain still refuses an unbind. Fails on the original |
| 185 | // wrapper, which resolved "" through ConfigSetName and always refused. |
| 186 | func TestClaimMailProvisionerSetIdentityConfigSetUnbinds(t *testing.T) { |
| 187 | raw := release.NewDirStore(t.TempDir()) |
| 188 | titogo := hostops.AppScope{Account: "bab", App: "titogo"} |
| 189 | ctx := mailGuardPermits(t, raw, titogo) |
| 190 | w := claimTestWriter(t, raw) |
| 191 | lease, err := w.AcquireMailGate(ctx, hostops.MailGateOwner{Console: "c", Enrollment: "e", Controller: "k"}, hostops.MailGateScope{}) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | ctx = hostops.WithMailGateLease(ctx, lease) |
| 196 | |
| 197 | const domain = "titogo.bab.oncarlos.com" |
| 198 | putMailClaim(t, raw, mail.DomainClaim{Domain: domain, Account: "bab", App: "titogo"}) |
| 199 | putMailJSON(t, raw, mail.DomainKey("bab", domain), mail.DomainRecord{Domain: domain, Apps: []string{"titogo"}}) |
| 200 | |
| 201 | mem := mail.NewMemProvisioner("1") |
| 202 | if _, err = mem.EnsureIdentity(ctx, "eu-west-1", domain, "", false); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | p := newClaimMailProvisioner(mem, backendGuard{data: raw, control: w}) |
| 206 | set := func() string { return mem.Identities["eu-west-1|"+domain].ConfigSet } |
| 207 | |
| 208 | if err = p.SetIdentityConfigSet(ctx, "eu-west-1", domain, mail.ConfigSetName("bab")); err != nil || set() != mail.ConfigSetName("bab") { |
| 209 | t.Fatalf("bind to the account's own set: err=%v set=%q", err, set()) |
| 210 | } |
| 211 | if err = p.SetIdentityConfigSet(ctx, "eu-west-1", domain, "carlos-zed"); err == nil || set() != mail.ConfigSetName("bab") { |
| 212 | t.Fatalf("bind to another account's set: err=%v set=%q", err, set()) |
| 213 | } |
| 214 | if err = p.SetIdentityConfigSet(ctx, "eu-west-1", domain, ""); err != nil { |
| 215 | t.Fatalf("unbind: %v", err) |
| 216 | } |
| 217 | if set() != "" { |
| 218 | t.Fatalf("unbind left set %q", set()) |
| 219 | } |
| 220 | |
| 221 | // The same call through the generated-wrapper check path. |
| 222 | if _, err = mem.EnsureIdentity(ctx, "eu-west-1", "other.example.com", "", false); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | check := claimBackendCheck(backendGuard{data: raw, control: w}) |
| 226 | after := func(domain, set string) json.RawMessage { |
| 227 | b, _ := json.Marshal(map[string]string{"region": "eu-west-1", "domain": domain, "configSet": set}) |
| 228 | return b |
| 229 | } |
| 230 | target := func(domain, set string) hostops.MutationTarget { |
| 231 | return hostops.MutationTarget{Surface: "mail.Provisioner", Method: "SetIdentityConfigSet", Key: "eu-west-1", After: after(domain, set)} |
| 232 | } |
| 233 | if err = check(ctx, target(domain, "")); err != nil { |
| 234 | t.Fatalf("check of an unbind: %v", err) |
| 235 | } |
| 236 | if err = check(ctx, target("other.example.com", "")); err == nil { |
| 237 | t.Fatal("an unbind on a domain nothing proves was authorized") |
| 238 | } |
| 239 | if err = p.SetIdentityConfigSet(ctx, "eu-west-1", "other.example.com", ""); err == nil { |
| 240 | t.Fatal("wrapper unbound a domain nothing proves") |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | // Standalone and sender principals resolve by exact forward rendering only. |
| 245 | func TestMailGuardUserNamesForwardOnly(t *testing.T) { |
| 246 | raw := release.NewDirStore(t.TempDir()) |
| 247 | shop := hostops.AppScope{Account: "bab", App: "shop"} |
| 248 | ctx := mailGuardPermits(t, raw, shop) |
| 249 | g := backendGuard{data: raw} |
| 250 | |
| 251 | // CreateStandalone's EnsureUser runs before its index is written. |
| 252 | user := mail.StandaloneUserName("bab", "shop", "laptop") |
| 253 | if scopes, err := g.resolveMailUser(ctx, user); err != nil || !scopesEqual(scopes, shop) { |
| 254 | t.Fatalf("standalone of a permitted app: %v %v", scopes, err) |
| 255 | } |
| 256 | if scopes, err := g.resolveMailUser(ctx, mail.UserName("bab", "shop")); err != nil || !scopesEqual(scopes, shop) { |
| 257 | t.Fatalf("delivered user: %v %v", scopes, err) |
| 258 | } |
| 259 | // Hyphen collision: app shop-eu's names are never shop's. |
| 260 | for _, u := range []string{ |
| 261 | mail.UserName("bab", "shop-eu"), |
| 262 | mail.StandaloneUserName("bab", "shop-eu", "laptop"), |
| 263 | "carlos-mail-bab-shop+s+", |
| 264 | "carlos-mail-bab-shop+s+Bad", |
| 265 | "carlos-mail-bab-shop+s+a+s+b", |
| 266 | mail.StandaloneUserName("zed", "shop", "laptop"), |
| 267 | } { |
| 268 | if _, err := g.resolveMailUser(ctx, u); !errors.Is(err, errBackendUnproven) { |
| 269 | t.Fatalf("%s resolved: %v", u, err) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | // An index naming bab/shop for a name outside bab/shop's namespace is |
| 274 | // refused, so an index cannot move another app's principal. |
| 275 | victim := mail.UserName("bab", "shop-eu") |
| 276 | putMailJSON(t, raw, mail.CallerKey(victim), mail.CallerIndex{Schema: 1, IAMUser: victim, Account: "bab", App: "shop", Sender: mail.SenderAppID}) |
| 277 | if _, err := g.resolveMailUser(ctx, victim); !errors.Is(err, errBackendUnproven) { |
| 278 | t.Fatalf("forged caller index: %v", err) |
| 279 | } |
| 280 | // A correct index resolves without any permit in ctx. |
| 281 | own := mail.StandaloneUserName("bab", "shop", "ci") |
| 282 | putMailJSON(t, raw, mail.CallerKey(own), mail.CallerIndex{Schema: 1, IAMUser: own, Account: "bab", App: "shop", Sender: mail.SenderAppID}) |
| 283 | if scopes, err := g.resolveMailUser(t.Context(), own); err != nil || !scopesEqual(scopes, shop) { |
| 284 | t.Fatalf("indexed standalone: %v %v", scopes, err) |
| 285 | } |
| 286 | } |
| 287 | |