Files
at-container-registry/pkg/auth/oauth/client_test.go
T
Evan JarrettandClaude Fable 5.1 dcee8f6a62 deps: upgrade every module; gate loopback OAuth tests on testmode
go get -u across the root, scanner, and deploy modules, then tidy. The
credential helpers pin atcr.io v0.1.4 for standalone go install and are
left alone (go work sync tried to strip that pin; reverted). Direct
upgrades in the root: indigo 20260901 to 20260903, aws-sdk-go-v2 core
1.45.1 to 1.47.0 with config, credentials, and s3 alongside, x/crypto
0.55 to 0.57, x/net, x/sync, x/sys, x/image, klauspost/compress 1.20,
go-containerregistry 0.22.1, goldmark 1.8.6, regclient 0.11.6 (pinned
only by the integration-tagged package, so the bulk upgrade skipped it).
Scanner and deploy had no direct updates; their indirect sets moved.

The indigo delta is a hardening series: identity.DefaultDirectory and
oauth.NewClientApp now carry an SSRF-guarded transport that refuses
loopback and private ranges, did:web and well-known bodies are size
capped, all auth-server endpoints must be HTTPS URLs, and MST decoding
validates PrefixLen on untrusted nodes. Production is unaffected. The
testmode seam in pkg/atproto absorbs the rest: a probe confirmed an
untagged build now refuses 127.0.0.1 with indigo's unsafe-address error
and a tagged build dials through.

Two OAuth tests drove the real client against httptest servers on
loopback and failed untagged after the bump; three siblings in the same
fixtures passed only because the refused dial happened to satisfy a
"transient error" assertion. All five, with their fixtures and fake
stores, move under //go:build testmode in sibling files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 16:41:58 -05:00

210 lines
6.6 KiB
Go

package oauth
import (
"context"
"errors"
"fmt"
"testing"
"github.com/bluesky-social/indigo/atproto/atclient"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
)
func TestNewClientApp(t *testing.T) {
keyPath := t.TempDir() + "/oauth-key.bin"
store := oauth.NewMemStore()
baseURL := "http://localhost:5000"
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
if clientApp == nil {
t.Fatal("Expected non-nil clientApp")
}
if clientApp.Dir == nil {
t.Error("Expected directory to be set")
}
}
func TestNewClientAppWithCustomScopes(t *testing.T) {
keyPath := t.TempDir() + "/oauth-key.bin"
store := oauth.NewMemStore()
baseURL := "http://localhost:5000"
scopes := []string{"atproto", "custom:scope"}
clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
if clientApp == nil {
t.Fatal("Expected non-nil clientApp")
}
// Verify clientApp was created successfully
// (Note: indigo's oauth.ClientApp doesn't expose scopes directly,
// but we can verify it was created without error)
if clientApp.Dir == nil {
t.Error("Expected directory to be set")
}
}
func TestScopesMatch(t *testing.T) {
tests := []struct {
name string
stored []string
desired []string
expected bool
}{
{
name: "exact match",
stored: []string{"atproto", "blob:image/png"},
desired: []string{"atproto", "blob:image/png"},
expected: true,
},
{
name: "different order",
stored: []string{"blob:image/png", "atproto"},
desired: []string{"atproto", "blob:image/png"},
expected: true,
},
{
name: "missing scope in stored",
stored: []string{"atproto"},
desired: []string{"atproto", "blob:image/png"},
expected: false,
},
{
name: "extra scope in stored",
stored: []string{"atproto", "blob:image/png", "extra"},
desired: []string{"atproto", "blob:image/png"},
expected: false,
},
{
name: "both empty",
stored: []string{},
desired: []string{},
expected: true,
},
{
name: "nil vs empty",
stored: nil,
desired: []string{},
expected: true,
},
{
name: "completely different",
stored: []string{"foo", "bar"},
desired: []string{"baz", "qux"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ScopesMatch(tt.stored, tt.desired)
if result != tt.expected {
t.Errorf("ScopesMatch(%v, %v) = %v, want %v",
tt.stored, tt.desired, result, tt.expected)
}
})
}
}
// ----------------------------------------------------------------------------
// Session Management (Refresher) Tests
// ----------------------------------------------------------------------------
func TestNewRefresher(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
refresher := NewRefresher(clientApp)
if refresher == nil {
t.Fatal("Expected non-nil refresher")
}
if refresher.clientApp == nil {
t.Error("Expected clientApp to be set")
}
}
func TestRefresher_SetUISessionStore(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
refresher := NewRefresher(clientApp)
// Test that SetUISessionStore doesn't panic with nil
// Full mock implementation requires implementing the interface
refresher.SetUISessionStore(nil)
// Verify nil is accepted
if refresher.uiSessionStore != nil {
t.Error("Expected UI session store to be nil after setting nil")
}
}
// ----------------------------------------------------------------------------
// Refresh-cancellation regression tests
// ----------------------------------------------------------------------------
func TestIsSessionInvalidError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"plain canceled", context.Canceled, false},
{"wrapped canceled", fmt.Errorf("token refresh failed: %w", context.Canceled), false},
{"wrapped deadline", fmt.Errorf("fetch: %w", context.DeadlineExceeded), false},
// Even if the message mentions an auth string, cancellation wins.
{"canceled with auth-ish text", fmt.Errorf("invalid_grant: %w", context.Canceled), false},
{"api error 401", &atclient.APIError{StatusCode: 401}, true},
{"api error InvalidGrant", &atclient.APIError{StatusCode: 400, Name: "InvalidGrant"}, true},
{"api error InvalidToken", &atclient.APIError{StatusCode: 400, Name: "InvalidToken"}, true},
{"api error 500", &atclient.APIError{StatusCode: 500, Name: "InternalServerError"}, false},
// ExpiredToken means "refresh me", not "revoked". Treating it as a dead
// session signs the user out of every UI session over an ordinary
// access-token expiry that a refresh would have fixed.
{"api error ExpiredToken is refreshable, not dead", &atclient.APIError{StatusCode: 400, Name: "ExpiredToken"}, false},
// Transient upstream failures must never evict: these are the shapes the
// service-token path now wraps as APIErrors.
{"api error 502", &atclient.APIError{StatusCode: 502, Name: ""}, false},
{"api error 429", &atclient.APIError{StatusCode: 429, Name: ""}, false},
{"api error 500 html body", &atclient.APIError{StatusCode: 500, Name: "", Message: "<html>bad gateway</html>"}, false},
// A revoked session reported as 401 with an atproto name — the case the
// service-token path was previously flattening into an unmatchable string.
{"api error 401 InvalidToken", &atclient.APIError{StatusCode: 401, Name: "InvalidToken"}, true},
// The refresh-replay failure arrives as a plain wrapped string from indigo.
{"plain invalid_grant string", errors.New("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant"), true},
{"plain invalid_token string", errors.New("auth server request failed (HTTP 401): invalid_token"), true},
{"connection refused", errors.New(`Post "https://pds.example.com/oauth/token": dial tcp: connection refused`), false},
{"generic 500", errors.New("token refresh failed (HTTP 500): server exploded"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsSessionInvalidError(tt.err); got != tt.want {
t.Errorf("IsSessionInvalidError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}