Finished implementing AuthService (Unit/Integration tests, documentation and code)

This commit is contained in:
2026-04-01 19:34:59 +02:00
parent 0852cbd4f0
commit 7b6760952e
11 changed files with 389 additions and 38 deletions

View File

@@ -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;

View File

@@ -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(<OtpPleCodeSendTestingResp>{
code: 5000
})
}),
http.post('*/v2/user/otpVerifyCode', async ({request}) => {
const body = await request.json() as OtpVerifyCodeReq;
return HttpResponse.json(<SignInSuccessResp>{
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(<GenericSuccessBody>{
response: "success"
}, {status: body.password == "password" ? 200 : 401})
}),
http.get('*/v2/user/unameUsage', async ({request}) => {
const url = new URL(request.url);
return HttpResponse.json(<UserDataValidationResp>{
used: url.searchParams.get('username') == "username"
})
}),
http.get('*/v2/user/emailUsage', async ({request}) => {
const url = new URL(request.url);
return HttpResponse.json(<UserDataValidationResp>{
used: url.searchParams.get('email') == "taken@example.com"
})
}),
http.post('*/v2/user/pleSendVCode', () => {
return HttpResponse.json(<OtpPleCodeSendTestingResp>{
code: 5000
})
}),
http.post('*/v2/user/pleVerifyCode', async ({request}) => {
const body = await request.json() as PleVerifyCodeReq;
return HttpResponse.json(<GenericSuccessBody>{
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(<SignInSuccessResp>{
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(<SignInSuccessResp>{
username: body.username,
displayName: body.displayName,
token: "token"
}, {status: 200})
}),
http.post('*/user/resetPassword', () => {
return HttpResponse.json(<OtpPleCodeSendTestingResp>{
code: 5000
})
}),
http.post('*/user/verifyResetCode', async ({request}) => {
const body = await request.json() as VerifyPasswordResetReq;
return HttpResponse.json(<GenericSuccessBody>{
response: "success"
}, {status: body.vCode == 5000 ? 200 : 401})
})
]

View File

@@ -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")
})
})

View File

@@ -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<AuthMethods> {
try {
const resp = await getClient().get<AuthMethods>(`user/authOptions?unameMailPhone=${unameMailPhone}`);
@@ -22,13 +27,23 @@ export class AuthService {
}
}
async otpSendCode(unameMailPhone: string, type: number): Promise<void> {
/**
* 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<void|number> {
try {
await getClient().post<GenericSuccessBody>("v2/user/otpSendCode", <OtpPleSendCodeReq>{
unameMailPhone: unameMailPhone,
const resp = await getClient().post<OtpPleCodeSendTestingResp>("v2/user/otpSendCode", <OtpSendCodeReq>{
usernamePhoneMail: unameMailPhone,
type: type
});
return
if (resp.data.code != null) {
return resp.data.code
} else {
return
}
} catch (e) {
if (isAxiosError<GenericErrorBody>(e)) {
throw e;
@@ -37,14 +52,20 @@ export class AuthService {
}
}
async otpVerifyCode(unameMailPhone: string, type: number, code: number): Promise<void> {
/**
* 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<SignInSuccessResp> {
try {
await getClient().post<GenericSuccessBody>("v2/user/otpVerifyCode", <OtpPleVerifyCodeReq>{
unameMailPhone: unameMailPhone,
const resp = await getClient().post<SignInSuccessResp>("v2/user/otpVerifyCode", <OtpVerifyCodeReq>{
usernamePhoneMail: unameMailPhone,
type: type,
code: code
});
return
return resp.data
} catch (e) {
if (isAxiosError<GenericErrorBody>(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<SignInSuccessResp> {
try {
const resp = await getClient().post<SignInSuccessResp>("v2/user/loginPasswordAuth", <LoginPasswordAuthReq>{
@@ -68,6 +94,9 @@ export class AuthService {
}
}
/**
* Checks whether the provided username is already taken
*/
async isUsernameUsed(username: string): Promise<Boolean> {
try {
const resp = await getClient().get<UserDataValidationResp>(`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<Boolean> {
try {
const resp = await getClient().get<UserDataValidationResp>(`v2/user/emailUsage?email=${email}`);
@@ -92,13 +124,23 @@ export class AuthService {
}
}
async pleSendVCode(phoneMail: string, type: number): Promise<void> {
/**
* 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<void|number> {
try {
await getClient().post<UserDataValidationResp>("v2/user/pleSendVCode", <OtpPleSendCodeReq>{
unameMailPhone: phoneMail,
const resp = await getClient().post<OtpPleCodeSendTestingResp>("v2/user/pleSendVCode", <PleSendCodeReq>{
phoneMail: phoneMail,
type: type,
});
return
if (resp.data.code == null) {
return
} else {
return resp.data.code
}
} catch (e) {
if (isAxiosError<GenericErrorBody>(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<string> {
try {
const resp = await getClient().post<PleVerifyCodeResp>("v2/user/pleVerifyCode", <OtpPleVerifyCodeReq>{
unameMailPhone: phoneMail,
const resp = await getClient().post<PleVerifyCodeResp>("v2/user/pleVerifyCode", <PleVerifyCodeReq>{
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<SignInSuccessResp> {
/**
* 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<SignInSuccessResp> {
try {
const resp = await getClient().post<SignInSuccessResp>("v2/user/finishPLEAccount", <FinishPleAccountReq>{
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<SignInSuccessResp> {
try {
const resp = await getClient().post<SignInSuccessResp>("user/loginWithGoogle", <LoginWithGoogleReq>{
@@ -154,6 +213,10 @@ export class AuthService {
}
}
/**
* A social login method via Apple
* @param code Provided by AppleJS
*/
async loginWithApple(code: string): Promise<SignInSuccessResp> {
try {
const resp = await getClient().post<SignInSuccessResp>("user/loginWithGoogle", <LoginWithApple>{
@@ -169,7 +232,13 @@ export class AuthService {
}
}
async register(username: string, displayName: string, password: string): Promise<SignInSuccessResp> {
/**
* 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<SignInSuccessResp> {
try {
const resp = await getClient().post<SignInSuccessResp>("v2/user/register", <RegisterReq>{
username: username,
@@ -185,12 +254,22 @@ export class AuthService {
}
}
async resetPassword(unameMailPhone: string): Promise<string> {
/**
* 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<void|number> {
try {
const resp = await getClient().post<ResetPasswordResp>("user/resetPassword", <ResetPasswordReq>{
const resp = await getClient().post<OtpPleCodeSendTestingResp>("user/resetPassword", <ResetPasswordReq>{
unameMailPhone: unameMailPhone,
});
return resp.data.userid
if (resp.data.code != null) {
return resp.data.code
} else {
return
}
} catch (e) {
if (isAxiosError<GenericErrorBody>(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<void> {
try {
await getClient().post<GenericSuccessBody>("user/resetPassword", <VerifyPasswordResetReq>{
await getClient().post<GenericSuccessBody>("user/verifyResetCode", <VerifyPasswordResetReq>{
unameMailPhone: unameMailPhone,
vCode: code,
newPassword: newPassword,

6
src/vitest.setup.unit.ts Normal file
View File

@@ -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());