@@ -161,4 +161,134 @@ describe("OAuthClient", () => {
161161 expect ( logger . logs . length ) . toBeGreaterThan ( 0 ) ;
162162 } ) ;
163163 } ) ;
164+
165+ describe ( "scope validation" , ( ) => {
166+ it ( "should log error for invalid scope" , async ( ) => {
167+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
168+ const logger = new MockLogger ( ) ;
169+ const configWithInvalidScope = await createTestConfigAsync ( {
170+ logger,
171+ oauth : {
172+ ...config . oauth ,
173+ scope : "atproto invalid:scope another-bad-scope" ,
174+ } ,
175+ } ) ;
176+
177+ new OAuthClient ( configWithInvalidScope ) ;
178+
179+ // Should have logged an error for invalid permissions
180+ const errorLogs = logger . logs . filter ( ( log ) => log . level === "error" ) ;
181+ expect ( errorLogs . length ) . toBeGreaterThan ( 0 ) ;
182+ expect ( errorLogs [ 0 ] . message ) . toContain ( "Invalid OAuth scope detected" ) ;
183+ } ) ;
184+
185+ it ( "should log warning for missing atproto scope" , async ( ) => {
186+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
187+ const logger = new MockLogger ( ) ;
188+ const configWithoutAtproto = await createTestConfigAsync ( {
189+ logger,
190+ oauth : {
191+ ...config . oauth ,
192+ scope : "transition:email" ,
193+ } ,
194+ } ) ;
195+
196+ // Note: The underlying @atproto /oauth-client library will throw during async initialization
197+ // because it requires "atproto" scope. However, our validation runs synchronously first and logs the warning.
198+ const client = new OAuthClient ( configWithoutAtproto ) ;
199+
200+ // The client initialization promise will reject - we need to handle it to prevent unhandled rejection
201+ // We use authorize() to trigger initialization, then catch the rejection
202+ try {
203+ await client . authorize ( "test.bsky.social" ) ;
204+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
205+ } catch ( error ) {
206+ // Expected - underlying client initialization will fail due to missing atproto scope
207+ }
208+
209+ // Should have logged a warning for missing atproto during buildClientMetadata()
210+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
211+ expect ( warnLogs . length ) . toBeGreaterThan ( 0 ) ;
212+ expect ( warnLogs [ 0 ] . message ) . toContain ( "missing 'atproto'" ) ;
213+ } ) ;
214+
215+ it ( "should detect mixed transitional and granular permissions" , async ( ) => {
216+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
217+ const logger = new MockLogger ( ) ;
218+ const configWithMixedScopes = await createTestConfigAsync ( {
219+ logger,
220+ oauth : {
221+ ...config . oauth ,
222+ scope : "atproto transition:email account:email?action=read" ,
223+ } ,
224+ } ) ;
225+
226+ new OAuthClient ( configWithMixedScopes ) ;
227+
228+ // Should have logged a warning about mixing permission models
229+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
230+ const mixedWarning = warnLogs . find ( ( log ) => log . message . includes ( "Mixing transitional and granular" ) ) ;
231+ expect ( mixedWarning ) . toBeDefined ( ) ;
232+ } ) ;
233+
234+ it ( "should suggest migration for transition:email" , async ( ) => {
235+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
236+ const logger = new MockLogger ( ) ;
237+ const configWithTransitionEmail = await createTestConfigAsync ( {
238+ logger,
239+ oauth : {
240+ ...config . oauth ,
241+ scope : "atproto transition:email" ,
242+ } ,
243+ } ) ;
244+
245+ new OAuthClient ( configWithTransitionEmail ) ;
246+
247+ // Should have logged info about transitional scopes
248+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
249+ const migrationSuggestion = infoLogs . find ( ( log ) => log . message . includes ( "migrating 'transition:email'" ) ) ;
250+ expect ( migrationSuggestion ) . toBeDefined ( ) ;
251+ expect ( migrationSuggestion ?. args [ 0 ] ) . toHaveProperty ( "suggestion" ) ;
252+ } ) ;
253+
254+ it ( "should suggest migration for transition:generic" , async ( ) => {
255+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
256+ const logger = new MockLogger ( ) ;
257+ const configWithTransitionGeneric = await createTestConfigAsync ( {
258+ logger,
259+ oauth : {
260+ ...config . oauth ,
261+ scope : "atproto transition:generic" ,
262+ } ,
263+ } ) ;
264+
265+ new OAuthClient ( configWithTransitionGeneric ) ;
266+
267+ // Should have logged info about transitional scopes
268+ const infoLogs = logger . logs . filter ( ( log ) => log . level === "info" ) ;
269+ const migrationSuggestion = infoLogs . find ( ( log ) => log . message . includes ( "migrating 'transition:generic'" ) ) ;
270+ expect ( migrationSuggestion ) . toBeDefined ( ) ;
271+ expect ( migrationSuggestion ?. args [ 0 ] ) . toHaveProperty ( "suggestion" ) ;
272+ } ) ;
273+
274+ it ( "should not log warnings for valid granular permissions with atproto" , async ( ) => {
275+ const { MockLogger } = await import ( "../utils/mocks.js" ) ;
276+ const logger = new MockLogger ( ) ;
277+ const configWithValidScope = await createTestConfigAsync ( {
278+ logger,
279+ oauth : {
280+ ...config . oauth ,
281+ scope : "atproto account:email?action=read repo:app.bsky.feed.post?action=create" ,
282+ } ,
283+ } ) ;
284+
285+ new OAuthClient ( configWithValidScope ) ;
286+
287+ // Should not have logged any errors or warnings (only info about granular permissions is OK)
288+ const errorLogs = logger . logs . filter ( ( log ) => log . level === "error" ) ;
289+ const warnLogs = logger . logs . filter ( ( log ) => log . level === "warn" ) ;
290+ expect ( errorLogs . length ) . toBe ( 0 ) ;
291+ expect ( warnLogs . length ) . toBe ( 0 ) ;
292+ } ) ;
293+ } ) ;
164294} ) ;
0 commit comments