diff --git a/packages/tests-e2e/src/connect/models/BaseModel.ts b/packages/tests-e2e/src/connect/models/BaseModel.ts index dddc3aa90..362c8b2fc 100644 --- a/packages/tests-e2e/src/connect/models/BaseModel.ts +++ b/packages/tests-e2e/src/connect/models/BaseModel.ts @@ -10,6 +10,7 @@ import { HomeModel } from './HomeModel'; import { LoginModel } from './LoginModel'; import { PasskeyListModel } from './PasskeyListModel'; import { SignupModel } from './SignupModel'; +import { StorageModel } from './StorageModel'; import { WebhookModel } from './WebhookModel'; export class BaseModel { @@ -22,6 +23,7 @@ export class BaseModel { home: HomeModel; passkeyList: PasskeyListModel; webhook: WebhookModel; + storage: StorageModel; email = ''; constructor(page: Page, authenticator: VirtualAuthenticator, blocker: NetworkRequestBlocker) { @@ -34,10 +36,7 @@ export class BaseModel { this.home = new HomeModel(page); this.passkeyList = new PasskeyListModel(page, authenticator); this.webhook = new WebhookModel(page); - } - - loadInvitationToken() { - return this.page.goto('/login?invitationToken=inv-token-correct'); + this.storage = new StorageModel(page); } loadSignup() { @@ -74,9 +73,4 @@ export class BaseModel { } } } - - async clearLocalStorageAndCookies() { - await this.page.evaluate(() => localStorage.clear()); - await this.page.context().clearCookies(); - } } diff --git a/packages/tests-e2e/src/connect/models/PasskeyListModel.ts b/packages/tests-e2e/src/connect/models/PasskeyListModel.ts index acbc2b1c5..7644268f4 100644 --- a/packages/tests-e2e/src/connect/models/PasskeyListModel.ts +++ b/packages/tests-e2e/src/connect/models/PasskeyListModel.ts @@ -38,6 +38,10 @@ export class PasskeyListModel { } } + checkCreatePasskeyDisabled() { + return expect(this.page.getByRole('button', { name: 'Add a passkey' })).not.toBeVisible(); + } + confirmModal() { return this.page.getByRole('button', { name: 'Okay' }).click(); } diff --git a/packages/tests-e2e/src/connect/models/StorageModel.ts b/packages/tests-e2e/src/connect/models/StorageModel.ts new file mode 100644 index 000000000..f28ba1971 --- /dev/null +++ b/packages/tests-e2e/src/connect/models/StorageModel.ts @@ -0,0 +1,134 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; + +import { ScreenNames } from '../utils/Constants'; +import { expectScreen } from '../utils/ExpectScreen'; + +export class StorageModel { + page: Page; + + constructor(page: Page) { + this.page = page; + } + + async loadInvitationToken() { + await this.page.goto('/login?invitationToken=inv-token-correct'); + await expectScreen(this.page, ScreenNames.InitLogin); + } + + async checkInvitationToken() { + const cboConnectInvitationRaw = await this.page.evaluate(k => localStorage.getItem(k), 'cbo_connect_invitation'); + if (!cboConnectInvitationRaw) { + throw new Error('cbo_connect_invitation not found in local storage'); + } + const cboConnectInvitation = JSON.parse(cboConnectInvitationRaw); + expect(cboConnectInvitation.token).toEqual('inv-token-correct'); + } + + deleteInvitationToken() { + return this.page.evaluate(k => localStorage.removeItem(k), 'cbo_connect_invitation'); + } + + async getProcessID(): Promise { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + expect(cboConnectProcess.id).not.toBeNull(); + + return cboConnectProcess.id; + } + + async checkProcessID(expectedID: string) { + expect(await this.getProcessID()).toEqual(expectedID); + } + + async getLoginLifetime(): Promise { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + return cboConnectProcess.loginData.expiresAt; + } + + async setLoginLifetime(newLifetime: number) { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + cboConnectProcess.loginData.expiresAt = newLifetime; + await this.page.evaluate(({ k, p }) => localStorage.setItem(k, JSON.stringify(p)), { + k: key, + p: cboConnectProcess, + }); + } + + async getAppendLifetime(): Promise { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + return cboConnectProcess.appendData.expiresAt; + } + + async setAppendLifetime(newLifetime: number) { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + cboConnectProcess.appendData.expiresAt = newLifetime; + await this.page.evaluate(({ k, p }) => localStorage.setItem(k, JSON.stringify(p)), { + k: key, + p: cboConnectProcess, + }); + } + + async getManageLifetime(): Promise { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + return cboConnectProcess.manageData.expiresAt; + } + + async setManageLifetime(newLifetime: number) { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + cboConnectProcess.manageData.expiresAt = newLifetime; + await this.page.evaluate(({ k, p }) => localStorage.setItem(k, JSON.stringify(p)), { + k: key, + p: cboConnectProcess, + }); + } + + async checkLoginDataDeleted() { + const key = `cbo_connect_process-${process.env.PLAYWRIGHT_CONNECT_PROJECT_ID}`; + const cboConnectProcessRaw = await this.page.evaluate(k => localStorage.getItem(k), key); + if (!cboConnectProcessRaw) { + throw new Error('cbo_connect_process not found in local storage'); + } + const cboConnectProcess = JSON.parse(cboConnectProcessRaw); + expect(cboConnectProcess.loginData).toBeNull(); + } + + async clearLocalStorageAndCookies() { + await this.page.evaluate(() => localStorage.clear()); + await this.page.context().clearCookies(); + } +} diff --git a/packages/tests-e2e/src/connect/scenarios/append.spec.ts b/packages/tests-e2e/src/connect/scenarios/append.spec.ts index f726181ae..b4da3cdb5 100644 --- a/packages/tests-e2e/src/connect/scenarios/append.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/append.spec.ts @@ -1,3 +1,5 @@ +import { expect } from '@playwright/test'; + import { test } from '../fixtures/BaseTest'; import { password, ScreenNames, WebhookTypes } from '../utils/Constants'; import { loadPasskeyAppend, setupNetworkBlocker, setupUser, setupVirtualAuthenticator, setupWebhooks } from './hooks'; @@ -63,4 +65,18 @@ test.describe('skip append component', () => { await model.login.submitFallbackCredentials(model.email, password, true); await model.expectScreen(ScreenNames.Home); }); + + test('expired append lifetime leads to skipped append screen', async ({ model }) => { + await model.home.logout(); + await model.expectScreen(ScreenNames.InitLogin); + + await model.login.submitEmail(model.email, false); + await model.expectScreen(ScreenNames.InitLoginFallback); + expect(await model.storage.getAppendLifetime()).toBeGreaterThan(Math.floor(Date.now() / 1000)); + + await model.storage.setAppendLifetime(Math.floor(Date.now() / 1000) - 1); + await model.storage.deleteInvitationToken(); + await model.login.submitFallbackCredentials(model.email, password, true); + await model.expectScreen(ScreenNames.Home); + }); }); diff --git a/packages/tests-e2e/src/connect/scenarios/hooks.ts b/packages/tests-e2e/src/connect/scenarios/hooks.ts index 92e2cbfa1..b6e47a19e 100644 --- a/packages/tests-e2e/src/connect/scenarios/hooks.ts +++ b/packages/tests-e2e/src/connect/scenarios/hooks.ts @@ -7,7 +7,8 @@ import type { } from '@playwright/test'; import type { BaseModel } from '../models/BaseModel'; -import { password, ScreenNames, WebhookTypes } from '../utils/Constants'; +import type { WebhookTypes } from '../utils/Constants'; +import { password, ScreenNames } from '../utils/Constants'; export function setupVirtualAuthenticator( test: TestType< @@ -58,7 +59,7 @@ export function loadInvitationToken( >, ) { test.beforeEach(async ({ model }) => { - await model.loadInvitationToken(); + await model.storage.loadInvitationToken(); }); } @@ -72,7 +73,7 @@ export function setupUser( ) { test.beforeEach(async ({ model }) => { if (invited) { - await model.loadInvitationToken(); + await model.storage.loadInvitationToken(); } await model.loadSignup(); await model.expectScreen(ScreenNames.InitSignup); diff --git a/packages/tests-e2e/src/connect/scenarios/login.spec.ts b/packages/tests-e2e/src/connect/scenarios/login.spec.ts index 3598a04d4..b27092e9d 100644 --- a/packages/tests-e2e/src/connect/scenarios/login.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/login.spec.ts @@ -1,3 +1,5 @@ +import { expect } from '@playwright/test'; + import { test } from '../fixtures/BaseTest'; import { ErrorTexts, password, ScreenNames, WebhookTypes } from '../utils/Constants'; import { loadInvitationToken, setupNetworkBlocker, setupUser, setupVirtualAuthenticator, setupWebhooks } from './hooks'; @@ -80,8 +82,8 @@ test.describe('login component (with invitation token, with passkeys)', () => { await model.expectScreen(ScreenNames.InitLoginOneTap); await model.authenticator.clearCredentials(); - await model.clearLocalStorageAndCookies(); - await model.loadInvitationToken(); + await model.storage.clearLocalStorageAndCookies(); + await model.storage.loadInvitationToken(); await model.expectScreen(ScreenNames.InitLogin); await model.login.submitEmail(model.email, false); @@ -129,6 +131,19 @@ test.describe('login component (with invitation token, with passkeys)', () => { await model.expectScreen(ScreenNames.InitLoginFallback); await model.expectError(ErrorTexts.DeletedPasskeyUsed); }); + + // TODO: unskip when loginData reset feature is fixed + test.skip('successful login deletes loginData', async ({ model }) => { + await model.home.logout(); + await model.expectScreen(ScreenNames.InitLoginOneTap); + + await model.login.removePasskeyButton(); + await model.expectScreen(ScreenNames.InitLogin); + + await model.login.submitEmail(model.email, true); + await model.expectScreen(ScreenNames.Home); + await model.storage.checkLoginDataDeleted(); + }); }); test.describe('login component (without user)', () => { @@ -155,6 +170,29 @@ test.describe('login component (without user)', () => { test('Corbado FAPI unavailable', async ({ model }) => { await model.blocker.blockCorbadoFAPI(); + await model.loadLogin(); + // It seems that the InitLogin page is now cached so that email needs to be submitted before reaching the InitLoginFallback screen. + await model.login.submitEmail('dummy-email@corbado.com', false); + await model.expectScreen(ScreenNames.InitLoginFallback); + }); + + test('invitation token and process id persists after page refresh', async ({ model }) => { + await model.expectScreen(ScreenNames.InitLogin); + await model.storage.checkInvitationToken(); + const processId = await model.storage.getProcessID(); + + await model.loadLogin(); + await model.expectScreen(ScreenNames.InitLogin); + await model.storage.checkInvitationToken(); + await model.storage.checkProcessID(processId); + }); + + test('expired login lifetime leads to fallback screen', async ({ model }) => { + await model.expectScreen(ScreenNames.InitLogin); + expect(await model.storage.getLoginLifetime()).toBeGreaterThan(Math.floor(Date.now() / 1000)); + + await model.storage.setLoginLifetime(Math.floor(Date.now() / 1000) - 1); + await model.storage.deleteInvitationToken(); await model.loadLogin(); await model.expectScreen(ScreenNames.InitLoginFallback); }); diff --git a/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts b/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts index db20076f0..e0c29aad3 100644 --- a/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts +++ b/packages/tests-e2e/src/connect/scenarios/passkey-list.spec.ts @@ -120,4 +120,21 @@ test.describe('skip passkey-list component', () => { await model.expectScreen(ScreenNames.PasskeyList); await model.expectError(ErrorTexts.PasskeyFetchFail); }); + + test('expired manage lifetime leads to skipped passkey-list screen', async ({ model }) => { + await model.home.gotoPasskeyList(); + await model.expectScreen(ScreenNames.PasskeyList); + await model.passkeyList.expectPasskeys(1); + await model.loadHome(); + await model.expectScreen(ScreenNames.Home); + expect(await model.storage.getManageLifetime()).toBeGreaterThan(Math.floor(Date.now() / 1000)); + + await model.storage.setManageLifetime(Math.floor(Date.now() / 1000) - 1); + await model.storage.deleteInvitationToken(); + + await model.home.gotoPasskeyList(); + await model.expectScreen(ScreenNames.PasskeyList); + await model.passkeyList.expectPasskeys(1); + await model.passkeyList.checkCreatePasskeyDisabled(); + }); });