Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions test/e2e_tests/backend/brigRepository.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ export class BrigRepositoryE2E {
);
}

public async unlockAppLock(teamId: string) {
await this.axiosInstance.put(
`i/teams/${teamId}/features/appLock/unlocked`,
{},
{
headers: {
Authorization: `Basic ${BASIC_AUTH}`,
},
},
);
}

public async enableChannelsFeature(teamId: string) {
await this.axiosInstance.patch(
`i/teams/${teamId}/features/channels`,
Expand All @@ -122,6 +134,7 @@ export class BrigRepositoryE2E {
},
);
}

public async enableMLSFeature(teamId: string) {
await this.axiosInstance.patch(
`i/teams/${teamId}/features/mls`,
Expand All @@ -143,6 +156,24 @@ export class BrigRepositoryE2E {
);
}

public async toggleAppLock(teamId: string, status: 'enabled' | 'disabled', enforceAppLock: boolean = true) {
await this.axiosInstance.patch(
`i/teams/${teamId}/features/appLock`,
{
status: status,
config: {
enforceAppLock: enforceAppLock,
inactivityTimeoutSecs: 30,
},
},
{
headers: {
Authorization: `Basic ${BASIC_AUTH}`,
},
},
);
}

public async unlockCellsFeature(teamId: string) {
await this.axiosInstance.put(
`i/teams/${teamId}/features/cells/unlocked`,
Expand Down
29 changes: 27 additions & 2 deletions test/e2e_tests/pageManager/webapp/modals/appLock.modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,37 @@

import {Locator, Page} from '@playwright/test';

import {selectByDataAttribute} from 'test/e2e_tests/utils/selector.util';

export class AppLockModal {
readonly page: Page;

readonly lockPasscodeInput: Locator;
readonly unlockPasscodeInput: Locator;
readonly appLockWipeInput: Locator;
readonly appLockModal: Locator;
readonly appLockActionButton: Locator;
readonly appLockModalHeader: Locator;
readonly appLockModalText: Locator;
readonly loadingBar: Locator;
readonly errorMessage: Locator;
readonly forgotPassphraseButton: Locator;
readonly wipeDatabaseButton: Locator;

constructor(page: Page) {
this.page = page;

this.appLockModal = page.locator("[data-uie-name='applock-modal']");
this.lockPasscodeInput = page.locator("[data-uie-name='applock-modal'] [data-uie-name='input-applock-set-a']");
this.unlockPasscodeInput = page.locator("[data-uie-name='applock-modal'] [data-uie-name='input-applock-unlock']");
this.appLockActionButton = page.locator("[data-uie-name='applock-modal'] [data-uie-name='do-action']");
this.appLockWipeInput = this.appLockModal.locator(selectByDataAttribute('input-applock-wipe'));
this.appLockActionButton = this.appLockModal.locator("[data-uie-name='do-action']");
this.appLockModalHeader = page.locator("[data-uie-name='applock-modal'] [data-uie-name='applock-modal-header']");
this.appLockModalText = page.locator("[data-uie-name='applock-modal'] [data-uie-name='label-applock-unlock-text']");
this.loadingBar = page.locator('.progress-bar');
this.errorMessage = this.appLockModal.locator(selectByDataAttribute('label-applock-unlock-error'));
this.forgotPassphraseButton = this.appLockModal.locator(selectByDataAttribute('go-forgot-passphrase'));
this.wipeDatabaseButton = this.appLockModal.locator(selectByDataAttribute('go-wipe-database'));
}

async setPasscode(passcode: string) {
Expand All @@ -52,6 +62,11 @@ export class AppLockModal {
await this.appLockActionButton.click();
}

async inputUserPassword(password: string) {
await this.appLockWipeInput.fill(password);
await this.appLockActionButton.click();
}

async isVisible() {
await this.appLockModal.waitFor({state: 'visible'});
return await this.appLockModal.isVisible();
Expand All @@ -67,6 +82,16 @@ export class AppLockModal {
}

async getAppLockModalHeader() {
return (await this.appLockModalHeader.textContent()) ?? '';
return await this.appLockModalHeader.textContent();
}

async clickForgotPassphrase() {
await this.forgotPassphraseButton.click();
}
async clickReset() {
await this.appLockActionButton.click();
}
async clickWipeDB() {
await this.wipeDatabaseButton.click();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class DataShareConsentModal {
}

async isModalPresent() {
return this.modal.isVisible();
return this.modalTitle.isVisible();
}

async getModalTitle() {
Expand Down
6 changes: 4 additions & 2 deletions test/e2e_tests/pageManager/webapp/pages/account.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class AccountPage {
readonly page: Page;

readonly sendUsageDataCheckbox: Locator;
readonly appLockCheckboxLabel: Locator;
readonly appLockCheckbox: Locator;
readonly deleteAccountButton: Locator;
readonly backUpButton: Locator;
Expand All @@ -42,7 +43,8 @@ export class AccountPage {
this.page = page;

this.sendUsageDataCheckbox = page.locator("[data-uie-name='status-preference-telemetry']+label");
this.appLockCheckbox = page.locator("[data-uie-name='status-preference-applock']+label");
this.appLockCheckboxLabel = page.locator("[data-uie-name='status-preference-applock']+label");
this.appLockCheckbox = page.locator("[data-uie-name='status-preference-applock']");
this.deleteAccountButton = page.locator(selectByDataAttribute('go-delete-account'));
this.backUpButton = page.locator(selectByDataAttribute('do-backup-export'));
this.backupFileInput = page.locator(selectByDataAttribute('input-import-file'));
Expand Down Expand Up @@ -89,7 +91,7 @@ export class AccountPage {
}

async toggleAppLock() {
await this.appLockCheckbox.click();
await this.appLockCheckboxLabel.click();
}

async clickLogoutButton() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {selectByDataAttribute} from 'test/e2e_tests/utils/selector.util';

export class HistoryInfoPage {
readonly page: Page;
private readonly continueButton: Locator;
readonly continueButton: Locator;

constructor(page: Page) {
this.page = page;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import {getUser, User} from 'test/e2e_tests/data/user';
import {setupBasicTestScenario} from 'test/e2e_tests/utils/setup.utli';
import {setupBasicTestScenario} from 'test/e2e_tests/utils/setup.util';
import {tearDownAll} from 'test/e2e_tests/utils/tearDown.util';
import {loginUser} from 'test/e2e_tests/utils/userActions';

Expand Down
174 changes: 174 additions & 0 deletions test/e2e_tests/specs/AppLock/AppLock.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {getUser} from 'test/e2e_tests/data/user';
import {checkAnyIndexedDBExists} from 'test/e2e_tests/utils/indexedDB.util';
import {completeLogin, setupBasicTestScenario} from 'test/e2e_tests/utils/setup.util';
import {tearDownAll} from 'test/e2e_tests/utils/tearDown.util';
import {handleAppLockState} from 'test/e2e_tests/utils/userActions';

import {test, expect} from '../../test.fixtures';

test.describe('AppLock', () => {
test.slow();

let owner = getUser();
const members = Array.from({length: 2}, () => getUser());
const [memberA] = members;
const teamName = 'AppLock';
const appLockPassCode = '1a3!567N4';

test.beforeAll(async ({api}) => {
const user = await setupBasicTestScenario(api, members, owner, teamName);
owner = {...owner, ...user};
await api.brig.toggleAppLock(owner.teamId, 'enabled', true);
});

test(
'I want to see app lock setup modal on login after app lock has been enforced for the team',
{tag: ['@TC-2744', '@TC-2740', '@regression']},
async ({pageManager}) => {
const {modals} = pageManager.webapp;

await completeLogin(pageManager, memberA);
await expect(modals.appLock().isVisible()).toBeTruthy();

await test.step('Web: I should not be able to close app lock setup modal if app lock is enforced', async () => {
// click outside the modal
const page = await pageManager.getPage();
await page.mouse.click(200, 350);
// check if the modal still there
expect(await modals.appLock().isVisible()).toBeTruthy();
});
},
);

test(
'Web: App should not lock if I switch back to webapp tab in time (during inactivity timeout)',
{tag: ['@TC-2752', '@TC-2753', '@regression']},
async ({pageManager, browser}) => {
const {modals} = pageManager.webapp;
const webappPageA = await pageManager.getPage();

await completeLogin(pageManager, memberA);
await handleAppLockState(pageManager, appLockPassCode);
const unrelatedPage = await browser.newPage();
await unrelatedPage.goto('about:blank');
await unrelatedPage.bringToFront();
await unrelatedPage.waitForTimeout(2_000); // open be only 2 sec in the other tab
await webappPageA.bringToFront();

await expect(modals.appLock().appLockModalHeader).not.toBeVisible();

await test.step('Web: I want the app to lock when I switch back to webapp tab after inactivity timeout expired', async () => {
await unrelatedPage.goto('about:blank');
await unrelatedPage.bringToFront();
await unrelatedPage.waitForTimeout(31_000);
await webappPageA.bringToFront();
await expect(modals.appLock().appLockModalHeader).toBeVisible();
});
},
);

test(
'Web: I want to unlock the app with passphrase after login',
{tag: ['@TC-2754', '@TC-2755', '@TC-2758', '@TC-2763', '@regression']},
async ({pageManager}) => {
const {modals, pages} = pageManager.webapp;

await completeLogin(pageManager, memberA);

await test.step('Web: I want the app to automatically lock after refreshing the page', async () => {
await handleAppLockState(pageManager, appLockPassCode);
await pageManager.refreshPage();

expect(await modals.appLock().isVisible()).toBeTruthy();
});

await test.step('Web: I should not be able to unlock the app with wrong passphrase', async () => {
await handleAppLockState(pageManager, 'wrongCredentials');
await expect(modals.appLock().errorMessage).toHaveText('Wrong passcode');
});

await test.step('Web: I should not be able to wipe database with wrong account password', async () => {
await modals.appLock().clickForgotPassphrase();
await modals.appLock().clickWipeDB();
await modals.appLock().clickReset();
await modals.appLock().inputUserPassword('wrong password');

expect(await checkAnyIndexedDBExists(await pageManager.getPage())).toBeTruthy();
});

await test.step('I want to wipe database when I forgot my app lock passphrase', async () => {
await modals.appLock().inputUserPassword(memberA.password);

await expect(pages.singleSignOn().ssoCodeEmailInput).toBeVisible();
expect(await checkAnyIndexedDBExists(await pageManager.getPage())).toBeFalsy();
});
},
);

test(
'I should not be able to switch off app lock if it is enforced for the team',
{tag: ['@TC-2770', '@TC-2767', '@regression']},
async ({pageManager}) => {
const {components, pages} = pageManager.webapp;
await completeLogin(pageManager, memberA);
await handleAppLockState(pageManager, appLockPassCode);
await components.conversationSidebar().clickPreferencesButton();
const page = await pageManager.getPage();

await expect(pages.account().appLockCheckbox).toBeDisabled();
// check here string

await expect(
page.getByText('Lock Wire after 30 seconds in the background. Unlock with Touch ID or enter your passcode.'),
).toHaveCount(1);
},
);

test('I want to switch off app lock', {tag: ['@TC-2771', '@TC-2772', '@regression']}, async ({pageManager, api}) => {
await api.brig.toggleAppLock(owner.teamId, 'enabled', false);

const {components, pages, modals} = pageManager.webapp;

await completeLogin(pageManager, memberA);
await components.conversationSidebar().clickPreferencesButton();
await pages.account().toggleAppLock();
await handleAppLockState(pageManager, appLockPassCode);

await pages.account().toggleAppLock();

await modals.removeMember().clickConfirm();
await expect(pages.account().appLockCheckbox).not.toBeChecked();
});

test.skip(
'Web: Verify inactivity timeout can be set if app lock is not enforced on a team level',
{tag: ['@TC-2772', '@regression']},
async ({pageManager, api}) => {
await completeLogin(pageManager, memberA);
// not implemented
},
);

test.afterAll(async ({api}) => {
await tearDownAll(api);
});
});
48 changes: 48 additions & 0 deletions test/e2e_tests/utils/indexedDB.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Wire
* Copyright (C) 2025 Wire Swiss GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see http://www.gnu.org/licenses/.
*
*/

import {Page} from '@playwright/test';
/**
* Checks if ANY IndexedDB databases exist in the current browser context.
* This is used when database names are randomly generated (e.g., UUIDs).
* * @param page The Playwright Page object.
* @returns A Promise that resolves to true if ONE or MORE DBs exist, otherwise false.
*/
export const checkAnyIndexedDBExists = async (page: Page): Promise<boolean> => {
// The logic is executed directly inside the browser environment
return page.evaluate(async () => {
// Fallback for older browsers (though unlikely in modern Playwright tests)
if (!indexedDB.databases) {
console.error('Browser does not support indexedDB.databases(). Cannot check for existence.');
return false;
}

try {
// Retrieve a list of all existing databases for the current origin
const dbs = await indexedDB.databases();

// Return true if the list of databases is not empty
return dbs.length > 0;
} catch (error) {
// Handle potential errors (e.g., security restrictions)
console.error('Error retrieving IndexedDB list:', error);
return false;
}
});
};
Loading
Loading