| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/tls" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "golang.org/x/crypto/acme" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/certwarm" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // THE trap this whole file exists to avoid. autocert picks the key type from |
| 20 | // the hello. A hello with no signature schemes, no curves and no cipher |
| 21 | // suites reads as "no ECDSA", so autocert mints an RSA cert under |
| 22 | // "<host>+rsa" while every browser asks for "<host>" — the order spent, the |
| 23 | // name still uncovered, and the first real visitor triggering a second |
| 24 | // issuance. |
| 25 | // |
| 26 | // This pins the warm's hello against the repo's own port of autocert's |
| 27 | // chooser, so the two cannot drift. |
| 28 | func TestWarmHelloAsksForAnECDSACert(t *testing.T) { |
| 29 | if !helloSupportsECDSA(warmHello("app.example.com")) { |
| 30 | t.Fatal("the warm's hello reads as RSA-only — autocert would mint <host>+rsa and no browser would ever be served it") |
| 31 | } |
| 32 | // The bare hello is the bug, kept here so the test documents WHY the |
| 33 | // cipher-suite entry above is load-bearing rather than decorative. |
| 34 | if helloSupportsECDSA(&tls.ClientHelloInfo{ServerName: "app.example.com"}) { |
| 35 | t.Fatal("an empty hello now reads as ECDSA-capable — warmHello's cipher suite may no longer be needed, re-derive the trap before deleting it") |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | func TestWarmHelloCarriesTheServerName(t *testing.T) { |
| 40 | if got := warmHello("App.Example.com").ServerName; got != "App.Example.com" { |
| 41 | t.Fatalf("ServerName = %q", got) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // The warm set is "exact hosts we serve that are not under the apps domain |
| 46 | // and not wildcard-covered". Each exclusion below is a separate bug if it |
| 47 | // regresses. |
| 48 | func TestWarmSetSelectsCustomDomainsOnly(t *testing.T) { |
| 49 | e := &Edge{} |
| 50 | e.upstreams.Store(&map[string]upstream{ |
| 51 | "www.customer.test": {}, |
| 52 | "shop.customer.test": {}, |
| 53 | "app.acct.oncarlos.test": {}, // platform host — apps domain |
| 54 | "c1.app.acct.oncarlos.test": {}, // canary — apps domain, 3 labels |
| 55 | "*.catchall.test": {}, // catch-all — wildcard covers it |
| 56 | "one.go.tito.test": {}, // one label under a configured parent |
| 57 | "deep.one.go.tito.test": {}, // TWO labels under it: NOT covered |
| 58 | }) |
| 59 | parents := func() []string { return []string{"go.tito.test"} } |
| 60 | |
| 61 | got := e.warmSet("oncarlos.test", parents) |
| 62 | want := []string{"deep.one.go.tito.test", "shop.customer.test", "www.customer.test"} |
| 63 | if strings.Join(got, ",") != strings.Join(want, ",") { |
| 64 | t.Fatalf("warm set = %v, want %v", got, want) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | // A parent whose cert has not been obtained yet must STILL exclude its |
| 69 | // children. Testing coverage by asking certFor (which returns nil until the |
| 70 | // wildcard lands) would warm every app of a new account per-host during |
| 71 | // exactly the window before their wildcard arrives — the burst the shape |
| 72 | // test exists to prevent. |
| 73 | func TestWarmSetExcludesChildrenOfAnUnobtainedParent(t *testing.T) { |
| 74 | e := &Edge{} |
| 75 | e.upstreams.Store(&map[string]upstream{"new.acct.test": {}}) |
| 76 | // currentParents lists the parent; no cert exists for it anywhere. |
| 77 | if got := e.warmSet("oncarlos.test", func() []string { return []string{"acct.test"} }); len(got) != 0 { |
| 78 | t.Fatalf("warm set = %v; a configured parent covers its children whether or not its cert exists yet", got) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestWarmSetWithNoWildcardManagerCoversNothing(t *testing.T) { |
| 83 | e := &Edge{} |
| 84 | e.upstreams.Store(&map[string]upstream{"www.customer.test": {}}) |
| 85 | if got := e.warmSet("oncarlos.test", nil); len(got) != 1 || got[0] != "www.customer.test" { |
| 86 | t.Fatalf("warm set = %v, want the custom domain", got) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | // ALL answers must be in the edge set. A name mid-migration resolves to the |
| 91 | // old host AND ours; an any-match gate passes it, the order goes out, and |
| 92 | // validation fails from the CA's vantage anyway — burning the shared |
| 93 | // account's failed-validation budget, which is exactly what the gate is for. |
| 94 | func TestDNSGateRequiresEveryAnswerToBeOurs(t *testing.T) { |
| 95 | edges := func() []string { return []string{"203.0.113.7", "198.51.100.9"} } |
| 96 | for name, tc := range map[string]struct { |
| 97 | ips []string |
| 98 | err error |
| 99 | want bool |
| 100 | }{ |
| 101 | "all ours": {ips: []string{"203.0.113.7", "198.51.100.9"}, want: true}, |
| 102 | "one of ours": {ips: []string{"203.0.113.7"}, want: true}, |
| 103 | "partly elsewhere": {ips: []string{"203.0.113.7", "192.0.2.1"}, want: false}, |
| 104 | "wholly elsewhere": {ips: []string{"192.0.2.1"}, want: false}, |
| 105 | "no answer": {ips: nil, want: false}, |
| 106 | "resolver failed": {err: errors.New("SERVFAIL"), want: false}, |
| 107 | } { |
| 108 | gate := dnsGate(func(string) ([]string, error) { return tc.ips, tc.err }, edges) |
| 109 | if got := gate("www.customer.test"); got != tc.want { |
| 110 | t.Errorf("%s: gate = %v, want %v", name, got, tc.want) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // An unreadable edge set is not evidence about anybody's DNS. Failing closed |
| 116 | // costs one pass of delay; failing open spends a shared rate limit. |
| 117 | func TestDNSGateFailsClosedOnAnEmptyEdgeSet(t *testing.T) { |
| 118 | gate := dnsGate( |
| 119 | func(string) ([]string, error) { return []string{"203.0.113.7"}, nil }, |
| 120 | func() []string { return nil }, |
| 121 | ) |
| 122 | if gate("www.customer.test") { |
| 123 | t.Fatal("an empty edge set opened the gate") |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // The heart of the design: a host that already has a cert is asked for AGAIN |
| 128 | // every pass. autocert renews through in-memory timers that die on every |
| 129 | // fleet roll and re-arm only via a GetCertificate that reaches m.cert — so a |
| 130 | // skip-if-fresh check would silently stop renewing every custom domain whose |
| 131 | // visitors never land on the issuer. |
| 132 | func TestWarmReAsksForAlreadyWarmHostsEveryPass(t *testing.T) { |
| 133 | var mu sync.Mutex |
| 134 | calls := map[string]int{} |
| 135 | w := newCertWarm( |
| 136 | func() []string { return []string{"www.customer.test"} }, |
| 137 | func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| 138 | mu.Lock() |
| 139 | defer mu.Unlock() |
| 140 | calls[h.ServerName]++ |
| 141 | return &tls.Certificate{}, nil |
| 142 | }, |
| 143 | func(string) bool { return true }, |
| 144 | nil, |
| 145 | nil, |
| 146 | ) |
| 147 | for i := 0; i < 3; i++ { |
| 148 | w.pass() |
| 149 | } |
| 150 | if calls["www.customer.test"] != 3 { |
| 151 | t.Fatalf("GetCertificate calls = %d, want one per pass — the renewal re-arm depends on it", calls["www.customer.test"]) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // A renewal re-arm is NOT gated on DNS. It is a cache read that costs the CA |
| 156 | // nothing, and gating it would strand exactly the domain whose resolver is |
| 157 | // briefly sick — dropping its renewal on the floor for as long as the |
| 158 | // resolver stays unhappy. |
| 159 | func TestWarmRenewalReArmIgnoresTheDNSGate(t *testing.T) { |
| 160 | calls := 0 |
| 161 | pointing := true |
| 162 | w := newCertWarm( |
| 163 | func() []string { return []string{"www.customer.test"} }, |
| 164 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| 165 | func(string) bool { return pointing }, |
| 166 | nil, |
| 167 | nil, |
| 168 | ) |
| 169 | w.pass() // first order: gate open, host becomes warmed |
| 170 | pointing = false |
| 171 | w.pass() // DNS now fails; the re-arm must still happen |
| 172 | if calls != 2 { |
| 173 | t.Fatalf("calls = %d, want 2 — a warmed host's re-arm is not DNS-gated", calls) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // A never-warmed host IS DNS-gated: ordering for a half-pointed name spends |
| 178 | // the shared account's failed-validation budget. |
| 179 | func TestWarmDoesNotOrderForAHostThatDoesNotPointHere(t *testing.T) { |
| 180 | calls := 0 |
| 181 | w := newCertWarm( |
| 182 | func() []string { return []string{"www.customer.test"} }, |
| 183 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| 184 | func(string) bool { return false }, |
| 185 | nil, |
| 186 | nil, |
| 187 | ) |
| 188 | w.pass() |
| 189 | if calls != 0 { |
| 190 | t.Fatalf("calls = %d, want 0 — the DNS gate must hold the first order", calls) |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | // A non-issuer does no work at all: unlike the wildcard manager there is no |
| 195 | // cache to load first, because the warm serves nothing. |
| 196 | func TestWarmStandsDownWhenNotTheIssuer(t *testing.T) { |
| 197 | calls := 0 |
| 198 | w := newCertWarm( |
| 199 | func() []string { return []string{"www.customer.test"} }, |
| 200 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| 201 | func(string) bool { return true }, |
| 202 | func() (context.Context, bool) { return nil, false }, |
| 203 | nil, |
| 204 | ) |
| 205 | if d := w.pass(); d != warmInterval { |
| 206 | t.Fatalf("delay = %s", d) |
| 207 | } |
| 208 | if calls != 0 { |
| 209 | t.Fatalf("a non-issuer placed %d call(s)", calls) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // A rate limit must back off much harder than an ordinary failure: hourly |
| 214 | // retries INTO a rate limit are what turn a transient limit into a sustained |
| 215 | // one, and every host shares the one ACME account's budget. |
| 216 | func TestWarmBacksOffHarderOnARateLimit(t *testing.T) { |
| 217 | rateLimited := &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"} |
| 218 | w := newCertWarm(nil, nil, nil, nil, nil) |
| 219 | w.fail(context.Background(), "a.test", rateLimited, certwarm.Record{}) |
| 220 | w.fail(context.Background(), "b.test", errors.New("connection refused"), certwarm.Record{}) |
| 221 | w.mu.Lock() |
| 222 | defer w.mu.Unlock() |
| 223 | limited, ordinary := time.Until(w.nextAttempt["a.test"]), time.Until(w.nextAttempt["b.test"]) |
| 224 | if limited <= ordinary { |
| 225 | t.Fatalf("rate-limited backoff %s is not harder than the ordinary %s", limited, ordinary) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | // A failing host is not retried until its backoff expires — and a healthy |
| 230 | // host in the same set is not held up by it. |
| 231 | func TestWarmHonoursPerHostBackoff(t *testing.T) { |
| 232 | calls := map[string]int{} |
| 233 | w := newCertWarm( |
| 234 | func() []string { return []string{"bad.test", "good.test"} }, |
| 235 | func(h *tls.ClientHelloInfo) (*tls.Certificate, error) { |
| 236 | calls[h.ServerName]++ |
| 237 | if h.ServerName == "bad.test" { |
| 238 | return nil, errors.New("order failed") |
| 239 | } |
| 240 | return &tls.Certificate{}, nil |
| 241 | }, |
| 242 | func(string) bool { return true }, |
| 243 | nil, |
| 244 | nil, |
| 245 | ) |
| 246 | w.pass() |
| 247 | w.pass() |
| 248 | if calls["bad.test"] != 1 { |
| 249 | t.Errorf("bad.test tried %d times, want 1 — backoff not honoured", calls["bad.test"]) |
| 250 | } |
| 251 | if calls["good.test"] != 2 { |
| 252 | t.Errorf("good.test tried %d times, want 2 — a failing sibling must not stall it", calls["good.test"]) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | // New orders are capped per pass, and a capped pass comes back SOON so a |
| 257 | // backlog drains in minutes rather than trickling out over days. Renewal |
| 258 | // re-arms are never capped. |
| 259 | func TestWarmCapsNewOrdersAndReturnsSoon(t *testing.T) { |
| 260 | var hosts []string |
| 261 | for i := 0; i < warmOrdersPerPass+5; i++ { |
| 262 | hosts = append(hosts, string(rune('a'+i))+".customer.test") |
| 263 | } |
| 264 | calls := 0 |
| 265 | w := newCertWarm( |
| 266 | func() []string { return hosts }, |
| 267 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return &tls.Certificate{}, nil }, |
| 268 | func(string) bool { return true }, |
| 269 | nil, |
| 270 | nil, |
| 271 | ) |
| 272 | d := w.pass() |
| 273 | if calls != warmOrdersPerPass { |
| 274 | t.Fatalf("placed %d orders, want the cap of %d", calls, warmOrdersPerPass) |
| 275 | } |
| 276 | if d != warmCappedDelay { |
| 277 | t.Fatalf("capped pass returned %s, want the short %s so the backlog drains", d, warmCappedDelay) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | // ---- the durable outcome records (#299 §3e) --------------------------------- |
| 282 | |
| 283 | func warmRecordIn(t *testing.T, s release.Store, host string) (certwarm.Record, bool) { |
| 284 | t.Helper() |
| 285 | body, _, err := s.Get(context.Background(), certwarm.Key(host)) |
| 286 | if errors.Is(err, os.ErrNotExist) { |
| 287 | return certwarm.Record{}, false |
| 288 | } |
| 289 | if err != nil { |
| 290 | t.Fatalf("reading the record for %s: %v", host, err) |
| 291 | } |
| 292 | rec, err := certwarm.Parse(body) |
| 293 | if err != nil { |
| 294 | t.Fatalf("parsing the record for %s: %v", host, err) |
| 295 | } |
| 296 | return rec, true |
| 297 | } |
| 298 | |
| 299 | // THE point of §3e. Attempt counts used to live in process memory, so every |
| 300 | // fleet roll and every failover restarted the clock on a domain that had |
| 301 | // been broken for days — and the console could never say more than |
| 302 | // "issuing-cert". The count and the "failing since" stamp must be carried |
| 303 | // forward from the bucket, not from the heap. |
| 304 | func TestWarmCarriesAttemptCountsAcrossAFailover(t *testing.T) { |
| 305 | s := release.NewDirStore(t.TempDir()) |
| 306 | newIssuer := func() *certWarm { |
| 307 | return newCertWarm( |
| 308 | func() []string { return []string{"bad.customer.test"} }, |
| 309 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| 310 | func(string) bool { return true }, |
| 311 | nil, |
| 312 | s, |
| 313 | ) |
| 314 | } |
| 315 | first := newIssuer() |
| 316 | first.pass() |
| 317 | rec, ok := warmRecordIn(t, s, "bad.customer.test") |
| 318 | if !ok || rec.Attempts != 1 { |
| 319 | t.Fatalf("after one pass: record %+v ok=%v; want attempts 1", rec, ok) |
| 320 | } |
| 321 | failingSince := rec.FirstFailedAt |
| 322 | |
| 323 | // A brand-new process, with no memory of the first one: exactly what a |
| 324 | // roll or a lease handover produces. |
| 325 | for i := 2; i <= 4; i++ { |
| 326 | w := newIssuer() |
| 327 | w.pass() |
| 328 | rec, ok = warmRecordIn(t, s, "bad.customer.test") |
| 329 | if !ok || rec.Attempts != i { |
| 330 | t.Fatalf("failover %d: record %+v ok=%v; want attempts %d — the count reset", i, rec, ok, i) |
| 331 | } |
| 332 | } |
| 333 | if rec.FirstFailedAt != failingSince { |
| 334 | t.Errorf("first_failed_at moved from %d to %d — \"failing since\" must survive a failover", failingSince, rec.FirstFailedAt) |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | // A rate limit is carried as its own class, and with the twelve-hour retry |
| 339 | // time, because those are the two facts that make it explicable rather than |
| 340 | // just another failure. |
| 341 | func TestWarmRecordsTheRateLimitClassAndItsNextAttempt(t *testing.T) { |
| 342 | s := release.NewDirStore(t.TempDir()) |
| 343 | w := newCertWarm( |
| 344 | func() []string { return []string{"limited.customer.test"} }, |
| 345 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| 346 | return nil, &acme.Error{ProblemType: "urn:ietf:params:acme:error:rateLimited"} |
| 347 | }, |
| 348 | func(string) bool { return true }, |
| 349 | nil, |
| 350 | s, |
| 351 | ) |
| 352 | w.pass() |
| 353 | rec, ok := warmRecordIn(t, s, "limited.customer.test") |
| 354 | if !ok { |
| 355 | t.Fatal("no record written for a rate-limited host") |
| 356 | } |
| 357 | if rec.Class != certwarm.ClassRateLimited { |
| 358 | t.Errorf("class = %q, want %q", rec.Class, certwarm.ClassRateLimited) |
| 359 | } |
| 360 | if until := time.Until(time.Unix(rec.NextAttemptAt, 0)); until < warmRateLimitBackoff-time.Minute { |
| 361 | t.Errorf("next attempt in %s, want about %s", until, warmRateLimitBackoff) |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | // A record exists only while a host is FAILING. Success removes it, which is |
| 366 | // what makes absence the healthy case and keeps the console's fold one |
| 367 | // object per broken host rather than one per domain. |
| 368 | func TestWarmClearsTheRecordOnRecovery(t *testing.T) { |
| 369 | s := release.NewDirStore(t.TempDir()) |
| 370 | broken := true |
| 371 | w := newCertWarm( |
| 372 | func() []string { return []string{"flaky.customer.test"} }, |
| 373 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { |
| 374 | if broken { |
| 375 | return nil, errors.New("order failed") |
| 376 | } |
| 377 | return &tls.Certificate{}, nil |
| 378 | }, |
| 379 | func(string) bool { return true }, |
| 380 | nil, |
| 381 | s, |
| 382 | ) |
| 383 | w.pass() |
| 384 | if _, ok := warmRecordIn(t, s, "flaky.customer.test"); !ok { |
| 385 | t.Fatal("no record after the failure") |
| 386 | } |
| 387 | broken = false |
| 388 | // The per-host backoff is in memory, so a fresh issuer is the honest way |
| 389 | // to reach the next attempt without sleeping an hour. |
| 390 | recovered := newCertWarm(w.hosts, w.getCert, w.pointsHere, nil, s) |
| 391 | recovered.pass() |
| 392 | if rec, ok := warmRecordIn(t, s, "flaky.customer.test"); ok { |
| 393 | t.Errorf("record %+v survived recovery — absence must be the healthy case", rec) |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | // A detached domain leaves the warm set, and its record goes with it. |
| 398 | // Deleting on an inference is forbidden for CERTIFICATES (§3f) because those |
| 399 | // are the member's property; a record of our own attempts is ours to drop. |
| 400 | func TestWarmPrunesRecordsForHostsThatLeftTheSet(t *testing.T) { |
| 401 | s := release.NewDirStore(t.TempDir()) |
| 402 | hosts := []string{"gone.customer.test", "stays.customer.test"} |
| 403 | w := newCertWarm( |
| 404 | func() []string { return hosts }, |
| 405 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| 406 | func(string) bool { return true }, |
| 407 | nil, |
| 408 | s, |
| 409 | ) |
| 410 | w.pass() |
| 411 | for _, h := range hosts { |
| 412 | if _, ok := warmRecordIn(t, s, h); !ok { |
| 413 | t.Fatalf("no record for %s after the failure", h) |
| 414 | } |
| 415 | } |
| 416 | hosts = []string{"stays.customer.test"} |
| 417 | w.pass() |
| 418 | if rec, ok := warmRecordIn(t, s, "gone.customer.test"); ok { |
| 419 | t.Errorf("record %+v for a detached host survived the prune", rec) |
| 420 | } |
| 421 | if _, ok := warmRecordIn(t, s, "stays.customer.test"); !ok { |
| 422 | t.Error("the prune took a record for a host still in the set") |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | // The one prune case that would delete EVERY record is a warm set that |
| 427 | // briefly folds to nothing — an edges-file read that came back empty, say. |
| 428 | // pass() returns before any of this on an empty set, so it can never happen; |
| 429 | // this pins that, because the guard is one `if` away from being refactored out. |
| 430 | func TestWarmPruneNeverRunsOnAnEmptySet(t *testing.T) { |
| 431 | s := release.NewDirStore(t.TempDir()) |
| 432 | hosts := []string{"bad.customer.test"} |
| 433 | w := newCertWarm( |
| 434 | func() []string { return hosts }, |
| 435 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| 436 | func(string) bool { return true }, |
| 437 | nil, |
| 438 | s, |
| 439 | ) |
| 440 | w.pass() |
| 441 | hosts = nil |
| 442 | w.pass() |
| 443 | if _, ok := warmRecordIn(t, s, "bad.customer.test"); !ok { |
| 444 | t.Error("an empty warm set pruned every record — a transient fold to nothing must never delete") |
| 445 | } |
| 446 | } |
| 447 | |
| 448 | // A non-issuer writes nothing at all. Two boxes both recording their own |
| 449 | // view of a host would be exactly the whole-file overwrite §3e's per-host |
| 450 | // CAS exists to avoid, with the added wrinkle that the non-issuer's view is |
| 451 | // not even real — it places no orders. |
| 452 | func TestWarmStandDownWritesNoRecords(t *testing.T) { |
| 453 | s := release.NewDirStore(t.TempDir()) |
| 454 | w := newCertWarm( |
| 455 | func() []string { return []string{"bad.customer.test"} }, |
| 456 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return nil, errors.New("order failed") }, |
| 457 | func(string) bool { return true }, |
| 458 | func() (context.Context, bool) { return nil, false }, |
| 459 | s, |
| 460 | ) |
| 461 | w.pass() |
| 462 | if _, ok := warmRecordIn(t, s, "bad.customer.test"); ok { |
| 463 | t.Error("a stood-down box wrote an outcome record") |
| 464 | } |
| 465 | } |
| 466 | |
| 467 | // The records are observability, never a precondition. A bucket that cannot |
| 468 | // be listed or written must not stop the warm from warming: the certificate |
| 469 | // is the job, the record only explains it. |
| 470 | func TestWarmKeepsWarmingWhenTheRecordStoreIsBroken(t *testing.T) { |
| 471 | s := &failingStore{Store: release.NewDirStore(t.TempDir()), putErr: errors.New("bucket down")} |
| 472 | calls := 0 |
| 473 | w := newCertWarm( |
| 474 | func() []string { return []string{"www.customer.test"} }, |
| 475 | func(*tls.ClientHelloInfo) (*tls.Certificate, error) { calls++; return nil, errors.New("order failed") }, |
| 476 | func(string) bool { return true }, |
| 477 | nil, |
| 478 | s, |
| 479 | ) |
| 480 | if d := w.pass(); d != warmInterval { |
| 481 | t.Fatalf("delay = %s, want the ordinary %s", d, warmInterval) |
| 482 | } |
| 483 | if calls != 1 { |
| 484 | t.Fatalf("GetCertificate calls = %d, want 1 — a broken record store stopped the warm", calls) |
| 485 | } |
| 486 | } |
| 487 | |