mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 18:54:16 +00:00
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
210 lines
6.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|