package fikenauth import ( "errors" "net/http" "strings" "testing" "time" ) func TestSourceReturnsAValidTokenWithoutRefreshing(t *testing.T) { server := newAuthServer(t) store := tempStore(t) if err := store.Save(Token{ AccessToken: "still-good", RefreshToken: "r1", ExpiresAt: fixedNow.Add(time.Hour), }); err != nil { t.Fatal(err) } got, err := NewSource(server.config(), store).Token(t.Context()) if err != nil { t.Fatalf("Token: %v", err) } if got != "still-good" { t.Errorf("Token = %q", got) } if server.gotForm != nil { t.Error("the token endpoint was called for a token that was still valid") } } // An expired token is renewed and the new one stored, because Fiken invalidates // the refresh token once it is used: not saving would strand the next run. func TestSourceRefreshesAndPersists(t *testing.T) { server := newAuthServer(t) server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}` store := tempStore(t) if err := store.Save(Token{ AccessToken: "expired", RefreshToken: "refresh-1", ExpiresAt: fixedNow.Add(-time.Hour), }); err != nil { t.Fatal(err) } source := NewSource(server.config(), store) got, err := source.Token(t.Context()) if err != nil { t.Fatalf("Token: %v", err) } if got != "access-2" { t.Errorf("Token = %q, want the refreshed one", got) } if sent := server.gotForm.Get("refresh_token"); sent != "refresh-1" { t.Errorf("refreshed with %q", sent) } stored, err := store.Load() if err != nil { t.Fatal(err) } if stored.AccessToken != "access-2" || stored.RefreshToken != "refresh-2" { t.Errorf("stored %+v, want the refreshed grant", stored) } // A second call within the same run reuses what it already has. server.gotForm = nil if _, err := source.Token(t.Context()); err != nil { t.Fatalf("second Token: %v", err) } if server.gotForm != nil { t.Error("the token was refreshed twice in one run") } } func TestSourceWithoutAStoredToken(t *testing.T) { _, err := NewSource(newAuthServer(t).config(), tempStore(t)).Token(t.Context()) if !errors.Is(err, ErrNoToken) { t.Errorf("err = %v, want ErrNoToken", err) } } func TestSourceCannotRenewWithoutARefreshToken(t *testing.T) { store := tempStore(t) if err := store.Save(Token{AccessToken: "expired", ExpiresAt: fixedNow.Add(-time.Hour)}); err != nil { t.Fatal(err) } _, err := NewSource(newAuthServer(t).config(), store).Token(t.Context()) if !errors.Is(err, ErrNoToken) { t.Errorf("err = %v, want ErrNoToken", err) } } // A revoked grant should tell the operator what to do, not report a raw // OAuth code at them. func TestSourceExplainsARevokedGrant(t *testing.T) { server := newAuthServer(t) server.replyCode = http.StatusBadRequest server.reply = `{"error":"invalid_grant","error_description":"revoked"}` store := tempStore(t) if err := store.Save(Token{ AccessToken: "expired", RefreshToken: "r1", ExpiresAt: fixedNow.Add(-time.Hour), }); err != nil { t.Fatal(err) } // A caller that says what its login command is gets named in the error, // because every program embedding this spells it differently. source := NewSource(server.config(), store) source.LoginCommand = "bill fiken login" _, err := source.Token(t.Context()) if err == nil || !strings.Contains(err.Error(), "run `bill fiken login` again") { t.Fatalf("err = %v, want it to name the caller's login command", err) } // One that does not still has to say what to do, without inventing a // command that does not exist. _, err = NewSource(server.config(), store).Token(t.Context()) switch { case err == nil || !strings.Contains(err.Error(), "log in to Fiken again"): t.Fatalf("err = %v, want it to say how to recover", err) case strings.Contains(err.Error(), "`"): t.Errorf("err = %v, want no command quoted when none was given", err) } } func TestLogoutRevokesAndForgets(t *testing.T) { server := newAuthServer(t) store := tempStore(t) if err := store.Save(Token{ AccessToken: "access-1", RefreshToken: "r1", ExpiresAt: fixedNow.Add(time.Hour), }); err != nil { t.Fatal(err) } source := NewSource(server.config(), store) if err := source.Logout(t.Context()); err != nil { t.Fatalf("Logout: %v", err) } if server.gotRevokeAuth != "Bearer access-1" { t.Errorf("revoke Authorization = %q", server.gotRevokeAuth) } if _, err := store.Load(); !errors.Is(err, ErrNoToken) { t.Errorf("the token file survived logout: %v", err) } } // A token that cannot be revoked is exactly the one not to keep on disk. func TestLogoutForgetsEvenWhenRevokeFails(t *testing.T) { server := newAuthServer(t) server.replyCode = http.StatusInternalServerError store := tempStore(t) if err := store.Save(Token{AccessToken: "access-1", ExpiresAt: fixedNow.Add(time.Hour)}); err != nil { t.Fatal(err) } if err := NewSource(server.config(), store).Logout(t.Context()); err == nil { t.Error("Logout hid the revoke failure") } if _, err := store.Load(); !errors.Is(err, ErrNoToken) { t.Errorf("the token file survived logout: %v", err) } } func TestLogoutWithoutATokenIsNoToken(t *testing.T) { err := NewSource(newAuthServer(t).config(), tempStore(t)).Logout(t.Context()) if !errors.Is(err, ErrNoToken) { t.Errorf("err = %v, want ErrNoToken", err) } } // Two programs sharing one grant: the second to find it expired must use what // the first saved, because refresh-1 died when the first one renewed it. func TestSourceRereadsAGrantAnotherProgramRenewed(t *testing.T) { server := newAuthServer(t) server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}` store := tempStore(t) if err := store.Save(Token{ AccessToken: "expired", RefreshToken: "refresh-1", ExpiresAt: fixedNow.Add(-time.Hour), }); err != nil { t.Fatal(err) } late := NewSource(server.config(), NewStore(store.Path())) if _, err := late.Current(); err != nil { t.Fatal(err) } if _, err := NewSource(server.config(), store).Token(t.Context()); err != nil { t.Fatalf("first program: %v", err) } server.gotForm = nil got, err := late.Token(t.Context()) if err != nil { t.Fatalf("second program: %v", err) } if got != "access-2" || server.gotForm != nil { t.Errorf("Token = %q, refreshed again: %v; want the first program's grant", got, server.gotForm != nil) } }