@@ -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"
@@ -25,25 +28,40 @@ import (
2528func tokenServer (t * testing.T , hold * atomic.Bool ) * httptest.Server {
2629 t .Helper ()
2730 var jwt string
28- // A request the client never cancels is let go at the end, so a
29- // failure reports itself rather than hanging the server's Close.
31+ // The server notices a client that hung up a moment after the client
32+ // has gone: it reads the closed connection on a goroutine of its own.
33+ // So the test's end waits for each held request to see its
34+ // cancellation, for a bounded time, before the server is closed (which
35+ // would end the request's context itself and hide a request the client
36+ // never cancelled).
37+ var held sync.WaitGroup
3038 done := make (chan struct {})
3139 server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
3240 if hold .Load () {
41+ held .Add (1 )
42+ defer held .Done ()
3343 // The server notices a client that hung up only once the body
3444 // is read.
3545 _ , _ = io .Copy (io .Discard , r .Body )
3646 select {
3747 case <- r .Context ().Done ():
48+ return
3849 case <- done :
50+ }
51+ select {
52+ case <- r .Context ().Done ():
53+ case <- time .After (5 * time .Second ):
3954 t .Error ("a held request was never cancelled" )
4055 }
4156 return
4257 }
4358 fmt .Fprintf (w , `{"accessToken":%q,"expiry":%d}` , jwt , time .Now ().Add (time .Hour ).Unix ())
4459 }))
4560 t .Cleanup (server .Close )
46- t .Cleanup (func () { close (done ) })
61+ t .Cleanup (func () {
62+ close (done )
63+ held .Wait ()
64+ })
4765 jwt = testJWT (t , server .URL )
4866 return server
4967}
@@ -69,13 +87,114 @@ func TestATokenFetchCancelledFailsOnlyItself(t *testing.T) {
6987 if _ , err := strategy .Token (ctx ); ! errors .Is (err , context .DeadlineExceeded ) {
7088 t .Fatalf ("cancelled Token: %v, want the deadline" , err )
7189 }
72- if profile .root .closed || profile .root .inst .module .IsClosed () {
73- t .Fatal ("the profile was closed" )
74- }
7590 hold .Store (false )
91+ // The next Token waits for the abandoned one to finish.
7692 if token , err := strategy .Token (context .Background ()); err != nil || token == "" {
7793 t .Fatalf ("the next Token: %q, %v" , token , err )
7894 }
95+ if profile .root .closed || profile .root .inst .module .IsClosed () {
96+ t .Fatal ("the profile was closed" )
97+ }
98+ }
99+
100+ // An access-key exchange rotates nothing, so it ends with its caller: the
101+ // held request is cancelled at the caller's deadline, not run on for the
102+ // refresh grace, and Close does not wait for it.
103+ func TestAnAccessKeyExchangeEndsWithItsCaller (t * testing.T ) {
104+ guestOrSkip (t )
105+ var hold atomic.Bool
106+ server := tokenServer (t , & hold )
107+ profile , err := Open (context .Background (), t .TempDir ())
108+ if err != nil {
109+ t .Fatal (err )
110+ }
111+ defer profile .Close ()
112+ strategy , err := profile .AccessKey (context .Background (), testCRN , "CSAKtestKeyId.testKeySecret" , WithBaseURL (server .URL ))
113+ if err != nil {
114+ t .Fatal (err )
115+ }
116+
117+ hold .Store (true )
118+ ctx , cancel := context .WithTimeout (context .Background (), 30 * time .Millisecond )
119+ defer cancel ()
120+ if _ , err := strategy .Token (ctx ); ! errors .Is (err , context .DeadlineExceeded ) {
121+ t .Fatalf ("cancelled Token: %v, want the deadline" , err )
122+ }
123+ // The grace is 10 seconds; a Close that waited for it would take that.
124+ start := time .Now ()
125+ if err := strategy .Close (); err != nil {
126+ t .Fatal (err )
127+ }
128+ if waited := time .Since (start ); waited > 2 * time .Second {
129+ t .Fatalf ("Close took %v: the exchange ran on past its caller" , waited )
130+ }
131+ }
132+
133+ // A device session's refresh is the request that rotates a credential: the
134+ // service answers with a new refresh token and retires the old one. A
135+ // caller that gives up returns at once, but the refresh runs on, and what
136+ // it brings is saved. The next Token is served from it with no second
137+ // request, and the profile on disk holds the rotated refresh token.
138+ func TestARefreshAnsweredAfterItsCallerGaveUpIsKept (t * testing.T ) {
139+ guestOrSkip (t )
140+ dir , _ := expiredDeviceProfile (t )
141+ var requests atomic.Int32
142+ answered := make (chan struct {})
143+ server := httptest .NewServer (http .HandlerFunc (func (w http.ResponseWriter , r * http.Request ) {
144+ if requests .Add (1 ) > 1 {
145+ // The old refresh token is retired once the service answers.
146+ w .WriteHeader (http .StatusBadRequest )
147+ fmt .Fprint (w , `{"error":"invalid_grant"}` )
148+ return
149+ }
150+ _ , _ = io .Copy (io .Discard , r .Body )
151+ time .Sleep (150 * time .Millisecond )
152+ fmt .Fprint (w , `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}` )
153+ close (answered )
154+ }))
155+ t .Cleanup (server .Close )
156+ profile , err := Open (context .Background (), dir )
157+ if err != nil {
158+ t .Fatal (err )
159+ }
160+ defer profile .Close ()
161+ ws , err := profile .WorkspaceStore (context .Background (), wsA )
162+ if err != nil {
163+ t .Fatal (err )
164+ }
165+ strategy , err := ws .DeviceSession (context .Background (), WithBaseURL (server .URL ))
166+ if err != nil {
167+ t .Fatal (err )
168+ }
169+ defer strategy .Close ()
170+
171+ ctx , cancel := context .WithTimeout (context .Background (), 30 * time .Millisecond )
172+ defer cancel ()
173+ if _ , err := strategy .Token (ctx ); ! errors .Is (err , context .DeadlineExceeded ) {
174+ t .Fatalf ("cancelled Token: %v, want the deadline" , err )
175+ }
176+ select {
177+ case <- answered :
178+ t .Fatal ("the cancelled Token waited for the service's answer" )
179+ default :
180+ }
181+ if token , err := strategy .Token (context .Background ()); err != nil || token != "fresh" {
182+ t .Fatalf ("the next Token: %q, %v, want the refreshed token" , token , err )
183+ }
184+ if n := requests .Load (); n != 1 {
185+ t .Fatalf ("%d refresh requests, want 1: the late answer was dropped" , n )
186+ }
187+ data , err := os .ReadFile (filepath .Join (dir , "workspaces" , wsA , "auth.json" ))
188+ if err != nil {
189+ t .Fatal (err )
190+ }
191+ var stored map [string ]any
192+ if err := json .Unmarshal (data , & stored ); err != nil {
193+ t .Fatal (err )
194+ }
195+ if stored ["refresh_token" ] != "refresh-2" {
196+ t .Fatalf ("stored refresh token = %v, want the rotated one" , stored ["refresh_token" ])
197+ }
79198}
80199
81200// A refresh runs on after its caller gives up, so the guest call under it
@@ -155,7 +274,7 @@ func TestATokenRequestWhoseContextEndsInTheQueueNeverReachesTheGuest(t *testing.
155274}
156275
157276// The backstop: a call still running long after its caller gave up closes
158- // the profile, and says so .
277+ // the profile. The caller has returned by then; the next call is ErrState .
159278func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile (t * testing.T ) {
160279 guestOrSkip (t )
161280 profile , err := Open (context .Background (), t .TempDir (), WithRoundTripper (roundTripFunc (func (* http.Request ) (* http.Response , error ) {
@@ -175,8 +294,8 @@ func TestATokenFetchThatRunsOnAfterItsContextEndsClosesTheProfile(t *testing.T)
175294 profile .root .stuck = 20 * time .Millisecond
176295 ctx , cancel := context .WithTimeout (context .Background (), 20 * time .Millisecond )
177296 defer cancel ()
178- if _ , err := strategy .Token (ctx ); err == nil || ! strings . Contains (err . Error (), "the profile is closed" ) {
179- t .Fatalf ("stuck Token: %v, want the profile closed " , err )
297+ if _ , err := strategy .Token (ctx ); ! errors . Is (err , context . DeadlineExceeded ) {
298+ t .Fatalf ("stuck Token: %v, want the deadline " , err )
180299 }
181300 if _ , err := strategy .Token (context .Background ()); ! errors .Is (err , ErrState ) {
182301 t .Fatalf ("a Token after the close: %v, want ErrState" , err )
0 commit comments