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() }