From 7b6760952e3bb32f0b02344ed8ce38ecb24ce239 Mon Sep 17 00:00:00 2001 From: chatenium Date: Wed, 1 Apr 2026 19:34:59 +0200 Subject: [PATCH] Finished implementing AuthService (Unit/Integration tests, documentation and code) --- package-lock.json | 17 ++++ package.json | 4 +- src/domain/authService.schema.ts | 25 +++++- src/mocks/handlers/auth.http.ts | 89 +++++++++++++++++++- src/services/authService.test.ts | 67 +++++++++++++++ src/services/authService.ts | 131 ++++++++++++++++++++++++------ src/vitest.setup.unit.ts | 6 ++ tests/authService.test.ts | 44 ++++++++++ tests/vitest.setup.integration.ts | 11 +++ vitest.config.ts | 27 +++++- vitest.setup.ts | 6 -- 11 files changed, 389 insertions(+), 38 deletions(-) create mode 100644 src/vitest.setup.unit.ts create mode 100644 tests/authService.test.ts create mode 100644 tests/vitest.setup.integration.ts delete mode 100644 vitest.setup.ts diff --git a/package-lock.json b/package-lock.json index 2ac027a..c94f7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "chatenium-sdk", "version": "1.0.0", "dependencies": { + "@faker-js/faker": "^10.4.0", "axios": "^1.14.0", "msw": "^2.12.14" }, @@ -53,6 +54,22 @@ "tslib": "^2.4.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz", + "integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", diff --git a/package.json b/package.json index 380517f..d3396c1 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "test": "vitest run", + "test": "vitest run --project unit", + "test-integration": "vitest run --project integration", "test:watch": "vitest" }, "devDependencies": { @@ -14,6 +15,7 @@ }, "private": true, "dependencies": { + "@faker-js/faker": "^10.4.0", "axios": "^1.14.0", "msw": "^2.12.14" } diff --git a/src/domain/authService.schema.ts b/src/domain/authService.schema.ts index fde6c4c..c6fb988 100644 --- a/src/domain/authService.schema.ts +++ b/src/domain/authService.schema.ts @@ -1,11 +1,24 @@ // Request schemas -export interface OtpPleSendCodeReq { - unameMailPhone: string; +export interface PleSendCodeReq { + phoneMail: string; type: number; } -export interface OtpPleVerifyCodeReq { - unameMailPhone: string; +export interface OtpSendCodeReq { + usernamePhoneMail: string; + type: number; +} + +export interface PleVerifyCodeReq { + phoneMail: string; + type: number; + code: number; + os: string | null; + language: string | null; +} + +export interface OtpVerifyCodeReq { + usernamePhoneMail: string; type: number; code: number; os: string | null; @@ -79,6 +92,10 @@ export interface SignInSuccessResp { userid: string; } +export interface OtpPleCodeSendTestingResp { + code: number | null; +} + // Types export interface AuthMethods { password: boolean; diff --git a/src/mocks/handlers/auth.http.ts b/src/mocks/handlers/auth.http.ts index 13aaae2..94f1317 100644 --- a/src/mocks/handlers/auth.http.ts +++ b/src/mocks/handlers/auth.http.ts @@ -1,5 +1,12 @@ import { http, HttpResponse } from 'msw' -import {AuthMethods} from "../../domain/authService.schema"; +import { + AuthMethods, FinishPleAccountReq, LoginPasswordAuthReq, + OtpPleCodeSendTestingResp, + OtpSendCodeReq, + OtpVerifyCodeReq, PleVerifyCodeReq, PleVerifyCodeResp, RegisterReq, SignInSuccessResp, + UserDataValidationResp, VerifyPasswordResetReq +} from "../../domain/authService.schema"; +import {GenericSuccessBody} from "../../domain/http.schema"; export const authHandlers = [ http.get('*/user/authOptions', () => { @@ -9,4 +16,84 @@ export const authHandlers = [ sms: false, }) }), + + http.post('*/v2/user/otpSendCode', () => { + return HttpResponse.json({ + code: 5000 + }) + }), + + http.post('*/v2/user/otpVerifyCode', async ({request}) => { + const body = await request.json() as OtpVerifyCodeReq; + return HttpResponse.json({ + username: "bob", + displayName: "Bob", + token: "token" + }, {status: body.code == 5000 ? 200 : 401}) + }), + + http.post('*/v2/user/loginPasswordAuth', async ({request}) => { + const body = await request.json() as LoginPasswordAuthReq; + return HttpResponse.json({ + response: "success" + }, {status: body.password == "password" ? 200 : 401}) + }), + + http.get('*/v2/user/unameUsage', async ({request}) => { + const url = new URL(request.url); + return HttpResponse.json({ + used: url.searchParams.get('username') == "username" + }) + }), + + http.get('*/v2/user/emailUsage', async ({request}) => { + const url = new URL(request.url); + return HttpResponse.json({ + used: url.searchParams.get('email') == "taken@example.com" + }) + }), + + http.post('*/v2/user/pleSendVCode', () => { + return HttpResponse.json({ + code: 5000 + }) + }), + + http.post('*/v2/user/pleVerifyCode', async ({request}) => { + const body = await request.json() as PleVerifyCodeReq; + return HttpResponse.json({ + response: "success" + }, {status: body.code == 5000 ? 200 : 401}) + }), + + http.post('*/v2/user/finishPLEAccount', async ({request}) => { + const body = await request.json() as FinishPleAccountReq; + return HttpResponse.json({ + username: body.username, + displayName: body.displayName, + token: "token" + }, {status: body.authCode == "goodAuthCode" ? 200 : 401}) + }), + + http.post('*/v2/user/register', async ({request}) => { + const body = await request.json() as RegisterReq; + return HttpResponse.json({ + username: body.username, + displayName: body.displayName, + token: "token" + }, {status: 200}) + }), + + http.post('*/user/resetPassword', () => { + return HttpResponse.json({ + code: 5000 + }) + }), + + http.post('*/user/verifyResetCode', async ({request}) => { + const body = await request.json() as VerifyPasswordResetReq; + return HttpResponse.json({ + response: "success" + }, {status: body.vCode == 5000 ? 200 : 401}) + }) ] \ No newline at end of file diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts index 0741453..1fd47b0 100644 --- a/src/services/authService.test.ts +++ b/src/services/authService.test.ts @@ -1,5 +1,7 @@ import {describe, expect, it} from "vitest"; import {AuthService} from "./authService"; +import {VerificationTypeEmail} from "../domain/authService.schema"; +import {faker} from "@faker-js/faker/locale/en"; describe("AuthService", () => { it("should return authMethods", async () => { @@ -9,4 +11,69 @@ describe("AuthService", () => { expect(methods.email).toBeTruthy() expect(methods.password).toBeTruthy() }) + + it("should send OTP code", async () => { + const service = new AuthService(); + const code = await service.otpSendCode("bob@example.com", VerificationTypeEmail) + expect(code).toBe(5000) + }) + + it("should send password reset code", async () => { + const service = new AuthService(); + const code = await service.resetPassword("bob@example.com") + expect(code).toBe(5000) + }) + + it("should send PLE code", async () => { + const service = new AuthService(); + const code = await service.pleSendVCode("bob@example.com", VerificationTypeEmail) + expect(code).toBe(5000) + }) + + it("should throw on wrong OTP code", async () => { + const service = new AuthService(); + await expect(service.otpVerifyCode("bob@example.com", VerificationTypeEmail, 9999)).rejects.toThrow() + }) + + it("should throw on wrong password reset code", async () => { + const service = new AuthService(); + await expect(service.verifyPasswordReset("bob@example.com", 9999, "newPassword")).rejects.toThrow() + }) + + it("should throw on wrong PLE code", async () => { + const service = new AuthService(); + await expect(service.pleVerifyCode("bob@example.com", VerificationTypeEmail, 9999)).rejects.toThrow() + }) + + it("should throw on wrong password", async () => { + const service = new AuthService(); + await expect(service.loginPasswordAuth("bob@example.com", "wrongPasswd")).rejects.toThrow(); + }) + + it("should detect taken username", async () => { + const service = new AuthService(); + const taken = await service.isUsernameUsed("username") + expect(taken).toBeTruthy() + }) + + it("should detect taken e-mail address", async () => { + const service = new AuthService(); + const taken = await service.isEmailUsed("taken@example.com") + expect(taken).toBeTruthy() + }) + + it("should register passwordless account", async () => { + const service = new AuthService(); + const username = faker.internet.username() + const displayName = faker.internet.displayName() + const userData = await service.finishPLEAccount( + "bob@example.com", + "goodAuthCode", + username, + displayName, + ) + expect(userData.username).toBe(username) + expect(userData.displayName).toBe(displayName) + expect(userData.token).toBe("token") + }) }) \ No newline at end of file diff --git a/src/services/authService.ts b/src/services/authService.ts index 83036a0..eb76e82 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -1,15 +1,20 @@ import {getClient} from "../core/http"; import { AuthMethods, FinishPleAccountReq, - LoginPasswordAuthReq, LoginWithApple, LoginWithGoogleReq, - OtpPleSendCodeReq, - OtpPleVerifyCodeReq, PleVerifyCodeResp, RegisterReq, ResetPasswordReq, ResetPasswordResp, + LoginPasswordAuthReq, LoginWithApple, LoginWithGoogleReq, OtpPleCodeSendTestingResp, + OtpSendCodeReq, + OtpVerifyCodeReq, PleSendCodeReq, + PleVerifyCodeReq, PleVerifyCodeResp, RegisterReq, ResetPasswordReq, ResetPasswordResp, SignInSuccessResp, UserDataValidationResp, VerifyPasswordResetReq } from "../domain/authService.schema"; import {isAxiosError} from "axios"; import {GenericErrorBody, GenericSuccessBody} from "../domain/http.schema"; export class AuthService { + /** + * Fetches the available authentication methods for a user + * @param unameMailPhone Username, e-mail address or phone number in international format + */ async getAuthMethods(unameMailPhone: string): Promise { try { const resp = await getClient().get(`user/authOptions?unameMailPhone=${unameMailPhone}`); @@ -22,13 +27,23 @@ export class AuthService { } } - async otpSendCode(unameMailPhone: string, type: number): Promise { + /** + * Starts the one-time-password sign-in procedure by sending a code to the preferred destination + * @param unameMailPhone Username, e-mail address or phone number in international format + * @param type Whether to send the code via e-mail or SMS + * @returns void in production. Number (the code) is only returned when unit testing with the API in testing mode + */ + async otpSendCode(unameMailPhone: string, type: number): Promise { try { - await getClient().post("v2/user/otpSendCode", { - unameMailPhone: unameMailPhone, + const resp = await getClient().post("v2/user/otpSendCode", { + usernamePhoneMail: unameMailPhone, type: type }); - return + if (resp.data.code != null) { + return resp.data.code + } else { + return + } } catch (e) { if (isAxiosError(e)) { throw e; @@ -37,14 +52,20 @@ export class AuthService { } } - async otpVerifyCode(unameMailPhone: string, type: number, code: number): Promise { + /** + * With the provided code, this registers a new session and returns user data and auth token + * @param unameMailPhone Username, e-mail address or phone number in international format + * @param type Whether to send the code via e-mail or SMS + * @param code The verification code + */ + async otpVerifyCode(unameMailPhone: string, type: number, code: number): Promise { try { - await getClient().post("v2/user/otpVerifyCode", { - unameMailPhone: unameMailPhone, + const resp = await getClient().post("v2/user/otpVerifyCode", { + usernamePhoneMail: unameMailPhone, type: type, code: code }); - return + return resp.data } catch (e) { if (isAxiosError(e)) { throw e; @@ -53,6 +74,11 @@ export class AuthService { } } + /** + * Signs into a Chatenium account via password + * @param usernamePhoneMail Username, e-mail address or phone number in international format + * @param password The password associated with the account + */ async loginPasswordAuth(usernamePhoneMail: string, password: string): Promise { try { const resp = await getClient().post("v2/user/loginPasswordAuth", { @@ -68,6 +94,9 @@ export class AuthService { } } + /** + * Checks whether the provided username is already taken + */ async isUsernameUsed(username: string): Promise { try { const resp = await getClient().get(`v2/user/unameUsage?username=${username}`); @@ -80,6 +109,9 @@ export class AuthService { } } + /** + * Checks whether the provided e-mail address is already taken + */ async isEmailUsed(email: string): Promise { try { const resp = await getClient().get(`v2/user/emailUsage?email=${email}`); @@ -92,13 +124,23 @@ export class AuthService { } } - async pleSendVCode(phoneMail: string, type: number): Promise { + /** + * Starts the passwordless register procedure by sending a code to the preferred destination + * @param phoneMail E-mail address or phone number in international format + * @param type Whether to send the code via e-mail or SMS + * @returns void in production. Number (the code) is only returned when unit testing with the API in testing mode + */ + async pleSendVCode(phoneMail: string, type: number): Promise { try { - await getClient().post("v2/user/pleSendVCode", { - unameMailPhone: phoneMail, + const resp = await getClient().post("v2/user/pleSendVCode", { + phoneMail: phoneMail, type: type, }); - return + if (resp.data.code == null) { + return + } else { + return resp.data.code + } } catch (e) { if (isAxiosError(e)) { throw e; @@ -107,10 +149,16 @@ export class AuthService { } } + /** + * Verifies if the provided code is valid to continue the procedure + * @param phoneMail E-mail address or phone number in international format + * @param type Whether to send the code via e-mail or SMS + * @param code The code that was sent out + */ async pleVerifyCode(phoneMail: string, type: number, code: number): Promise { try { - const resp = await getClient().post("v2/user/pleVerifyCode", { - unameMailPhone: phoneMail, + const resp = await getClient().post("v2/user/pleVerifyCode", { + phoneMail: phoneMail, type: type, code: code }); @@ -123,7 +171,14 @@ export class AuthService { } } - async finishPLEAccount(phoneMail: string, type: number, authCode: string, username: string, displayName: string): Promise { + /** + * Finishes the procedure by permanently registering the new account in the database + * @param phoneMail E-mail address or phone number in international format + * @param authCode Provided in pleVerifyCode + * @param username Must be provided by the user + * @param displayName Let the user optionally provide it + */ + async finishPLEAccount(phoneMail: string, authCode: string, username: string, displayName: string|null): Promise { try { const resp = await getClient().post("v2/user/finishPLEAccount", { phoneMail: phoneMail, @@ -140,6 +195,10 @@ export class AuthService { } } + /** + * A social login method via Google + * @param code Provided by the Google JavaScript API + */ async loginWithGoogle(code: string): Promise { try { const resp = await getClient().post("user/loginWithGoogle", { @@ -154,6 +213,10 @@ export class AuthService { } } + /** + * A social login method via Apple + * @param code Provided by AppleJS + */ async loginWithApple(code: string): Promise { try { const resp = await getClient().post("user/loginWithGoogle", { @@ -169,7 +232,13 @@ export class AuthService { } } - async register(username: string, displayName: string, password: string): Promise { + /** + * A quick way to register a new Chatenium account + * @param username Must be provided by the user + * @param password Must be provided by the user + * @param displayName Let the user optionally provide it + */ + async register(username: string, password: string, displayName: string|null): Promise { try { const resp = await getClient().post("v2/user/register", { username: username, @@ -185,12 +254,22 @@ export class AuthService { } } - async resetPassword(unameMailPhone: string): Promise { + + /** + * Starts the password reset procedure by sending out a code via phone (or via e-mail if SMS is not available) + * @param unameMailPhone Username, e-mail address or phone number + * @returns void in production. Number (the code) is only returned when unit testing with the API in testing mode + */ + async resetPassword(unameMailPhone: string): Promise { try { - const resp = await getClient().post("user/resetPassword", { + const resp = await getClient().post("user/resetPassword", { unameMailPhone: unameMailPhone, }); - return resp.data.userid + if (resp.data.code != null) { + return resp.data.code + } else { + return + } } catch (e) { if (isAxiosError(e)) { throw e; @@ -199,9 +278,15 @@ export class AuthService { } } + /** + * Finishes the password reset procedure by verifying the code and providing a new password + * @param unameMailPhone Username, e-mail address or phone number + * @param code The code that was sent out + * @param newPassword Must be provided by the user + */ async verifyPasswordReset(unameMailPhone: string, code: number, newPassword: string): Promise { try { - await getClient().post("user/resetPassword", { + await getClient().post("user/verifyResetCode", { unameMailPhone: unameMailPhone, vCode: code, newPassword: newPassword, diff --git a/src/vitest.setup.unit.ts b/src/vitest.setup.unit.ts new file mode 100644 index 0000000..66e5350 --- /dev/null +++ b/src/vitest.setup.unit.ts @@ -0,0 +1,6 @@ +import {beforeAll, afterEach, afterAll, vi} from 'vitest'; +import {mockServer} from "./mocks/node"; + +beforeAll(() => mockServer.listen({onUnhandledRequest: 'error'})); +afterEach(() => mockServer.resetHandlers()); +afterAll(() => mockServer.close()); diff --git a/tests/authService.test.ts b/tests/authService.test.ts new file mode 100644 index 0000000..c7c3a3f --- /dev/null +++ b/tests/authService.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from "vitest"; +import {AuthService} from "../src/services/authService"; +import {VerificationTypeEmail} from "../src/domain/authService.schema"; + +describe("AuthService", () => { + it("should return authMethods", async () => { + const service = new AuthService(); + const methods = await service.getAuthMethods("") + expect(methods.sms).toBeFalsy() + expect(methods.password).toBeFalsy() + expect(methods.email).toBeTruthy() + }) + + it("should register a password-less account", async () => { + const service = new AuthService(); + const code = await service.pleSendVCode("alice@example.com", VerificationTypeEmail) + expect(code).not.toBeNull() + const authCode = await service.pleVerifyCode("alice@example.com", VerificationTypeEmail, code ?? 0) + expect(authCode).not.toBeNull() + const userData = await service.finishPLEAccount("alice@example.com", authCode, "alice", "Alice") + expect(userData.displayName).toBe("Alice") + expect(userData.username).toBe("alice") + expect(userData.token).not.toBeNull() + }) + + it("should login via a one-time-password", async () => { + const service = new AuthService(); + const code = await service.otpSendCode("bob@example.com", VerificationTypeEmail) + expect(code).not.toBeNull() + const userData = await service.otpVerifyCode("bob@example.com", VerificationTypeEmail, code ?? 0) + expect(userData.displayName).toBe("Bob") + expect(userData.username).toBe("bob") + expect(userData.token).not.toBeNull() + }) + + it("should reset the password", async () => { + const service = new AuthService(); + const code = await service.resetPassword("bob@example.com") + expect(code).not.toBeNull() + await service.verifyPasswordReset("bob@example.com", code ?? 0, "newPasswd") + const methods = await service.getAuthMethods("bob@example.com") + expect(methods.password).toBeTruthy() + }) +}) \ No newline at end of file diff --git a/tests/vitest.setup.integration.ts b/tests/vitest.setup.integration.ts new file mode 100644 index 0000000..727136f --- /dev/null +++ b/tests/vitest.setup.integration.ts @@ -0,0 +1,11 @@ +import {beforeAll, beforeEach} from 'vitest'; +import {environment, SDKConfig} from "../src/core/environment"; +import {getClient} from "../src/core/http"; + +beforeEach(async () => { + environment.overwrite({ + apiUrl: "http://localhost:3000" + }) + + await getClient().post("v2/reset") +}) diff --git a/vitest.config.ts b/vitest.config.ts index 339a212..52db107 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,28 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - globals: true, - setupFiles: ['./vitest.setup.ts'], - }, + // Top-level configuration (global only) + coverage: { provider: 'v8' }, + + projects: [ + { + extends: true, + test: { + name: 'unit', + include: ['src/**/*.test.ts'], + environment: 'node', + setupFiles: ['./src/vitest.setup.unit.ts'], + } + }, + { + extends: true, + test: { + name: 'integration', + include: ['tests/**/*.test.ts'], + environment: 'node', + setupFiles: ['./tests/vitest.setup.integration.ts'], + } + } + ] + } }); \ No newline at end of file diff --git a/vitest.setup.ts b/vitest.setup.ts deleted file mode 100644 index 12412e3..0000000 --- a/vitest.setup.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { beforeAll, afterEach, afterAll, vi } from 'vitest'; -import {mockServer} from "./src/mocks/node"; - -beforeAll(() => mockServer.listen({ onUnhandledRequest: 'error' })); -afterEach(() => mockServer.resetHandlers()); -afterAll(() => mockServer.close()); \ No newline at end of file