Files
tapir/internal/web/connect_test.go
T
mathiasandClaude Opus 4.8 2aad79b2a8
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s
feat(web): web-initiated YouTube OAuth connect flow (ADR-006)
Add GET /oauth/youtube/connect and /oauth/youtube/callback, mounted inside
the login + registration guard so CurrentUserID is always set and every
connection binds to the authenticated tapir user.

- connect: generate a per-user CSRF state (single-use, short TTL, bound to
  the user), redirect to Google consent with access_type=offline and
  prompt=consent so a refresh token comes back.
- callback: verify the state belongs to this user, exchange the code via the
  existing auth.Exchange, persist the refresh token under a PER-USER ref
  (web.YouTubeTokenRef = "youtube/<userID>/refresh_token") so tenants never
  collide, then UpsertConnection (provider=youtube, status=active). Any
  failure renders a clean error page and leaves no half-written state.

Reuses auth.Exchange and adds auth.AuthCodeURL (offline + consent) rather
than the CLI's listener/terminal flow (ADR-006: web flow, not CLI). The
ConnectHandler depends on a narrow web.Connections port, not the concrete
store. Wired in cmdServe only when YT client credentials are present;
TAPIR_YT_CONNECT_REDIRECT_URL configures the callback URL. Per-user token-ref
scheme documented in docs/homelab-integration.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:12:42 +02:00

176 lines
5.9 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"
"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")
}