From c0a01ac65bb492640b169274614e0d8022f5076d Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Sat, 17 Oct 2026 14:19:09 +0200 Subject: Fiken API client and OAuth2 for Go Extracted from gcbill, where it was internal/fiken and internal/fikenauth, so adaa and sokkel-billing can use the same client instead of each carrying its own. The login command named when a grant cannot be renewed is now injected (Source.LoginCommand): it was hardcoded to gcbill's, which would have sent every other caller's operator to a command that does not exist. --- fikenauth/source_test.go | 172 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 fikenauth/source_test.go (limited to 'fikenauth/source_test.go') diff --git a/fikenauth/source_test.go b/fikenauth/source_test.go new file mode 100644 index 0000000..1c960d6 --- /dev/null +++ b/fikenauth/source_test.go @@ -0,0 +1,172 @@ +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) + } +} -- cgit v1.3.1