| 1 | import type { |
| 2 | import request from 'supertest'; |
| 3 | import { afterAll, beforeAll, describe, expect, it } from 'vitest'; |
| 4 |
| 5 | import { hasBackends, scopedKey, signUp, startApp } from './harness.js'; |
| 6 |
| 7 | describe.skipIf(!hasBackends)('GitHub scopes', () => { |
| 8 | let app: INestApplication; |
| 9 | let owner: { cookie: string; key: string; userId: string }; |
| 10 | const username = `ghscope${Date.now()}`; |
| 11 |
| 12 | const graphql = (query: string, variables: object, token: string) => |
| 13 | request(app.getHttpServer()) |
| 14 | .post('/api/graphql') |
| 15 | .set('authorization', `token ${token}`) |
| 16 | .send({ query, variables }); |
| 17 |
| 18 | beforeAll(async () => { |
| 19 | ({ app } = await startApp()); |
| 20 | owner = await signUp(app, username); |
| 21 | for (const [name, visibility] of [ |
| 22 | ['public-repo', 'public'], |
| 23 | ['secret-repo', 'private'], |
| 24 | ]) { |
| 25 | await request(app.getHttpServer()) |
| 26 | .post('/api/repositories') |
| 27 | .set('cookie', owner.cookie) |
| 28 | .send({ name, visibility }) |
| 29 | .expect(201); |
| 30 | } |
| 31 | }); |
| 32 |
| 33 | afterAll(async () => { |
| 34 | await app?.close(); |
| 35 | }); |
| 36 |
| 37 | it('reports a scoped key its own scopes, and a Ghost key every scope', async () => { |
| 38 | const key = await scopedKey(app, owner.userId, ['read:org', 'gist']); |
| 39 | const scoped = await request(app.getHttpServer()) |
| 40 | .get('/api/v3/') |
| 41 | .set('authorization', `token ${key}`) |
| 42 | .expect(200); |
| 43 | expect(scoped.headers['x-oauth-scopes']).toBe('read:org, gist'); |
| 44 | const full = await request(app.getHttpServer()) |
| 45 | .get('/api/v3/') |
| 46 | .set('authorization', `token ${owner.key}`) |
| 47 | .expect(200); |
| 48 | expect(full.headers['x-oauth-scopes']).toContain('repo'); |
| 49 | }); |
| 50 |
| 51 | it("never lets an API key into Ghost's own API", async () => { |
| 52 | const key = await scopedKey(app, owner.userId, ['repo']); |
| 53 | await request(app.getHttpServer()) |
| 54 | .get('/api/notifications') |
| 55 | .set('authorization', `Bearer ${key}`) |
| 56 | .expect(401); |
| 57 | await request(app.getHttpServer()) |
| 58 | .get('/api/notifications') |
| 59 | .set('x-api-key', key) |
| 60 | .expect(401); |
| 61 | }); |
| 62 |
| 63 | it('hides a private repository from a key without repo, and shows it to one with repo', async () => { |
| 64 | const publicOnly = await scopedKey(app, owner.userId, [ |
| 65 | 'public_repo', |
| 66 | 'read:org', |
| 67 | ]); |
| 68 | const hidden = await graphql( |
| 69 | 'query($o: String!) { repository(owner: $o, name: "secret-repo") { name } }', |
| 70 | { o: username }, |
| 71 | publicOnly, |
| 72 | ).expect(200); |
| 73 | expect(hidden.body.errors[0]).toMatchObject({ type: 'NOT_FOUND' }); |
| 74 | await request(app.getHttpServer()) |
| 75 | .get(`/api/v3/repos/${username}/secret-repo`) |
| 76 | .set('authorization', `token ${publicOnly}`) |
| 77 | .expect(404); |
| 78 | const full = await scopedKey(app, owner.userId, ['repo']); |
| 79 | const shown = await graphql( |
| 80 | 'query($o: String!) { repository(owner: $o, name: "secret-repo") { name } }', |
| 81 | { o: username }, |
| 82 | full, |
| 83 | ).expect(200); |
| 84 | expect(shown.body.data.repository).toEqual({ name: 'secret-repo' }); |
| 85 | }); |
| 86 |
| 87 | it('lets public_repo write to a public repository and refuses a key with neither', async () => { |
| 88 | const repo = await graphql( |
| 89 | 'query($o: String!) { repository(owner: $o, name: "public-repo") { id } }', |
| 90 | { o: username }, |
| 91 | owner.key, |
| 92 | ).expect(200); |
| 93 | const repositoryId = repo.body.data.repository.id; |
| 94 | const create = (token: string) => |
| 95 | graphql( |
| 96 | 'mutation($input: CreateIssueInput!) { createIssue(input: $input) { issue { title } } }', |
| 97 | { input: { repositoryId, title: 'Scoped' } }, |
| 98 | token, |
| 99 | ).expect(200); |
| 100 | const allowed = await create( |
| 101 | await scopedKey(app, owner.userId, ['public_repo']), |
| 102 | ); |
| 103 | expect(allowed.body.data.createIssue.issue).toEqual({ title: 'Scoped' }); |
| 104 | const refused = await create( |
| 105 | await scopedKey(app, owner.userId, ['read:org']), |
| 106 | ); |
| 107 | expect(refused.body.errors[0]).toMatchObject({ |
| 108 | type: 'FORBIDDEN', |
| 109 | message: expect.stringContaining( |
| 110 | "The 'createIssue' field requires one of the following scopes: ['repo', 'public_repo']", |
| 111 | ), |
| 112 | }); |
| 113 | }); |
| 114 |
| 115 | it('answers a REST call without its scope with 403 and X-Accepted-OAuth-Scopes', async () => { |
| 116 | const key = await scopedKey(app, owner.userId, ['repo']); |
| 117 | const response = await request(app.getHttpServer()) |
| 118 | .get('/api/v3/user/keys') |
| 119 | .set('authorization', `token ${key}`) |
| 120 | .expect(403); |
| 121 | expect(response.headers['x-accepted-oauth-scopes']).toBe('read:public_key'); |
| 122 | }); |
| 123 |
| 124 | it('refuses creating a private repository with public_repo only', async () => { |
| 125 | const key = await scopedKey(app, owner.userId, ['public_repo']); |
| 126 | await request(app.getHttpServer()) |
| 127 | .post('/api/v3/user/repos') |
| 128 | .set('authorization', `token ${key}`) |
| 129 | .send({ name: 'scoped-private', private: true }) |
| 130 | .expect(403); |
| 131 | await request(app.getHttpServer()) |
| 132 | .post('/api/v3/user/repos') |
| 133 | .set('authorization', `token ${key}`) |
| 134 | .send({ name: 'scoped-public' }) |
| 135 | .expect(201); |
| 136 | const refused = await graphql( |
| 137 | 'mutation($input: CreateRepositoryInput!) { createRepository(input: $input) { repository { name } } }', |
| 138 | { input: { name: 'scoped-graphql', visibility: 'PRIVATE' } }, |
| 139 | key, |
| 140 | ).expect(200); |
| 141 | expect(refused.body.errors[0]).toMatchObject({ |
| 142 | type: 'FORBIDDEN', |
| 143 | message: expect.stringContaining( |
| 144 | "The 'createRepository' field requires one of the following scopes: ['repo']", |
| 145 | ), |
| 146 | }); |
| 147 | }); |
| 148 | }); |