carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// End-to-end acceptance for the domain-attach half of #82 (Task 8), driven
2// through the REAL binary against a REAL console.Server: `carlos deploy
3// -kind static -host <h>` attaches an unclaimed custom domain on its way
4// through (claim + declared record in the bucket), a second attach from a
5// DIFFERENT account is refused with the neutral "hostname unavailable" and
6// nothing else, and `carlos domains list -app` shows the member their own
7// claim. Subprocesses throughout, because the feature's surface is the
8// binary's own composition (deploy → attach POST → ship → promote → watch →
9// DNS guidance) and the refusal text on a real stderr is the thing the spec
10// pins.
11//
12// Env discipline: every subprocess builds its env through carlosTestEnv AND
13// sets cmd.Dir to a scratch dir (the repo rule — the binary walks cwd
14// ancestors for .carlos/config, and a logged-in laptop once minted 40+ real
15// accounts on the production console). The explicit CARLOS_CREDENTIALS
16// appended after carlosTestEnv's own wins (os/exec keeps the LAST duplicate),
17// landing each subprocess on exactly the credential file this test minted.
18package main
19
20import (
21 "context"
22 "errors"
23 "net/http"
24 "net/http/httptest"
25 "os"
26 "os/exec"
27 "path/filepath"
28 "strings"
29 "testing"
30 "time"
31
32 "github.com/carlosframework/platform/internal/console"
33 "github.com/carlosframework/platform/internal/domains"
34 "github.com/carlosframework/platform/internal/provision"
35 "github.com/carlosframework/platform/internal/release"
36)
37
38// e2eWriteCredential mints a credential file at path for a device-flow token
39// against consoleURL — the state `carlos auth login` leaves behind, minted
40// through the console's real approve/poll handlers (e2eDeviceLogin).
41func e2eWriteCredential(t *testing.T, path, consoleURL, addr, token string, expiresAt time.Time) {
42 t.Helper()
43 t.Setenv("CARLOS_CREDENTIALS", path)
44 credFile, err := loadCredentials()
45 if err != nil {
46 t.Fatal(err)
47 }
48 credFile.put(credential{Console: consoleURL, Address: addr, Token: token, ExpiresAt: expiresAt})
49 if err := credFile.save(); err != nil {
50 t.Fatal(err)
51 }
52}
53
54func TestE2EDomainsAttachThroughDeploy(t *testing.T) {
55 bin := buildCarlos(t)
56 const (
57 ownerAddr = "paul@keymail.dev"
58 rivalAddr = "rival@keymail.dev"
59 app = "sitely"
60 rivalApp = "rival"
61 channel = "canary/site"
62 // .test never resolves, so the watch leg's connection-class failure —
63 // the shape a domain nobody has pointed yet produces — is hermetic.
64 host = "e2e-domains.example.test"
65 )
66
67 tmp := t.TempDir()
68 relStore := release.NewDirStore(filepath.Join(tmp, "store"))
69 seed, pubB64, err := release.GenerateKey()
70 if err != nil {
71 t.Fatal(err)
72 }
73 priv, err := release.ParseSigningKey(seed, "e2e")
74 if err != nil {
75 t.Fatal(err)
76 }
77 pub, err := release.ParsePublicKey(pubB64, "e2e")
78 if err != nil {
79 t.Fatal(err)
80 }
81 cst, err := console.Open(filepath.Join(tmp, "console.db"))
82 if err != nil {
83 t.Fatal(err)
84 }
85 t.Cleanup(func() { cst.Close() })
86
87 var srv *console.Server
88 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
89 srv.ServeHTTP(w, r)
90 }))
91 t.Cleanup(ts.Close)
92 srv, err = console.NewServer(console.Config{
93 Origin: ts.URL,
94 Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
95 Releases: relStore,
96 ReleasePub: pub,
97 Key: priv,
98 AppScheme: "http",
99 AppsDomain: "oncarlos.test",
100 }, cst)
101 if err != nil {
102 t.Fatal(err)
103 }
104
105 // Account 1: the ops owner and the static app the deploy targets.
106 if err := cst.AddMember(console.OpsAccountID, ownerAddr, console.RoleOwner, time.Now()); err != nil {
107 t.Fatal(err)
108 }
109 if _, err := cst.CreateAppIn(console.OpsAccountID, app, ownerAddr, time.Now()); err != nil {
110 t.Fatal(err)
111 }
112 // Account 2: a different tenant entirely — the prober the neutrality
113 // half of this test speaks as.
114 const rivalID, rivalSqid = 42, "cdef"
115 if err := cst.CreateAccount(rivalID, rivalSqid, "Rival", rivalAddr, time.Now()); err != nil {
116 t.Fatal(err)
117 }
118 if _, err := cst.CreateAppIn(rivalID, rivalApp, rivalAddr, time.Now()); err != nil {
119 t.Fatal(err)
120 }
121
122 ownerToken, ownerExp := e2eDeviceLogin(t, ts.URL, cst, ownerAddr, "e2e@owner")
123 rivalToken, rivalExp := e2eDeviceLogin(t, ts.URL, cst, rivalAddr, "e2e@rival")
124 ownerCreds := filepath.Join(t.TempDir(), "owner-credentials")
125 rivalCreds := filepath.Join(t.TempDir(), "rival-credentials")
126 e2eWriteCredential(t, ownerCreds, ts.URL, ownerAddr, ownerToken, ownerExp)
127 e2eWriteCredential(t, rivalCreds, ts.URL, rivalAddr, rivalToken, rivalExp)
128
129 siteDir := t.TempDir()
130 if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil {
131 t.Fatal(err)
132 }
133
134 run := func(creds string, args ...string) (string, error) {
135 cmd := exec.Command(bin, args...)
136 cmd.Env = carlosTestEnv(t, "CARLOS_CREDENTIALS="+creds,
137 "CARLOS_DEPLOYMENT_BUCKET=", "CARLOS_DEPLOYMENT_DIR=")
138 cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule
139 out, err := cmd.CombinedOutput()
140 return string(out), err
141 }
142
143 // --- 1. deploy to a fresh static host: the attach happens on the way --
144 out, err := run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static",
145 "-version", "sha0001", "-channel", channel, "-host", host, "-console", ts.URL,
146 "-timeout", "3s", siteDir)
147 // The deploy itself succeeds (attach + ship + promote) and then exits
148 // NONZERO on the watch: nobody has pointed DNS for the host, and a zero
149 // exit would claim a URL that does not serve. The exit text is the DNS
150 // guidance, not "deploy failed".
151 if err == nil {
152 t.Fatalf("deploy to an unpointed domain exited 0:\n%s", out)
153 }
154 for _, want := range []string{
155 "attached " + host + " (first route: static, channel " + channel + ")",
156 "shipped " + app + " sha0001",
157 "promoted " + app + " sha0001 -> " + channel,
158 "DNS for " + host + " is not pointing at this deployment yet",
159 } {
160 if !strings.Contains(out, want) {
161 t.Fatalf("deploy output missing %q:\n%s", want, out)
162 }
163 }
164 // The two bucket writes the attach is made of, read back directly.
165 claim, err := domains.GetClaim(context.Background(), relStore, host)
166 if err != nil {
167 t.Fatalf("claim after deploy: %v", err)
168 }
169 if claim.Account != release.OpsAccount || claim.App != app || claim.Route != "" {
170 t.Fatalf("claim = %+v, want a first-route claim (empty route) held by ops/%s", claim, app)
171 }
172 rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host)
173 if err != nil {
174 t.Fatalf("record after deploy: %v", err)
175 }
176 if rec.Kind != "static" || rec.Channel != channel || rec.Slug != "" {
177 t.Fatalf("record = %+v, want a static record on %s", *rec, channel)
178 }
179
180 // --- 1b. a SECOND static host on the same app: the first deploy's
181 // record must not pull this one off the static path (the kind-aware
182 // gate), and the fresh host attaches through the same door on the way.
183 const second = "e2e-domains-more.example.test"
184 out, err = run(ownerCreds, "deploy", "-app", app, "-account", "ops", "-kind", "static",
185 "-version", "sha0002", "-channel", channel, "-host", second, "-console", ts.URL,
186 "-timeout", "3s", siteDir)
187 if err == nil {
188 t.Fatalf("second-host deploy to an unpointed domain exited 0:\n%s", out)
189 }
190 for _, want := range []string{
191 "attached " + second,
192 "DNS for " + second + " is not pointing at this deployment yet",
193 } {
194 if !strings.Contains(out, want) {
195 t.Fatalf("second-host deploy output missing %q:\n%s", want, out)
196 }
197 }
198 if _, err := domains.GetClaim(context.Background(), relStore, second); err != nil {
199 t.Fatalf("claim for the second host: %v", err)
200 }
201 if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, second); err != nil {
202 t.Fatalf("record for the second host: %v", err)
203 }
204
205 // --- 2. the same host from ANOTHER account: neutral refusal ----------
206 out, err = run(rivalCreds, "domains", "attach", "-app", rivalApp, "-account", rivalSqid,
207 "-route", "web.cdef.oncarlos.test", "-console", ts.URL, host)
208 if err == nil {
209 t.Fatalf("rival attach of a held host exited 0:\n%s", out)
210 }
211 if !strings.Contains(out, "hostname unavailable") {
212 t.Fatalf("rival attach refusal missing the neutral \"hostname unavailable\":\n%s", out)
213 }
214 // Neutral means NEUTRAL: no owner, no account, no app of the holder's in
215 // the refusal — the door must not be a hostname-ownership oracle.
216 for _, leak := range []string{release.OpsAccount, app, ownerAddr} {
217 if strings.Contains(out, leak) {
218 t.Fatalf("refusal leaks the holder (%q):\n%s", leak, out)
219 }
220 }
221 // And the claim is untouched.
222 if after, err := domains.GetClaim(context.Background(), relStore, host); err != nil ||
223 after.Account != release.OpsAccount || after.App != app {
224 t.Fatalf("claim after rival attach = %+v (err %v), want the original untouched", after, err)
225 }
226
227 // --- 3. the owner lists it -------------------------------------------
228 out, err = run(ownerCreds, "domains", "list", "-app", app, "-account", "ops", "-console", ts.URL)
229 if err != nil {
230 t.Fatalf("domains list: %v\n%s", err, out)
231 }
232 // "checking" is the #299 state for a claim no edge has reported on yet —
233 // the ordinary first few minutes after an attach, and what this
234 // just-attached host must read as.
235 if !strings.Contains(out, host) || !strings.Contains(out, "checking") {
236 t.Fatalf("domains list missing %s as checking:\n%s", host, out)
237 }
238
239 // --- 4. detach releases it, and the record goes with it --------------
240 out, err = run(ownerCreds, "domains", "detach", "-app", app, "-account", "ops", "-console", ts.URL, host)
241 if err != nil {
242 t.Fatalf("domains detach: %v\n%s", err, out)
243 }
244 if !strings.Contains(out, "detached "+host) {
245 t.Fatalf("detach output:\n%s", out)
246 }
247 if _, err := domains.GetClaim(context.Background(), relStore, host); !errors.Is(err, os.ErrNotExist) {
248 t.Fatalf("claim after detach: %v, want gone", err)
249 }
250 // The record goes with the claim (detach's record-then-claim order): a
251 // record outliving its claim would keep a route materializing for a
252 // host anyone may now claim.
253 if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host); !errors.Is(err, os.ErrNotExist) {
254 t.Fatalf("record after detach: %v, want gone", err)
255 }
256}
257