diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..48594a9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,35 @@ +name: Build +on: + pull_request: + push: + +jobs: + test: + timeout-minutes: 15 + strategy: + matrix: + node: ['18.18.x'] + pkg: ['sdk'] + runs-on: + labels: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + cache: pnpm + node-version: ${{ matrix.node }} + + - name: Install dependencies for ${{ matrix.pkg }} + run: | + pnpm install --frozen-lockfile --filter='...${{ matrix.pkg }}' + + - name: Test ${{ matrix.pkg }} + run: | + pnpm --filter='${{ matrix.pkg }}' test diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 2970bd0..a3ce293 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compile an ESM version of this codebase for Node.JS v18. -## [0.1.1] - 2023-09-08 +### Fixed + +- `MermaidChart#handleAuthorizationResponse()` now supports relative URLs. +## [0.1.1] - 2023-09-08 - Browser-only build. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index e089071..85a5528 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -14,7 +14,8 @@ "build:code:browser": "esbuild src/index.ts --bundle --minify --outfile=dist/bundle.iife.js", "build:code:node": "esbuild src/index.ts --bundle --platform=node --target=node18.18 --format=esm --packages=external --minify --outfile=dist/index.mjs", "build:types": "tsc -p ./tsconfig.json --emitDeclarationOnly", - "prepare": "pnpm run build" + "prepare": "pnpm run build", + "test": "vitest" }, "type": "module", "keywords": [], diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts new file mode 100644 index 0000000..1a73688 --- /dev/null +++ b/packages/sdk/src/index.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MermaidChart } from './index.js'; +import { AuthorizationData } from './types.js'; + +import { OAuth2Client } from '@badgateway/oauth2-client'; + +describe('MermaidChart', () => { + let client: MermaidChart; + beforeEach(() => { + vi.resetAllMocks(); + + vi.spyOn(OAuth2Client.prototype, 'request').mockImplementation( + async (endpoint: 'tokenEndpoint' | 'introspectionEndpoint', _body: Record) => { + switch (endpoint) { + case 'tokenEndpoint': + return { + access_token: 'test-example-access_token', + refresh_token: 'test-example-refresh_token', + token_type: 'Bearer', + expires_in: 3600, + }; + default: + throw new Error('mock unimplemented'); + } + }, + ); + + client = new MermaidChart({ + clientID: '00000000-0000-0000-0000-000000000dead', + baseURL: 'https://test.mermaidchart.invalid', + redirectURI: 'https://localhost.invalid', + }); + + vi.spyOn(client, 'getUser').mockImplementation(async () => { + return { + fullName: 'Test User', + emailAddress: 'test@invalid.invalid', + }; + }); + }); + + describe('#handleAuthorizationResponse', () => { + let state: AuthorizationData['state']; + beforeEach(async () => { + ({ state } = await client.getAuthorizationData({ state })); + }); + + it('should set token', async () => { + const code = 'hello-world'; + + await client.handleAuthorizationResponse( + `https://response.invalid?code=${code}&state=${state}`, + ); + await expect(client.getAccessToken()).resolves.toBe('test-example-access_token'); + }); + + it('should throw with invalid state', async () => { + await expect(() => + client.handleAuthorizationResponse( + 'https://response.invalid?code=hello-world&state=my-invalid-state', + ), + ).rejects.toThrowError('invalid_state'); + }); + + it('should throw with nicer error if URL has no query params', async () => { + await expect(() => + client.handleAuthorizationResponse( + // missing the ? so it's not read as a query + 'code=hello-world&state=my-invalid-state', + ), + ).rejects.toThrowError(/no query parameters/); + }); + + it('should work in Node.JS with url fragment', async () => { + const code = 'hello-nodejs-world'; + await client.handleAuthorizationResponse(`?code=${code}&state=${state}`); + await expect(client.getAccessToken()).resolves.toBe('test-example-access_token'); + }); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 31b88ab..8a4aab0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -100,12 +100,20 @@ export class MermaidChart { }; } + /** + * Handle authorization response. + * + * @param urlString - URL, only the query string is required (e.g. `?code=xxxx&state=xxxxx`) + */ public async handleAuthorizationResponse(urlString: string) { - const url = new URL(urlString); + const url = new URL(urlString, 'https://dummy.invalid'); const state = url.searchParams.get('state') ?? undefined; const authorizationToken = url.searchParams.get('code'); if (!authorizationToken) { + if (url.searchParams.size === 0) { + throw new Error(`URL ${JSON.stringify(urlString)} has no query parameters.`); + } throw new RequiredParameterMissingError('token'); } if (!state) {