package oauthclient import ( "strings" "testing" "atmoquest/config" "atmoquest/internal/oauthstore" ) // nilStore is a typed nil that satisfies oauth.ClientAuthStore. Build only // stashes the store on the ClientApp; it doesn't dereference it. We use this // instead of spinning up SQLite for what's a pure-construction test. var nilStore = (*oauthstore.Store)(nil) func TestFullScopes(t *testing.T) { if len(FullScopes) < 2 { t.Fatalf("FullScopes should request at least atproto + a write scope; got %v", FullScopes) } set := make(map[string]bool, len(FullScopes)) for _, s := range FullScopes { set[s] = true } if !set["atproto"] { t.Errorf("FullScopes must include 'atproto'; got %v", FullScopes) } if !set["repo:app.bsky.actor.profile"] { t.Errorf("FullScopes must include 'repo:app.bsky.actor.profile'; got %v", FullScopes) } // FullScopes should be a superset of DefaultScopes. for _, s := range DefaultScopes { if !set[s] { t.Errorf("FullScopes missing scope from DefaultScopes: %q; got %v", s, FullScopes) } } } func TestBuild_Localhost(t *testing.T) { cfg := &config.Config{PublicURL: "http://localhost:3000"} app, clientID, err := Build(cfg, nilStore) if err != nil { t.Fatalf("Build: %v", err) } if app == nil { t.Fatal("Build returned nil app") } // indigo's localhost client_id is a synthetic URL that begins with // http://localhost and embeds the redirect URI as a query param. if !strings.HasPrefix(clientID, "http://localhost") { t.Errorf("localhost client_id should start with http://localhost; got %q", clientID) } if !strings.Contains(clientID, "redirect_uri=") { t.Errorf("localhost client_id should contain redirect_uri param; got %q", clientID) } if app.Config.IsConfidential() { t.Error("localhost client should not be confidential") } } func TestBuild_Localhost127001(t *testing.T) { cfg := &config.Config{PublicURL: "http://127.0.0.1:3000"} _, clientID, err := Build(cfg, nilStore) if err != nil { t.Fatalf("Build: %v", err) } if !strings.HasPrefix(clientID, "http://localhost") { t.Errorf("127.0.0.1 should still produce a loopback localhost client_id; got %q", clientID) } } func TestBuild_HTTPSProduction(t *testing.T) { cfg := &config.Config{PublicURL: "https://atmoquest"} app, clientID, err := Build(cfg, nilStore) if err != nil { t.Fatalf("Build: %v", err) } want := "https://atmoquest/oauth/client-metadata.json" if clientID != want { t.Errorf("client_id = %q, want %q", clientID, want) } if app.Config.IsConfidential() { t.Error("v1 production client should be a public client (not confidential) until a signing key is wired") } } func TestDefaultScopes(t *testing.T) { if len(DefaultScopes) < 2 { t.Fatalf("DefaultScopes should request at least atproto + a write scope; got %v", DefaultScopes) } set := make(map[string]bool, len(DefaultScopes)) for _, s := range DefaultScopes { set[s] = true } // `atproto` is mandatory per the atproto OAuth profile. if !set["atproto"] { t.Errorf("DefaultScopes must include 'atproto'; got %v", DefaultScopes) } // Principle of least privilege: we should NOT request the legacy blanket // scope. Anything write-related must be a granular `repo:` scope. if set["transition:generic"] { t.Errorf("DefaultScopes must NOT include 'transition:generic' (legacy blanket scope); got %v", DefaultScopes) } if set["transition:chat.bsky"] || set["transition:email"] { t.Errorf("DefaultScopes should not request transition:* scopes; got %v", DefaultScopes) } // DefaultScopes should NOT include repo:app.bsky.actor.profile — // that scope is gated behind an opt-in upgrade flow (FullScopes). if set["repo:app.bsky.actor.profile"] { t.Errorf("DefaultScopes must NOT include 'repo:app.bsky.actor.profile' (moved to FullScopes); got %v", DefaultScopes) } // Every app-owned lexicon under quest.atmo.* must have a corresponding // repo: write scope, otherwise the feature that needs it will silently // fail at write time. Update this list when adding a new lexicon. wantRepoScopes := []string{ "repo:quest.atmo.profile", "repo:quest.atmo.event", "repo:quest.atmo.checkin", "repo:quest.atmo.connection", "repo:quest.atmo.badge", } for _, want := range wantRepoScopes { if !set[want] { t.Errorf("DefaultScopes missing required scope %q; got %v", want, DefaultScopes) } } // Catch over-broad wildcards that would defeat the point of the granular // list above. for _, s := range DefaultScopes { if s == "repo:*" || s == "rpc:*" { t.Errorf("DefaultScopes should not include broad wildcard %q", s) } } }