| 1 | package domains |
| 2 | |
| 3 | // Internal-package tests for reconcile.go's unexported helpers |
| 4 | // (hostCertExpiry, mintedRoute, materializeAccounts): reconcile_test.go |
| 5 | // is package domains_test and cannot reach these, so this file follows |
| 6 | // slugname_test.go's existing convention of a same-package test file living |
| 7 | // alongside it. |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "context" |
| 12 | "crypto/ecdsa" |
| 13 | "crypto/elliptic" |
| 14 | "crypto/rand" |
| 15 | "crypto/x509" |
| 16 | "crypto/x509/pkix" |
| 17 | "encoding/pem" |
| 18 | "errors" |
| 19 | "io" |
| 20 | "log" |
| 21 | "math/big" |
| 22 | "net" |
| 23 | "os" |
| 24 | "path/filepath" |
| 25 | "testing" |
| 26 | "time" |
| 27 | |
| 28 | "github.com/carlosframework/platform/internal/registry" |
| 29 | "github.com/carlosframework/platform/internal/release" |
| 30 | ) |
| 31 | |
| 32 | // writeWildcardPEM writes a self-signed cert for name valid until notAfter, |
| 33 | // in the acme-cache bundle layout certExpiry parses: key block first, then |
| 34 | // the certificate. |
| 35 | func writeWildcardPEM(t *testing.T, path, name string, notAfter time.Time) { |
| 36 | t.Helper() |
| 37 | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | tmpl := &x509.Certificate{ |
| 42 | SerialNumber: big.NewInt(1), |
| 43 | Subject: pkix.Name{CommonName: name}, |
| 44 | DNSNames: []string{name}, |
| 45 | NotBefore: time.Now().Add(-time.Hour), |
| 46 | NotAfter: notAfter, |
| 47 | } |
| 48 | der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | keyDER, err := x509.MarshalECPrivateKey(key) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | var buf bytes.Buffer |
| 57 | if err := pem.Encode(&buf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // A wildcard-covered host has no per-host acme cache entry — its expiry must |
| 69 | // come from the covering wildcard-<parent>.pem instead of reporting 0 |
| 70 | // (certless) forever. |
| 71 | func TestCertExpiryWildcardFallback(t *testing.T) { |
| 72 | dir := t.TempDir() |
| 73 | // certExpiry parses "key PEM block(s) first, then CERTIFICATE" — build a |
| 74 | // minimal self-signed cert the same way the edge's wildcard PEM is laid |
| 75 | // out. Reuse/adapt the cert-building helper if reconcile_test.go already |
| 76 | // has one; otherwise inline ecdsa+x509 self-sign exactly as in |
| 77 | // internal/edge/wildcard_test.go's selfSignedWildcard. |
| 78 | notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second) |
| 79 | writeWildcardPEM(t, filepath.Join(dir, "wildcard-abs.oncarlos.com.pem"), "*.abs.oncarlos.com", notAfter) |
| 80 | |
| 81 | host := "kass.abs.oncarlos.com" |
| 82 | if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() { |
| 83 | t.Fatalf("hostCertExpiry = %d, want %d (wildcard fallback)", got, notAfter.Unix()) |
| 84 | } |
| 85 | // A grandfathered per-host file that expires SOONER than the wildcard |
| 86 | // (autocert stopped renewing it once the wildcard took over serving the |
| 87 | // host): the wildcard's later expiry must win, not the stale file — the |
| 88 | // edge serves the wildcard, so status must not shadow that with a cert |
| 89 | // nothing serves anymore. |
| 90 | stalePerHost := time.Now().Add(20 * 24 * time.Hour).Truncate(time.Second) |
| 91 | writeWildcardPEM(t, filepath.Join(dir, host), host, stalePerHost) |
| 92 | if got := hostCertExpiry(dir, host, "oncarlos.com"); got != notAfter.Unix() { |
| 93 | t.Fatalf("hostCertExpiry with stale per-host file = %d, want %d (wildcard's later expiry wins)", got, notAfter.Unix()) |
| 94 | } |
| 95 | // A per-host entry that is LATER than the wildcard still wins. |
| 96 | perHost := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second) |
| 97 | writeWildcardPEM(t, filepath.Join(dir, host), host, perHost) |
| 98 | if got := hostCertExpiry(dir, host, "oncarlos.com"); got != perHost.Unix() { |
| 99 | t.Fatalf("hostCertExpiry with per-host file = %d, want %d", got, perHost.Unix()) |
| 100 | } |
| 101 | // Nothing at all → 0, unknown, never a guess. |
| 102 | if got := hostCertExpiry(dir, "kass.zzz.oncarlos.com", "oncarlos.com"); got != 0 { |
| 103 | t.Fatalf("hostCertExpiry with no files = %d, want 0", got) |
| 104 | } |
| 105 | |
| 106 | // A host under a NON-apps-domain wildcard parent (static go.tito.io / |
| 107 | // delegated woodstar.app): the fallback must strip one label and try |
| 108 | // the parent's wildcard PEM, not require the apps-domain shape. |
| 109 | delegatedNotAfter := time.Now().Add(60 * 24 * time.Hour).Truncate(time.Second) |
| 110 | writeWildcardPEM(t, filepath.Join(dir, "wildcard-woodstar.app.pem"), "*.woodstar.app", delegatedNotAfter) |
| 111 | if got := hostCertExpiry(dir, "paul.woodstar.app", "oncarlos.com"); got != delegatedNotAfter.Unix() { |
| 112 | t.Fatalf("delegated-parent fallback = %d, want %d", got, delegatedNotAfter.Unix()) |
| 113 | } |
| 114 | // The apex itself must NOT match its own wildcard. |
| 115 | if got := hostCertExpiry(dir, "woodstar.app", "oncarlos.com"); got != 0 { |
| 116 | t.Fatalf("apex must not claim the wildcard: got %d, want 0", got) |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // A host whose only cert is the RSA variant (autocert writes <host>+rsa |
| 121 | // alongside <host> when a client can't do ECDSA) has a cert. Reading it as |
| 122 | // certless would hold it at "issuing cert" forever. |
| 123 | func TestHostCertExpiryFindsTheRSAVariant(t *testing.T) { |
| 124 | dir := t.TempDir() |
| 125 | notAfter := time.Now().Add(45 * 24 * time.Hour).Truncate(time.Second) |
| 126 | writeWildcardPEM(t, filepath.Join(dir, "shop.customer.com+rsa"), "shop.customer.com", notAfter) |
| 127 | if got := hostCertExpiry(dir, "shop.customer.com", "oncarlos.com"); got != notAfter.Unix() { |
| 128 | t.Fatalf("hostCertExpiry = %d, want %d (rsa variant)", got, notAfter.Unix()) |
| 129 | } |
| 130 | |
| 131 | // The ECDSA per-host file, when it also exists and is LATER, still wins. |
| 132 | later := time.Now().Add(80 * 24 * time.Hour).Truncate(time.Second) |
| 133 | writeWildcardPEM(t, filepath.Join(dir, "shop.customer.com"), "shop.customer.com", later) |
| 134 | if got := hostCertExpiry(dir, "shop.customer.com", "oncarlos.com"); got != later.Unix() { |
| 135 | t.Fatalf("hostCertExpiry with later ecdsa file = %d, want %d", got, later.Unix()) |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // An authoritative "no such name" is the customer's record not existing yet. |
| 140 | // A resolver FAILURE is ours. Collapsing both to "" makes the status machine |
| 141 | // blame the customer for our outage. |
| 142 | func TestDNSStatusSeparatesNoRecordFromLookupFailure(t *testing.T) { |
| 143 | edge := map[string]bool{"1.1.1.1": true} |
| 144 | |
| 145 | notFound := func(string) ([]string, error) { |
| 146 | return nil, &net.DNSError{Err: "no such host", IsNotFound: true} |
| 147 | } |
| 148 | if got := dnsStatus(notFound, "absent.customer.com", edge); got != "" { |
| 149 | t.Fatalf("NXDOMAIN = %q, want \"\" (no record yet)", got) |
| 150 | } |
| 151 | |
| 152 | servfail := func(string) ([]string, error) { |
| 153 | return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true} |
| 154 | } |
| 155 | if got := dnsStatus(servfail, "unknown.customer.com", edge); got != "unresolved" { |
| 156 | t.Fatalf("resolver failure = %q, want \"unresolved\" — our fault, not the customer's", got) |
| 157 | } |
| 158 | |
| 159 | plain := func(string) ([]string, error) { |
| 160 | return nil, errors.New("boom") |
| 161 | } |
| 162 | if got := dnsStatus(plain, "other.customer.com", edge); got != "unresolved" { |
| 163 | t.Fatalf("non-DNSError failure = %q, want \"unresolved\"", got) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // TestMintedRouteCarriesRegionAndDropsSocketWhenFleetPlaced: a minted |
| 168 | // platform apex must carry its source's Region so the edge dispatches it |
| 169 | // through the same fleet channel — but when that Region is a fleet |
| 170 | // placement, the source's Socket is a path on the FLEET BOX, not this |
| 171 | // platform process, so it must NOT be copied onto the mint (I1, final |
| 172 | // customer-fleets review). |
| 173 | func TestMintedRouteCarriesRegionAndDropsSocketWhenFleetPlaced(t *testing.T) { |
| 174 | src := registry.Route{ |
| 175 | Kind: "instance", |
| 176 | Socket: "/run/carlos/acme.sock", |
| 177 | Region: registry.PlacementTarget("pi"), |
| 178 | Status: "ready", |
| 179 | App: "acme", |
| 180 | Account: "abs", |
| 181 | } |
| 182 | rt := mintedRoute("acme.abs.oncarlos.com", "acme", "abs", src) |
| 183 | if rt.Region != src.Region { |
| 184 | t.Fatalf("mintedRoute Region = %q, want %q (fleet-placed source)", rt.Region, src.Region) |
| 185 | } |
| 186 | if rt.Socket != "" { |
| 187 | t.Fatalf("mintedRoute Socket = %q, want empty — a fleet-placed source's socket lives on the fleet box, not here", rt.Socket) |
| 188 | } |
| 189 | |
| 190 | // A plain (non-fleet) Region is still copied, and Socket is still copied |
| 191 | // too, unchanged from today's behavior — only the fleet-placed case |
| 192 | // clears it. |
| 193 | src2 := registry.Route{ |
| 194 | Kind: "instance", |
| 195 | Socket: "/run/carlos/acme.sock", |
| 196 | Region: "ie", |
| 197 | Status: "ready", |
| 198 | App: "acme", |
| 199 | Account: "abs", |
| 200 | } |
| 201 | rt2 := mintedRoute("acme.abs.oncarlos.com", "acme", "abs", src2) |
| 202 | if rt2.Region != "ie" { |
| 203 | t.Fatalf("mintedRoute Region = %q, want %q (plain region)", rt2.Region, "ie") |
| 204 | } |
| 205 | if rt2.Socket != src2.Socket { |
| 206 | t.Fatalf("mintedRoute Socket = %q, want %q — a non-fleet source's socket is still copied", rt2.Socket, src2.Socket) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // A failed LOCAL row write does not unmake an account. The manifest is the |
| 211 | // authority on which accounts exist; the registry row is only this box's copy |
| 212 | // of that fact. known is what makes an apps/<sqid>/<app>/… key path |
| 213 | // unambiguous, so dropping the sqid on a write failure would make this box |
| 214 | // read every one of that account's keys as legacy for the rest of the pass — |
| 215 | // silently skipping its trash markers, route linkage and canary pointers |
| 216 | // until some later pass happened to get the row written. |
| 217 | func TestMaterializeAccountsKeepsAnAccountKnownWhenTheRowWriteFails(t *testing.T) { |
| 218 | log.SetOutput(io.Discard) |
| 219 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 220 | |
| 221 | ctx := context.Background() |
| 222 | s := release.NewDirStore(t.TempDir()) |
| 223 | const id = 793481920157 |
| 224 | sqid := Sqid(id) |
| 225 | body := []byte(`{"id":793481920157,"sqid":"` + sqid + `","name":"Kass","created_at":"2026-08-02T09:00:00Z"}`) |
| 226 | if _, err := s.Put(ctx, "accounts/"+sqid+"/account.json", body, ""); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | acctKeys, err := s.List(ctx, "accounts/") |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | |
| 234 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 235 | if err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | if err := reg.Close(); err != nil { // every write from here on fails |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | if err := reg.PutAccount(id, sqid, "Kass"); err == nil { |
| 242 | t.Fatal("a closed registry accepted PutAccount — this test injects nothing") |
| 243 | } |
| 244 | |
| 245 | known := materializeAccounts(ctx, s, acctKeys, reg) |
| 246 | |
| 247 | if !known[sqid] { |
| 248 | t.Fatalf("account %s missing from the set: %v — a failed local row write does not unmake an account whose identity already checked out", sqid, known) |
| 249 | } |
| 250 | // And the consequence that actually matters: the account's app keys still |
| 251 | // read as qualified, so the rest of the pass still sees its objects. |
| 252 | acct, app, _, ok := appKeyOwner("apps/"+sqid+"/kass/deleted.json", known) |
| 253 | if !ok || acct != sqid || app != "kass" { |
| 254 | t.Fatalf("appKeyOwner = (%q, %q, %v); want the key read as qualified under %s", acct, app, ok, sqid) |
| 255 | } |
| 256 | } |
| 257 | |