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/store_test.go | 298 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 fikenauth/store_test.go (limited to 'fikenauth/store_test.go') diff --git a/fikenauth/store_test.go b/fikenauth/store_test.go new file mode 100644 index 0000000..bcd940b --- /dev/null +++ b/fikenauth/store_test.go @@ -0,0 +1,298 @@ +package fikenauth + +import ( + "errors" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func tempStore(t *testing.T) *Store { + t.Helper() + return NewStore(filepath.Join(t.TempDir(), "nested", "fiken-token.json")) +} + +func TestStoreRoundTrip(t *testing.T) { + store := tempStore(t) + if _, err := store.Load(); !errors.Is(err, ErrNoToken) { + t.Fatalf("err = %v, want ErrNoToken before anything is stored", err) + } + + want := Token{ + AccessToken: "access-1", + RefreshToken: "refresh-1", + TokenType: "bearer", + ExpiresAt: fixedNow.Add(time.Hour).UTC(), + } + if err := store.Save(want); err != nil { + t.Fatalf("Save: %v", err) + } + got, err := store.Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken { + t.Errorf("round trip changed the token: %+v", got) + } + if !got.ExpiresAt.Equal(want.ExpiresAt) { + t.Errorf("ExpiresAt = %s, want %s", got.ExpiresAt, want.ExpiresAt) + } +} + +// The file holds a live credential, so nobody else on the machine may read it. +func TestStoredTokenIsPrivate(t *testing.T) { + store := tempStore(t) + if err := store.Save(Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + + info, err := os.Stat(store.Path()) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Errorf("token file mode is %o, want 600", mode) + } + dir, err := os.Stat(filepath.Dir(store.Path())) + if err != nil { + t.Fatal(err) + } + if mode := dir.Mode().Perm(); mode != 0o700 { + t.Errorf("token directory mode is %o, want 700", mode) + } +} + +func TestSaveReplaces(t *testing.T) { + store := tempStore(t) + if err := store.Save(Token{AccessToken: "first", RefreshToken: "r1"}); err != nil { + t.Fatal(err) + } + if err := store.Save(Token{AccessToken: "second"}); err != nil { + t.Fatal(err) + } + got, err := store.Load() + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "second" || got.RefreshToken != "" { + t.Errorf("got %+v, want the second token entirely", got) + } + + // The temporary file used for the atomic write must not survive. + entries, err := os.ReadDir(filepath.Dir(store.Path())) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 { + t.Errorf("directory holds %d files, want just the token", len(entries)) + } +} + +func TestDeleteIsIdempotent(t *testing.T) { + store := tempStore(t) + if err := store.Save(Token{AccessToken: "a"}); err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := store.Delete(); err != nil { + t.Fatalf("Delete %d: %v", i, err) + } + } + if _, err := store.Load(); !errors.Is(err, ErrNoToken) { + t.Errorf("err = %v, want ErrNoToken", err) + } +} + +func TestLoadRejectsRubbish(t *testing.T) { + store := tempStore(t) + if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.Path(), []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + _, err := store.Load() + if err == nil || errors.Is(err, ErrNoToken) { + t.Fatalf("err = %v, want a complaint about the file", err) + } + if !strings.Contains(err.Error(), "log in again") { + t.Errorf("error %q should say how to fix it", err) + } +} + +func TestLoadTreatsAnEmptyTokenAsAbsent(t *testing.T) { + store := tempStore(t) + if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(store.Path(), []byte(`{"access_token":""}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := store.Load(); !errors.Is(err, ErrNoToken) { + t.Errorf("err = %v, want ErrNoToken", err) + } +} + +// Login runs a one-shot local server; the browser is simulated by fetching the +// authorization URL's redirect target directly. +func TestLoginLoopback(t *testing.T) { + server := newAuthServer(t) + cfg := server.config() + cfg.RedirectURL = "http://127.0.0.1:18137/callback" + + token, err := cfg.Login(t.Context(), func(authURL string) { + go visitAsBrowser(t, server, authURL, "the-code", "") + }) + if err != nil { + t.Fatalf("Login: %v", err) + } + if token.AccessToken != "access-1" { + t.Errorf("token = %+v", token) + } + if got := server.gotForm.Get("code"); got != "the-code" { + t.Errorf("exchanged code %q, want the one from the redirect", got) + } +} + +// A redirect carrying somebody else's state must not complete this login. +func TestLoginRejectsAWrongState(t *testing.T) { + server := newAuthServer(t) + cfg := server.config() + cfg.RedirectURL = "http://127.0.0.1:18138/callback" + + _, err := cfg.Login(t.Context(), func(authURL string) { + go visitAsBrowser(t, server, authURL, "the-code", "not-the-state") + }) + if err == nil || !strings.Contains(err.Error(), "wrong state") { + t.Fatalf("err = %v, want a state mismatch", err) + } +} + +func TestLoginSurfacesADeclinedConsent(t *testing.T) { + server := newAuthServer(t) + cfg := server.config() + cfg.RedirectURL = "http://127.0.0.1:18139/callback" + + _, err := cfg.Login(t.Context(), func(authURL string) { + go func() { + redirect := redirectTargetOf(t, authURL) + get(t, server, redirect+"?error=access_denied&error_description=Nei") + }() + }) + var oauthErr *Error + if !errors.As(err, &oauthErr) || !oauthErr.AccessDenied() { + t.Fatalf("err = %v, want an access_denied *Error", err) + } +} + +// A redirect URL that is not on this machine cannot be listened on; say so +// rather than failing obscurely. +func TestLoginRefusesARemoteRedirect(t *testing.T) { + cfg := newAuthServer(t).config() + cfg.RedirectURL = "https://example.com/callback" + + _, err := cfg.Login(t.Context(), func(string) {}) + if err == nil || !strings.Contains(err.Error(), "--manual") { + t.Fatalf("err = %v, want it to point at --manual", err) + } +} + +func TestManualLogin(t *testing.T) { + server := newAuthServer(t) + cfg := server.config() + + authURL, state, err := cfg.ManualAuthCodeURL() + if err != nil { + t.Fatalf("ManualAuthCodeURL: %v", err) + } + if !strings.Contains(authURL, url.QueryEscape(state)) { + t.Errorf("the URL %q should carry the state", authURL) + } + + pasted := "http://localhost:8137/callback?code=pasted-code&state=" + url.QueryEscape(state) + token, err := cfg.CompleteManual(t.Context(), state, " "+pasted+" ") + if err != nil { + t.Fatalf("CompleteManual: %v", err) + } + if token.AccessToken != "access-1" { + t.Errorf("token = %+v", token) + } + if got := server.gotForm.Get("code"); got != "pasted-code" { + t.Errorf("exchanged %q", got) + } +} + +// Some browsers show the code instead of navigating, so a bare code is taken. +func TestManualLoginAcceptsABareCode(t *testing.T) { + server := newAuthServer(t) + token, err := server.config().CompleteManual(t.Context(), "state-1", "just-the-code") + if err != nil { + t.Fatalf("CompleteManual: %v", err) + } + if token.AccessToken != "access-1" { + t.Errorf("token = %+v", token) + } + if got := server.gotForm.Get("code"); got != "just-the-code" { + t.Errorf("exchanged %q, want the bare code", got) + } +} + +func TestManualLoginRejects(t *testing.T) { + cfg := newAuthServer(t).config() + cases := []struct{ name, pasted, want string }{ + {"nothing", " ", "nothing pasted"}, + {"another attempt", "http://localhost:8137/callback?code=c&state=other", "different login attempt"}, + {"an error redirect", "http://localhost:8137/callback?error=access_denied", "access_denied"}, + {"no code", "http://localhost:8137/callback?foo=bar", "no authorization code"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := cfg.CompleteManual(t.Context(), "state-1", tc.pasted) + if err == nil { + t.Fatalf("CompleteManual accepted %s", tc.name) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("error %q should mention %q", err, tc.want) + } + }) + } +} + +// visitAsBrowser plays the part of the browser: it reads the redirect target +// and state out of the authorization URL and calls back with a code. +func visitAsBrowser(t *testing.T, server *authServer, authURL, code, state string) { + t.Helper() + parsed, err := url.Parse(authURL) + if err != nil { + t.Errorf("parse %q: %v", authURL, err) + return + } + query := parsed.Query() + if state == "" { + state = query.Get("state") + } + get(t, server, query.Get("redirect_uri")+ + "?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state)) +} + +func redirectTargetOf(t *testing.T, authURL string) string { + t.Helper() + parsed, err := url.Parse(authURL) + if err != nil { + t.Fatalf("parse %q: %v", authURL, err) + } + return parsed.Query().Get("redirect_uri") +} + +func get(t *testing.T, server *authServer, target string) { + t.Helper() + resp, err := server.server.Client().Get(target) + if err != nil { + t.Errorf("GET %s: %v", target, err) + return + } + resp.Body.Close() +} -- cgit v1.3.1