@@ -2,12 +2,15 @@ package auth
22
33import (
44 "context"
5+ "encoding/json"
56 "errors"
67 "fmt"
78 "io"
89 "net/http"
910 "net/http/httptest"
10- "strings"
11+ "os"
12+ "path/filepath"
13+ "sync"
1114 "sync/atomic"
1215 "testing"
1316 "time"
@@ -22,25 +25,40 @@ import (
2225func tokenServer (t * testing.T , hold * atomic.Bool ) * httptest.Server {
2326 t .Helper ()
2427 var jwt string
25- // A request the client never cancels is let go at the end, so a
26- // failure reports itself rather than hanging the server's Close.
28+ // The server notices a client that hung up a moment after the client
29+ // has gone: it reads the closed connection on a goroutine of its own.
30+ // So the test's end waits for each held request to see its
31+ // cancellation, for a bounded time, before the server is closed (which
32+ // would end the request's context itself and hide a request the client
33+ // never cancelled).
34+ var held sync.WaitGroup
2735 done := make (chan struct {})
2836 server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
2937 if hold .Load () {
38+ held .Add (1 )
39+ defer held .Done ()
3040 // The server notices a client that hung up only once the body
3141 // is read.
3242 _ , _ = io .Copy (io .Discard , r .Body )
3343 select {
3444 case <- r .Context ().Done ():
45+ return
3546 case <- done :
47+ }
48+ select {
49+ case <- r .Context ().Done ():
50+ case <- time .After (5 * time .Second ):
3651 t .Error ("a held request was never cancelled" )
3752 }
3853 return
3954 }
4055 fmt .Fprintf (w , `{"accessToken":%q,"expiry":%d}` , jwt , time .Now ().Add (time .Hour ).Unix ())
4156 }))
4257 t .Cleanup (server .Close )
43- t .Cleanup (func () { close (done ) })
58+ t .Cleanup (func () {
59+ close (done )
60+ held .Wait ()
61+ })
4462 jwt = testJWT (t , server .URL )
4563 return server
4664}
@@ -66,13 +84,114 @@ func TestATokenFetchCancelledFailsOnlyItself(t *testing.T) {
6684 if _ , err := strategy .Token (ctx ); ! errors .Is (err , context .DeadlineExceeded ) {
6785 t .Fatalf ("cancelled Token: %v, want the deadline" , err )
6886 }
69- if profile .root .closed || profile .root .inst .module .IsClosed () {
70- t .Fatal ("the profile was closed" )
71- }
7287 hold .Store (false )
88+ // The next Token waits for the abandoned one to finish.
7389 if token , err := strategy .Token (context .Background ()); err != nil || token == "" {
7490 t .Fatalf ("the next Token: %q, %v" , token , err )
7591 }
92+ if profile .root .closed || profile .root .inst .module .IsClosed () {
93+ t .Fatal ("the profile was closed" )
94+ }
95+ }
96+
97+ // An access-key exchange rotates nothing, so it ends with its caller: the
98+ // held request is cancelled at the caller's deadline, not run on for the
99+ // refresh grace, and Close does not wait for it.
100+ func TestAnAccessKeyExchangeEndsWithItsCaller (t * testing.T ) {
101+ guestOrSkip (t )
102+ var hold atomic.Bool
103+ server := tokenServer (t , & hold )
104+ profile , err := Open (context .Background (), t .TempDir ())
105+ if err != nil {
106+ t .Fatal (err )
107+ }
108+ defer profile .Close ()
109+ strategy , err := profile .AccessKey (context .Background (), testCRN , "CSAKtestKeyId.testKeySecret" , WithBaseURL (server .URL ))
110+ if err != nil {
111+ t .Fatal (err )
112+ }
113+
114+ hold .Store (true )
115+ ctx , cancel := context .WithTimeout (context .Background (), 30 * time .Millisecond )
116+ defer cancel ()
117+ if _ , err := strategy .Token (ctx ); ! errors .Is (err , context .DeadlineExceeded ) {
118+ t .Fatalf ("cancelled Token: %v, want the deadline" , err )
119+ }
120+ // The grace is 10 seconds; a Close that waited for it would take that.
121+ start := time .Now ()
122+ if err := strategy .Close (); err != nil {
123+ t .Fatal (err )
124+ }
125+ if waited := time .Since (start ); waited > 2 * time .Second {
126+ t .Fatalf ("Close took %v: the exchange ran on past its caller" , waited )
127+ }
128+ }
129+
130+ // A device session's refresh is the request that rotates a credential: the
131+ // service answers with a new refresh token and retires the old one. A
132+ // caller that gives up returns at once, but the refresh runs on, and what
133+ // it brings is saved. The next Token is served from it with no second
134+ // request, and the profile on disk holds the rotated refresh token.
135+ func TestARefreshAnsweredAfterItsCallerGaveUpIsKept (t * testing.T ) {
136+ guestOrSkip (t )
137+ dir , _ := expiredDeviceProfile (t )
138+ var requests atomic.Int32
139+ answered := make (chan struct {})
140+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
141+ if requests .Add (1 ) > 1 {
142+ // The old refresh token is retired once the service answers.
143+ w .WriteHeader (http .StatusBadRequest )
144+ fmt .Fprint (w , `{"error":"invalid_grant"}` )
145+ return
146+ }
147+ _ , _ = io .Copy (io .Discard , r .Body )
148+ time .Sleep (150 * time .Millisecond )
149+ fmt .Fprint (w , `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}` )
150+ close (answered )
151+ }))
152+ t .Cleanup (server .Close )
153+ profile , err := Open (context .Background (), dir )
154+ if err != nil {
155+ t .Fatal (err )
156+ }
157+ defer profile .Close ()
158+ ws , err := profile .WorkspaceStore (context .Background (), wsA )
159+ if err != nil {
160+ t .Fatal (err )
161+ }
162+ strategy , err := ws .DeviceSession (context .Background (), WithBaseURL (server .URL ))
163+ if err != nil {
164+ t .Fatal (err )
165+ }
166+ defer strategy .Close ()
167+
168+ ctx , cancel := context .WithTimeout (context .Background (), 30 * time .Millisecond )
169+ defer cancel ()
170+ if _ , err := strategy .Token (ctx ); ! errors .Is (err , context .DeadlineExceeded ) {
171+ t .Fatalf ("cancelled Token: %v, want the deadline" , err )
172+ }
173+ select {
174+ case <- answered :
175+ t .Fatal ("the cancelled Token waited for the service's answer" )
176+ default :
177+ }
178+ if token , err := strategy .Token (context .Background ()); err != nil || token != "fresh" {
179+ t .Fatalf ("the next Token: %q, %v, want the refreshed token" , token , err )
180+ }
181+ if n := requests .Load (); n != 1 {
182+ t .Fatalf ("%d refresh requests, want 1: the late answer was dropped" , n )
183+ }
184+ data , err := os .ReadFile (filepath .Join (dir , "workspaces" , wsA , "auth.json" ))
185+ if err != nil {
186+ t .Fatal (err )
187+ }
188+ var stored map [string ]any
189+ if err := json .Unmarshal (data , & stored ); err != nil {
190+ t .Fatal (err )
191+ }
192+ if stored ["refresh_token" ] != "refresh-2" {
193+ t .Fatalf ("stored refresh token = %v, want the rotated one" , stored ["refresh_token" ])
194+ }
76195}
77196
78197func TestATokenRequestWhoseContextEndsInTheQueueNeverReachesTheGuest (t * testing.T ) {
@@ -114,7 +233,7 @@ func TestATokenRequestWhoseContextEndsInTheQueueNeverReachesTheGuest(t *testing.
114233}
115234
116235// The backstop: a call still running long after its caller gave up closes
117- // the profile, and says so .
236+ // the profile. The caller has returned by then; the next call is ErrState .
118237func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile (t * testing.T ) {
119238 guestOrSkip (t )
120239 profile , err := Open (context .Background (), t .TempDir (), WithRoundTripper (roundTripFunc (func (* http.Request ) (* http.Response , error ) {
@@ -134,8 +253,8 @@ func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile(t *testing.T)
134253 profile .root .stuck = 20 * time .Millisecond
135254 ctx , cancel := context .WithTimeout (context .Background (), 20 * time .Millisecond )
136255 defer cancel ()
137- if _ , err := strategy .Token (ctx ); err == nil || ! strings . Contains (err . Error (), "the profile is closed" ) {
138- t .Fatalf ("stuck Token: %v, want the profile closed " , err )
256+ if _ , err := strategy .Token (ctx ); ! errors . Is (err , context . DeadlineExceeded ) {
257+ t .Fatalf ("stuck Token: %v, want the deadline " , err )
139258 }
140259 if _ , err := strategy .Token (context .Background ()); ! errors .Is (err , ErrState ) {
141260 t .Fatalf ("a Token after the close: %v, want ErrState" , err )
0 commit comments