@@ -3,21 +3,12 @@ import { main } from './cli.ts';
33import { createFakeContext } from './testHelpers/index.ts' ;
44import type { FakeGithubClient } from './testHelpers/index.ts' ;
55
6- function repoBatchResponse ( nodes : unknown [ ] = [ ] ) : Record < string , unknown > {
7- return { nodes } ;
8- }
9-
10- const npmConfigBlob = {
11- __typename : 'Repository' ,
12- yml : { text : 'updates:\n - package-ecosystem: "npm"\n' } ,
13- yaml : null ,
14- defaultBranchRef : null ,
15- } ;
16-
17- // The pre-flight probes one repo's pulls endpoint before scanning; stub it as
18- // readable so the happy-path tests proceed to the scan.
19- function stubPrReadable ( githubClient : FakeGithubClient ) : void {
20- githubClient . onRequest ( 'GET /repos/{owner}/{repo}/pulls' ) . resolves ( [ ] ) ;
6+ // The Dependabot PR search is the only GraphQL call; resolve it empty so the
7+ // happy-path tests reach the report without exercising the search internals.
8+ function stubEmptyPrSearch ( githubClient : FakeGithubClient ) : void {
9+ githubClient . onGraphql ( 'DependabotPrsSearch' ) . resolves ( {
10+ search : { issueCount : 0 , pageInfo : { hasNextPage : false , endCursor : null } , nodes : [ ] } ,
11+ } ) ;
2112}
2213
2314test ( 'prints usage and exits 0 when --help is passed' , async ( ) => {
@@ -31,7 +22,6 @@ test('prompts for the target when no positional is provided, picking from the li
3122 const { ctx, prompter, githubClient } = createFakeContext ( ) ;
3223 githubClient . onRequest ( 'GET /user' ) . resolves ( { login : 'ben' } ) ;
3324 githubClient . onPaginate ( 'GET /user/orgs' ) . resolves ( [ { login : 'acme' } ] ) ;
34- githubClient . onRequest ( 'GET /user/repos' ) . resolves ( [ ] ) ;
3525 prompter . scriptSelect ( 'acme' ) ;
3626 // The chosen org then drives the real run, which fails the listing — we
3727 // only care that the select fired and that target_prompted is recorded.
@@ -47,7 +37,6 @@ test('cancelling the target prompt returns failed and tells the user why', async
4737 const { ctx, prompter, githubClient } = createFakeContext ( ) ;
4838 githubClient . onRequest ( 'GET /user' ) . resolves ( { login : 'ben' } ) ;
4939 githubClient . onPaginate ( 'GET /user/orgs' ) . resolves ( [ ] ) ;
50- githubClient . onRequest ( 'GET /user/repos' ) . resolves ( [ ] ) ;
5140 prompter . scriptSelect ( { kind : 'cancelled' } ) ;
5241
5342 const result = await main ( ctx , [ ] ) ;
@@ -86,10 +75,8 @@ test('writes a report when the GitHub calls succeed', async () => {
8675 pushed_at : '2026-04-01T00:00:00Z' ,
8776 } ,
8877 ] ) ;
89- stubPrReadable ( githubClient ) ;
90- githubClient . onGraphql ( 'RepoMetadataBatch' ) . resolves ( repoBatchResponse ( [ npmConfigBlob ] ) ) ;
9178 githubClient . onPaginate ( 'GET /orgs/{org}/dependabot/alerts' , { } ) . resolves ( [ ] ) ;
92- githubClient . onGraphql ( 'DependabotPrs' ) . resolves ( { nodes : [ ] } ) ;
79+ stubEmptyPrSearch ( githubClient ) ;
9380
9481 const result = await main ( ctx , [ 'acme' ] ) ;
9582 expect ( result . kind ) . toBe ( 'completed' ) ;
@@ -98,9 +85,9 @@ test('writes a report when the GitHub calls succeed', async () => {
9885
9986 // Output lands in a temp dir, not the CWD; locate it via the returned paths.
10087 expect ( result . run . target ) . toBe ( 'acme' ) ;
101- expect ( result . run . paths . html . endsWith ( 'patchwave-report.html' ) ) . toBe ( true ) ;
88+ expect ( result . run . htmlPath . endsWith ( 'patchwave-report.html' ) ) . toBe ( true ) ;
10289
103- const written = fs . read ( result . run . paths . html ) ;
90+ const written = fs . read ( result . run . htmlPath ) ;
10491 expect ( written ) . toBeDefined ( ) ;
10592 expect ( written ) . toContain ( '<html' ) ;
10693 const match = / < s c r i p t t y p e = " a p p l i c a t i o n \/ j s o n " i d = " p a t c h w a v e - d a t a " > ( [ \s \S ] * ?) < \/ s c r i p t > / . exec ( written ?? '' ) ;
@@ -115,8 +102,6 @@ test('writes a report when the GitHub calls succeed', async () => {
115102
116103 expect ( analytics . capturedEvents ( 'run_started' ) [ 0 ] ?. properties ) . toMatchObject ( {
117104 window_days : 90 ,
118- has_include : false ,
119- has_exclude : false ,
120105 target_prompted : false ,
121106 } ) ;
122107 const completed = analytics . capturedEvents ( 'run_completed' ) [ 0 ] ;
@@ -161,10 +146,8 @@ test('excludes forked repos from the crawl', async () => {
161146 pushed_at : '2026-04-01T00:00:00Z' ,
162147 } ,
163148 ] ) ;
164- stubPrReadable ( githubClient ) ;
165- githubClient . onGraphql ( 'RepoMetadataBatch' ) . resolves ( repoBatchResponse ( [ npmConfigBlob ] ) ) ;
166149 githubClient . onPaginate ( 'GET /orgs/{org}/dependabot/alerts' , { } ) . resolves ( [ ] ) ;
167- githubClient . onGraphql ( 'DependabotPrs' ) . resolves ( { nodes : [ ] } ) ;
150+ stubEmptyPrSearch ( githubClient ) ;
168151
169152 const result = await main ( ctx , [ 'acme' ] ) ;
170153 expect ( result . kind ) . toBe ( 'completed' ) ;
@@ -194,11 +177,9 @@ test('uses the per-repo CVE endpoint for user targets', async () => {
194177 pushed_at : '2026-04-01T00:00:00Z' ,
195178 } ,
196179 ] ) ;
197- stubPrReadable ( githubClient ) ;
198- githubClient . onGraphql ( 'RepoMetadataBatch' ) . resolves ( repoBatchResponse ( [ npmConfigBlob ] ) ) ;
199- // Per-repo CVE endpoint — the path Task 4 keeps for user targets.
180+ // Per-repo CVE endpoint — the path kept for user targets.
200181 githubClient . onPaginate ( 'GET /repos/{owner}/{repo}/dependabot/alerts' , { } ) . resolves ( [ ] ) ;
201- githubClient . onGraphql ( 'DependabotPrs' ) . resolves ( { nodes : [ ] } ) ;
182+ stubEmptyPrSearch ( githubClient ) ;
202183
203184 const result = await main ( ctx , [ 'blimmer' ] ) ;
204185 expect ( result . kind ) . toBe ( 'completed' ) ;
@@ -224,15 +205,13 @@ test('falls back to the per-repo CVE endpoint when the org-level call fails', as
224205 pushed_at : '2026-04-01T00:00:00Z' ,
225206 } ,
226207 ] ) ;
227- stubPrReadable ( githubClient ) ;
228- githubClient . onGraphql ( 'RepoMetadataBatch' ) . resolves ( repoBatchResponse ( [ npmConfigBlob ] ) ) ;
229208 // Org-level endpoint refuses: token can see the org but not its alerts.
230209 githubClient
231210 . onPaginate ( 'GET /orgs/{org}/dependabot/alerts' , { } )
232211 . fails ( { kind : 'forbidden' , message : 'no access to org alerts' } ) ;
233212 // Per-repo endpoint is the fallback so each repo still gets a real status.
234213 githubClient . onPaginate ( 'GET /repos/{owner}/{repo}/dependabot/alerts' , { } ) . resolves ( [ ] ) ;
235- githubClient . onGraphql ( 'DependabotPrs' ) . resolves ( { nodes : [ ] } ) ;
214+ stubEmptyPrSearch ( githubClient ) ;
236215
237216 const result = await main ( ctx , [ 'acme' ] ) ;
238217 expect ( result . kind ) . toBe ( 'completed' ) ;
@@ -242,65 +221,30 @@ test('falls back to the per-repo CVE endpoint when the org-level call fails', as
242221 expect ( paginateRoutes ) . toContain ( 'GET /repos/{owner}/{repo}/dependabot/alerts' ) ;
243222} ) ;
244223
245- test ( 'aborts before scanning when the token cannot read PRs and the user chooses to stop ' , async ( ) => {
224+ test ( 'aborts before scanning when the token is missing a required scope ' , async ( ) => {
246225 const { ctx, githubClient, prompter, analytics } = createFakeContext ( ) ;
247-
248- githubClient . onPaginate ( 'GET /orgs/{org}/repos' , { } ) . resolves ( [
249- {
250- name : 'widgets' ,
251- node_id : 'R_kgDOwidgets' ,
252- owner : { login : 'acme' } ,
253- private : true ,
254- visibility : 'private' ,
255- archived : false ,
256- default_branch : 'main' ,
257- language : 'TypeScript' ,
258- pushed_at : '2026-04-01T00:00:00Z' ,
259- } ,
260- ] ) ;
261- githubClient . onRequest ( 'GET /repos/{owner}/{repo}/pulls' ) . fails ( { kind : 'forbidden' , message : 'no pulls' } ) ;
262- prompter . scriptSelect ( 'stop' ) ;
226+ githubClient . setOAuthScopes ( [ 'read:org' ] ) ; // missing `repo`
263227
264228 const result = await main ( ctx , [ 'acme' ] ) ;
265229
266230 expect ( result ) . toMatchObject ( { kind : 'failed' , code : 1 } ) ;
267- // No scan was attempted: the Dependabot PR search never ran.
231+ // The gate fires before any repo listing or scan.
232+ expect ( githubClient . callsTo ( 'paginate' ) ) . toHaveLength ( 0 ) ;
268233 expect ( githubClient . callsTo ( 'graphql' ) ) . toHaveLength ( 0 ) ;
269- expect ( prompter . notes . some ( ( n ) => n . title === 'Grant pull-request access' ) ) . toBe ( true ) ;
270- expect ( analytics . capturedEvents ( 'run_failed' ) [ 0 ] ?. properties ) . toMatchObject ( { error_kind : 'pr-access-declined' } ) ;
234+ expect ( prompter . notes . some ( ( n ) => n . title === 'Fix the token' ) ) . toBe ( true ) ;
235+ expect ( analytics . capturedEvents ( 'run_failed' ) [ 0 ] ?. properties ) . toMatchObject ( {
236+ error_kind : 'token-scope-insufficient' ,
237+ } ) ;
271238} ) ;
272239
273- test ( 'continues with an incomplete report when the token cannot read PRs but the user proceeds' , async ( ) => {
274- const { ctx, githubClient, prompter, analytics } = createFakeContext ( ) ;
275-
276- githubClient . onPaginate ( 'GET /orgs/{org}/repos' , { } ) . resolves ( [
277- {
278- name : 'widgets' ,
279- node_id : 'R_kgDOwidgets' ,
280- owner : { login : 'acme' } ,
281- private : true ,
282- visibility : 'private' ,
283- archived : false ,
284- default_branch : 'main' ,
285- language : 'TypeScript' ,
286- pushed_at : '2026-04-01T00:00:00Z' ,
287- } ,
288- ] ) ;
289- githubClient . onRequest ( 'GET /repos/{owner}/{repo}/pulls' ) . fails ( { kind : 'forbidden' , message : 'no pulls' } ) ;
290- githubClient . onGraphql ( 'RepoMetadataBatch' ) . resolves ( repoBatchResponse ( [ npmConfigBlob ] ) ) ;
291- githubClient . onPaginate ( 'GET /orgs/{org}/dependabot/alerts' , { } ) . resolves ( [ ] ) ;
292- githubClient . onGraphql ( 'DependabotPrs' ) . resolves ( { nodes : [ ] } ) ;
293- prompter . scriptSelect ( 'continue' ) ;
240+ test ( 'rejects a fine-grained token, which carries no scopes header' , async ( ) => {
241+ const { ctx, githubClient, prompter } = createFakeContext ( ) ;
242+ githubClient . setOAuthScopes ( null ) ;
294243
295244 const result = await main ( ctx , [ 'acme' ] ) ;
296- expect ( result . kind ) . toBe ( 'completed' ) ;
297245
298- // The report is written, but the CLI warns that it's incomplete — the report
299- // itself carries no permission banner.
300- expect ( prompter . notes . some ( ( n ) => n . title === 'Heads up' ) ) . toBe ( true ) ;
301- expect ( analytics . capturedEvents ( 'run_completed' ) [ 0 ] ?. properties ) . toMatchObject ( {
302- pr_access : 'unreadable' ,
303- } ) ;
246+ expect ( result ) . toMatchObject ( { kind : 'failed' , code : 1 } ) ;
247+ expect ( prompter . notes . some ( ( n ) => n . message . includes ( 'classic token' ) ) ) . toBe ( true ) ;
304248} ) ;
305249
306250test ( 'captures run_failed when listTargetRepos fails' , async ( ) => {
0 commit comments