// End-to-end acceptance for the domain-attach half of #82 (Task 8), driven // through the REAL binary against a REAL console.Server: `carlos deploy // -kind static -host ` attaches an unclaimed custom domain on its way // through (claim + declared record in the bucket), a second attach from a // DIFFERENT account is refused with the neutral "hostname unavailable" and // nothing else, and `carlos domains list -app` shows the member their own // claim. Subprocesses throughout, because the feature's surface is the // binary's own composition (deploy → attach POST → ship → promote → watch → // DNS guidance) and the refusal text on a real stderr is the thing the spec // pins. // // Env discipline: every subprocess builds its env through carlosTestEnv AND // sets cmd.Dir to a scratch dir (the repo rule — the binary walks cwd // ancestors for .carlos/config, and a logged-in laptop once minted 40+ real // accounts on the production console). The explicit CARLOS_CREDENTIALS // appended after carlosTestEnv's own wins (os/exec keeps the LAST duplicate), // landing each subprocess on exactly the credential file this test minted. package main import ( "context" "errors" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // e2eWriteCredential mints a credential file at path for a device-flow token // against consoleURL — the state `carlos auth login` leaves behind, minted // through the console's real approve/poll handlers (e2eDeviceLogin). func e2eWriteCredential(t *testing.T, path, consoleURL, addr, token string, expiresAt time.Time) { t.Helper() t.Setenv("CARLOS_CREDENTIALS", path) credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: consoleURL, Address: addr, Token: token, ExpiresAt: expiresAt}) if err := credFile.save(); err != nil { t.Fatal(err) } } func TestE2EDomainsAttachThroughDeploy(t *testing.T) { bin := buildCarlos(t) const ( ownerAddr = "paul@keymail.dev" rivalAddr = "rival@keymail.dev" app = "sitely" rivalApp = "rival" channel = "canary/site" // .test never resolves, so the watch leg's connection-class failure — // the shape a domain nobody has pointed yet produces — is hermetic. host = "e2e-domains.example.test" ) tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "e2e") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } cst, err := console.Open(filepath.Join(tmp, "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { cst.Close() }) var srv *console.Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })) t.Cleanup(ts.Close) srv, err = console.NewServer(console.Config{ Origin: ts.URL, Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", }, cst) if err != nil { t.Fatal(err) } // Account 1: the ops owner and the static app the deploy targets. if err := cst.AddMember(console.OpsAccountID, ownerAddr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, app, ownerAddr, time.Now()); err != nil { t.Fatal(err) } // Account 2: a different tenant entirely — the prober the neutrality // half of this test speaks as. const rivalID, rivalSqid = 42, "cdef" if err := cst.CreateAccount(rivalID, rivalSqid, "Rival", rivalAddr, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(rivalID, rivalApp, rivalAddr, time.Now()); err != nil { t.Fatal(err) } ownerToken, ownerExp := e2eDeviceLogin(t, ts.URL, cst, ownerAddr, "e2e@owner") rivalToken, rivalExp := e2eDeviceLogin(t, ts.URL, cst, rivalAddr, "e2e@rival") ownerCreds := filepath.Join(t.TempDir(), "owner-credentials") rivalCreds := filepath.Join(t.TempDir(), "rival-credentials") e2eWriteCredential(t, ownerCreds, ts.URL, ownerAddr, ownerToken, ownerExp) e2eWriteCredential(t, rivalCreds, ts.URL, rivalAddr, rivalToken, rivalExp) siteDir := t.TempDir() if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } run := func(creds string, args ...string) (string, error) { cmd := exec.Command(bin, args...) cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds, "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=") cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule out, err := cmd.CombinedOutput() return string(out), err } // --- 1. deploy to a fresh static host: the attach happens on the way -- out, err := run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-host", host, "-console", ts.URL, "-timeout", "3s", siteDir) // The deploy itself succeeds (attach + ship + promote) and then exits // NONZERO on the watch: nobody has pointed DNS for the host, and a zero // exit would claim a URL that does not serve. The exit text is the DNS // guidance, not "deploy failed". if err == nil { t.Fatalf("deploy to an unpointed domain exited 0:\n%s", out) } for _, want := range []string{ "attached " + host + " (first route: static, channel " + channel + ")", "shipped " + app + " sha0001", "promoted " + app + " sha0001 -> " + channel, "DNS for " + host + " is not pointing at this deployment yet", } { if !strings.Contains(out, want) { t.Fatalf("deploy output missing %q:\n%s", want, out) } } // The two bucket writes the attach is made of, read back directly. claim, err := domains.GetClaim(context.Background(), relStore, host) if err != nil { t.Fatalf("claim after deploy: %v", err) } if claim.Account != release.OpsAccount || claim.App != app || claim.Route != "" { t.Fatalf("claim = %+v, want a first-route claim (empty route) held by ops/%s", claim, app) } rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host) if err != nil { t.Fatalf("record after deploy: %v", err) } if rec.Kind != "static" || rec.Channel != channel || rec.Slug != "" { t.Fatalf("record = %+v, want a static record on %s", *rec, channel) } // --- 1b. a SECOND static host on the same app: the first deploy's // record must not pull this one off the static path (the kind-aware // gate), and the fresh host attaches through the same door on the way. const second = "e2e-domains-more.example.test" out, err = run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", "-channel", channel, "-host", second, "-console", ts.URL, "-timeout", "3s", siteDir) if err == nil { t.Fatalf("second-host deploy to an unpointed domain exited 0:\n%s", out) } for _, want := range []string{ "attached " + second, "DNS for " + second + " is not pointing at this deployment yet", } { if !strings.Contains(out, want) { t.Fatalf("second-host deploy output missing %q:\n%s", want, out) } } if _, err := domains.GetClaim(context.Background(), relStore, second); err != nil { t.Fatalf("claim for the second host: %v", err) } if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, second); err != nil { t.Fatalf("record for the second host: %v", err) } // --- 2. the same host from ANOTHER account: neutral refusal ---------- out, err = run(rivalCreds, "domains", "attach", "-app", rivalApp, "-account", rivalSqid, "-route", "web.cdef.oncarlos.test", "-console", ts.URL, host) if err == nil { t.Fatalf("rival attach of a held host exited 0:\n%s", out) } if !strings.Contains(out, "hostname unavailable") { t.Fatalf("rival attach refusal missing the neutral \"hostname unavailable\":\n%s", out) } // Neutral means NEUTRAL: no owner, no account, no app of the holder's in // the refusal — the door must not be a hostname-ownership oracle. for _, leak := range []string{release.OpsAccount, app, ownerAddr} { if strings.Contains(out, leak) { t.Fatalf("refusal leaks the holder (%q):\n%s", leak, out) } } // And the claim is untouched. if after, err := domains.GetClaim(context.Background(), relStore, host); err != nil || after.Account != release.OpsAccount || after.App != app { t.Fatalf("claim after rival attach = %+v (err %v), want the original untouched", after, err) } // --- 3. the owner lists it ------------------------------------------- out, err = run(ownerCreds, "domains", "list", "-app", app, "-account", "ops", "-console", ts.URL) if err != nil { t.Fatalf("domains list: %v\n%s", err, out) } // "checking" is the #299 state for a claim no edge has reported on yet — // the ordinary first few minutes after an attach, and what this // just-attached host must read as. if !strings.Contains(out, host) || !strings.Contains(out, "checking") { t.Fatalf("domains list missing %s as checking:\n%s", host, out) } // --- 4. detach releases it, and the record goes with it -------------- out, err = run(ownerCreds, "domains", "detach", "-app", app, "-account", "ops", "-console", ts.URL, host) if err != nil { t.Fatalf("domains detach: %v\n%s", err, out) } if !strings.Contains(out, "detached "+host) { t.Fatalf("detach output:\n%s", out) } if _, err := domains.GetClaim(context.Background(), relStore, host); !errors.Is(err, os.ErrNotExist) { t.Fatalf("claim after detach: %v, want gone", err) } // The record goes with the claim (detach's record-then-claim order): a // record outliving its claim would keep a route materializing for a // host anyone may now claim. if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host); !errors.Is(err, os.ErrNotExist) { t.Fatalf("record after detach: %v, want gone", err) } }