/* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ import { IncomingHttpHeaders } from 'node:http'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { Test, TestingModule } from '@nestjs/testing'; import { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import { HttpHeader } from 'fastify/types/utils.js'; import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; import { MiUser } from '@/models/User.js'; import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { DI } from '@/di-symbols.js'; import { CoreModule } from '@/core/CoreModule.js'; import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js'; import { RateLimiterService } from '@/server/api/RateLimiterService.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { SigninService } from '@/server/api/SigninService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; const moduleMocker = new ModuleMocker(global); class FakeLimiter { public async limit() { return; } } class FakeSigninService { public signin(..._args: any): any { return true; } } class DummyFastifyReply { public statusCode: number; code(num: number): void { this.statusCode = num; } header(_key: HttpHeader, _value: any): void { } } class DummyFastifyRequest { public ip: string; public body: {credential: any, context: string}; public headers: IncomingHttpHeaders = { 'accept': 'application/json' }; constructor(body?: any) { this.ip = '0.0.0.0'; this.body = body; } } type ApiFastifyRequestType = FastifyRequest<{ Body: { credential?: AuthenticationResponseJSON; context?: string; }; }>; describe('SigninWithPasskeyApiService', () => { let app: TestingModule; let passkeyApiService: SigninWithPasskeyApiService; let usersRepository: UsersRepository; let userProfilesRepository: UserProfilesRepository; let webAuthnService: WebAuthnService; let idService: IdService; let FakeWebauthnVerify: ()=>Promise; async function createUser(data: Partial = {}) { const user = await usersRepository .save({ ...data, }); return user; } async function createUserProfile(data: Partial = {}) { const userProfile = await userProfilesRepository .save({ ...data }, ); return userProfile; } beforeAll(async () => { app = await Test.createTestingModule({ imports: [GlobalModule, CoreModule], providers: [ SigninWithPasskeyApiService, { provide: RateLimiterService, useClass: FakeLimiter }, { provide: SigninService, useClass: FakeSigninService }, ], }).useMocker((token) => { if (typeof token === 'function') { const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } }).compile(); passkeyApiService = app.get(SigninWithPasskeyApiService); usersRepository = app.get(DI.usersRepository); userProfilesRepository = app.get(DI.userProfilesRepository); webAuthnService = app.get(WebAuthnService); idService = app.get(IdService); }); beforeEach(async () => { const uid = idService.gen(); FakeWebauthnVerify = async () => { return uid; }; jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); const dummyUser = { id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, }; const dummyProfile = { userId: uid, password: 'qwerty', usePasswordLessLogin: true, }; await createUser(dummyUser); await createUserProfile(dummyProfile); }); afterAll(async () => { await app.close(); }); describe('Get Passkey Options', () => { it('Should return passkey Auth Options', async () => { const req = new DummyFastifyRequest({}) as ApiFastifyRequestType; const res = new DummyFastifyReply() as unknown as FastifyReply; const res_body = await passkeyApiService.signin(req, res); expect(res.statusCode).toBe(200); expect((res_body as any).option).toBeDefined(); expect(typeof (res_body as any).context).toBe('string'); }); }); describe('Try Passkey Auth', () => { it('Should Success', async () => { const req = new DummyFastifyRequest({ context: 'auth-context', credential: { dummy: [] } }) as ApiFastifyRequestType; const res = new DummyFastifyReply() as FastifyReply; const res_body = await passkeyApiService.signin(req, res); expect((res_body as any).signinResponse).toBeDefined(); }); it('Should return 400 Without Auth Context', async () => { const req = new DummyFastifyRequest({ credential: { dummy: [] } }) as ApiFastifyRequestType; const res = new DummyFastifyReply() as FastifyReply; const res_body = await passkeyApiService.signin(req, res); expect(res.statusCode).toBe(400); expect((res_body as any).error?.id).toStrictEqual('1658cc2e-4495-461f-aee4-d403cdf073c1'); }); it('Should return 403 When Challenge Verify fail', async () => { const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType; const res = new DummyFastifyReply() as FastifyReply; jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication') .mockImplementation(async () => { throw new IdentifiableError('THIS_ERROR_CODE_SHOULD_BE_FORWARDED'); }); const res_body = await passkeyApiService.signin(req, res); expect(res.statusCode).toBe(403); expect((res_body as any).error?.id).toStrictEqual('THIS_ERROR_CODE_SHOULD_BE_FORWARDED'); }); it('Should return 403 When The user not Enabled Passwordless login', async () => { const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType; const res = new DummyFastifyReply() as FastifyReply; const userId = await FakeWebauthnVerify(); const data = { userId: userId, usePasswordLessLogin: false }; await userProfilesRepository.update({ userId: userId }, data); const res_body = await passkeyApiService.signin(req, res); expect(res.statusCode).toBe(403); expect((res_body as any).error?.id).toStrictEqual('2d84773e-f7b7-4d0b-8f72-bb69b584c912'); }); }); });