| new file mode 100644 |
| index 000000000..9e0b26919 |
| --- /dev/null |
| +++ b/domains_e2e_test.go |
| @@ -0,0 +1,228 @@ |
| +// 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 <h>` 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) |
| + } |
| + |
| + // --- 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) |
| + } |
| + if !strings.Contains(out, host) || !strings.Contains(out, "pending") { |
| + t.Fatalf("domains list missing %s as pending:\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) |
| + } |
| +} |