aboutsummaryrefslogtreecommitdiffstats
path: root/fikenauth/store_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'fikenauth/store_test.go')
-rw-r--r--fikenauth/store_test.go298
1 files changed, 298 insertions, 0 deletions
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()
+}