Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and all .go import paths. Build and tests pass unchanged. Closes #20 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
189 lines
5.0 KiB
Go
189 lines
5.0 KiB
Go
package auth_test
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"regexp"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/oauth2"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
|
)
|
|
|
|
// fakeWriter is a TokenWriter capturing the persisted (ref, value).
|
|
type fakeWriter struct {
|
|
mu sync.Mutex
|
|
ref, val string
|
|
calls int
|
|
}
|
|
|
|
func (w *fakeWriter) Put(ref, value string) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
w.ref, w.val, w.calls = ref, value, w.calls+1
|
|
return nil
|
|
}
|
|
|
|
// tokenServer fakes Google's token endpoint, returning the given JSON body for
|
|
// any POST. No live Google contact.
|
|
func tokenServer(t *testing.T, body string) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
}
|
|
|
|
func cfg(srvURL string) auth.Config {
|
|
return auth.Config{
|
|
ClientID: "cid",
|
|
ClientSecret: "csecret",
|
|
RedirectURL: "http://localhost:18099/callback",
|
|
TokenRef: "youtube/refresh_token",
|
|
Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
|
|
}
|
|
}
|
|
|
|
func TestExchange_PersistsRefreshToken(t *testing.T) {
|
|
srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
|
defer srv.Close()
|
|
|
|
w := &fakeWriter{}
|
|
if err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code"); err != nil {
|
|
t.Fatalf("Exchange: %v", err)
|
|
}
|
|
if w.ref != "youtube/refresh_token" {
|
|
t.Errorf("persisted ref = %q", w.ref)
|
|
}
|
|
if w.val != "rt-secret" {
|
|
t.Errorf("persisted token = %q, want rt-secret", w.val)
|
|
}
|
|
}
|
|
|
|
func TestExchange_RejectsMissingRefreshToken(t *testing.T) {
|
|
srv := tokenServer(t, `{"access_token":"at","token_type":"Bearer","expires_in":3600}`)
|
|
defer srv.Close()
|
|
|
|
w := &fakeWriter{}
|
|
err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code")
|
|
if err == nil {
|
|
t.Fatal("want error when no refresh token returned")
|
|
}
|
|
if w.calls != 0 {
|
|
t.Errorf("nothing should be persisted on failure; Put called %d times", w.calls)
|
|
}
|
|
}
|
|
|
|
// stateRe pulls the CSRF state out of the printed consent URL.
|
|
var stateRe = regexp.MustCompile(`[?&]state=([a-f0-9]+)`)
|
|
|
|
// urlWriter forwards each Write to a channel so the test can read the consent
|
|
// URL Run prints before it blocks on the redirect.
|
|
type urlWriter struct{ ch chan string }
|
|
|
|
func (w urlWriter) Write(p []byte) (int, error) {
|
|
w.ch <- string(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
func TestRun_FullFlow(t *testing.T) {
|
|
srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
|
defer srv.Close()
|
|
|
|
// Ensure the fixed redirect port is free before binding.
|
|
if ln, err := net.Listen("tcp", "localhost:18099"); err == nil {
|
|
_ = ln.Close()
|
|
} else {
|
|
t.Skipf("redirect port 18099 unavailable: %v", err)
|
|
}
|
|
|
|
w := &fakeWriter{}
|
|
out := urlWriter{ch: make(chan string, 4)}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }()
|
|
|
|
// First message carries the consent URL with the state param.
|
|
var state string
|
|
select {
|
|
case msg := <-out.ch:
|
|
m := stateRe.FindStringSubmatch(msg)
|
|
if m == nil {
|
|
t.Fatalf("no state in consent message: %q", msg)
|
|
}
|
|
state = m[1]
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for consent URL")
|
|
}
|
|
|
|
// Simulate the browser hitting the local redirect with code + state.
|
|
resp, err := http.Get("http://localhost:18099/callback?state=" + state + "&code=the-code")
|
|
if err != nil {
|
|
t.Fatalf("callback GET: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
|
|
select {
|
|
case err := <-runErr:
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Run did not complete after callback")
|
|
}
|
|
|
|
if w.val != "rt-secret" {
|
|
t.Errorf("persisted token = %q, want rt-secret", w.val)
|
|
}
|
|
}
|
|
|
|
func TestRun_RejectsStateMismatch(t *testing.T) {
|
|
srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
|
defer srv.Close()
|
|
|
|
if ln, err := net.Listen("tcp", "localhost:18099"); err == nil {
|
|
_ = ln.Close()
|
|
} else {
|
|
t.Skipf("redirect port 18099 unavailable: %v", err)
|
|
}
|
|
|
|
w := &fakeWriter{}
|
|
out := urlWriter{ch: make(chan string, 4)}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }()
|
|
|
|
select {
|
|
case <-out.ch: // drain consent URL
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("timed out waiting for consent URL")
|
|
}
|
|
|
|
resp, err := http.Get("http://localhost:18099/callback?state=WRONG&code=the-code")
|
|
if err != nil {
|
|
t.Fatalf("callback GET: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
|
|
select {
|
|
case err := <-runErr:
|
|
if err == nil {
|
|
t.Fatal("want error on state mismatch")
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Run did not return after bad callback")
|
|
}
|
|
if w.calls != 0 {
|
|
t.Errorf("no token should be persisted on state mismatch; Put called %d times", w.calls)
|
|
}
|
|
}
|