Add support for OTPs and enhance sensitive apis with OTP verification (#191)

One-Time Password (OTP) package added with comprehensive token management, including OTP verification for team account deletion and ownership transfer.
This commit is contained in:
Giancarlo Buomprisco
2025-03-01 16:35:09 +07:00
committed by GitHub
parent 20f7fd2c22
commit d31f3eb993
60 changed files with 3543 additions and 1363 deletions

View File

@@ -1,14 +1,17 @@
import { Page, expect } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { OtpPo } from '../utils/otp.po';
export class AccountPageObject {
private readonly page: Page;
public auth: AuthPageObject;
private otp: OtpPo;
constructor(page: Page) {
this.page = page;
this.auth = new AuthPageObject(page);
this.otp = new OtpPo(page);
}
async setup() {
@@ -58,32 +61,16 @@ export class AccountPageObject {
await this.page.click('[data-test="account-password-form"] button');
}
async deleteAccount() {
await expect(async () => {
await this.page.click('[data-test="delete-account-button"]');
async deleteAccount(email: string) {
// Click the delete account button to open the modal
await this.page.click('[data-test="delete-account-button"]');
await this.page.fill(
'[data-test="delete-account-input-field"]',
'DELETE',
);
// Complete the OTP verification process
await this.otp.completeOtpVerification(email);
const click = this.page.click(
'[data-test="confirm-delete-account-button"]',
);
await this.page.waitForTimeout(500);
const response = await this.page
.waitForResponse((resp) => {
return (
resp.url().includes('home/settings') &&
resp.request().method() === 'POST'
);
})
.then((response) => {
expect(response.status()).toBe(303);
});
await Promise.all([click, response]);
}).toPass();
await this.page.click('[data-test="confirm-delete-account-button"]');
}
getProfileName() {

View File

@@ -1,6 +1,7 @@
import { Page, expect, test } from '@playwright/test';
import { AccountPageObject } from './account.po';
import {AuthPageObject} from "../authentication/auth.po";
test.describe('Account Settings', () => {
let page: Page;
@@ -51,22 +52,22 @@ test.describe('Account Settings', () => {
test.describe('Account Deletion', () => {
test('user can delete their own account', async ({ page }) => {
const account = new AccountPageObject(page);
const auth = new AuthPageObject(page);
await account.setup();
const { email } = await account.setup();
const request = account.deleteAccount();
await account.deleteAccount(email);
const response = page
.waitForResponse((resp) => {
return (
resp.url().includes('home/settings') &&
resp.request().method() === 'POST'
);
})
.then((response) => {
expect(response.status()).toBe(303);
});
await page.waitForURL('/');
await Promise.all([request, response]);
await page.goto('/auth/sign-in');
// sign in will now fail
await auth.signIn({
email,
password: 'testingpassword',
});
await expect(page.locator('[data-test="auth-error-message"]')).toBeVisible();
});
});

View File

@@ -25,7 +25,7 @@ export class AuthPageObject {
}
async signIn(params: { email: string; password: string }) {
await this.page.waitForTimeout(1000);
await this.page.waitForTimeout(500);
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
@@ -37,7 +37,7 @@ export class AuthPageObject {
password: string;
repeatPassword: string;
}) {
await this.page.waitForTimeout(1000);
await this.page.waitForTimeout(500);
await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password);
@@ -50,6 +50,7 @@ export class AuthPageObject {
email: string,
params: {
deleteAfter: boolean;
subject?: string;
} = {
deleteAfter: true,
},
@@ -79,6 +80,10 @@ export class AuthPageObject {
});
await this.visitConfirmEmailLink(email);
return {
email,
};
}
async updatePassword(password: string) {

View File

@@ -51,6 +51,23 @@ test.describe('Auth flow', () => {
expect(page.url()).toContain('/');
});
test('will sign out using the dropdown', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/home/settings');
await auth.signIn({
email: 'test@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/home/settings');
await auth.signOut();
await page.waitForURL('/');
});
});
test.describe('Protected routes', () => {

View File

@@ -2,35 +2,60 @@ import { expect, test } from '@playwright/test';
import { AuthPageObject } from './auth.po';
const email = 'owner@makerkit.dev';
const newPassword = (Math.random() * 10000).toString();
test.describe('Password Reset Flow', () => {
test.describe.configure({ mode: 'serial' });
test('will reset the password and sign in with new one', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/auth/password-reset');
let email = '';
await page.fill('[name="email"]', email);
await page.click('[type="submit"]');
await expect(async () => {
email = `test-${Math.random() * 10000}@makerkit.dev`;
await auth.visitConfirmEmailLink(email);
await page.goto('/auth/sign-up');
await page.waitForURL('/update-password');
await auth.signUp({
email,
password: 'password',
repeatPassword: 'password',
});
await auth.updatePassword(newPassword);
await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Confirm your email',
});
await page
.locator('a', {
hasText: 'Back to Home Page',
})
.click();
await page.context().clearCookies();
await page.reload();
await page.waitForURL('/home');
await page.goto('/auth/password-reset');
await auth.signOut();
await page.fill('[name="email"]', email);
await page.click('[type="submit"]');
await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Reset your password',
});
await page.waitForURL('/update-password', {
timeout: 1000,
});
await auth.updatePassword(newPassword);
await page
.locator('a', {
hasText: 'Back to Home Page',
})
.click();
await page.waitForURL('/home');
}).toPass();
await page.context().clearCookies();
await page.reload();
await page
.locator('a', {
@@ -43,6 +68,8 @@ test.describe('Password Reset Flow', () => {
password: newPassword,
});
await page.waitForURL('/home');
await page.waitForURL('/home', {
timeout: 2000,
});
});
});

View File

@@ -64,7 +64,7 @@ export class InvitationsPageObject {
})
.click();
return expect(this.page.url()).toContain('members');
await this.page.waitForURL('**/home/*/members');
}).toPass()
}

View File

@@ -1,20 +1,25 @@
import { Page, expect } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { OtpPo } from '../utils/otp.po';
export class TeamAccountsPageObject {
private readonly page: Page;
public auth: AuthPageObject;
public otp: OtpPo;
constructor(page: Page) {
this.page = page;
this.auth = new AuthPageObject(page);
this.otp = new OtpPo(page);
}
async setup(params = this.createTeamName()) {
await this.auth.signUpFlow('/home');
const { email } = await this.auth.signUpFlow('/home');
await this.createTeam(params);
return { email, teamName: params.teamName, slug: params.slug };
}
getTeamFromSelector(teamName: string) {
@@ -39,6 +44,18 @@ export class TeamAccountsPageObject {
}).toPass();
}
goToMembers() {
return expect(async () => {
await this.page
.locator('a', {
hasText: 'Members',
})
.click();
await this.page.waitForURL('**/home/*/members');
}).toPass();
}
goToBilling() {
return expect(async () => {
await this.page
@@ -94,18 +111,11 @@ export class TeamAccountsPageObject {
}).toPass();
}
async deleteAccount(teamName: string) {
async deleteAccount(email: string) {
await expect(async () => {
await this.page.click('[data-test="delete-team-trigger"]');
await expect(
this.page.locator('[data-test="delete-team-form-confirm-input"]'),
).toBeVisible();
await this.page.fill(
'[data-test="delete-team-form-confirm-input"]',
teamName,
);
await this.otp.completeOtpVerification(email);
const click = this.page.click(
'[data-test="delete-team-form-confirm-button"]',
@@ -117,6 +127,51 @@ export class TeamAccountsPageObject {
}).toPass();
}
async updateMemberRole(memberEmail: string, newRole: string) {
await expect(async () => {
// Find the member row and click the actions button
const memberRow = this.page.getByRole('row', { name: memberEmail });
await memberRow.getByRole('button').click();
// Click the update role option in the dropdown menu
await this.page.getByText('Update Role').click();
// Select the new role
await this.page.click('[data-test="role-selector-trigger"]');
await this.page.click(`[data-test="role-option-${newRole}"]`);
// Click the confirm button
const click = this.page.click('[data-test="confirm-update-member-role"]');
// Wait for the update to complete and page to reload
const response = this.page.waitForURL('**/home/*/members');
return Promise.all([click, response]);
}).toPass();
}
async transferOwnership(memberEmail: string, ownerEmail: string) {
await expect(async () => {
// Find the member row and click the actions button
const memberRow = this.page.getByRole('row', { name: memberEmail });
await memberRow.getByRole('button').click();
// Click the transfer ownership option in the dropdown menu
await this.page.getByText('Transfer Ownership').click();
// Complete OTP verification
await this.otp.completeOtpVerification(ownerEmail);
// Click the confirm button
const click = this.page.click('[data-test="confirm-transfer-ownership-button"]');
// Wait for the transfer to complete and page to reload
const response = this.page.waitForURL('**/home/*/members');
return Promise.all([click, response]);
}).toPass();
}
createTeamName() {
const random = (Math.random() * 100000000).toFixed(0);

View File

@@ -1,7 +1,74 @@
import { Page, expect, test } from '@playwright/test';
import { InvitationsPageObject } from '../invitations/invitations.po';
import { TeamAccountsPageObject } from './team-accounts.po';
// Helper function to set up a team with a member
async function setupTeamWithMember(page: Page, memberRole = 'member') {
// Setup invitations page object
const invitations = new InvitationsPageObject(page);
const teamAccounts = invitations.teamAccounts;
// Setup team with owner
const { email: ownerEmail, slug } = await invitations.setup();
// Navigate to members page
await invitations.navigateToMembers();
// Create a new member email and invite them with the specified role
const memberEmail = invitations.auth.createRandomEmail();
const invites = [
{
email: memberEmail,
role: memberRole,
},
];
await invitations.openInviteForm();
await invitations.inviteMembers(invites);
// Verify the invitation was sent
await expect(invitations.getInvitations()).toHaveCount(1);
// Sign out the current user
await page.context().clearCookies();
// Sign up with the new member email and accept the invitation
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.auth.signUp({
email: memberEmail,
password: 'password',
repeatPassword: 'password',
});
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.acceptInvitation();
await invitations.teamAccounts.openAccountsSelector();
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
// Sign out and sign back in as the original owner
await page.context().clearCookies();
await page.goto('/auth/sign-in');
await invitations.auth.signIn({
email: ownerEmail,
password: 'password',
});
await page.waitForURL('/home');
// Navigate to the team members page
await page.goto(`/home/${slug}/members`);
return { invitations, teamAccounts, ownerEmail, memberEmail, slug };
}
test.describe('Team Accounts', () => {
let page: Page;
let teamAccounts: TeamAccountsPageObject;
@@ -31,15 +98,15 @@ test.describe('Team Accounts', () => {
});
});
test.describe('Account Deletion', () => {
test.describe('Team Account Deletion', () => {
test('user can delete their team account', async ({ page }) => {
const teamAccounts = new TeamAccountsPageObject(page);
const params = teamAccounts.createTeamName();
await teamAccounts.setup(params);
const { email } = await teamAccounts.setup(params);
await teamAccounts.goToSettings();
await teamAccounts.deleteAccount(params.teamName);
await teamAccounts.deleteAccount(email);
await teamAccounts.openAccountsSelector();
await expect(
@@ -47,3 +114,62 @@ test.describe('Account Deletion', () => {
).not.toBeVisible();
});
});
test.describe('Team Member Role Management', () => {
test("owner can update a team member's role", async ({ page }) => {
// Setup team with a regular member
const { teamAccounts, memberEmail } = await setupTeamWithMember(page);
// Get the current role badge text
const memberRow = page.getByRole('row', { name: memberEmail });
const initialRoleBadge = memberRow.locator(
'[data-test="member-role-badge"]',
);
await expect(initialRoleBadge).toHaveText('Member');
// Update the member's role to admin
await teamAccounts.updateMemberRole(memberEmail, 'owner');
// Wait for the page to fully load after the update
await page.waitForTimeout(1000);
// Verify the role was updated successfully
const updatedRoleBadge = page
.getByRole('row', { name: memberEmail })
.locator('[data-test="member-role-badge"]');
await expect(updatedRoleBadge).toHaveText('Owner');
});
});
test.describe('Team Ownership Transfer', () => {
test('owner can transfer ownership to another team member', async ({
page,
}) => {
// Setup team with an owner member (required for ownership transfer)
const { teamAccounts, ownerEmail, memberEmail } = await setupTeamWithMember(
page,
'owner',
);
// Transfer ownership to the member
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
// Wait for the page to fully load after the transfer
await page.waitForTimeout(1000);
// Verify the transfer was successful by checking if the primary owner badge
// is now on the new owner's row
const memberRow = page.getByRole('row', { name: memberEmail });
// Check for the primary owner badge on the member's row
await expect(memberRow.locator('text=Primary Owner')).toBeVisible({
timeout: 5000,
});
// The original owner should no longer have the primary owner badge
const ownerRow = page.getByRole('row', { name: ownerEmail.split('@')[0] });
await expect(ownerRow.locator('text=Primary Owner')).not.toBeVisible();
});
});

View File

@@ -8,6 +8,7 @@ export class Mailbox {
email: string,
params: {
deleteAfter: boolean;
subject?: string;
},
) {
const mailbox = email.split('@')[0];
@@ -18,13 +19,17 @@ export class Mailbox {
throw new Error('Invalid email');
}
const json = await this.getInviteEmail(mailbox, params);
const json = await this.getEmail(mailbox, params);
if (!json?.body) {
throw new Error('Email body was not found');
}
console.log('Email found');
console.log(`Email found for ${email}`, {
id: json.id,
subject: json.subject,
date: json.date,
});
const html = (json.body as { html: string }).html;
const el = parse(html);
@@ -40,10 +45,49 @@ export class Mailbox {
return this.page.goto(linkHref);
}
async getInviteEmail(
/**
* Retrieves an OTP code from an email
* @param email The email address to check for the OTP
* @param deleteAfter Whether to delete the email after retrieving the OTP
* @returns The OTP code
*/
async getOtpFromEmail(email: string, deleteAfter: boolean = true) {
const mailbox = email.split('@')[0];
console.log(`Retrieving OTP from mailbox ${email} ...`);
if (!mailbox) {
throw new Error('Invalid email');
}
const json = await this.getEmail(mailbox, {
deleteAfter,
subject: `One-time password for Makerkit`,
});
if (!json?.body) {
throw new Error('Email body was not found');
}
const html = (json.body as { html: string }).html;
const text = html.match(
new RegExp(`Your one-time password is: (\\d{6})`),
)?.[1];
if (text) {
console.log(`OTP code found in text: ${text}`);
return text;
}
throw new Error('Could not find OTP code in email');
}
async getEmail(
mailbox: string,
params: {
deleteAfter: boolean;
subject?: string;
},
) {
const url = `http://127.0.0.1:54324/api/v1/mailbox/${mailbox}`;
@@ -54,13 +98,34 @@ export class Mailbox {
throw new Error(`Failed to fetch emails: ${response.statusText}`);
}
const json = (await response.json()) as Array<{ id: string }>;
const json = (await response.json()) as Array<{
id: string;
subject: string;
}>;
if (!json || !json.length) {
console.log(`No emails found for mailbox ${mailbox}`);
return;
}
const messageId = json[0]?.id;
const message = params.subject
? (() => {
const filtered = json.filter(
(item) => item.subject === params.subject,
);
console.log(
`Found ${filtered.length} emails with subject ${params.subject}`,
);
return filtered[filtered.length - 1];
})()
: json[0];
console.log(`Message: ${JSON.stringify(message)}`);
const messageId = message?.id;
const messageUrl = `${url}/${messageId}`;
const messageResponse = await fetch(messageUrl);

View File

@@ -0,0 +1,63 @@
import { Page, expect } from '@playwright/test';
import { Mailbox } from './mailbox';
export class OtpPo {
private readonly page: Page;
private readonly mailbox: Mailbox;
constructor(page: Page) {
this.page = page;
this.mailbox = new Mailbox(page);
}
/**
* Completes the OTP verification process
* @param email The email address to send the OTP to
*/
async completeOtpVerification(email: string) {
// Click the "Send Verification Code" button
await this.page.click('[data-test="otp-send-verification-button"]');
// wait for the OTP to be sent
await this.page.waitForTimeout(500);
await expect(async () => {
// Get the OTP code from the email
const otpCode = await this.getOtpCodeFromEmail(email);
expect(otpCode).not.toBeNull();
// Enter the OTP code
await this.enterOtpCode(otpCode);
}).toPass();
// Click the "Verify Code" button
await this.page.click('[data-test="otp-verify-button"]');
}
/**
* Retrieves the OTP code from an email
* @param email The email address to check for the OTP
* @returns The OTP code
*/
async getOtpCodeFromEmail(email: string) {
// Get the OTP from the email
const otpCode = await this.mailbox.getOtpFromEmail(email);
if (!otpCode) {
throw new Error('Failed to retrieve OTP code from email');
}
return otpCode;
}
/**
* Enters the OTP code into the input fields
* @param otpCode The 6-digit OTP code
*/
async enterOtpCode(otpCode: string) {
console.log(`Entering OTP code: ${otpCode}`);
await this.page.fill('[data-input-otp]', otpCode);
}
}