Files
plezy/server/oauth_test.go
T

793 lines
25 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
)
// mockUpstream runs an httptest server that impersonates MAL/AniList. It
// records the last token-exchange form submission and returns a canned
// access_token/refresh_token response.
type mockUpstream struct {
srv *httptest.Server
mu sync.Mutex
lastForm url.Values
tokenReply string
tokenCode int
}
// httpGet / httpPost / httpDo wrap the stdlib calls to fail the test on error.
// Keeps test bodies one-liner without tripping `go vet`'s
// "using resp before checking errors" rule.
func httpGet(t *testing.T, url string) *http.Response {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
return resp
}
func httpPost(t *testing.T, url, contentType string, body io.Reader) *http.Response {
t.Helper()
resp, err := http.Post(url, contentType, body)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
return resp
}
func httpDo(t *testing.T, req *http.Request) *http.Response {
t.Helper()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do %s %s: %v", req.Method, req.URL, err)
}
return resp
}
func newMockUpstream(t *testing.T) *mockUpstream {
t.Helper()
m := &mockUpstream{
tokenReply: `{"access_token":"tok-abc","refresh_token":"ref-xyz","expires_in":2678400}`,
tokenCode: http.StatusOK,
}
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/oauth/authorize":
// Unused in tests — we assert on the 302 Location from our proxy.
w.WriteHeader(http.StatusOK)
case "/oauth/token":
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
m.mu.Lock()
m.lastForm = r.PostForm
code := m.tokenCode
reply := m.tokenReply
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
io.WriteString(w, reply)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(m.srv.Close)
return m
}
func (m *mockUpstream) setReply(code int, body string) {
m.mu.Lock()
defer m.mu.Unlock()
m.tokenCode = code
m.tokenReply = body
}
func (m *mockUpstream) form() url.Values {
m.mu.Lock()
defer m.mu.Unlock()
return m.lastForm
}
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
// with `mal` and `anilist` services pointed at a shared mock upstream.
type oauthHarness struct {
proxy *oauthProxy
srv *httptest.Server
base string
upstream *mockUpstream
}
func newOAuthHarness(t *testing.T) *oauthHarness {
t.Helper()
return newOAuthHarnessWithResolver(t, mustClientIPResolver(t, "127.0.0.0/8"))
}
func newOAuthHarnessWithResolver(t *testing.T, clientIPs clientIPResolver) *oauthHarness {
t.Helper()
up := newMockUpstream(t)
proxy := newOAuthProxy("http://placeholder", map[string]oauthServiceConfig{
"mal": {
ClientID: "mal-id",
AuthorizeURL: up.srv.URL + "/oauth/authorize",
TokenURL: up.srv.URL + "/oauth/token",
UsePKCE: true,
PKCEMethod: "plain",
},
"anilist": {
ClientID: "anilist-id",
ClientSecret: "anilist-secret",
AuthorizeURL: up.srv.URL + "/oauth/authorize",
TokenURL: up.srv.URL + "/oauth/token",
},
}, clientIPs)
mux := http.NewServeMux()
registerOAuthRoutes(mux, proxy)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
// Rewire baseURL to the real httptest URL so redirect_uri computes correctly.
proxy.baseURL = srv.URL
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
}
func (h *oauthHarness) startSession(t *testing.T, service, ip string) (pollSecret, browserState, authorizeURL string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"service": service})
req, _ := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if ip != "" {
req.Header.Set("X-Forwarded-For", ip)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("start: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("start status=%d", resp.StatusCode)
}
if got := resp.Header.Get("Cache-Control"); got != "no-store, private" {
t.Fatalf("start Cache-Control=%q", got)
}
var out struct {
Session string `json:"session"`
URL string `json:"url"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
parsed, err := url.Parse(out.URL)
if err != nil {
t.Fatalf("parse authorize URL: %v", err)
}
state := parsed.Query().Get("state")
if out.Session == "" || out.URL == "" || state == "" {
t.Fatalf("empty poll secret/url/browser state: %+v", out)
}
return out.Session, state, out.URL
}
func postOAuthStart(t *testing.T, h *oauthHarness, service, xff string) *http.Response {
t.Helper()
body, err := json.Marshal(map[string]string{"service": service})
if err != nil {
t.Fatalf("marshal: %v", err)
}
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
if xff != "" {
req.Header.Set("X-Forwarded-For", xff)
}
return httpDo(t, req)
}
// ====== /auth/start ======
func TestOAuthStartSeparatesDeviceCapabilityFromBrowserState(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, authorizeURL := h.startSession(t, "mal", "1.2.3.4")
if pollSecret == browserState {
t.Fatal("device poll capability must differ from browser state")
}
if !strings.HasPrefix(authorizeURL, h.base+"/auth/mal?state=") {
t.Fatalf("url=%q doesn't look like the authorize start URL", authorizeURL)
}
if strings.Contains(authorizeURL, pollSecret) {
t.Fatalf("authorize URL disclosed device poll capability")
}
if got := h.proxy.pollDigests[digestPollSecret(pollSecret)]; got == nil {
t.Fatal("poll capability digest was not indexed")
}
if got := h.proxy.browserStates[browserState]; got == nil {
t.Fatal("browser state was not indexed")
}
pollAsBrowser := httpGet(t, h.base+"/auth/mal?state="+url.QueryEscape(pollSecret))
pollAsBrowser.Body.Close()
if pollAsBrowser.StatusCode != http.StatusNotFound {
t.Fatalf("poll capability authorized browser path: status=%d", pollAsBrowser.StatusCode)
}
}
func TestOAuthStartRejectsUnknownService(t *testing.T) {
h := newOAuthHarness(t)
body, _ := json.Marshal(map[string]string{"service": "nope"})
resp := httpPost(t, h.base+"/auth/start", "application/json", bytes.NewReader(body))
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d want 400", resp.StatusCode)
}
}
func TestOAuthStartRejectsInvalidJSON(t *testing.T) {
h := newOAuthHarness(t)
resp := httpPost(t, h.base+"/auth/start", "application/json", strings.NewReader("not json"))
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d want 400", resp.StatusCode)
}
}
func TestOAuthStartRateLimitedPerIP(t *testing.T) {
h := newOAuthHarness(t)
ip := "5.5.5.5"
for range oauthStartBurst {
_, _, _ = h.startSession(t, "mal", ip) // should all succeed
}
body, _ := json.Marshal(map[string]string{"service": "mal"})
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", ip)
resp := httpDo(t, req)
defer resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("status=%d want 429", resp.StatusCode)
}
}
func TestOAuthSessionLimitCountsLogicalSessions(t *testing.T) {
h := newOAuthHarness(t)
h.proxy.mu.Lock()
for i := range oauthMaxSessions - 1 {
sess := &oauthSession{
browserState: fmt.Sprintf("state-%d", i),
pollDigest: digestPollSecret(fmt.Sprintf("poll-%d", i)),
}
h.proxy.addSessionLocked(sess)
}
h.proxy.mu.Unlock()
body, _ := json.Marshal(map[string]string{"service": "mal"})
req := httptest.NewRequest(http.MethodPost, "/auth/start", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.proxy.handleStart(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start with %d logical sessions: status=%d want 200", oauthMaxSessions-1, rec.Code)
}
h.proxy.mu.Lock()
browserCount := len(h.proxy.browserStates)
pollCount := len(h.proxy.pollDigests)
h.proxy.mu.Unlock()
if browserCount != oauthMaxSessions || pollCount != oauthMaxSessions {
t.Fatalf("index counts browser=%d poll=%d want %d each", browserCount, pollCount, oauthMaxSessions)
}
}
func TestOAuthStartUsesTrustedCanonicalClientIdentity(t *testing.T) {
t.Run("untrusted spoof rotation shares direct peer bucket", func(t *testing.T) {
h := newOAuthHarnessWithResolver(t, newClientIPResolver(nil))
for i := range oauthStartBurst {
resp := postOAuthStart(t, h, "mal", fmt.Sprintf("203.0.113.%d", i+1))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("start %d status=%d", i, resp.StatusCode)
}
}
denied := postOAuthStart(t, h, "mal", "198.51.100.10")
denied.Body.Close()
if denied.StatusCode != http.StatusTooManyRequests {
t.Fatalf("rotated spoof status=%d, want 429", denied.StatusCode)
}
})
t.Run("validated clients have independent buckets", func(t *testing.T) {
h := newOAuthHarness(t)
for _, ip := range []string{"203.0.113.1", "203.0.113.2"} {
for range oauthStartBurst {
resp := postOAuthStart(t, h, "mal", ip)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("client %s status=%d", ip, resp.StatusCode)
}
}
}
})
t.Run("malformed trusted chain mutates no state", func(t *testing.T) {
h := newOAuthHarness(t)
resp := postOAuthStart(t, h, "mal", "203.0.113.1,")
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status=%d, want 400", resp.StatusCode)
}
h.proxy.ipMu.Lock()
rateCount := len(h.proxy.ipRate)
h.proxy.ipMu.Unlock()
h.proxy.mu.Lock()
browserCount := len(h.proxy.browserStates)
pollCount := len(h.proxy.pollDigests)
h.proxy.mu.Unlock()
if rateCount != 0 || browserCount != 0 || pollCount != 0 {
t.Fatalf(
"malformed chain mutated OAuth state: rates=%d browser=%d poll=%d",
rateCount,
browserCount,
pollCount,
)
}
})
t.Run("untrusted malformed header is ignored", func(t *testing.T) {
h := newOAuthHarnessWithResolver(t, newClientIPResolver(nil))
resp := postOAuthStart(t, h, "mal", "bad,")
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
})
}
func TestOAuthStartMethodNotAllowed(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/start")
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status=%d want 405", resp.StatusCode)
}
}
// ====== /auth/:service (authorize redirect) ======
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, "mal", "1.1.1.1")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(h.base + "/auth/mal?state=" + url.QueryEscape(browserState))
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusFound {
t.Fatalf("status=%d want 302", resp.StatusCode)
}
loc, err := url.Parse(resp.Header.Get("Location"))
if err != nil {
t.Fatalf("parse Location: %v", err)
}
q := loc.Query()
if q.Get("client_id") != "mal-id" {
t.Errorf("client_id=%q", q.Get("client_id"))
}
if q.Get("response_type") != "code" {
t.Errorf("response_type=%q", q.Get("response_type"))
}
if q.Get("state") != browserState {
t.Errorf("state=%q, want browser state %q", q.Get("state"), browserState)
}
if q.Get("state") == pollSecret {
t.Error("provider state disclosed device poll capability")
}
if q.Get("code_challenge_method") != "plain" {
t.Errorf("code_challenge_method=%q, want plain", q.Get("code_challenge_method"))
}
if q.Get("code_challenge") == "" {
t.Error("code_challenge missing")
}
if !strings.HasSuffix(q.Get("redirect_uri"), "/auth/mal/callback") {
t.Errorf("redirect_uri=%q", q.Get("redirect_uri"))
}
}
func TestOAuthAuthorizeAnilistRedirectOmitsPKCE(t *testing.T) {
h := newOAuthHarness(t)
_, browserState, _ := h.startSession(t, "anilist", "1.1.1.2")
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(h.base + "/auth/anilist?state=" + url.QueryEscape(browserState))
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
loc, _ := url.Parse(resp.Header.Get("Location"))
q := loc.Query()
if q.Get("code_challenge") != "" {
t.Errorf("anilist redirect should not include code_challenge, got %q", q.Get("code_challenge"))
}
}
func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/mal?state=bogus")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "no longer valid") {
t.Errorf("expected error page html, got: %s", body)
}
}
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
h := newOAuthHarness(t)
_, browserState, _ := h.startSession(t, "mal", "1.1.1.3")
// Try to use the MAL browser state against the AniList authorize endpoint.
resp := httpGet(t, h.base+"/auth/anilist?state="+url.QueryEscape(browserState))
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
}
}
// ====== /auth/:service/callback + /auth/result ======
type oauthResultResponse struct {
status int
cacheControl string
body map[string]any
err error
}
func requestOAuthResult(rawURL string) oauthResultResponse {
resp, err := http.Get(rawURL)
if err != nil {
return oauthResultResponse{err: err}
}
defer resp.Body.Close()
out := oauthResultResponse{
status: resp.StatusCode,
cacheControl: resp.Header.Get("Cache-Control"),
}
if resp.StatusCode == http.StatusOK {
out.body = make(map[string]any)
out.err = json.NewDecoder(resp.Body).Decode(&out.body)
}
return out
}
func TestOAuthBrowserStateCannotClaimResult(t *testing.T) {
for _, service := range []string{"mal", "anilist"} {
t.Run(service, func(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, service, "2.2.2.1")
browserClaim := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(browserState))
if browserClaim.err != nil {
t.Fatalf("browser-state result request: %v", browserClaim.err)
}
if browserClaim.status != http.StatusGone {
t.Fatalf("browser-state result status=%d want 410", browserClaim.status)
}
callback := fmt.Sprintf("%s/auth/%s/callback?code=CODE123&state=%s", h.base, service, url.QueryEscape(browserState))
resp := httpGet(t, callback)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("callback status=%d", resp.StatusCode)
}
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if result.err != nil {
t.Fatalf("device result request: %v", result.err)
}
if result.status != http.StatusOK {
t.Fatalf("device result status=%d want 200", result.status)
}
if result.cacheControl != "no-store, private" {
t.Fatalf("result Cache-Control=%q", result.cacheControl)
}
if result.body["accessToken"] != "tok-abc" || result.body["refreshToken"] != "ref-xyz" {
t.Fatalf("unexpected result: %v", result.body)
}
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if second.err != nil {
t.Fatalf("second result request: %v", second.err)
}
if second.status != http.StatusGone {
t.Fatalf("second result status=%d want 410", second.status)
}
form := h.upstream.form()
if form.Get("code") != "CODE123" {
t.Errorf("upstream code=%q", form.Get("code"))
}
if service == "mal" && form.Get("code_verifier") == "" {
t.Error("upstream missing MAL code verifier")
}
if service == "anilist" && form.Get("code_verifier") != "" {
t.Error("AniList exchange unexpectedly included a code verifier")
}
})
}
}
func TestOAuthConcurrentResultClaimIsOneShot(t *testing.T) {
for _, tc := range []struct {
name string
result oauthTokenResult
}{
{name: "token", result: oauthTokenResult{AccessToken: "tok"}},
{name: "provider error", result: oauthTokenResult{Error: "authorization_failed"}},
} {
t.Run(tc.name, func(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.2")
digest := digestPollSecret(pollSecret)
h.proxy.mu.Lock()
sess := h.proxy.pollDigests[digest]
h.proxy.mu.Unlock()
if sess == nil {
t.Fatal("session missing from poll index")
}
seated := make(chan struct{}, 2)
sess.waitStarted = func() { seated <- struct{}{} }
results := make(chan oauthResultResponse, 2)
resultURL := h.base + "/auth/result?session=" + url.QueryEscape(pollSecret)
go func() { results <- requestOAuthResult(resultURL) }()
go func() { results <- requestOAuthResult(resultURL) }()
for range 2 {
select {
case <-seated:
case <-time.After(3 * time.Second):
t.Fatal("result waiter did not reach readiness boundary")
}
}
if !h.proxy.completeSession(sess, tc.result) {
t.Fatal("could not complete current session")
}
statuses := map[int]int{}
var winningBody map[string]any
for range 2 {
select {
case got := <-results:
if got.err != nil {
t.Fatalf("result request: %v", got.err)
}
if got.cacheControl != "no-store, private" {
t.Errorf("result Cache-Control=%q", got.cacheControl)
}
statuses[got.status]++
if got.status == http.StatusOK {
winningBody = got.body
}
case <-time.After(3 * time.Second):
t.Fatal("result request did not return")
}
}
if statuses[http.StatusOK] != 1 || statuses[http.StatusGone] != 1 {
t.Fatalf("statuses=%v want one 200 and one 410", statuses)
}
if tc.result.Error != "" && winningBody["error"] != tc.result.Error {
t.Fatalf("winning error result=%v", winningBody)
}
if tc.result.AccessToken != "" && winningBody["accessToken"] != tc.result.AccessToken {
t.Fatalf("winning token result=%v", winningBody)
}
h.proxy.mu.Lock()
_, hasBrowserState := h.proxy.browserStates[browserState]
_, hasPollDigest := h.proxy.pollDigests[digest]
h.proxy.mu.Unlock()
sess.mu.Lock()
storedResult := sess.result
sess.mu.Unlock()
if hasBrowserState || hasPollDigest || storedResult != nil {
t.Fatalf("claim left state: browser=%v poll=%v result=%v", hasBrowserState, hasPollDigest, storedResult)
}
})
}
}
func TestOAuthCallbackErrorsAreGenericAndOneShot(t *testing.T) {
for _, tc := range []struct {
name string
callback func(base, state string) string
wantError string
wantCBState int
}{
{
name: "provider detail",
callback: func(base, state string) string {
return fmt.Sprintf("%s/auth/mal/callback?error=provider_detail_canary&state=%s", base, url.QueryEscape(state))
},
wantError: "authorization_failed",
wantCBState: http.StatusOK,
},
{
name: "user cancelled",
callback: func(base, state string) string {
return fmt.Sprintf("%s/auth/mal/callback?error=access_denied&state=%s", base, url.QueryEscape(state))
},
wantError: "access_denied",
wantCBState: http.StatusOK,
},
} {
t.Run(tc.name, func(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.3")
resp := httpGet(t, tc.callback(h.base, browserState))
resp.Body.Close()
if resp.StatusCode != tc.wantCBState {
t.Fatalf("callback status=%d want %d", resp.StatusCode, tc.wantCBState)
}
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if result.err != nil {
t.Fatalf("result request: %v", result.err)
}
if result.status != http.StatusOK || result.body["error"] != tc.wantError {
t.Fatalf("result status=%d body=%v", result.status, result.body)
}
if result.cacheControl != "no-store, private" {
t.Fatalf("result Cache-Control=%q", result.cacheControl)
}
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if second.status != http.StatusGone {
t.Fatalf("second result status=%d want 410", second.status)
}
})
}
}
func TestOAuthCallbackExchangeFailureIsOneShot(t *testing.T) {
h := newOAuthHarness(t)
h.upstream.setReply(http.StatusBadRequest, `{"error":"invalid_grant"}`)
pollSecret, browserState, _ := h.startSession(t, "mal", "2.2.2.4")
resp := httpGet(t, fmt.Sprintf("%s/auth/mal/callback?code=CODE&state=%s", h.base, url.QueryEscape(browserState)))
resp.Body.Close()
if resp.StatusCode != http.StatusBadGateway {
t.Fatalf("callback status=%d want 502", resp.StatusCode)
}
result := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if result.status != http.StatusOK || result.body["error"] != "exchange_failed" {
t.Fatalf("result status=%d body=%v", result.status, result.body)
}
if result.cacheControl != "no-store, private" {
t.Fatalf("result Cache-Control=%q", result.cacheControl)
}
second := requestOAuthResult(h.base + "/auth/result?session=" + url.QueryEscape(pollSecret))
if second.status != http.StatusGone {
t.Fatalf("second result status=%d want 410", second.status)
}
}
func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/mal/callback?code=X&state=bogus")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status=%d want 404", resp.StatusCode)
}
}
// ====== Cleanup ======
func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T) {
h := newOAuthHarness(t)
pollSecret, browserState, _ := h.startSession(t, "mal", "4.4.4.1")
digest := digestPollSecret(pollSecret)
h.proxy.mu.Lock()
sess := h.proxy.browserStates[browserState]
sess.createdAt = time.Now().Add(-2 * oauthSessionTTL)
h.proxy.mu.Unlock()
h.proxy.cleanup()
h.proxy.mu.Lock()
_, hasBrowserState := h.proxy.browserStates[browserState]
_, hasPollDigest := h.proxy.pollDigests[digest]
h.proxy.mu.Unlock()
if hasBrowserState || hasPollDigest {
t.Fatalf("expired session indexes remain: browser=%v poll=%v", hasBrowserState, hasPollDigest)
}
if h.proxy.completeSession(sess, oauthTokenResult{AccessToken: "stale-token"}) {
t.Fatal("stale callback completed a removed session")
}
sess.mu.Lock()
storedResult := sess.result
sess.mu.Unlock()
if storedResult != nil {
t.Fatal("stale callback retained an orphaned token result")
}
}
// ====== /auth/done ======
func TestOAuthDoneRendersSuccessPage(t *testing.T) {
h := newOAuthHarness(t)
resp := httpGet(t, h.base+"/auth/done")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "Signed in to Plezy") {
t.Errorf("body missing success message: %s", body)
}
}
// ====== Disabled proxy returns 503 ======
func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) {
mux := http.NewServeMux()
registerOAuthRoutes(mux, nil)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
resp := httpGet(t, srv.URL+"/auth/start")
resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status=%d want 503", resp.StatusCode)
}
}
// ====== Path dispatch ======
func TestOAuthAuthRootRejectsBadPaths(t *testing.T) {
h := newOAuthHarness(t)
for _, path := range []string{"/auth/mal/weird", "/auth/unknown", "/auth/mal/callback/extra"} {
resp := httpGet(t, h.base+path)
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s: status=%d want 404", path, resp.StatusCode)
}
}
}
// ====== Long-poll timeout ======
func TestOAuthResultBlocksUntilCancel(t *testing.T) {
// Pending sessions must NOT respond immediately; the long-poll contract is
// that /auth/result blocks until the session completes or the client
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
h := newOAuthHarness(t)
sess, _, _ := h.startSession(t, "mal", "5.5.5.1")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.base+"/auth/result?session="+url.QueryEscape(sess), nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
t.Fatalf("expected client-side cancel, got status=%d", resp.StatusCode)
}
}