@@ -15,6 +15,8 @@ import (
1515 "testing"
1616 "time"
1717
18+ "github.com/grokify/goauth/dpop"
19+
1820 "github.com/plexusone/systemforge/identity/publicclient"
1921 "github.com/plexusone/systemforge/identity/relyingparty"
2022 "github.com/plexusone/systemforge/identity/systemauth"
@@ -426,3 +428,106 @@ func TestFileStore(t *testing.T) {
426428 t .Errorf ("deleting nothing: %v" , err )
427429 }
428430}
431+
432+ // lockingStore is a FileStore whose lock counts its holders and can fail
433+ // to release.
434+ type lockingStore struct {
435+ publicclient.FileStore
436+ locks int
437+ unlockErr error
438+ }
439+
440+ func (l * lockingStore ) Lock (ctx context.Context ) (func () error , error ) {
441+ unlock , err := l .FileStore .Lock (ctx )
442+ if err != nil {
443+ return nil , err
444+ }
445+ l .locks ++
446+ return func () error { return errors .Join (unlock (), l .unlockErr ) }, nil
447+ }
448+
449+ // TestStoreLock checks that a refresh reports a failure to release the
450+ // store's lock, and that logout holds the lock.
451+ func TestStoreLock (t * testing.T ) {
452+ e := newEnv (t )
453+ ctx := t .Context ()
454+ if _ , err := publicclient .Login (ctx , e .config (), e .browser (t )); err != nil {
455+ t .Fatalf ("login: %v" , err )
456+ }
457+ ls := & lockingStore {FileStore : e .store , unlockErr : errors .New ("unlock failed" )}
458+ cfg := e .config ()
459+ cfg .Store = ls
460+
461+ e .expire (t )
462+ s , err := publicclient .Open (ctx , cfg )
463+ if err != nil {
464+ t .Fatal (err )
465+ }
466+ if _ , err := s .AccessToken (ctx ); err == nil || ! strings .Contains (err .Error (), "unlock failed" ) {
467+ t .Errorf ("refresh with a failing unlock: %v" , err )
468+ }
469+
470+ ls .unlockErr , ls .locks = nil , 0
471+ if err := s .Logout (ctx ); err != nil {
472+ t .Fatalf ("logout: %v" , err )
473+ }
474+ if ls .locks != 1 {
475+ t .Errorf ("logout took the store's lock %d times, want 1" , ls .locks )
476+ }
477+ }
478+
479+ // trackedBody records whether it was closed.
480+ type trackedBody struct {
481+ io.Reader
482+ closed bool
483+ }
484+
485+ func (b * trackedBody ) Close () error {
486+ b .closed = true
487+ return nil
488+ }
489+
490+ // TestTransportClosesBody checks the RoundTripper contract: a request that
491+ // cannot be sent, here because the access token expired and there is no
492+ // refresh token, has its body closed.
493+ func TestTransportClosesBody (t * testing.T ) {
494+ ctx := t .Context ()
495+ key , err := dpop .GenerateKeyPair ()
496+ if err != nil {
497+ t .Fatal (err )
498+ }
499+ serialized , err := key .SerializeJSON ()
500+ if err != nil {
501+ t .Fatal (err )
502+ }
503+ store := publicclient.FileStore {Path : filepath .Join (t .TempDir (), "credentials.json" )}
504+ if err := store .Save (ctx , & publicclient.Credentials {
505+ Issuer : "https://auth.example.com" , ClientID : "cli" , DPoPKey : serialized ,
506+ AccessToken : "expired" , Expiry : time .Now ().Add (- time .Hour ),
507+ }); err != nil {
508+ t .Fatal (err )
509+ }
510+ s , err := publicclient .Open (ctx , publicclient.Config {
511+ Issuer : "https://auth.example.com" , ClientID : "cli" , Store : store ,
512+ Metadata : & relyingparty.Metadata { //nolint:gosec // G101: endpoint URLs, not credentials
513+ Issuer : "https://auth.example.com" ,
514+ AuthorizationEndpoint : "https://auth.example.com/oauth/authorize" ,
515+ TokenEndpoint : "https://auth.example.com/oauth/token" ,
516+ JWKSURI : "https://auth.example.com/.well-known/jwks.json" ,
517+ },
518+ })
519+ if err != nil {
520+ t .Fatal (err )
521+ }
522+ body := & trackedBody {Reader : strings .NewReader ("payload" )}
523+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , "https://api.example.com/x" , body )
524+ if err != nil {
525+ t .Fatal (err )
526+ }
527+ if _ , err := s .Transport (nil ).RoundTrip (req ); ! errors .Is (err , publicclient .ErrNotLoggedIn ) {
528+ t .Errorf ("round trip without a usable token: %v" , err )
529+ }
530+ if ! body .closed {
531+ t .Error ("the request body was not closed" )
532+ }
533+ }
0 commit comments