diff --git a/bin/test.ts b/bin/test.ts index 1b83905..3ee1fcd 100644 --- a/bin/test.ts +++ b/bin/test.ts @@ -1,4 +1,6 @@ import { assert } from '@japa/assert' +import { expectTypeOf } from '@japa/expect-type' +import { fileSystem } from '@japa/file-system' import { processCLIArgs, configure, run } from '@japa/runner' /* @@ -16,8 +18,8 @@ import { processCLIArgs, configure, run } from '@japa/runner' */ processCLIArgs(process.argv.slice(2)) configure({ - files: ['test/**/*.spec.ts'], - plugins: [assert()], + files: ['tests/**/*.spec.ts'], + plugins: [assert(), expectTypeOf(), fileSystem()], }) /* diff --git a/package.json b/package.json index 4e40970..e38dc67 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "release": "np", "version": "npm run build", "sync-labels": "github-label-sync --labels .github/labels.json adonisjs/ally", - "quick:test": "node --loader=ts-node/esm bin/test.ts" + "quick:test": "node --enable-source-maps --loader=ts-node/esm bin/test.ts" }, "keywords": [ "adonis", @@ -54,6 +54,7 @@ "@commitlint/cli": "^17.6.6", "@commitlint/config-conventional": "^17.6.6", "@japa/assert": "^2.0.0-1", + "@japa/expect-type": "^2.0.0-0", "@japa/runner": "^3.0.0-6", "@swc/core": "^1.3.69", "@types/node": "^20.4.2", @@ -71,6 +72,7 @@ "typescript": "^5.1.6" }, "dependencies": { + "@japa/file-system": "^2.0.0-1", "@poppinss/oauth-client": "^5.1.0-3", "@poppinss/utils": "^6.5.0-3" }, @@ -107,7 +109,10 @@ "html" ], "exclude": [ - "tests/**" + "tests/**", + "src/drivers/**", + "src/abstract_drivers/**", + "stubs/**" ] } } diff --git a/src/ally_manager.ts b/src/ally_manager.ts index fa8ae49..f40b5c0 100644 --- a/src/ally_manager.ts +++ b/src/ally_manager.ts @@ -47,6 +47,9 @@ export class AllyManager + const driverInstance = driver(this.#ctx) as ReturnType + this.#driversCache.set(provider, driverInstance) + + return driverInstance } } diff --git a/src/redirect_request.ts b/src/redirect_request.ts index 78ee669..0e07cfb 100644 --- a/src/redirect_request.ts +++ b/src/redirect_request.ts @@ -24,6 +24,10 @@ export class RedirectRequest extends UrlBuilder { this.#scopeSeparator = scopeSeparator } + /** + * Register a custom function to transform scopes. Exposed for drivers + * to implement. + */ transformScopes(callback: (scopes: LiteralStringUnion[]) => string[]): this { this.#scopesTransformer = callback return this @@ -41,14 +45,6 @@ export class RedirectRequest extends UrlBuilder { return this } - /** - * Clear existing scopes - */ - clearScopes(): this { - this.clearParam(this.#scopeParamName) - return this - } - /** * Merge to existing scopes */ @@ -57,10 +53,23 @@ export class RedirectRequest extends UrlBuilder { scopes = this.#scopesTransformer(scopes) } - const params = this.getParams() - const mergedScopes = (params[this.#scopeParamName] || []).concat(scopes) - this.scopes(mergedScopes) + const existingScopes = this.getParams()[this.#scopeParamName] + const scopesString = scopes.join(this.#scopeSeparator) + if (!existingScopes) { + this.param(this.#scopeParamName, scopesString) + return this + } + + this.param(this.#scopeParamName, `${existingScopes}${this.#scopeSeparator}${scopesString}`) + return this + } + + /** + * Clear existing scopes + */ + clearScopes(): this { + this.clearParam(this.#scopeParamName) return this } } diff --git a/src/types.ts b/src/types.ts index cfcc7fc..9849f88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,7 +208,6 @@ export type DiscordToken = { * Extra options available for Discord */ export type DiscordDriverConfig = Oauth2ClientConfig & { - driver: 'discord' userInfoUrl?: string scopes?: LiteralStringUnion[] prompt?: 'consent' | 'none' @@ -278,7 +277,6 @@ export type GithubToken = { * Extra options available for Github */ export type GithubDriverConfig = Oauth2ClientConfig & { - driver: 'github' login?: string scopes?: LiteralStringUnion[] allowSignup?: boolean @@ -310,7 +308,6 @@ export type TwitterToken = { * Extra options available for twitter */ export type TwitterDriverConfig = Oauth1ClientConfig & { - driver: 'twitter' userInfoUrl?: string } @@ -383,7 +380,6 @@ export type GoogleToken = Oauth2AccessToken & { * https://developers.google.com/identity/protocols/oauth2/openid-connect#re-consent */ export type GoogleDriverConfig = Oauth2ClientConfig & { - driver: 'google' userInfoUrl?: string /** @@ -436,7 +432,6 @@ export type LinkedInToken = { * https://docs.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow?context=linkedin%2Fcontext&tabs=HTTPS#step-2-request-an-authorization-code */ export type LinkedInDriverConfig = Oauth2ClientConfig & { - driver: 'linkedin' userInfoUrl?: string userEmailUrl?: string @@ -534,7 +529,6 @@ export type FacebookToken = { * https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow */ export type FacebookDriverConfig = Oauth2ClientConfig & { - driver: 'facebook' userInfoUrl?: string /** @@ -596,7 +590,6 @@ export type SpotifyToken = { * Extra options available for Spotify */ export type SpotifyDriverConfig = Oauth2ClientConfig & { - driver: 'spotify' scopes?: LiteralStringUnion[] showDialog?: boolean } diff --git a/tests/ally_manager.spec.ts b/tests/ally_manager.spec.ts new file mode 100644 index 0000000..2e95920 --- /dev/null +++ b/tests/ally_manager.spec.ts @@ -0,0 +1,47 @@ +/* + * @adonisjs/ally + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { AllyManager } from '../src/ally_manager.js' +import { GithubDriver } from '../src/drivers/github.js' + +test.group('Ally manager', () => { + test('create an instance of a driver', ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + + const ally = new AllyManager( + { + github: ($ctx) => { + return new GithubDriver($ctx, { + clientId: '', + clientSecret: '', + callbackUrl: '', + }) + }, + }, + ctx + ) + + assert.instanceOf(ally.use('github'), GithubDriver) + assert.strictEqual(ally.use('github'), ally.use('github')) + expectTypeOf(ally.use).parameters.toEqualTypeOf<['github']>() + expectTypeOf(ally.use('github')).toMatchTypeOf() + }) + + test('throw error when making an unknown driver', () => { + const ctx = new HttpContextFactory().create() + + const ally = new AllyManager({}, ctx) + ;(ally.use as any)('github') + }).throws( + 'Unknown ally provider "github". Make sure it is registered inside the config/ally.ts file' + ) +}) diff --git a/tests/configure.spec.ts b/tests/configure.spec.ts new file mode 100644 index 0000000..a9c0b9f --- /dev/null +++ b/tests/configure.spec.ts @@ -0,0 +1,51 @@ +/* + * @adonisjs/static + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { fileURLToPath } from 'node:url' +import { IgnitorFactory } from '@adonisjs/core/factories' +import Configure from '@adonisjs/core/commands/configure' + +const BASE_URL = new URL('./tmp/', import.meta.url) + +test.group('Configure', (group) => { + group.each.setup(({ context }) => { + context.fs.baseUrl = BASE_URL + context.fs.basePath = fileURLToPath(BASE_URL) + }) + + test('create config file and register provider', async ({ assert }) => { + const ignitor = new IgnitorFactory() + .withCoreProviders() + .withCoreConfig() + .create(BASE_URL, { + importer: (filePath) => { + if (filePath.startsWith('./') || filePath.startsWith('../')) { + return import(new URL(filePath, BASE_URL).href) + } + + return import(filePath) + }, + }) + + const app = ignitor.createApp('web') + await app.init() + await app.boot() + + const ace = await app.container.make('ace') + const command = await ace.create(Configure, ['../../index.js']) + await command.exec() + + await assert.fileExists('config/ally.ts') + await assert.fileExists('contracts/ally.ts') + await assert.fileExists('.adonisrc.json') + await assert.fileContains('.adonisrc.json', '@adonisjs/ally/ally_provider') + await assert.fileContains('config/ally.ts', 'defineConfig') + }) +}) diff --git a/tests/define_config.spec.ts b/tests/define_config.spec.ts new file mode 100644 index 0000000..4e96f4a --- /dev/null +++ b/tests/define_config.spec.ts @@ -0,0 +1,37 @@ +/* + * @adonisjs/ally + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { AllyManager, defineConfig } from '../index.js' +import { GithubDriver } from '../src/drivers/github.js' +import type { GithubDriverContract } from '../src/types.js' + +test.group('Define config', () => { + test('define manager config from user defined config', ({ assert, expectTypeOf }) => { + const managerConfig = defineConfig({ + github: { + driver: 'github', + clientId: '', + clientSecret: '', + callbackUrl: '', + scopes: ['admin:org'], + }, + }) + + const ctx = new HttpContextFactory().create() + const ally = new AllyManager(managerConfig, ctx) + + assert.instanceOf(ally.use('github'), GithubDriver) + assert.strictEqual(ally.use('github'), ally.use('github')) + expectTypeOf(ally.use).parameters.toEqualTypeOf<['github']>() + expectTypeOf(ally.use('github')).toMatchTypeOf() + }) +}) diff --git a/tests/drivers_collection.spec.ts b/tests/drivers_collection.spec.ts new file mode 100644 index 0000000..3ce4b53 --- /dev/null +++ b/tests/drivers_collection.spec.ts @@ -0,0 +1,107 @@ +/* + * @adonisjs/ally + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { HttpContextFactory } from '@adonisjs/core/factories/http' + +import { GithubDriver } from '../src/drivers/github.js' +import { GoogleDriver } from '../src/drivers/google.js' +import { TwitterDriver } from '../src/drivers/twitter.js' +import { DiscordDriver } from '../src/drivers/discord.js' +import { SpotifyDriver } from '../src/drivers/spotify.js' +import { FacebookDriver } from '../src/drivers/facebook.js' +import { LinkedInDriver } from '../src/drivers/linked_in.js' +import allyDriversCollection from '../src/drivers_collection.js' +import { + DiscordDriverContract, + FacebookDriverContract, + GithubDriverContract, + GoogleDriverContract, + LinkedInDriverContract, + SpotifyDriverContract, + TwitterDriverContract, +} from '../src/types.js' + +test.group('Drivers Collection', () => { + test('create an instance of a unknown driver', ({ assert, expectTypeOf }) => { + const ctx = new HttpContextFactory().create() + + const discord = allyDriversCollection.create( + 'discord', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(discord, DiscordDriver) + expectTypeOf(discord).toEqualTypeOf() + + const github = allyDriversCollection.create( + 'github', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(github, GithubDriver) + expectTypeOf(github).toEqualTypeOf() + + const google = allyDriversCollection.create( + 'google', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(google, GoogleDriver) + expectTypeOf(google).toEqualTypeOf() + + const facebook = allyDriversCollection.create( + 'facebook', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(facebook, FacebookDriver) + expectTypeOf(facebook).toEqualTypeOf() + + const linkedin = allyDriversCollection.create( + 'linkedin', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(linkedin, LinkedInDriver) + expectTypeOf(linkedin).toEqualTypeOf() + + const spotify = allyDriversCollection.create( + 'spotify', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(spotify, SpotifyDriver) + expectTypeOf(spotify).toEqualTypeOf() + + const twitter = allyDriversCollection.create( + 'twitter', + { clientId: '', clientSecret: '', callbackUrl: '' }, + ctx + ) + assert.instanceOf(twitter, TwitterDriver) + expectTypeOf(twitter).toEqualTypeOf() + }) + + test('extend drivers collection', ({ assert }) => { + class Foo {} + + allyDriversCollection.extend('foo' as any, () => { + return new Foo() + }) + + const ctx = new HttpContextFactory().create() + assert.instanceOf(allyDriversCollection.create('foo' as any, {}, ctx), Foo) + }) + + test('throw error when trying to create an unknown driver', () => { + const ctx = new HttpContextFactory().create() + allyDriversCollection.create('bar' as any, {}, ctx) + }).throws('Unknown ally driver "bar". Make sure the driver is registered') +}) diff --git a/tests/redirect_request.spec.ts b/tests/redirect_request.spec.ts new file mode 100644 index 0000000..6cd494c --- /dev/null +++ b/tests/redirect_request.spec.ts @@ -0,0 +1,55 @@ +/* + * @adonisjs/ally + * + * (c) Ally + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { test } from '@japa/runner' +import { RedirectRequest } from '../src/redirect_request.js' + +test.group('Redirect request', () => { + test('define scopes param', ({ assert }) => { + const redirect = new RedirectRequest('http://foo.com', 'scopes', ',') + redirect.scopes(['username', 'email']) + + assert.deepEqual(redirect.getParams(), { scopes: ['username', 'email'].join(',') }) + }) + + test('merge to existing scopes', ({ assert }) => { + const redirect = new RedirectRequest('http://foo.com', 'scopes', ',') + redirect.scopes(['username', 'email']) + redirect.mergeScopes(['avatar_url']) + + assert.deepEqual(redirect.getParams(), { + scopes: ['username', 'email', 'avatar_url'].join(','), + }) + }) + + test('clear existing scopes', ({ assert }) => { + const redirect = new RedirectRequest('http://foo.com', 'scopes', ',') + redirect.scopes(['username', 'email']) + redirect.clearScopes() + redirect.mergeScopes(['avatar_url']) + + assert.deepEqual(redirect.getParams(), { + scopes: ['avatar_url'].join(','), + }) + }) + + test('use scopes transformer', ({ assert }) => { + const redirect = new RedirectRequest('http://foo.com', 'scopes', ',') + redirect.transformScopes((scopes) => { + return scopes.map((scope) => `foo.com/${scope}`) + }) + + redirect.scopes(['username', 'email']) + redirect.mergeScopes(['avatar_url']) + + assert.deepEqual(redirect.getParams(), { + scopes: ['foo.com/username', 'foo.com/email', 'foo.com/avatar_url'].join(','), + }) + }) +})