diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-17 14:19:09 +0200 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-17 14:19:09 +0200 |
| commit | c0a01ac65bb492640b169274614e0d8022f5076d (patch) | |
| tree | f7d3f0e81d1a9dc05288ba24f1ecc57387f3e0d5 /fikenauth/source_test.go | |
| download | fiken-f112e25ee77c31812d0c59265284cc410bbb3b97.tar.gz fiken-f112e25ee77c31812d0c59265284cc410bbb3b97.zip | |
Fiken API client and OAuth2 for Gov0.1.0
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.
Diffstat (limited to 'fikenauth/source_test.go')
| -rw-r--r-- | fikenauth/source_test.go | 172 |
1 files changed, 172 insertions, 0 deletions
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) + } +} |
