| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "slices" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/certwarm" |
| 10 | "github.com/carlosframework/platform/internal/domains" |
| 11 | "github.com/carlosframework/platform/internal/status" |
| 12 | ) |
| 13 | |
| 14 | func TestDomainStateVocabulary(t *testing.T) { |
| 15 | now := time.Unix(1_000_000, 0) |
| 16 | future, past := now.Unix()+86400, now.Unix()-86400 |
| 17 | |
| 18 | for name, tc := range map[string]struct { |
| 19 | reading status.AliasReading |
| 20 | seen bool |
| 21 | wantState string |
| 22 | wantReason string |
| 23 | }{ |
| 24 | "no box has reported": {seen: false, wantState: StateChecking, wantReason: "no-reading"}, |
| 25 | "our lookup failed": {reading: status.AliasReading{DNSStatus: "unresolved"}, seen: true, wantState: StateUnknown, wantReason: "lookup-failed"}, |
| 26 | "no record yet": {reading: status.AliasReading{DNSStatus: ""}, seen: true, wantState: StatePendingDNS, wantReason: "no-record"}, |
| 27 | "points elsewhere": {reading: status.AliasReading{DNSStatus: "elsewhere"}, seen: true, wantState: StateMisdirected, wantReason: "points-elsewhere"}, |
| 28 | "pointing, no cert": {reading: status.AliasReading{DNSStatus: "pointing"}, seen: true, wantState: StateIssuingCert, wantReason: "awaiting-certificate"}, |
| 29 | "pointing, dead cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: past}, seen: true, wantState: StateIssuingCert, wantReason: "awaiting-certificate"}, |
| 30 | "pointing, live cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: future}, seen: true, wantState: StateLive, wantReason: "serving"}, |
| 31 | "a status we don't know": {reading: status.AliasReading{DNSStatus: "quantum"}, seen: true, wantState: StateUnknown, wantReason: "unrecognised-status"}, |
| 32 | } { |
| 33 | state, reason, detail := domainState(tc.reading, tc.seen, nil, now) |
| 34 | if state != tc.wantState || reason != tc.wantReason { |
| 35 | t.Errorf("%s: (%q, %q), want (%q, %q)", name, state, reason, tc.wantState, tc.wantReason) |
| 36 | } |
| 37 | if strings.TrimSpace(detail) == "" { |
| 38 | t.Errorf("%s: no human sentence", name) |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | // "we could not check" must never be phrased as "you have not done your |
| 44 | // part". A resolver failure on our side and a genuinely missing record are |
| 45 | // the two cases most likely to be collapsed, and collapsing them blames a |
| 46 | // customer for our outage. |
| 47 | func TestDomainStateNeverBlamesTheCustomerForOurOutage(t *testing.T) { |
| 48 | now := time.Unix(1_000_000, 0) |
| 49 | ourFault, _, ourDetail := domainState(status.AliasReading{DNSStatus: "unresolved"}, true, nil, now) |
| 50 | theirs, _, _ := domainState(status.AliasReading{DNSStatus: ""}, true, nil, now) |
| 51 | if ourFault == theirs { |
| 52 | t.Fatalf("a failed lookup and a missing record both read %q", ourFault) |
| 53 | } |
| 54 | low := strings.ToLower(ourDetail) |
| 55 | if !strings.Contains(low, "our side") { |
| 56 | t.Errorf("the lookup-failure sentence does not own the problem: %q", ourDetail) |
| 57 | } |
| 58 | for _, blame := range []string{"create", "you have not", "your record is missing"} { |
| 59 | if strings.Contains(low, blame) { |
| 60 | t.Errorf("the lookup-failure sentence asks the customer to act (%q): %q", blame, ourDetail) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // Every state the capability block advertises must be reachable, and every |
| 66 | // state the deriver can return must be advertised. A vocabulary that names a |
| 67 | // state nothing produces is the dishonesty that got "verifying" renamed. |
| 68 | func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) { |
| 69 | now := time.Unix(1_000_000, 0) |
| 70 | produced := map[string]bool{} |
| 71 | // The warm inputs are as real as the readings: a fresh record with the |
| 72 | // attempt count the threshold names is exactly what the issuer writes, |
| 73 | // so "failed" earns its place in the vocabulary the same way every other |
| 74 | // state does — by being produced from an input the platform can produce. |
| 75 | failing := &certwarm.Record{ |
| 76 | Attempts: domainFailedAttempts, Class: certwarm.ClassOther, |
| 77 | WrittenAt: now.Unix(), NextAttemptAt: now.Unix() + 3600, |
| 78 | } |
| 79 | for _, tc := range []struct { |
| 80 | r status.AliasReading |
| 81 | seen bool |
| 82 | warm *certwarm.Record |
| 83 | }{ |
| 84 | {seen: false}, |
| 85 | {r: status.AliasReading{DNSStatus: "unresolved"}, seen: true}, |
| 86 | {r: status.AliasReading{DNSStatus: ""}, seen: true}, |
| 87 | {r: status.AliasReading{DNSStatus: "elsewhere"}, seen: true}, |
| 88 | {r: status.AliasReading{DNSStatus: "pointing"}, seen: true}, |
| 89 | {r: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: now.Unix() + 86400}, seen: true}, |
| 90 | {r: status.AliasReading{DNSStatus: "pointing"}, seen: true, warm: failing}, |
| 91 | } { |
| 92 | s, _, _ := domainState(tc.r, tc.seen, tc.warm, now) |
| 93 | produced[s] = true |
| 94 | } |
| 95 | // "unverified" comes from the claim, not the readings (#215): a claim |
| 96 | // stored pending is exactly what a member's unproven attach writes. |
| 97 | s, _, _ := claimState(domains.Claim{Pending: true}, status.AliasReading{DNSStatus: "pointing"}, true, nil, now) |
| 98 | produced[s] = true |
| 99 | for _, s := range domainStates { |
| 100 | if !produced[s] { |
| 101 | t.Errorf("state %q is advertised but nothing produces it", s) |
| 102 | } |
| 103 | } |
| 104 | for s := range produced { |
| 105 | if !slices.Contains(domainStates, s) { |
| 106 | t.Errorf("state %q is produced but not advertised", s) |
| 107 | } |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | // "failed" is a claim about HISTORY — that the platform has been trying and |
| 112 | // failing for hours — so the only thing that may produce it is a record of |
| 113 | // that history. Three ways of having no such record must all still read as |
| 114 | // "issuing-cert", and each of them was a live bug shape before §3e: no |
| 115 | // record at all (the ordinary case), a record from an issuer that has since |
| 116 | // wedged, and a record that has not yet reached the threshold. |
| 117 | func TestFailedIsOnlyEverSaidOnFreshEnoughEvidence(t *testing.T) { |
| 118 | now := time.Unix(1_000_000, 0) |
| 119 | pointing := status.AliasReading{DNSStatus: "pointing"} |
| 120 | fresh := func(r certwarm.Record) *certwarm.Record { |
| 121 | r.WrittenAt, r.NextAttemptAt = now.Unix(), now.Unix()+3600 |
| 122 | return &r |
| 123 | } |
| 124 | for _, tc := range []struct { |
| 125 | name string |
| 126 | warm *certwarm.Record |
| 127 | want string |
| 128 | }{ |
| 129 | {"no record at all", nil, StateIssuingCert}, |
| 130 | {"below the threshold", fresh(certwarm.Record{Attempts: domainFailedAttempts - 1}), StateIssuingCert}, |
| 131 | {"at the threshold", fresh(certwarm.Record{Attempts: domainFailedAttempts}), StateFailed}, |
| 132 | {"rate-limited on the first attempt", fresh(certwarm.Record{Attempts: 1, Class: certwarm.ClassRateLimited}), StateFailed}, |
| 133 | {"stale record from a wedged issuer", &certwarm.Record{ |
| 134 | Attempts: 40, WrittenAt: now.Add(-30 * 24 * time.Hour).Unix(), |
| 135 | NextAttemptAt: now.Add(-30 * 24 * time.Hour).Unix(), |
| 136 | }, StateIssuingCert}, |
| 137 | } { |
| 138 | if got, _, _ := domainState(pointing, true, tc.warm, now); got != tc.want { |
| 139 | t.Errorf("%s: state = %q, want %q", tc.name, got, tc.want) |
| 140 | } |
| 141 | } |
| 142 | // A live certificate outranks any record. The record is evidence about |
| 143 | // an ORDER; the cert is evidence about the hostname, and a hostname that |
| 144 | // is being served must never be told it has failed. |
| 145 | served := status.AliasReading{DNSStatus: "pointing", CertExpiresAt: now.Unix() + 86400} |
| 146 | if got, _, _ := domainState(served, true, fresh(certwarm.Record{Attempts: 99}), now); got != StateLive { |
| 147 | t.Errorf("a served hostname with a stale failure record = %q, want %q", got, StateLive) |
| 148 | } |
| 149 | // A customer-side condition is never overwritten by our cert trouble: |
| 150 | // telling somebody their certificate failed when their DNS points at |
| 151 | // their old host would send them looking in the wrong place. |
| 152 | elsewhere := status.AliasReading{DNSStatus: "elsewhere"} |
| 153 | if got, _, _ := domainState(elsewhere, true, fresh(certwarm.Record{Attempts: 99}), now); got != StateMisdirected { |
| 154 | t.Errorf("a misdirected hostname with a failure record = %q, want %q", got, StateMisdirected) |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // The structured records and the English sentence are the same advice. If |
| 159 | // they disagree, an app creates records that contradict what the person |
| 160 | // operating it was told. |
| 161 | func TestDomainRecordsMatchTheGuidanceSentence(t *testing.T) { |
| 162 | steering := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}, SteeringHost: "edge.oncarlos.test"} |
| 163 | |
| 164 | recs := domainRecords("www.customer.test", steering, false, "") |
| 165 | sentence := domainGuidance("www.customer.test", steering) |
| 166 | if !strings.Contains(sentence, "CNAME") { |
| 167 | t.Fatal("precondition: the steering sentence should offer a CNAME") |
| 168 | } |
| 169 | var cname, a []dnsRecord |
| 170 | for _, r := range recs { |
| 171 | switch r.Set { |
| 172 | case "cname": |
| 173 | cname = append(cname, r) |
| 174 | case "a": |
| 175 | a = append(a, r) |
| 176 | } |
| 177 | } |
| 178 | if len(cname) != 1 || cname[0].Value != "edge.oncarlos.test" { |
| 179 | t.Errorf("CNAME alternative = %+v", cname) |
| 180 | } |
| 181 | if len(a) != 2 { |
| 182 | t.Errorf("A alternative = %+v, want one per edge IP", a) |
| 183 | } |
| 184 | // The two must be distinguishable AS alternatives: a client creating |
| 185 | // every row would publish a CNAME beside A records for one name, which |
| 186 | // is illegal and is exactly what the sentence's "works too" avoids. |
| 187 | if cname[0].Set == a[0].Set { |
| 188 | t.Error("the CNAME and A records share a set — a client cannot tell they are alternatives") |
| 189 | } |
| 190 | for _, r := range recs { |
| 191 | if r.TTL != domainRecordTTL { |
| 192 | t.Errorf("record %+v: TTL %d, want %d — steering withdraws a dead edge in ~90s and a long TTL nullifies that", r, r.TTL, domainRecordTTL) |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | // An apex cannot carry a CNAME (RFC 1034), and the sentence already says so. |
| 198 | // The records must agree. |
| 199 | func TestDomainRecordsOfferNoCNAMEForAnApex(t *testing.T) { |
| 200 | steering := DNSTargets{EdgeIPs: []string{"203.0.113.7"}, SteeringHost: "edge.oncarlos.test"} |
| 201 | for _, r := range domainRecords("customer.test", steering, false, "") { |
| 202 | if r.Type == "CNAME" { |
| 203 | t.Fatalf("offered an apex a CNAME: %+v", r) |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | // Without steering, edge.<apps-domain> has no records behind it, so naming |
| 209 | // it would send a customer at a hostname that does not resolve. |
| 210 | func TestDomainRecordsNameNoAliasTargetWithoutSteering(t *testing.T) { |
| 211 | plain := DNSTargets{EdgeIPs: []string{"203.0.113.7"}} |
| 212 | recs := domainRecords("www.customer.test", plain, false, "") |
| 213 | if len(recs) != 1 || recs[0].Type != "A" { |
| 214 | t.Fatalf("records = %+v, want A records only", recs) |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | // A wildcard-enabled claim needs its delegation CNAME, and it is required |
| 219 | // whichever alternative the customer picks — the platform already instructs |
| 220 | // it at the attach door and stamps delegation_status from it. |
| 221 | func TestDomainRecordsIncludeTheACMEChallengeForWildcardClaims(t *testing.T) { |
| 222 | targets := DNSTargets{EdgeIPs: []string{"203.0.113.7"}, SteeringHost: "edge.oncarlos.test"} |
| 223 | recs := domainRecords("www.customer.test", targets, true, "acme.oncarlos.test") |
| 224 | var found *dnsRecord |
| 225 | for i, r := range recs { |
| 226 | if strings.HasPrefix(r.Name, "_acme-challenge.") { |
| 227 | found = &recs[i] |
| 228 | } |
| 229 | } |
| 230 | if found == nil { |
| 231 | t.Fatal("a wildcard claim was given no _acme-challenge record") |
| 232 | } |
| 233 | if !found.Required { |
| 234 | t.Error("the challenge record is not marked required — it is needed whichever alternative is chosen") |
| 235 | } |
| 236 | if found.Value != "_acme-challenge.www.customer.test.acme.oncarlos.test" { |
| 237 | t.Errorf("challenge target = %q", found.Value) |
| 238 | } |
| 239 | // With no delegation zone configured there is nothing to delegate to. |
| 240 | for _, r := range domainRecords("www.customer.test", targets, true, "") { |
| 241 | if strings.HasPrefix(r.Name, "_acme-challenge.") { |
| 242 | t.Error("emitted a challenge record with no delegation zone configured") |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | // `status` is LEGACY and its semantics are frozen: it means "some non-stale |
| 248 | // box reported ANY reading", nothing more. That is why a row can carry |
| 249 | // status "live" while its state is "misdirected" — the hostname resolves |
| 250 | // somewhere else entirely, and only one of those two words says so. |
| 251 | // |
| 252 | // This test exists to stop a well-meaning change from "fixing" status toward |
| 253 | // state. Anything that reads status as a health verdict is reading it wrong; |
| 254 | // the field is kept only so clients that predate state keep working. |
| 255 | func TestLegacyStatusStaysDecoupledFromState(t *testing.T) { |
| 256 | now := time.Unix(1_000_000, 0) |
| 257 | // The exact divergence: a box HAS reported (so status is "live"), and |
| 258 | // what it reported is that the hostname points somewhere else. |
| 259 | state, _, _ := domainState(status.AliasReading{DNSStatus: "elsewhere"}, true, nil, now) |
| 260 | if state != StateMisdirected { |
| 261 | t.Fatalf("state = %q, want %q", state, StateMisdirected) |
| 262 | } |
| 263 | // If this ever fails, someone has redefined status. The list handler |
| 264 | // sets it to "live" for any seen reading; that is the frozen meaning. |
| 265 | const legacyStatusForAnySeenReading = "live" |
| 266 | if legacyStatusForAnySeenReading == state { |
| 267 | t.Fatal("status and state have converged — the legacy field's frozen meaning has been changed") |
| 268 | } |
| 269 | } |
| 270 | |