Files
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
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
2026-07-02 14:37:33 +02:00

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