package web_test import ( "context" "net/http" "net/http/httptest" "net/url" "sync" "testing" "github.com/stretchr/testify/require" "golang.org/x/oauth2" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/auth" "gitea.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 } // 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 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") }