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
224 lines
7.4 KiB
Go
224 lines
7.4 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/oauth2"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/adapters/store"
|
|
"git.d-ma.be/mathias/tapir/internal/auth"
|
|
"git.d-ma.be/mathias/tapir/internal/web"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// fakeConns captures UpsertConnection calls without a database.
|
|
type fakeConns struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
userID string
|
|
conn store.Connection
|
|
}
|
|
|
|
func (c *fakeConns) UpsertConnection(_ context.Context, userID string, conn store.Connection) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.calls, c.userID, c.conn = c.calls+1, userID, conn
|
|
return nil
|
|
}
|
|
|
|
// fakeTrigger records Enqueue calls so a test can assert connect fired discovery.
|
|
type fakeTrigger struct {
|
|
mu sync.Mutex
|
|
users []string
|
|
}
|
|
|
|
func (f *fakeTrigger) Enqueue(userID string) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.users = append(f.users, userID)
|
|
}
|
|
|
|
func (f *fakeTrigger) seen() []string {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]string(nil), f.users...)
|
|
}
|
|
|
|
// tokenServer fakes Google's token endpoint, returning body for any POST.
|
|
func tokenServer(t *testing.T, body string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(body))
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// newConnectApp builds a registered-stub-user App with a wired ConnectHandler.
|
|
// The OAuth endpoint points at srvURL so Exchange never contacts live Google.
|
|
func newConnectApp(t *testing.T, srvURL string, secrets auth.TokenWriter, conns web.Connections) *web.App {
|
|
t.Helper()
|
|
s := newStore(t) // applies migrations
|
|
resetDB(t, rawPool(t)) // seeds stubSubject -> userID so the gate resolves a user
|
|
connect := web.NewConnectHandler(auth.Config{
|
|
ClientID: "cid",
|
|
ClientSecret: "csecret",
|
|
RedirectURL: "https://tapir.d-ma.be/oauth/youtube/callback",
|
|
Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
|
|
}, secrets, conns, nil)
|
|
return &web.App{
|
|
Store: s,
|
|
Identity: s,
|
|
Auth: web.StubAuth{U: web.User{Subject: stubSubject}},
|
|
Connect: connect,
|
|
}
|
|
}
|
|
|
|
// connectState drives GET /oauth/youtube/connect and returns the CSRF state from
|
|
// the consent redirect, so the callback test can present a valid state.
|
|
func connectState(t *testing.T, app *web.App) string {
|
|
t.Helper()
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/oauth/youtube/connect", nil))
|
|
require.Equal(t, http.StatusFound, rec.Code)
|
|
loc := rec.Header().Get("Location")
|
|
u, err := url.Parse(loc)
|
|
require.NoError(t, err)
|
|
q := u.Query()
|
|
require.Equal(t, "offline", q.Get("access_type"), "must request offline access for a refresh token")
|
|
require.Equal(t, "consent", q.Get("prompt"), "must force consent for a refresh token")
|
|
state := q.Get("state")
|
|
require.NotEmpty(t, state, "consent URL must carry a CSRF state")
|
|
return state
|
|
}
|
|
|
|
func TestConnectRedirectsToConsent(t *testing.T) {
|
|
srv := tokenServer(t, `{}`)
|
|
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
|
|
_ = connectState(t, app) // assertions live in the helper
|
|
}
|
|
|
|
func TestCallbackExchangesAndRecordsConnection(t *testing.T) {
|
|
srv := tokenServer(t,
|
|
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
|
w := &fakeWriter{}
|
|
conns := &fakeConns{}
|
|
app := newConnectApp(t, srv.URL, w, conns)
|
|
|
|
state := connectState(t, app)
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
|
"/oauth/youtube/callback?state="+state+"&code=the-code", nil))
|
|
|
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
|
require.Equal(t, "/", rec.Header().Get("Location"))
|
|
|
|
// Token persisted under the per-user ref.
|
|
wantRef := web.YouTubeTokenRef(userID)
|
|
require.Equal(t, wantRef, w.ref, "refresh token stored under the per-user ref")
|
|
require.Equal(t, "rt-secret", w.val)
|
|
|
|
// Connection recorded for the authenticated user.
|
|
require.Equal(t, 1, conns.calls)
|
|
require.Equal(t, userID, conns.userID)
|
|
require.Equal(t, "youtube", conns.conn.Provider)
|
|
require.Equal(t, "active", conns.conn.Status)
|
|
require.Equal(t, wantRef, conns.conn.TokenRef)
|
|
}
|
|
|
|
func TestCallbackTriggersDiscovery(t *testing.T) {
|
|
srv := tokenServer(t,
|
|
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
|
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
|
|
trig := &fakeTrigger{}
|
|
app.Connect.Discovery = trig
|
|
|
|
state := connectState(t, app)
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
|
"/oauth/youtube/callback?state="+state+"&code=the-code", nil))
|
|
|
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
|
require.Equal(t, []string{userID}, trig.seen(),
|
|
"a successful connect must trigger discovery for the connecting user")
|
|
}
|
|
|
|
func TestCallbackNoDiscoveryOnFailedConnect(t *testing.T) {
|
|
srv := tokenServer(t,
|
|
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
|
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
|
|
trig := &fakeTrigger{}
|
|
app.Connect.Discovery = trig
|
|
|
|
// No state → CSRF reject → nothing connected, so no discovery.
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
|
"/oauth/youtube/callback?code=the-code", nil))
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Empty(t, trig.seen(), "a failed connect must not trigger discovery")
|
|
}
|
|
|
|
func TestCallbackRejectsMissingState(t *testing.T) {
|
|
srv := tokenServer(t,
|
|
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
|
w := &fakeWriter{}
|
|
conns := &fakeConns{}
|
|
app := newConnectApp(t, srv.URL, w, conns)
|
|
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
|
"/oauth/youtube/callback?code=the-code", nil)) // no state
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Equal(t, 0, w.calls, "nothing persisted on missing state")
|
|
require.Equal(t, 0, conns.calls, "no connection recorded on missing state")
|
|
}
|
|
|
|
func TestCallbackRejectsUnknownState(t *testing.T) {
|
|
srv := tokenServer(t,
|
|
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
|
|
w := &fakeWriter{}
|
|
conns := &fakeConns{}
|
|
app := newConnectApp(t, srv.URL, w, conns)
|
|
|
|
// A state never issued by connect must be rejected (CSRF).
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet,
|
|
"/oauth/youtube/callback?state=deadbeef&code=the-code", nil))
|
|
require.Equal(t, http.StatusBadRequest, rec.Code)
|
|
require.Equal(t, 0, w.calls)
|
|
require.Equal(t, 0, conns.calls)
|
|
}
|
|
|
|
func TestCallbackStateIsSingleUse(t *testing.T) {
|
|
srv := tokenServer(t,
|
|
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
|
|
w := &fakeWriter{}
|
|
conns := &fakeConns{}
|
|
app := newConnectApp(t, srv.URL, w, conns)
|
|
|
|
state := connectState(t, app)
|
|
url := "/oauth/youtube/callback?state=" + state + "&code=the-code"
|
|
|
|
rec := do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
|
|
require.Equal(t, http.StatusSeeOther, rec.Code)
|
|
|
|
// Replaying the same state must fail — it was consumed.
|
|
rec = do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
|
|
require.Equal(t, http.StatusBadRequest, rec.Code, "state is single-use")
|
|
require.Equal(t, 1, conns.calls, "replay must not record a second connection")
|
|
}
|