| 1 | package console |
| 2 | |
| 3 | // Proof of control at the /domains doors (#215, opt-in: CONSOLE_DOMAIN_PROOF), and the apps-domain |
| 4 | // namespace rules that came with it. Before this, a custom hostname went to |
| 5 | // whichever account attached it first: one whose DNS already pointed at the |
| 6 | // edge was served for the claimant with a platform-issued certificate, and |
| 7 | // any name could be held forever by someone who never controlled it. And |
| 8 | // without CONSOLE_RESERVE_PLATFORM_DOMAINS, one account could attach names |
| 9 | // inside another's <sqid>.<apps-domain> namespace. |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "encoding/json" |
| 14 | "net" |
| 15 | "net/http" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "github.com/carlosframework/platform/internal/domains" |
| 21 | "github.com/carlosframework/platform/internal/provision" |
| 22 | "github.com/carlosframework/platform/internal/registry" |
| 23 | "github.com/carlosframework/platform/internal/release" |
| 24 | ) |
| 25 | |
| 26 | // noTXT is a zone with no proof in it. |
| 27 | func noTXT(calls *int) func(context.Context, string) ([]string, error) { |
| 28 | return func(_ context.Context, name string) ([]string, error) { |
| 29 | *calls++ |
| 30 | return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | // txtFor is a zone that carries exactly one account's proof for one host, |
| 35 | // beside unrelated TXT noise. |
| 36 | func txtFor(s *Server, account, host string) func(context.Context, string) ([]string, error) { |
| 37 | return func(_ context.Context, name string) ([]string, error) { |
| 38 | if name != domains.ChallengeName(host) { |
| 39 | return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true} |
| 40 | } |
| 41 | return []string{"v=spf1 -all", domains.ChallengeToken(s.challengeDeployment(), account, host)}, nil |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | type proofAttachResp struct { |
| 46 | Host string `json:"host"` |
| 47 | Route string `json:"route"` |
| 48 | Created bool `json:"created"` |
| 49 | Verified *bool `json:"verified"` |
| 50 | Verify dnsRecord `json:"verify"` |
| 51 | } |
| 52 | |
| 53 | type proofListResp struct { |
| 54 | Domains []struct { |
| 55 | Host string `json:"host"` |
| 56 | State string `json:"state"` |
| 57 | Verified bool `json:"verified"` |
| 58 | Guidance string `json:"guidance"` |
| 59 | Records []dnsRecord `json:"records"` |
| 60 | } `json:"domains"` |
| 61 | } |
| 62 | |
| 63 | // The audit's takeover, at the CLI door: a member attaches a hostname whose |
| 64 | // DNS they do not control. On main this was a live claim — served the moment |
| 65 | // a box reconciled it, certificate and all. Now it is stored pending, the |
| 66 | // answer says exactly which record proves it, nothing is announced to the |
| 67 | // fleet, and repeating the attach once the record exists verifies it. |
| 68 | func TestAPIDomainAttachWithoutProofIsPending(t *testing.T) { |
| 69 | fx, member := instanceFixture(t, "code-dom-proof") |
| 70 | fx.srv.cfg.DomainProof = true |
| 71 | lookups := 0 |
| 72 | fx.srv.lookupTXT = noTXT(&lookups) |
| 73 | const host = "victim.example.com" |
| 74 | before := serialValue(t, fx) |
| 75 | |
| 76 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) |
| 77 | if code != http.StatusOK { |
| 78 | t.Fatalf("attach = %d %s, want 200", code, out) |
| 79 | } |
| 80 | var got proofAttachResp |
| 81 | if err := json.Unmarshal([]byte(out), &got); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | wantToken := domains.ChallengeToken(fx.srv.challengeDeployment(), "bab", host) |
| 85 | if got.Verified == nil || *got.Verified || got.Verify.Type != "TXT" || |
| 86 | got.Verify.Name != "_carlos-challenge."+host || got.Verify.Value != wantToken { |
| 87 | t.Fatalf("attach answer = %s, want verified false and the TXT record %s = %s", out, "_carlos-challenge."+host, wantToken) |
| 88 | } |
| 89 | if lookups != 1 { |
| 90 | t.Errorf("TXT lookups = %d, want 1", lookups) |
| 91 | } |
| 92 | if c := claimOf(t, fx, host); !c.Pending { |
| 93 | t.Fatalf("claim = %+v, want pending", *c) |
| 94 | } |
| 95 | if after := serialValue(t, fx); after != before { |
| 96 | t.Errorf("serial moved %q -> %q for a pending claim no box acts on", before, after) |
| 97 | } |
| 98 | |
| 99 | // The list says the same thing, in both forms. |
| 100 | code, _, out = rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "") |
| 101 | if code != http.StatusOK { |
| 102 | t.Fatalf("list = %d %s", code, out) |
| 103 | } |
| 104 | var list proofListResp |
| 105 | if err := json.Unmarshal([]byte(out), &list); err != nil { |
| 106 | t.Fatal(err) |
| 107 | } |
| 108 | if len(list.Domains) != 1 || list.Domains[0].State != StateUnverified || list.Domains[0].Verified { |
| 109 | t.Fatalf("list = %s, want one unverified row", out) |
| 110 | } |
| 111 | row := list.Domains[0] |
| 112 | found := false |
| 113 | for _, r := range row.Records { |
| 114 | if r.Type == "TXT" && r.Name == "_carlos-challenge."+host && r.Value == wantToken && r.Required { |
| 115 | found = true |
| 116 | } |
| 117 | } |
| 118 | if !found || !strings.Contains(row.Guidance, wantToken) { |
| 119 | t.Fatalf("list row = %+v, want the TXT record in records (required) and in guidance", row) |
| 120 | } |
| 121 | |
| 122 | // Publish the record and attach again: verified, announced. |
| 123 | fx.srv.lookupTXT = txtFor(fx.srv, "bab", host) |
| 124 | code, _, out = rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) |
| 125 | if code != http.StatusOK { |
| 126 | t.Fatalf("re-attach = %d %s", code, out) |
| 127 | } |
| 128 | got = proofAttachResp{} |
| 129 | _ = json.Unmarshal([]byte(out), &got) |
| 130 | if got.Verified == nil || !*got.Verified || got.Verify.Name != "" { |
| 131 | t.Fatalf("re-attach answer = %s, want verified true and no verify record", out) |
| 132 | } |
| 133 | if c := claimOf(t, fx, host); c.Pending || c.PendingUntil != "" { |
| 134 | t.Fatalf("claim = %+v, want verified", *c) |
| 135 | } |
| 136 | if after := serialValue(t, fx); after == before { |
| 137 | t.Error("verifying the claim did not bump the serial") |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // Another account's proof is not this account's proof: the token is |
| 142 | // account-bound, so a record published for one account cannot be replayed. |
| 143 | func TestAPIDomainAttachRefusesAnotherAccountsProof(t *testing.T) { |
| 144 | fx, member := instanceFixture(t, "code-dom-replay") |
| 145 | fx.srv.cfg.DomainProof = true |
| 146 | const host = "shop.example.com" |
| 147 | fx.srv.lookupTXT = txtFor(fx.srv, "cdef", host) |
| 148 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(host, "go.tito.io")) |
| 149 | if code != http.StatusOK { |
| 150 | t.Fatalf("attach = %d %s", code, out) |
| 151 | } |
| 152 | if c := claimOf(t, fx, host); !c.Pending { |
| 153 | t.Fatalf("claim = %+v, want pending: the TXT record proves account cdef, not bab", *c) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // Squatting: an unproven claim no longer holds a name against its owner. A |
| 158 | // proven attach supersedes it, an unproven one meets the ordinary 409 — the |
| 159 | // same bytes a verified holder produces, so a refusal still never says how |
| 160 | // firmly a name is held. |
| 161 | func TestAPIDomainProvenOwnerSupersedesSquatter(t *testing.T) { |
| 162 | fx, member := instanceFixture(t, "code-dom-squat") |
| 163 | fx.srv.cfg.DomainProof = true |
| 164 | ctx := context.Background() |
| 165 | const squatted, held = "shop.example.com", "held.example.com" |
| 166 | if _, _, _, err := domains.AttachClaimProven(ctx, fx.rel, domains.Claim{ |
| 167 | Host: squatted, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com", |
| 168 | }, time.Now(), func() bool { return false }); err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| 172 | Host: held, Account: "cdef", App: "squat", Route: "x.cdef.oncarlos.com", |
| 173 | }); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | lookups := 0 |
| 177 | fx.srv.lookupTXT = noTXT(&lookups) |
| 178 | code1, _, body1 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io")) |
| 179 | code2, _, body2 := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")) |
| 180 | if code1 != http.StatusConflict || code1 != code2 || body1 != body2 { |
| 181 | t.Fatalf("unproven attach over a reservation = %d %q, over a verified claim = %d %q; want the same 409", code1, body1, code2, body2) |
| 182 | } |
| 183 | |
| 184 | fx.srv.lookupTXT = txtFor(fx.srv, "bab", squatted) |
| 185 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(squatted, "go.tito.io")) |
| 186 | if code != http.StatusOK { |
| 187 | t.Fatalf("proven attach over a reservation = %d %s, want 200", code, out) |
| 188 | } |
| 189 | if c := claimOf(t, fx, squatted); c.Account != "bab" || c.App != "titogo" || c.Pending { |
| 190 | t.Fatalf("claim = %+v, want bab/titogo verified", *c) |
| 191 | } |
| 192 | // A verified claim is still first-writer-wins against proof: proving |
| 193 | // control of a name someone else already proved is a support case, not |
| 194 | // something the door decides. |
| 195 | fx.srv.lookupTXT = txtFor(fx.srv, "bab", held) |
| 196 | if code, _, _ := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody(held, "go.tito.io")); code != http.StatusConflict { |
| 197 | t.Fatalf("proven attach over a verified claim = %d, want 409", code) |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | // The first-route branch writes NO record for an unproven claim, because the |
| 202 | // record is what turns the host into a real route; the attach that proves it |
| 203 | // declares the record then. |
| 204 | func TestAttachFirstRouteWaitsForProof(t *testing.T) { |
| 205 | fx, acct := attachFixture(t) |
| 206 | fx.srv.cfg.DomainProof = true |
| 207 | shipVersion(t, fx, "story", "v1") |
| 208 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 209 | const host = "story.example.com" |
| 210 | lookups := 0 |
| 211 | fx.srv.lookupTXT = noTXT(&lookups) |
| 212 | ops := &opStore{Store: fx.rel} |
| 213 | fx.srv.cfg.Releases = ops |
| 214 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 215 | if ref != nil { |
| 216 | t.Fatalf("attach refused %+v", ref) |
| 217 | } |
| 218 | if !res.Pending || res.FirstRoute || res.Verify.Name != domains.ChallengeName(host) { |
| 219 | t.Fatalf("result = %+v, want pending, no first route, and the proof record", res) |
| 220 | } |
| 221 | keys, err := fx.rel.List(context.Background(), provision.RecordsPrefix(fx.sqid, "story")) |
| 222 | if err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | if len(keys) != 0 { |
| 226 | t.Fatalf("records for an unproven first route = %v, want none", keys) |
| 227 | } |
| 228 | if ops.opIndex("put "+release.SerialKey) >= 0 { |
| 229 | t.Errorf("ops %v: serial bumped for a pending claim", ops.ops) |
| 230 | } |
| 231 | |
| 232 | fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host) |
| 233 | res, ref = fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 234 | if ref != nil { |
| 235 | t.Fatalf("re-attach refused %+v", ref) |
| 236 | } |
| 237 | if res.Pending || !res.FirstRoute { |
| 238 | t.Fatalf("result = %+v, want verified first route", res) |
| 239 | } |
| 240 | if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { |
| 241 | t.Fatalf("record after proof: %v", err) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // Item 2 of #215: another account's <sqid>.<apps-domain> namespace is refused |
| 246 | // whatever CONSOLE_RESERVE_PLATFORM_DOMAINS says, a first route may not be |
| 247 | // minted under a namespace no account holds yet (that would order a |
| 248 | // wildcard certificate for the invented label), and the account's own |
| 249 | // namespace needs no TXT proof — there is no zone the member could publish |
| 250 | // one in. |
| 251 | func TestAppsDomainNamespacesBelongToTheirAccounts(t *testing.T) { |
| 252 | fx, acct := attachFixture(t) |
| 253 | fx.srv.cfg.DomainProof = true |
| 254 | if fx.srv.cfg.ReservePlatformDomains { |
| 255 | t.Fatal("fixture reserves platform domains; this test is about deployments that do not") |
| 256 | } |
| 257 | if err := fx.st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", time.Now()); err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | shipVersion(t, fx, "story", "v1") |
| 261 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 262 | lookups := 0 |
| 263 | fx.srv.lookupTXT = noTXT(&lookups) |
| 264 | |
| 265 | for _, host := range []string{"new.cdef.oncarlos.com", "cdef.oncarlos.com", "a.b.cdef.oncarlos.com"} { |
| 266 | _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "", "", "paul@keymail.dev") |
| 267 | if ref == nil || ref.Status != http.StatusUnprocessableEntity || ref.Msg != "that hostname is platform-managed" { |
| 268 | t.Errorf("attach %s in another account's namespace = %+v, want the platform-managed refusal", host, ref) |
| 269 | } |
| 270 | if _, err := domains.GetClaim(context.Background(), fx.rel, host); err == nil { |
| 271 | t.Errorf("claim written for %s", host) |
| 272 | } |
| 273 | } |
| 274 | // The CLI door asks the same question. |
| 275 | if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) { |
| 276 | t.Error("domainHostDenied admits another account's namespace") |
| 277 | } |
| 278 | |
| 279 | // An unheld sqid-shaped namespace: no first route. |
| 280 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "x.zzz.oncarlos.com", "", "", "paul@keymail.dev"); ref == nil { |
| 281 | t.Error("first route under an unheld namespace was accepted") |
| 282 | } |
| 283 | |
| 284 | // The account's own namespace: attached and verified without a lookup. |
| 285 | own := "blog." + fx.sqid + ".oncarlos.com" |
| 286 | res, ref := fx.srv.attachDomain(context.Background(), acct, "story", own, "", "", "paul@keymail.dev") |
| 287 | if ref != nil { |
| 288 | t.Fatalf("attach in own namespace refused %+v", ref) |
| 289 | } |
| 290 | if res.Pending || !res.FirstRoute { |
| 291 | t.Errorf("own-namespace attach = %+v, want verified first route", res) |
| 292 | } |
| 293 | if lookups != 0 { |
| 294 | t.Errorf("TXT lookups = %d, want none: own namespace needs no proof", lookups) |
| 295 | } |
| 296 | |
| 297 | // A name outside every namespace is unchanged on a self-hosted |
| 298 | // deployment: attachable (pending proof like any custom domain). |
| 299 | fx.srv.cfg.Registry = attachRegistry(t, registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", |
| 300 | Kind: "static", App: "story", Account: fx.sqid, Channel: "canary/live", Status: "ready"}) |
| 301 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "www.oncarlos.com", "", "", "paul@keymail.dev"); ref != nil { |
| 302 | t.Errorf("alias attach of www.oncarlos.com refused %+v; self-hosted behaviour must not change", ref) |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | // serialValue reads control/serial, "" when absent. |
| 307 | func serialValue(t *testing.T, fx *consoleFixture) string { |
| 308 | t.Helper() |
| 309 | b, _, err := fx.rel.Get(context.Background(), release.SerialKey) |
| 310 | if err != nil { |
| 311 | return "" |
| 312 | } |
| 313 | return string(b) |
| 314 | } |
| 315 | |
| 316 | // With CONSOLE_DOMAIN_PROOF unset — the default — nothing about attach |
| 317 | // changes (#215 keeps the 2026-08-26/27 ruling until a deployment opts in): |
| 318 | // no TXT lookup is ever made, every claim is written verified and announced, |
| 319 | // a first route declares its record at once, and the answer says verified. |
| 320 | func TestDomainProofOffBehavesAsBefore(t *testing.T) { |
| 321 | fx, member := instanceFixture(t, "code-dom-proof-off") |
| 322 | if fx.srv.cfg.DomainProof { |
| 323 | t.Fatal("DomainProof is on by default") |
| 324 | } |
| 325 | lookups := 0 |
| 326 | fx.srv.lookupTXT = noTXT(&lookups) |
| 327 | before := serialValue(t, fx) |
| 328 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, attachBody("tickets.example.com", "go.tito.io")) |
| 329 | if code != http.StatusOK { |
| 330 | t.Fatalf("attach = %d %s", code, out) |
| 331 | } |
| 332 | var got proofAttachResp |
| 333 | _ = json.Unmarshal([]byte(out), &got) |
| 334 | if got.Verified == nil || !*got.Verified || got.Verify.Name != "" { |
| 335 | t.Fatalf("attach answer = %s, want verified true and no verify record", out) |
| 336 | } |
| 337 | if c := claimOf(t, fx, "tickets.example.com"); c.Pending || c.PendingUntil != "" { |
| 338 | t.Fatalf("claim = %+v, want verified", *c) |
| 339 | } |
| 340 | if serialValue(t, fx) == before { |
| 341 | t.Error("attach did not bump the serial") |
| 342 | } |
| 343 | |
| 344 | afx, acct := attachFixture(t) |
| 345 | afx.srv.lookupTXT = noTXT(&lookups) |
| 346 | shipVersion(t, afx, "story", "v1") |
| 347 | seedChannel(t, afx, "story", "canary/live", "v1") |
| 348 | res, ref := afx.srv.attachDomain(context.Background(), acct, "story", "story.example.com", "", "", "paul@keymail.dev") |
| 349 | if ref != nil || res.Pending || !res.FirstRoute { |
| 350 | t.Fatalf("first-route attach = %+v / %+v, want a verified first route", res, ref) |
| 351 | } |
| 352 | if _, err := provision.GetRecord(context.Background(), afx.rel, afx.sqid, "story", "story.example.com"); err != nil { |
| 353 | t.Fatalf("record: %v", err) |
| 354 | } |
| 355 | if lookups != 0 { |
| 356 | t.Errorf("TXT lookups = %d with proof off, want none", lookups) |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | // A plain re-attach of one's own PENDING first-route claim (no route, no |
| 361 | // kind — what `carlos domains attach host` sends) must look for the proof. |
| 362 | // The own-first-route short-circuit used to answer it {created:false} with |
| 363 | // no verified field, which the CLI reads as attached (#215 review). |
| 364 | func TestAPIDomainPlainReattachVerifiesPendingFirstRoute(t *testing.T) { |
| 365 | fx, acct := attachFixture(t) |
| 366 | fx.srv.cfg.DomainProof = true |
| 367 | shipVersion(t, fx, "story", "v1") |
| 368 | seedChannel(t, fx, "story", "canary/live", "v1") |
| 369 | if _, err := fx.st.CreateAppIn(OpsAccountID, "story", "paul@keymail.dev", time.Now()); err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | token := cliToken(t, fx, "paul@keymail.dev", "code-dom-plain-reattach") |
| 373 | const host = "story.example.com" |
| 374 | lookups := 0 |
| 375 | fx.srv.lookupTXT = noTXT(&lookups) |
| 376 | if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", host, "static", "", "paul@keymail.dev"); ref != nil { |
| 377 | t.Fatalf("seed attach refused %+v", ref) |
| 378 | } |
| 379 | if c := claimOf(t, fx, host); !c.Pending || c.Route != "" { |
| 380 | t.Fatalf("seed claim = %+v, want a pending first-route claim", *c) |
| 381 | } |
| 382 | |
| 383 | post := func() proofAttachResp { |
| 384 | t.Helper() |
| 385 | code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, fx.sqid, "story"), token, attachBody(host, "")) |
| 386 | if code != http.StatusOK { |
| 387 | t.Fatalf("plain re-attach = %d %s", code, out) |
| 388 | } |
| 389 | var got proofAttachResp |
| 390 | if err := json.Unmarshal([]byte(out), &got); err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | return got |
| 394 | } |
| 395 | lookups = 0 |
| 396 | if got := post(); got.Verified == nil || *got.Verified || lookups != 1 { |
| 397 | t.Fatalf("plain re-attach without proof = %+v after %d lookups, want verified false after one", got, lookups) |
| 398 | } |
| 399 | fx.srv.lookupTXT = txtFor(fx.srv, fx.sqid, host) |
| 400 | if got := post(); got.Verified == nil || !*got.Verified || got.Route != host { |
| 401 | t.Fatalf("plain re-attach with proof = %+v, want verified first route", got) |
| 402 | } |
| 403 | if _, err := provision.GetRecord(context.Background(), fx.rel, fx.sqid, "story", host); err != nil { |
| 404 | t.Fatalf("record after proof: %v", err) |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | // A deleted account's namespace stays reserved (#215 review): its tombstone |
| 409 | // keeps the sqid from being reissued, and a name under it would still serve |
| 410 | // through the apps-domain wildcard looking like that account's address. |
| 411 | func TestDeletedAccountsNamespaceStaysReserved(t *testing.T) { |
| 412 | fx, _ := attachFixture(t) |
| 413 | if err := fx.st.CreateAccount(42, "cdef", "Gone", "sam@keymail.dev", time.Now()); err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | if err := fx.st.DeleteAccount(42, time.Now()); err != nil { |
| 417 | t.Fatal(err) |
| 418 | } |
| 419 | if a, _ := fx.st.AccountBySqid("cdef"); a != nil { |
| 420 | t.Fatal("account not deleted") |
| 421 | } |
| 422 | if !fx.srv.domainHostDenied("new.cdef.oncarlos.com", fx.sqid) { |
| 423 | t.Error("a deleted account's namespace is attachable") |
| 424 | } |
| 425 | } |
| 426 | |