diff --git a/docs/plan.md b/docs/plan.md index d381331..bb22c49 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -194,6 +194,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `exophase-client` | The `games` service's Exophase client: the games I last played since a given time, the last played first, each with its platforms, playtime and trophies, from the games list's pages until one ends before that time, four at most; a 403 from Cloudflare's challenge says so. The second of `games-exophase`'s eleven steps (decision 301) | Done (#172) | | `games-cache` | The `games` service polls Exophase only on request: the first request waits for the first poll, later ones get the latest list at once and start a poll in the background once the list is an hour old; after a failed poll the last good list stays, and the next poll waits 5 minutes, twice as long after each failure in a row, up to 6 hours. The third of `games-exophase`'s eleven steps (decision 302) | Done (#175) | | `games-service` | A new `games` service answers the games I played in the last 14 days at `GET /games/recent`, from the Exophase profile that `BAKHYARD_EXOPHASE_PLAYER` names, digits only; it runs on 8090 under `mise run up`, and its image builds with the others'. The fourth of `games-exophase`'s eleven steps (decision 303) | Done (#178) | +| `twitch-token` | The `games` service gets a Twitch app token for IGDB with the client credentials grant, keeps it in memory until 9 tenths of its life, asks Twitch one request at a time, and forgets a token that IGDB refuses. The fifth of `games-exophase`'s eleven steps (decision 304) | Done (#181) | | `vault-edit-sync` | Editing the vault from Bakhyard (§8.2 Phase 6), by the vault session in `../bakhyard-vault/`, which adds its steps' rows under this one and then does `copilot-planner` (decision 195) | In progress | | `vault-plan` | The vault session's first steps: where a save goes waits on the user's answer to §12's question on how edits get back to the Mac, so the version that a save checks and the editor come first; a note's version is the SHA-256 of its bytes, sent as its `ETag`, and a save is a `PUT` that sends it back in `If-Match` (decision 400) | Done (#167) | | `note-etag` | notes answers each note with its version as a strong `ETag`, the SHA-256 of its bytes, and a matching `If-None-Match` with a 304 (decision 400) | Done (#169) | @@ -1271,7 +1272,7 @@ Every item is pending unless it says done. The service an item lands in is in br - `exophase-client`: the `games` service's Exophase client reads the games I last played since a given time from the games list's JSON, with an honest user agent (decision 301); it never fetches or stores a profile's HTML. `tls-client` only if challenges start. Done (#172). - `games-cache`: the service fetches on request: the first request waits for the first fetch, later ones get the latest list at once and start a fetch in the background once it's an hour old; after a failure, the last good list stays and the next fetch backs off (decision 302). Done (#175). - `games-service`: a new `games` service answers the games I played in the last 14 days at `GET /games/recent` (decision 303). Done (#178). - - `twitch-token`: the service gets a Twitch app token for IGDB, keeps it and renews it before it expires. + - `twitch-token`: the service gets a Twitch app token for IGDB, keeps it and renews it before it expires (decision 304). Done (#181). - `igdb-search`: the service's IGDB client finds a title's cover with a filtered search and an exact-name fallback (decision 68), within IGDB's 4 requests a second. - `games-covers`: each game in the answer carries its IGDB cover; each title is matched once and the match kept. - `homelab-games`: in the homelab repo, `games` runs on the laptop with its settings and secret, and the gateway's peers name it. @@ -1773,6 +1774,14 @@ Decisions 300 to 399 are the `games-exophase` session's. - **The wiring**: as `pulls`' is: `exophase.go` reads the setting and starts the polls on request, `internal/api` serves the route, with the 404 when Exophase isn't set up, and `main` holds Exophase's address, `https://api.exophase.com`, which `routes` takes, so the tests give a stand-in's (decision 300). `mise run up` runs games on 8090, and Gitea builds its image with the others'; the gateway forwards to it from `games-gateway` on. - **Rejected**: the last 14 days as calendar days in the service's time zone, which is UTC in a container, so the list would change at a different hour on the Mac and on the laptop; trimming spaces around the id, as the Bitbucket workspaces' are: a list typed by hand needs it, one id doesn't. +304. **The Twitch token** (`twitch-token`): the lead's choices (2026-10-30), for the user to review. + - **The request**: `services/games/internal/twitch` asks Twitch's OAuth server, `https://id.twitch.tv`, for an app access token with the client credentials grant: a `POST` to `/oauth2/token` with the client ID, the secret and `grant_type=client_credentials` in a form body, as Twitch's docs show, rather than in the query string that IGDB's docs show, so the secret never shows in a URL. Each request is bounded by 10 seconds and its answer by 64 KiB. + - **Keeping the token**: in memory (decision 300), until 9 tenths of the lifetime that Twitch gives it, `expires_in`, counted from when the request began; the next call after that gets a new one. Twitch is asked only when a call needs a token, so the first search for a cover after a start asks it first. + - **One request at a time**: calls take turns through a one-slot channel, so only one asks Twitch and the others wait for its token. A call whose context ends while it waits returns the context's error at once. + - **IGDB's refusal**: `Drop` forgets the token that IGDB refused, so the next call gets a new one. When the token kept is no longer the one refused, because another call has renewed it, `Drop` changes nothing. It waits for a request under way, at most 10 seconds. + - **Failures**: a failed request changes nothing, so the next call asks again. A status other than 200 gives `get a Twitch token:` and the status, with Twitch's `message` when it gives one, such as `400 Bad Request, invalid client`; an answer with no token, or with a lifetime of 0 seconds or less, gives `read Twitch's answer:` and why. No error shows the secret or a token. + - **Rejected**: checking the token every hour at `/oauth2/validate`, which Twitch requires of apps that keep a user's session, not of an app token; renewing only when IGDB answers 401, which costs a refused search per token; asking for a token as the service starts, since only a title not matched yet needs one; a `sync.Mutex`, which a waiting call's context can't end; a user agent, which only Exophase's Cloudflare needed (decision 301). + Decisions 400 to 499 are the vault session's: `vault-edit-sync`, then `copilot-planner`. 400. **Editing notes, the first steps** (`vault-plan`, `note-etag`, `note-editor`): the lead's choices (2026-10-30), for the user to review. diff --git a/services/games/internal/twitch/app.go b/services/games/internal/twitch/app.go new file mode 100644 index 0000000..1ac4a4d --- /dev/null +++ b/services/games/internal/twitch/app.go @@ -0,0 +1,118 @@ +package twitch + +import ( + "context" + "encoding/json/v2" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // maxAnswer bounds the size of each answer from Twitch: many times what a token's needs. + maxAnswer = 64 << 10 + // requestTimeout bounds each request for a token. + requestTimeout = 10 * time.Second + // getting starts the errors of a request for a token. + getting = "get a Twitch token" +) + +// errAnswer marks the error of an answer from Twitch that holds no token that Tokens can keep. +var errAnswer = errors.New("read Twitch's answer") + +// app asks Twitch for the tokens of the app whose client ID and secret it holds. +type app struct { + api *url.URL + clientID string + secret string + client *http.Client +} + +// New returns the Tokens of the Twitch app with clientID and secret, which ask Twitch's OAuth +// server at api, https://id.twitch.tv, only when they need a token. +func New(api *url.URL, clientID, secret string) *Tokens { + return keep(newApp(api, clientID, secret).token) +} + +// newApp returns the app with clientID and secret, which asks Twitch's OAuth server at api. +func newApp(api *url.URL, clientID, secret string) *app { + return &app{ + api: api, clientID: clientID, secret: secret, + client: &http.Client{Timeout: requestTimeout}, + } +} + +// token asks Twitch for a new app token with the client credentials grant, whose credentials go +// in the form body, as Twitch's docs show, rather than in the URL, where errors would show them. +func (a *app) token(ctx context.Context) (string, time.Duration, error) { + form := url.Values{ + "client_id": {a.clientID}, + "client_secret": {a.secret}, + "grant_type": {"client_credentials"}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + a.api.JoinPath("oauth2/token").String(), strings.NewReader(form.Encode())) + if err != nil { + return "", 0, fmt.Errorf("%s: %w", getting, err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + res, err := a.client.Do(req) + if err != nil { + return "", 0, fmt.Errorf("%s: %w", getting, err) + } + + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return "", 0, refusal(res) + } + + return issued(res.Body) +} + +// issued returns the token in body, Twitch's answer to a request that it granted, with the +// lifetime that the answer gives it. +func issued(body io.Reader) (string, time.Duration, error) { + var answer struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + } + + err := json.UnmarshalRead(io.LimitReader(body, maxAnswer), &answer) + if err != nil { + return "", 0, fmt.Errorf("%w: %w", errAnswer, err) + } + + if answer.AccessToken == "" { + return "", 0, fmt.Errorf("%w: no access token", errAnswer) + } + + if answer.ExpiresIn <= 0 { + return "", 0, fmt.Errorf("%w: the token expires in %d seconds", errAnswer, answer.ExpiresIn) + } + + return answer.AccessToken, time.Duration(answer.ExpiresIn) * time.Second, nil +} + +// refusal returns the error of an answer whose status isn't 200, with the reason that Twitch +// gives, such as "invalid client", when it gives one. +func refusal(res *http.Response) error { + var answer struct { + Message string `json:"message"` + } + + err := json.UnmarshalRead(io.LimitReader(res.Body, maxAnswer), &answer) + if err != nil || answer.Message == "" { + return fmt.Errorf("%s: %s", getting, res.Status) + } + + return fmt.Errorf("%s: %s, %s", getting, res.Status, answer.Message) +} diff --git a/services/games/internal/twitch/app_internal_test.go b/services/games/internal/twitch/app_internal_test.go new file mode 100644 index 0000000..7ebce50 --- /dev/null +++ b/services/games/internal/twitch/app_internal_test.go @@ -0,0 +1,71 @@ +package twitch + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + "testing/synctest" + "time" +) + +// issuer stands in for Twitch's OAuth server in memory, so that it answers in a bubble: each +// request gets the next token, issued for 100 seconds. +type issuer struct { + issued int +} + +func (i *issuer) RoundTrip(*http.Request) (*http.Response, error) { + i.issued++ + answer := fmt.Sprintf(`{"access_token":"token %d","expires_in":100}`, i.issued) + + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(answer)), + }, nil +} + +// hanging stands in for a Twitch that never answers. +type hanging struct{} + +func (hanging) RoundTrip(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + + return nil, r.Context().Err() +} + +func TestATokenLivesAsLongAsTwitchSays(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tokens := keep(standIn(&issuer{}).token) + + wantToken(t, tokens, "token 1") + time.Sleep(90*time.Second - time.Nanosecond) + wantToken(t, tokens, "token 1") + time.Sleep(time.Nanosecond) + wantToken(t, tokens, "token 2") + }) +} + +func TestARequestForATokenGivesUpAfter10Seconds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + tokens := keep(standIn(hanging{}).token) + began := time.Now() + + wantError(t, tokens, context.DeadlineExceeded) + + if waited := time.Since(began); waited != 10*time.Second { + t.Errorf("gave up after %s, want 10s", waited) + } + }) +} + +// standIn returns the app whose requests go to answer instead of Twitch. +func standIn(answer http.RoundTripper) *app { + twitchApp := newApp(&url.URL{Scheme: "https", Host: "id.twitch.tv"}, "an-id", "a-secret") + twitchApp.client.Transport = answer + + return twitchApp +} diff --git a/services/games/internal/twitch/app_test.go b/services/games/internal/twitch/app_test.go new file mode 100644 index 0000000..32ee9f2 --- /dev/null +++ b/services/games/internal/twitch/app_test.go @@ -0,0 +1,166 @@ +package twitch_test + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync" + "testing" + + "github.com/codedogapp/bakhyard/services/games/internal/twitch" +) + +// secret is the stand-in app's client secret, which no error may show. +const secret = "hijklmn67890" + +func TestTokensAskTwitchForAnAppToken(t *testing.T) { + address, asked := twitchStandIn( + t, + http.StatusOK, + `{"access_token":"an-app-token","expires_in":5011271,"token_type":"bearer"}`, + ) + tokens := twitch.New(address, "abcdefg12345", secret) + + for range 2 { + got, err := tokens.Token(t.Context()) + if got != "an-app-token" || err != nil { + t.Fatalf("got %q, %v, want the token", got, err) + } + } + + want := []string{"POST /oauth2/token?, Content-Type application/x-www-form-urlencoded, " + + "Accept application/json: " + + "client_id=abcdefg12345&client_secret=hijklmn67890&grant_type=client_credentials"} + if got := asked(); !slices.Equal(got, want) { + t.Errorf("got the requests %q, want %q", got, want) + } +} + +func TestTokensSayWhyTwitchRefused(t *testing.T) { + for _, test := range []struct { + name, body, want string + status int + }{ + { + "no such client", `{"status":400,"message":"invalid client"}`, + "get a Twitch token: 400 Bad Request, invalid client", http.StatusBadRequest, + }, + { + "a wrong secret", `{"status":403,"message":"invalid client secret"}`, + "get a Twitch token: 403 Forbidden, invalid client secret", http.StatusForbidden, + }, + { + "no reason", `{"status":502}`, + "get a Twitch token: 502 Bad Gateway", http.StatusBadGateway, + }, + { + "not JSON", "

Down

", + "get a Twitch token: 500 Internal Server Error", http.StatusInternalServerError, + }, + { + "a reason over 64 KiB", `{"message":"` + strings.Repeat("a", 64<<10) + `"}`, + "get a Twitch token: 500 Internal Server Error", http.StatusInternalServerError, + }, + { + "a status that isn't 200", `{"access_token":"a","expires_in":100}`, + "get a Twitch token: 202 Accepted", http.StatusAccepted, + }, + {"no token", `{"expires_in":100}`, "read Twitch's answer: no access token", http.StatusOK}, + { + "no lifetime", `{"access_token":"a"}`, + "read Twitch's answer: the token expires in 0 seconds", http.StatusOK, + }, + { + "a lifetime past", `{"access_token":"a","expires_in":-1}`, + "read Twitch's answer: the token expires in -1 seconds", http.StatusOK, + }, + } { + t.Run(test.name, func(t *testing.T) { + address, _ := twitchStandIn(t, test.status, test.body) + + token, err := twitch.New(address, "abcdefg12345", secret).Token(t.Context()) + if token != "" || err == nil || err.Error() != test.want { + t.Errorf("got %q, %v, want the error %q", token, err, test.want) + } + }) + } +} + +func TestTokensRefuseAnAnswerTheyCannotRead(t *testing.T) { + for _, body := range []string{ + "

Hi

", + `{"access_token":"` + strings.Repeat("a", 64<<10) + `","expires_in":100}`, + } { + address, _ := twitchStandIn(t, http.StatusOK, body) + + token, err := twitch.New(address, "abcdefg12345", secret).Token(t.Context()) + if token != "" || err == nil || !strings.HasPrefix(err.Error(), "read Twitch's answer: ") { + t.Errorf("%.20s: got %q, %v, want an error that starts %q", body, token, err, + "read Twitch's answer: ") + } + } +} + +func TestTokensSayTwitchIsOutOfReach(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + server.Close() + + address, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + token, err := twitch.New(address, "abcdefg12345", secret).Token(t.Context()) + + want := `get a Twitch token: Post "` + server.URL + `/oauth2/token": ` + if token != "" || err == nil || !strings.HasPrefix(err.Error(), want) || + strings.Contains(err.Error(), secret) { + t.Errorf("got %q, %v, want an error that starts %q, without the secret", token, err, want) + } +} + +// twitchStandIn starts a stand-in Twitch that answers each request with status and body, and +// returns its address and a function that returns the requests it got: each one's method, path, +// query, content type, accepted type and form. +func twitchStandIn(t *testing.T, status int, body string) (*url.URL, func() []string) { + t.Helper() + + var ( + lock sync.Mutex + requests []string + ) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lock.Lock() + defer lock.Unlock() + + err := r.ParseForm() + if err != nil { + t.Error(err) + } + + requests = append(requests, fmt.Sprintf("%s %s?%s, Content-Type %s, Accept %s: %s", + r.Method, r.URL.Path, r.URL.RawQuery, r.Header.Get("Content-Type"), + r.Header.Get("Accept"), r.PostForm.Encode())) + + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(server.Close) + + address, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + + return address, func() []string { + lock.Lock() + defer lock.Unlock() + + return slices.Clone(requests) + } +} diff --git a/services/games/internal/twitch/tokens.go b/services/games/internal/twitch/tokens.go new file mode 100644 index 0000000..7ac753f --- /dev/null +++ b/services/games/internal/twitch/tokens.go @@ -0,0 +1,72 @@ +// Package twitch gets the app access token that IGDB's API takes from Twitch, keeps it, and gets +// a new one before it expires or when IGDB refuses it (docs/plan.md decisions 68, 300 and 304). +package twitch + +import ( + "context" + "time" +) + +// renewShare is the share of a token's life after which Tokens gets a new one. +const renewShare = 0.9 + +// getter asks Twitch for a new app token and returns it with its lifetime. +type getter func(ctx context.Context) (string, time.Duration, error) + +// Tokens keeps the latest app token of a Twitch app. It's safe for concurrent use. +type Tokens struct { + get getter + // turn holds a value while a call reads or changes the token, so that calls take turns and + // only one at a time asks Twitch. + turn chan struct{} + token string + renew time.Time +} + +// keep returns the Tokens that get their tokens from get. +func keep(get getter) *Tokens { + return &Tokens{get: get, turn: make(chan struct{}, 1)} +} + +// Token returns the token that Tokens keeps, or gets a new one when none is kept or the kept one +// has run 9 tenths of its life, counted from when the request for it began. One call at a time +// asks Twitch, and the others wait for its answer, since Twitch keeps only an app's 25 newest +// tokens. A failed request changes nothing, so the next call asks again. If ctx ends while the +// call waits, it returns ctx's error. +func (t *Tokens) Token(ctx context.Context) (string, error) { + select { + case t.turn <- struct{}{}: + case <-ctx.Done(): + return "", ctx.Err() + } + + defer func() { <-t.turn }() + + if t.token != "" && time.Now().Before(t.renew) { + return t.token, nil + } + + began := time.Now() + + token, life, err := t.get(ctx) + if err != nil { + return "", err + } + + t.token = token + t.renew = began.Add(time.Duration(float64(life) * renewShare)) + + return token, nil +} + +// Drop forgets token when IGDB has refused it, so the next call of Token gets a new one. A token +// that Tokens no longer keeps changes nothing, so a refusal of an older token keeps the newer. +func (t *Tokens) Drop(token string) { + t.turn <- struct{}{} + + defer func() { <-t.turn }() + + if token == t.token { + t.token = "" + } +} diff --git a/services/games/internal/twitch/tokens_internal_test.go b/services/games/internal/twitch/tokens_internal_test.go new file mode 100644 index 0000000..5cc1f2b --- /dev/null +++ b/services/games/internal/twitch/tokens_internal_test.go @@ -0,0 +1,189 @@ +package twitch + +import ( + "context" + "errors" + "sync" + "testing" + "testing/synctest" + "time" +) + +// answer is how the fake Twitch answers a request for a token, after took: with token and its +// life, or with err when it isn't nil. +type answer struct { + token string + life time.Duration + took time.Duration + err error +} + +// fakeTwitch answers the requests for a token in turn, as its answers say. +type fakeTwitch struct { + answers []answer + asked int +} + +func (f *fakeTwitch) get(context.Context) (string, time.Duration, error) { + if f.asked == len(f.answers) { + return "", 0, errors.New("one request too many") + } + + got := f.answers[f.asked] + f.asked++ + + time.Sleep(got.took) + + return got.token, got.life, got.err +} + +func TestTokensKeepATokenFor9TenthsOfItsLife(t *testing.T) { + for _, took := range []time.Duration{0, 10 * time.Second} { + t.Run(took.String(), func(t *testing.T) { + // A bubble's clock starts at midnight UTC on 1 January 2000. + synctest.Test(t, func(t *testing.T) { + twitch := &fakeTwitch{answers: []answer{ + {token: "first", life: 100 * time.Second, took: took}, + {token: "second", life: 100 * time.Second}, + }} + tokens := keep(twitch.get) + began := time.Now() + + wantToken(t, tokens, "first") + time.Sleep(time.Until(began.Add(90*time.Second - time.Nanosecond))) + wantToken(t, tokens, "first") + time.Sleep(time.Nanosecond) + wantToken(t, tokens, "second") + wantToken(t, tokens, "second") + + if twitch.asked != 2 { + t.Errorf("Twitch was asked %d times, want 2", twitch.asked) + } + }) + }) + } +} + +func TestTokensAskTwitchOnceAtATime(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + twitch := &fakeTwitch{answers: []answer{ + {token: "first", life: time.Hour, took: time.Second}, + }} + tokens := keep(twitch.get) + + var calls sync.WaitGroup + for range 3 { + calls.Go(func() { wantToken(t, tokens, "first") }) + } + + calls.Wait() + + if twitch.asked != 1 { + t.Errorf("Twitch was asked %d times, want once", twitch.asked) + } + }) +} + +func TestATokenThatWaitsEndsWithItsContext(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + twitch := &fakeTwitch{answers: []answer{ + {token: "first", life: time.Hour, took: time.Minute}, + }} + tokens := keep(twitch.get) + + var first sync.WaitGroup + first.Go(func() { wantToken(t, tokens, "first") }) + + synctest.Wait() + + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + + began := time.Now() + + token, err := tokens.Token(ctx) + if token != "" || !errors.Is(err, context.DeadlineExceeded) || + time.Since(began) != time.Second { + t.Errorf("got %q, %v after %s, want the context's error after 1s", token, err, + time.Since(began)) + } + + first.Wait() + }) +} + +func TestAFailedRequestForATokenChangesNothing(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + down := errors.New("Twitch is down") + twitch := &fakeTwitch{answers: []answer{ + {err: down}, + {token: "first", life: 100 * time.Second}, + {err: down}, + {token: "second", life: 100 * time.Second}, + }} + tokens := keep(twitch.get) + + wantError(t, tokens, down) + wantToken(t, tokens, "first") + time.Sleep(90 * time.Second) + wantError(t, tokens, down) + wantToken(t, tokens, "second") + }) +} + +func TestDropForgetsTheTokenThatIGDBRefused(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + twitch := &fakeTwitch{answers: []answer{ + {token: "first", life: time.Hour}, + {token: "second", life: time.Hour}, + }} + tokens := keep(twitch.get) + + wantToken(t, tokens, "first") + tokens.Drop("first") + wantToken(t, tokens, "second") + tokens.Drop("first") + wantToken(t, tokens, "second") + }) +} + +func TestDropWaitsForTheTokenThatIsComing(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + twitch := &fakeTwitch{answers: []answer{ + {token: "first", life: 100 * time.Second}, + {token: "second", life: 100 * time.Second, took: time.Second}, + }} + tokens := keep(twitch.get) + + wantToken(t, tokens, "first") + time.Sleep(90 * time.Second) + + var renewal sync.WaitGroup + renewal.Go(func() { wantToken(t, tokens, "second") }) + + synctest.Wait() + tokens.Drop("first") + renewal.Wait() + wantToken(t, tokens, "second") + }) +} + +// wantToken checks that tokens' Token returns want. +func wantToken(t *testing.T, tokens *Tokens, want string) { + t.Helper() + + got, err := tokens.Token(t.Context()) + if got != want || err != nil { + t.Errorf("at %s: got %q, %v, want %q", time.Now().UTC(), got, err, want) + } +} + +// wantError checks that tokens' Token fails with want. +func wantError(t *testing.T, tokens *Tokens, want error) { + t.Helper() + + got, err := tokens.Token(t.Context()) + if got != "" || !errors.Is(err, want) { + t.Errorf("at %s: got %q, %v, want the error %v", time.Now().UTC(), got, err, want) + } +}