From d8a3a77153f9d2e671c6bfff90e8fc1bb44881c3 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Tue, 16 Jan 2024 18:12:45 +0530 Subject: [PATCH] feat: write tests for japa api clients --- factories/access_tokens/main.ts | 4 +- factories/auth/main.ts | 8 +- modules/access_tokens_guard/guard.ts | 11 ++- .../access_tokens_guard/token_providers/db.ts | 2 +- modules/access_tokens_guard/types.ts | 22 ++++- .../user_providers/lucid.ts | 23 ++++- package.json | 3 + src/auth_manager.ts | 18 +--- src/plugins/japa/api_client.ts | 35 +++---- src/plugins/japa/browser_client.ts | 33 ++++--- src/types.ts | 5 +- .../access_tokens/guard/authenticate.spec.ts | 4 +- tests/auth/plugins/api_client.spec.ts | 98 +++++++++++++++++++ tests/auth/plugins/browser_client.spec.ts | 96 ++++++++++++++++++ tests/auth/plugins/global_types.ts | 26 +++++ 15 files changed, 329 insertions(+), 59 deletions(-) create mode 100644 tests/auth/plugins/api_client.spec.ts create mode 100644 tests/auth/plugins/browser_client.spec.ts create mode 100644 tests/auth/plugins/global_types.ts diff --git a/factories/access_tokens/main.ts b/factories/access_tokens/main.ts index af929fc..f4860db 100644 --- a/factories/access_tokens/main.ts +++ b/factories/access_tokens/main.ts @@ -71,8 +71,8 @@ export class AccessTokensFakeUserProvider async createToken( user: AccessTokensFakeUser, - expiresIn?: string | number, - abilities?: string[] + abilities?: string[], + expiresIn?: string | number ): Promise { const transientToken = AccessToken.createTransientToken(user.id, 40, expiresIn) const id = stringHelpers.random(15) diff --git a/factories/auth/main.ts b/factories/auth/main.ts index e4bc806..9b5074a 100644 --- a/factories/auth/main.ts +++ b/factories/auth/main.ts @@ -65,7 +65,11 @@ export class FakeGuard implements GuardContract { } } - async authenticateAsClient(_: FakeUser): Promise { - throw new Error('Not supported') + async authenticateAsClient( + _user: FakeUser, + _abilities?: string[], + _expiresIn?: string | number + ): Promise { + return {} } } diff --git a/modules/access_tokens_guard/guard.ts b/modules/access_tokens_guard/guard.ts index 50080c1..c3396d8 100644 --- a/modules/access_tokens_guard/guard.ts +++ b/modules/access_tokens_guard/guard.ts @@ -204,9 +204,16 @@ export class AccessTokensGuard { - throw new Error('Not supported') + const token = await this.#userProvider.createToken(user, abilities, expiresIn) + return { + headers: { + authorization: `Bearer ${token.value!.release()}`, + }, + } } /** diff --git a/modules/access_tokens_guard/token_providers/db.ts b/modules/access_tokens_guard/token_providers/db.ts index 942fa3f..9e1eda2 100644 --- a/modules/access_tokens_guard/token_providers/db.ts +++ b/modules/access_tokens_guard/token_providers/db.ts @@ -25,7 +25,7 @@ import { RuntimeException } from '@adonisjs/core/exceptions' * The user must be an instance of the associated user model. */ export class DbAccessTokensProvider - implements AccessTokensProviderContract + implements AccessTokensProviderContract { /** * Create tokens provider instance for a given Lucid model diff --git a/modules/access_tokens_guard/types.ts b/modules/access_tokens_guard/types.ts index 81a356f..9631b67 100644 --- a/modules/access_tokens_guard/types.ts +++ b/modules/access_tokens_guard/types.ts @@ -126,7 +126,16 @@ export type AccessTokenDbColumns = { * Access token providers are used verify an access token * during authentication */ -export interface AccessTokensProviderContract { +export interface AccessTokensProviderContract { + /** + * Create a token for a given user + */ + create( + user: InstanceType, + abilities?: string[], + expiresIn?: string | number + ): Promise + /** * Verifies a publicly shared access token and returns an * access token for it. @@ -139,7 +148,7 @@ export interface AccessTokensProviderContract { * authentication */ export type LucidTokenable = LucidModel & { - [K in TokenableProperty]: AccessTokensProviderContract + [K in TokenableProperty]: AccessTokensProviderContract } /** @@ -181,6 +190,15 @@ export interface AccessTokensUserProviderContract { */ createUserForGuard(user: RealUser): Promise> + /** + * Create a token for a given user + */ + createToken( + user: RealUser, + abilities?: string[], + expiresIn?: string | number + ): Promise + /** * Find a user by their id. */ diff --git a/modules/access_tokens_guard/user_providers/lucid.ts b/modules/access_tokens_guard/user_providers/lucid.ts index 420e38b..22b3d50 100644 --- a/modules/access_tokens_guard/user_providers/lucid.ts +++ b/modules/access_tokens_guard/user_providers/lucid.ts @@ -77,10 +77,6 @@ export class AccessTokensLucidUserProvider< user: InstanceType ): Promise>> { const model = await this.getModel() - - /** - * Ensure user is an instance of the model - */ if (user instanceof model === false) { throw new RuntimeException( `Invalid user object. It must be an instance of the "${model.name}" model` @@ -106,6 +102,25 @@ export class AccessTokensLucidUserProvider< } } + /** + * Create a token for a given user + */ + async createToken( + user: InstanceType, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined + ): Promise { + const model = await this.getModel() + if (user instanceof model === false) { + throw new RuntimeException( + `Invalid user object. It must be an instance of the "${model.name}" model` + ) + } + + const tokensProvider = await this.getTokensProvider() + return tokensProvider.create(user, abilities, expiresIn) + } + /** * Finds a user by their primary key value */ diff --git a/package.json b/package.json index 184ba77..a7439bd 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@types/luxon": "^3.4.0", "@types/node": "^20.10.8", "@types/set-cookie-parser": "^2.4.7", + "@types/sinon": "^17.0.3", "c8": "^9.0.0", "convert-hrtime": "^5.0.0", "copyfiles": "^2.4.1", @@ -91,10 +92,12 @@ "github-label-sync": "^2.3.1", "husky": "^8.0.3", "luxon": "^3.4.4", + "nock": "^13.5.0", "np": "^9.2.0", "playwright": "^1.40.1", "prettier": "^3.1.1", "set-cookie-parser": "^2.6.0", + "sinon": "^17.0.1", "sqlite3": "^5.1.7", "timekeeper": "^2.3.1", "ts-node": "^10.9.2", diff --git a/src/auth_manager.ts b/src/auth_manager.ts index 75c7d50..0ca9eb5 100644 --- a/src/auth_manager.ts +++ b/src/auth_manager.ts @@ -18,23 +18,15 @@ import { AuthenticatorClient } from './authenticator_client.js' * guards from the config */ export class AuthManager> { - /** - * Registered guards - */ - #config: { - default: keyof KnownGuards - guards: KnownGuards - } - /** * Name of the default guard */ get defaultGuard() { - return this.#config.default + return this.config.default } - constructor(config: { default: keyof KnownGuards; guards: KnownGuards }) { - this.#config = config + constructor(public config: { default: keyof KnownGuards; guards: KnownGuards }) { + this.config = config } /** @@ -42,7 +34,7 @@ export class AuthManager> { * is used to authenticated in incoming HTTP request */ createAuthenticator(ctx: HttpContext) { - return new Authenticator(ctx, this.#config) + return new Authenticator(ctx, this.config) } /** @@ -50,6 +42,6 @@ export class AuthManager> { * used to setup authentication state during testing. */ createAuthenticatorClient() { - return new AuthenticatorClient(this.#config) + return new AuthenticatorClient(this.config) } } diff --git a/src/plugins/japa/api_client.ts b/src/plugins/japa/api_client.ts index ae01537..b4b9bfe 100644 --- a/src/plugins/japa/api_client.ts +++ b/src/plugins/japa/api_client.ts @@ -19,21 +19,24 @@ import type { Authenticators, GuardContract, GuardFactory } from '../../types.js declare module '@japa/api-client' { export interface ApiRequest { authData: { - guard: string - user: unknown + guard: keyof Authenticators | '__default__' + args: [unknown, ...any[]] } /** * Login a user using the default authentication guard * when making an API call */ - loginAs(user: { - [K in keyof Authenticators]: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + loginAs( + user: { + [K in keyof Authenticators]: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never : never - : never - }): this + }[keyof Authenticators], + ...args: any[] + ): this /** * Define the authentication guard for login @@ -46,10 +49,8 @@ declare module '@japa/api-client' { * Login a user using a specific auth guard */ loginAs( - user: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A - : never + ...args: ReturnType extends GuardContract + ? Parameters['authenticateAsClient']> : never ): Self } @@ -68,10 +69,10 @@ export const authApiClient = (app: ApplicationService) => { * Login a user using the default authentication guard * when making an API call */ - ApiRequest.macro('loginAs', function (this: ApiRequest, user) { + ApiRequest.macro('loginAs', function (this: ApiRequest, user, ...args: any[]) { this.authData = { guard: '__default__', - user: user, + args: [user, ...args], } return this }) @@ -84,10 +85,10 @@ export const authApiClient = (app: ApplicationService) => { Self extends ApiRequest, >(this: Self, guard: K) { return { - loginAs: (user) => { + loginAs: (...args) => { this.authData = { guard, - user: user, + args: args, } return this }, @@ -107,7 +108,7 @@ export const authApiClient = (app: ApplicationService) => { const client = auth.createAuthenticatorClient() const guard = authData.guard === '__default__' ? client.use() : client.use(authData.guard) const requestData = await (guard as GuardContract).authenticateAsClient( - authData.user + ...authData.args ) if (requestData.headers) { diff --git a/src/plugins/japa/browser_client.ts b/src/plugins/japa/browser_client.ts index c5a071c..d58d4ea 100644 --- a/src/plugins/japa/browser_client.ts +++ b/src/plugins/japa/browser_client.ts @@ -24,13 +24,16 @@ declare module 'playwright' { * Login a user using the default authentication guard when * using the browser context to make page visits */ - loginAs(user: { - [K in keyof Authenticators]: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + loginAs( + user: { + [K in keyof Authenticators]: Authenticators[K] extends GuardFactory + ? ReturnType extends GuardContract + ? A + : never : never - : never - }): Promise + }[keyof Authenticators], + ...args: any[] + ): Promise /** * Define the authentication guard for login @@ -42,9 +45,13 @@ declare module 'playwright' { * Login a user using a specific auth guard */ loginAs( - user: Authenticators[K] extends GuardFactory - ? ReturnType extends GuardContract - ? A + user: ReturnType extends GuardContract ? A : never, + ...args: ReturnType extends GuardContract + ? ReturnType['authenticateAsClient'] extends ( + _: A, + ...args: infer Args + ) => any + ? Args : never : never ): Promise @@ -70,10 +77,10 @@ export const authBrowserClient = (app: ApplicationService) => { */ context.withGuard = function (guardName) { return { - async loginAs(user) { + async loginAs(...args) { const client = auth.createAuthenticatorClient() const guard = client.use(guardName) as GuardContract - const requestData = await guard.authenticateAsClient(user) + const requestData = await guard.authenticateAsClient(...args) if (requestData.headers) { throw new RuntimeException( @@ -100,10 +107,10 @@ export const authBrowserClient = (app: ApplicationService) => { * Login a user using the default authentication guard when * using the browser context to make page visits */ - context.loginAs = async function (user) { + context.loginAs = async function (user, ...args) { const client = auth.createAuthenticatorClient() const guard = client.use() as GuardContract - const requestData = await guard.authenticateAsClient(user) + const requestData = await guard.authenticateAsClient(user, ...args) if (requestData.headers) { throw new RuntimeException(`Cannot use "${guard.driverName}" guard with browser client`) diff --git a/src/types.ts b/src/types.ts index ce0a1e5..07f2e86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,8 +71,11 @@ export interface GuardContract { * The method is used to authenticate the user as client. * This method should return cookies, headers, or * session state. + * + * The rest of the arguments can be anything the guard wants + * to accept */ - authenticateAsClient(user: User): Promise + authenticateAsClient(user: User, ...args: any[]): Promise /** * Aymbol for infer the events emitted by a specific diff --git a/tests/access_tokens/guard/authenticate.spec.ts b/tests/access_tokens/guard/authenticate.spec.ts index 083ef0a..b291a56 100644 --- a/tests/access_tokens/guard/authenticate.spec.ts +++ b/tests/access_tokens/guard/authenticate.spec.ts @@ -243,7 +243,7 @@ test.group('Access tokens guard | authenticate', () => { const emitter = createEmitter() const userProvider = new AccessTokensFakeUserProvider() const user = await userProvider.findById(1) - const token = await userProvider.createToken(user!.getOriginal(), '20 mins') + const token = await userProvider.createToken(user!.getOriginal(), ['*'], '20 mins') timeTravel(21 * 60) ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` @@ -278,7 +278,7 @@ test.group('Access tokens guard | authenticate', () => { const guard = new AccessTokensGuard('api', ctx, emitter, userProvider) const user = await userProvider.findById(1) - const token = await userProvider.createToken(user!.getOriginal(), '20 mins') + const token = await userProvider.createToken(user!.getOriginal(), ['*'], '20 mins') await assert.rejects(() => guard.authenticate(), 'Unauthorized access') ctx.request.request.headers.authorization = `Bearer ${token.value!.release()}` diff --git a/tests/auth/plugins/api_client.spec.ts b/tests/auth/plugins/api_client.spec.ts new file mode 100644 index 0000000..05c2614 --- /dev/null +++ b/tests/auth/plugins/api_client.spec.ts @@ -0,0 +1,98 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import nock from 'nock' +import sinon from 'sinon' +import { test } from '@japa/runner' +import { apiClient } from '@japa/api-client' +import { runner } from '@japa/runner/factories' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' + +import { Guards } from './global_types.js' +import { AuthManager } from '../../../src/auth_manager.js' +import { FakeGuard, FakeUser } from '../../../factories/auth/main.js' +import { authApiClient } from '../../../src/plugins/japa/api_client.js' + +test.group('Api client | loginAs', () => { + test('login user using the guard authenticate as client method', async ({ + assert, + expectTypeOf, + }) => { + const fakeGuard = new FakeGuard() + const guards: Guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [apiClient({ baseURL: 'http://localhost:3333' }), authApiClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ client }) => { + const request = client.get('/') + await request.loginAs({ id: 1 }) + expectTypeOf(request.loginAs).parameters.toEqualTypeOf<[FakeUser, ...any[]]>() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 })) + }) + + test('pass additional params to loginAs method', async ({ assert, expectTypeOf }) => { + const fakeGuard = new FakeGuard() + const guards: Guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [apiClient({ baseURL: 'http://localhost:3333' }), authApiClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ client }) => { + const request = client.get('/') + await request.withGuard('web').loginAs({ id: 1 }, ['*'], '20 mins') + expectTypeOf(request.withGuard('web').loginAs).parameters.toEqualTypeOf< + [ + user: FakeUser, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined, + ] + >() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 }, ['*'], '20 mins')) + }) +}) diff --git a/tests/auth/plugins/browser_client.spec.ts b/tests/auth/plugins/browser_client.spec.ts new file mode 100644 index 0000000..4b0ae8c --- /dev/null +++ b/tests/auth/plugins/browser_client.spec.ts @@ -0,0 +1,96 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import nock from 'nock' +import sinon from 'sinon' +import { test } from '@japa/runner' +import { runner } from '@japa/runner/factories' +import { browserClient } from '@japa/browser-client' +import { AppFactory } from '@adonisjs/core/factories/app' +import type { ApplicationService } from '@adonisjs/core/types' + +import { Guards } from './global_types.js' +import { AuthManager } from '../../../src/auth_manager.js' +import { FakeGuard, FakeUser } from '../../../factories/auth/main.js' +import { authBrowserClient } from '../../../src/plugins/japa/browser_client.js' + +test.group('Api client | loginAs', () => { + test('login user using the guard authenticate as client method', async ({ + assert, + expectTypeOf, + }) => { + const fakeGuard = new FakeGuard() + const guards: Guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [browserClient({}), authBrowserClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ browserContext }) => { + await browserContext.loginAs({ id: 1 }) + expectTypeOf(browserContext.loginAs).parameters.toEqualTypeOf<[FakeUser, ...any[]]>() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 })) + }) + + test('pass additional params to loginAs method', async ({ assert, expectTypeOf }) => { + const fakeGuard = new FakeGuard() + const guards = { + web: () => fakeGuard, + } + + const authManager = new AuthManager({ + default: 'web', + guards: guards, + }) + + const app = new AppFactory().create(new URL('./', import.meta.url)) as ApplicationService + await app.init() + + app.container.singleton('auth.manager', () => authManager) + const spy = sinon.spy(fakeGuard, 'authenticateAsClient') + + nock('http://localhost:3333').get('/').reply(200) + + await runner() + .configure({ + plugins: [browserClient({}), authBrowserClient(app)], + files: ['*'], + }) + .runTest('sample test', async ({ browserContext }) => { + await browserContext.withGuard('web').loginAs({ id: 1 }, ['*'], '20 mins') + expectTypeOf(browserContext.withGuard('web').loginAs).parameters.toEqualTypeOf< + [ + user: FakeUser, + abilities?: string[] | undefined, + expiresIn?: string | number | undefined, + ] + >() + }) + + assert.isTrue(spy.calledOnceWithExactly({ id: 1 }, ['*'], '20 mins')) + }) +}) diff --git a/tests/auth/plugins/global_types.ts b/tests/auth/plugins/global_types.ts new file mode 100644 index 0000000..8d8edb9 --- /dev/null +++ b/tests/auth/plugins/global_types.ts @@ -0,0 +1,26 @@ +/* + * @adonisjs/auth + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { FakeGuard } from '../../../factories/auth/main.js' + +/** + * Guard to use for testing + */ +export type Guards = { + web: () => FakeGuard +} + +/** + * Inferrring types for the authenticators, since + * the japa plugins relies on the singleton + * service + */ +declare module '@adonisjs/auth/types' { + interface Authenticators extends Guards {} +}