1
0
mirror of https://github.com/pocket-id/pocket-id.git synced 2026-02-04 16:49:42 +00:00

refactor: move e2e tests to root of repository

This commit is contained in:
Elias Schneider
2025-05-22 22:02:44 +02:00
parent 5fa15f6098
commit 966a566ade
33 changed files with 1476 additions and 1153 deletions

BIN
tests/assets/clouds.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

14
tests/auth.setup.ts Normal file
View File

@@ -0,0 +1,14 @@
import { test as setup } from '@playwright/test';
import authUtil from './utils/auth.util';
import { cleanupBackend } from './utils/cleanup.util';
const authFile = 'tests/.auth/user.json';
setup('authenticate', async ({ page }) => {
await cleanupBackend();
await authUtil.authenticate(page);
await page.waitForURL('/settings/account');
await page.context().storageState({ path: authFile });
});

86
tests/data.ts Normal file
View File

@@ -0,0 +1,86 @@
export const users = {
tim: {
id: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
firstname: 'Tim',
lastname: 'Cook',
email: 'tim.cook@test.com',
username: 'tim'
},
craig: {
id: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
firstname: 'Craig',
lastname: 'Federighi',
email: 'craig.federighi@test.com',
username: 'craig'
},
steve: {
firstname: 'Steve',
lastname: 'Jobs',
email: 'steve.jobs@test.com',
username: 'steve'
}
};
export const oidcClients = {
nextcloud: {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
},
immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
}
};
export const userGroups = {
developers: {
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
friendlyName: 'Developers',
name: 'developers'
},
designers: {
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
friendlyName: 'Designers',
name: 'designers'
},
humanResources: {
friendlyName: 'Human Resources',
name: 'human_resources'
}
};
export const oneTimeAccessTokens = [
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
{ token: 'YCGDtftvsvYWiXd0', expired: true }
];
export const apiKeys = [
{
id: '5f1fa856-c164-4295-961e-175a0d22d725',
key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20',
name: 'Test API Key'
}
];
export const refreshTokens = [
{
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
clientId: oidcClients.nextcloud.id,
expired: false
},
{
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
clientId: oidcClients.nextcloud.id,
expired: true
}
];

86
tests/package-lock.json generated Normal file
View File

@@ -0,0 +1,86 @@
{
"name": "tests",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@playwright/test": "^1.52.0",
"jose": "^6.0.11"
}
},
"node_modules/@playwright/test": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
"integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.52.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/jose": {
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/playwright": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
"integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
"integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

7
tests/package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"type": "module",
"devDependencies": {
"@playwright/test": "^1.52.0",
"jose": "^6.0.11"
}
}

View File

@@ -0,0 +1,30 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
outputDir: './.output',
timeout: 10000,
testDir: './specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI
? [['html', { outputFolder: './.report' }], ['github']]
: [['line'], ['html', { open: 'never', outputFolder: './.report' }]],
use: {
baseURL: process.env.APP_URL ?? 'http://localhost:1411',
video: 'retain-on-failure',
trace: 'on-first-retry'
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: './.auth/user.json' },
dependencies: ['setup']
}
]
});

View File

@@ -0,0 +1,126 @@
import test, { expect } from "@playwright/test";
import { users } from "../data";
import authUtil from "../utils/auth.util";
import { cleanupBackend } from "../utils/cleanup.util";
import passkeyUtil from "../utils/passkey.util";
test.beforeEach(cleanupBackend);
test("Update account details", async ({ page }) => {
await page.goto("/settings/account");
await page.getByLabel("First name").fill("Timothy");
await page.getByLabel("Last name").fill("Apple");
await page.getByLabel("Email").fill("timothy.apple@test.com");
await page.getByLabel("Username").fill("timothy");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Account details updated successfully"
);
});
test("Update account details fails with already taken email", async ({
page,
}) => {
await page.goto("/settings/account");
await page.getByLabel("Email").fill(users.craig.email);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="error"]')).toHaveText(
"Email is already in use"
);
});
test("Update account details fails with already taken username", async ({
page,
}) => {
await page.goto("/settings/account");
await page.getByLabel("Username").fill(users.craig.username);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="error"]')).toHaveText(
"Username is already in use"
);
});
test("Change Locale", async ({ page }) => {
await page.goto("/settings/account");
await page.getByLabel("Select Locale").click();
await page.getByRole("option", { name: "Nederlands" }).click();
// Check if th language heading now says 'Taal' instead of 'Language'
await expect(page.getByRole("heading", { name: "Taal" })).toBeVisible();
// Clear all cookies and sign in again to check if the language is still set to Dutch
await page.context().clearCookies();
await authUtil.authenticate(page);
await expect(page.getByRole("heading", { name: "Taal" })).toBeVisible();
});
test("Add passkey to an account", async ({ page }) => {
await page.goto("/settings/account");
await (await passkeyUtil.init(page)).addPasskey("timNew");
await page.click('button:text("Add Passkey")');
await page.getByLabel("Name", { exact: true }).fill("Test Passkey");
await page
.getByLabel("Name Passkey")
.getByRole("button", { name: "Save" })
.click();
await expect(page.getByText("Test Passkey")).toBeVisible();
});
test("Rename passkey", async ({ page }) => {
await page.goto("/settings/account");
await page.getByLabel("Rename").first().click();
await page.getByLabel("Name", { exact: true }).fill("Renamed Passkey");
await page
.getByLabel("Name Passkey")
.getByRole("button", { name: "Save" })
.click();
await expect(page.getByText("Renamed Passkey")).toBeVisible();
});
test("Delete passkey from account", async ({ page }) => {
await page.goto("/settings/account");
await page.getByLabel("Delete").first().click();
await page.getByText("Delete", { exact: true }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Passkey deleted successfully"
);
});
test("Generate own one time access token as non admin", async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto("/login");
await (await passkeyUtil.init(page)).addPasskey("craig");
await page.getByRole("button", { name: "Authenticate" }).click();
await page.waitForURL("/settings/account");
await page.getByRole("button", { name: "Create" }).click();
const link = await page.getByTestId("login-code-link").textContent();
await context.clearCookies();
await page.goto(link!);
await page.waitForURL("/settings/account");
});

View File

@@ -0,0 +1,76 @@
// frontend/tests/api-key.spec.ts
import { expect, test } from "@playwright/test";
import { apiKeys } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
test.describe("API Key Management", () => {
test.beforeEach(async ({ page }) => {
await cleanupBackend();
await page.goto("/settings/admin/api-keys");
});
test("Create new API key", async ({ page }) => {
await page.getByRole("button", { name: "Add API Key" }).click();
// Fill out the API key form
const name = "New Test API Key";
await page.getByLabel("Name").fill(name);
await page.getByLabel("Description").fill("Created by automated test");
// Choose the date
const currentDate = new Date();
await page.getByLabel("Expires At").click();
await page.getByLabel("Select year").click();
// Select the next year
await page.getByText((currentDate.getFullYear() + 1).toString()).click();
// Select the first day of the month
await page
.getByRole("button", { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
.first()
.click();
// Submit the form
await page.getByRole("button", { name: "Save" }).click();
// Verify the success dialog appears
await expect(
page.getByRole("heading", { name: "API Key Created" })
).toBeVisible();
// Verify the key details are shown
await expect(page.getByRole("cell", { name })).toBeVisible();
// Verify the token is displayed (should be 32 characters)
const token = await page.locator(".font-mono").textContent();
expect(token?.length).toBe(32);
// Close the dialog
await page.getByRole("button", { name: "Close" }).click();
await page.reload();
// Verify the key appears in the list
await expect(page.getByRole("cell", { name }).first()).toContainText(name);
});
test("Revoke API key", async ({ page }) => {
const apiKey = apiKeys[0];
await page
.getByRole("row", { name: apiKey.name })
.getByRole("button", { name: "Revoke" })
.click();
await page.getByText("Revoke", { exact: true }).click();
// Verify success message
await expect(page.locator('[data-type="success"]')).toHaveText(
"API key revoked successfully"
);
// Verify key is no longer in the list
await expect(
page.getByRole("cell", { name: apiKey.name })
).not.toBeVisible();
});
});

View File

@@ -0,0 +1,99 @@
import test, { expect } from "@playwright/test";
import { cleanupBackend } from "../utils/cleanup.util";
test.beforeEach(cleanupBackend);
test("Update general configuration", async ({ page }) => {
await page.goto("/settings/admin/application-configuration");
await page
.getByLabel("Application Name", { exact: true })
.fill("Updated Name");
await page.getByLabel("Session Duration").fill("30");
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Application configuration updated successfully"
);
await expect(page.getByTestId("application-name")).toHaveText("Updated Name");
await page.reload();
await expect(
page.getByLabel("Application Name", { exact: true })
).toHaveValue("Updated Name");
await expect(page.getByLabel("Session Duration")).toHaveValue("30");
});
test("Update email configuration", async ({ page }) => {
await page.goto("/settings/admin/application-configuration");
await page.getByRole("button", { name: "Expand card" }).nth(1).click();
await page.getByLabel("SMTP Host").fill("smtp.gmail.com");
await page.getByLabel("SMTP Port").fill("587");
await page.getByLabel("SMTP User").fill("test@gmail.com");
await page.getByLabel("SMTP Password").fill("password");
await page.getByLabel("SMTP From").fill("test@gmail.com");
await page.getByLabel("Email Login Notification").click();
await page.getByLabel("Email Login Code Requested by User").click();
await page.getByLabel("Email Login Code from Admin").click();
await page.getByLabel("API Key Expiration").click();
await page.getByRole("button", { name: "Save" }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Email configuration updated successfully"
);
await page.reload();
await expect(page.getByLabel("SMTP Host")).toHaveValue("smtp.gmail.com");
await expect(page.getByLabel("SMTP Port")).toHaveValue("587");
await expect(page.getByLabel("SMTP User")).toHaveValue("test@gmail.com");
await expect(page.getByLabel("SMTP Password")).toHaveValue("password");
await expect(page.getByLabel("SMTP From")).toHaveValue("test@gmail.com");
await expect(page.getByLabel("Email Login Notification")).toBeChecked();
await expect(
page.getByLabel("Email Login Code Requested by User")
).toBeChecked();
await expect(page.getByLabel("Email Login Code from Admin")).toBeChecked();
await expect(page.getByLabel("API Key Expiration")).toBeChecked();
});
test("Update application images", async ({ page }) => {
await page.goto("/settings/admin/application-configuration");
await page.getByRole("button", { name: "Expand card" }).nth(3).click();
await page
.getByLabel("Favicon")
.setInputFiles("assets/w3-schools-favicon.ico");
await page
.getByLabel("Light Mode Logo")
.setInputFiles("assets/pingvin-share-logo.png");
await page
.getByLabel("Dark Mode Logo")
.setInputFiles("assets/nextcloud-logo.png");
await page
.getByLabel("Background Image")
.setInputFiles("assets/clouds.jpg");
await page.getByRole("button", { name: "Save" }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Images updated successfully"
);
await page.request
.get("/api/application-configuration/favicon")
.then((res) => expect.soft(res.status()).toBe(200));
await page.request
.get("/api/application-configuration/logo?light=true")
.then((res) => expect.soft(res.status()).toBe(200));
await page.request
.get("/api/application-configuration/logo?light=false")
.then((res) => expect.soft(res.status()).toBe(200));
await page.request
.get("/api/application-configuration/background-image")
.then((res) => expect.soft(res.status()).toBe(200));
});

94
tests/specs/ldap.spec.ts Normal file
View File

@@ -0,0 +1,94 @@
import test, { expect } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(cleanupBackend);
test.describe('LDAP Integration', () => {
test('LDAP configuration is working properly', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await expect(page.getByLabel('LDAP URL')).toHaveValue(/ldap:\/\/.*/);
await expect(page.getByLabel('LDAP Base DN')).not.toBeEmpty();
await expect(page.getByLabel('User Unique Identifier Attribute')).not.toBeEmpty();
await expect(page.getByLabel('Username Attribute')).not.toBeEmpty();
await expect(page.getByLabel('User Mail Attribute')).not.toBeEmpty();
await expect(page.getByLabel('Group Name Attribute')).not.toBeEmpty();
const syncButton = page.getByRole('button', { name: 'Sync now' });
await syncButton.click();
await expect(page.locator('[data-type="success"]')).toHaveText('LDAP sync finished');
});
test('LDAP users are synced into PocketID', async ({ page }) => {
// Navigate to user management
await page.goto('/settings/admin/users');
// Verify the LDAP users exist
await expect(page.getByText('testuser1@pocket-id.org')).toBeVisible();
await expect(page.getByText('testuser2@pocket-id.org')).toBeVisible();
// Check LDAP user details
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify user source is LDAP
await expect(page.getByText('LDAP').first()).toBeVisible();
// Verify essential fields are filled
await expect(page.getByLabel('Username')).not.toBeEmpty();
await expect(page.getByLabel('Email')).not.toBeEmpty();
});
test('LDAP groups are synced into PocketID', async ({ page }) => {
// Navigate to user groups
await page.goto('/settings/admin/user-groups');
// Verify LDAP groups exist
await expect(page.getByRole('cell', { name: 'test_group' }).first()).toBeVisible();
await expect(page.getByRole('cell', { name: 'admin_group' }).first()).toBeVisible();
await page
.getByRole('row', { name: 'test_group' })
.locator('#bits-10')
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify group source is LDAP
await expect(page.getByText('LDAP').first()).toBeVisible();
});
test('LDAP users cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP user details
await page.goto('/settings/admin/users');
await page.waitForLoadState('networkidle');
await page.getByRole('row', { name: 'testuser1' }).getByRole('button').click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify key fields are disabled
const usernameInput = page.getByLabel('Username');
await expect(usernameInput).toBeDisabled();
});
test('LDAP groups cannot be modified in PocketID', async ({ page }) => {
// Navigate to LDAP group details
await page.goto('/settings/admin/user-groups');
await page.waitForLoadState('networkidle');
await page
.getByRole('row', { name: 'test_group' })
.locator('#bits-10')
.getByRole('button', { name: 'Toggle menu' })
.click();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Verify key fields are disabled
const nameInput = page.getByLabel('Name', { exact: true });
await expect(nameInput).toBeDisabled();
});
});

View File

@@ -0,0 +1,103 @@
import test, { expect } from "@playwright/test";
import { oidcClients } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
test.beforeEach(cleanupBackend);
test("Create OIDC client", async ({ page }) => {
await page.goto("/settings/admin/oidc-clients");
const oidcClient = oidcClients.pingvinShare;
await page.getByRole("button", { name: "Add OIDC Client" }).click();
await page.getByLabel("Name").fill(oidcClient.name);
await page.getByTestId("callback-url-1").fill(oidcClient.callbackUrl);
await page.getByRole("button", { name: "Add another" }).click();
await page.getByTestId("callback-url-2").fill(oidcClient.secondCallbackUrl!);
await page
.getByLabel("logo")
.setInputFiles("assets/pingvin-share-logo.png");
await page.getByRole("button", { name: "Save" }).click();
const clientId = await page.getByTestId("client-id").textContent();
await expect(page.locator('[data-type="success"]')).toHaveText(
"OIDC client created successfully"
);
expect(clientId?.length).toBe(36);
expect((await page.getByTestId("client-secret").textContent())?.length).toBe(
32
);
await expect(page.getByLabel("Name")).toHaveValue(oidcClient.name);
await expect(page.getByTestId("callback-url-1")).toHaveValue(
oidcClient.callbackUrl
);
await expect(page.getByTestId("callback-url-2")).toHaveValue(
oidcClient.secondCallbackUrl!
);
await expect(
page.getByRole("img", { name: `${oidcClient.name} logo` })
).toBeVisible();
await page.request
.get(`/api/oidc/clients/${clientId}/logo`)
.then((res) => expect.soft(res.status()).toBe(200));
});
test("Edit OIDC client", async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel("Name").fill("Nextcloud updated");
await page
.getByTestId("callback-url-1")
.first()
.fill("http://nextcloud-updated/auth/callback");
await page
.getByLabel("logo")
.setInputFiles("assets/nextcloud-logo.png");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"OIDC client updated successfully"
);
await expect(
page.getByRole("img", { name: "Nextcloud updated logo" })
).toBeVisible();
await page.request
.get(`/api/oidc/clients/${oidcClient.id}/logo`)
.then((res) => expect.soft(res.status()).toBe(200));
});
test("Create new OIDC client secret", async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel("Create new client secret").click();
await page.getByRole("button", { name: "Generate" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"New client secret created successfully"
);
expect((await page.getByTestId("client-secret").textContent())?.length).toBe(
32
);
});
test("Delete OIDC client", async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
await page.goto("/settings/admin/oidc-clients");
await page
.getByRole("row", { name: oidcClient.name })
.getByLabel("Delete")
.click();
await page.getByText("Delete", { exact: true }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"OIDC client deleted successfully"
);
await expect(
page.getByRole("row", { name: oidcClient.name })
).not.toBeVisible();
});

451
tests/specs/oidc.spec.ts Normal file
View File

@@ -0,0 +1,451 @@
import test, { expect } from "@playwright/test";
import { oidcClients, refreshTokens, users } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
import { generateIdToken, generateOauthAccessToken } from "../utils/jwt.util";
import oidcUtil from "../utils/oidc.util";
import passkeyUtil from "../utils/passkey.util";
test.beforeEach(cleanupBackend);
test("Authorize existing client", async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
throw e;
}
});
});
test("Authorize existing client while not signed in", async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole("button", { name: "Sign in" }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
throw e;
}
});
});
test("Authorize new client", async ({ page }) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Email" })
).toBeVisible();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Profile" })
).toBeVisible();
await page.getByRole("button", { name: "Sign in" }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
throw e;
}
});
});
test("Authorize new client while not signed in", async ({ page }) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole("button", { name: "Sign in" }).click();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Email" })
).toBeVisible();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Profile" })
).toBeVisible();
await page.getByRole("button", { name: "Sign in" }).click();
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
throw e;
}
});
});
test("Authorize new client fails with user group not allowed", async ({
page,
}) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey("craig");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Email" })
).toBeVisible();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Profile" })
).toBeVisible();
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("paragraph").first()).toHaveText(
"You're not allowed to access this service."
);
});
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
return new URLSearchParams({
client_id: oidcClient.id,
response_type: "code",
scope: "openid profile email",
redirect_uri: oidcClient.callbackUrl,
state: "nXx-6Qr-owc1SHBa",
nonce: "P1gN3PtpKHJgKUVcLpLjm",
});
}
test("End session without id token hint shows confirmation page", async ({
page,
}) => {
await page.goto("/api/oidc/end-session");
await expect(page).toHaveURL("/logout");
await page.getByRole("button", { name: "Sign out" }).click();
await expect(page).toHaveURL("/login");
});
test("End session with id token hint redirects to callback URL", async ({
page,
}) => {
const client = oidcClients.nextcloud;
const idToken = await generateIdToken(users.tim, client.id);
let redirectedCorrectly = false;
await page
.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
)
.catch((e) => {
if (e.message.includes("net::ERR_NAME_NOT_RESOLVED")) {
redirectedCorrectly = true;
} else {
throw e;
}
});
expect(redirectedCorrectly).toBeTruthy();
});
test("Successfully refresh tokens with valid refresh token", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
client_secret: clientSecret,
},
});
// Verify we got new tokens
const tokenData = await refreshResponse.json();
expect(tokenData.access_token).toBeDefined();
expect(tokenData.refresh_token).toBeDefined();
expect(tokenData.token_type).toBe("Bearer");
expect(tokenData.expires_in).toBe(3600);
// The new refresh token should be different from the old one
expect(tokenData.refresh_token).not.toBe(token);
});
test("Using refresh token invalidates it for future use", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
client_secret: clientSecret,
},
});
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
client_secret: clientSecret,
},
});
expect(refreshResponse.status()).toBe(400);
});
test.describe("Introspection endpoint", () => {
const client = oidcClients.nextcloud;
test("without client_id and client_secret fails", async ({ request }) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
client.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(400);
});
test("with client_id and client_secret succeeds", async ({
request,
baseURL,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
client.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe("access_token");
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
});
test("non-expired refresh_token can be verified", async ({ request }) => {
const { token } = refreshTokens.filter((token) => !token.expired)[0];
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
},
form: {
token: token,
},
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe("refresh_token");
});
test("expired refresh_token can be verified", async ({ request }) => {
const { token } = refreshTokens.filter((token) => token.expired)[0];
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
},
form: {
token: token,
},
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(false);
});
test("expired access_token can't be verified", async ({ request }) => {
const expiredAccessToken = await generateOauthAccessToken(
users.tim,
client.id,
true
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
token: expiredAccessToken,
},
});
expect(introspectionResponse.status()).toBe(400);
});
});
test("Authorize new client with device authorization flow", async ({
page,
}) => {
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Email" })
).toBeVisible();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Profile" })
).toBeVisible();
await page.getByRole("button", { name: "Authorize" }).click();
await expect(
page
.getByRole("paragraph")
.filter({ hasText: "The device has been authorized." })
).toBeVisible();
});
test("Authorize new client with device authorization flow while not signed in", async ({
page,
}) => {
await page.context().clearCookies();
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole("button", { name: "Authorize" }).click();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Email" })
).toBeVisible();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Profile" })
).toBeVisible();
await page.getByRole("button", { name: "Authorize" }).click();
await expect(
page
.getByRole("paragraph")
.filter({ hasText: "The device has been authorized." })
).toBeVisible();
});
test("Authorize existing client with device authorization flow", async ({
page,
}) => {
const client = oidcClients.nextcloud;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await expect(
page
.getByRole("paragraph")
.filter({ hasText: "The device has been authorized." })
).toBeVisible();
});
test("Authorize existing client with device authorization flow while not signed in", async ({
page,
}) => {
await page.context().clearCookies();
const client = oidcClients.nextcloud;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole("button", { name: "Authorize" }).click();
await expect(
page
.getByRole("paragraph")
.filter({ hasText: "The device has been authorized." })
).toBeVisible();
});
test("Authorize client with device authorization flow with invalid code", async ({
page,
}) => {
await page.goto("/device?code=invalid-code");
await expect(
page.getByRole("paragraph").filter({ hasText: "Invalid device code." })
).toBeVisible();
});
test("Authorize new client with device authorization with user group not allowed", async ({
page,
}) => {
await page.context().clearCookies();
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey("craig");
await page.getByRole("button", { name: "Authorize" }).click();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Email" })
).toBeVisible();
await expect(
page.getByTestId("scopes").getByRole("heading", { name: "Profile" })
).toBeVisible();
await page.getByRole("button", { name: "Authorize" }).click();
await expect(
page
.getByRole("paragraph")
.filter({ hasText: "You're not allowed to access this service." })
).toBeVisible();
});

View File

@@ -0,0 +1,48 @@
import test, { expect } from "@playwright/test";
import { oneTimeAccessTokens } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
test.beforeEach(cleanupBackend);
// Disable authentication for these tests
test.use({ storageState: { cookies: [], origins: [] } });
test("Sign in with login code", async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
await page.goto(`/lc/${token.token}`);
await page.waitForURL("/settings/account");
});
test("Sign in with login code entered manually", async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
await page.goto("/lc");
await page.getByPlaceholder("Code").first().fill(token.token);
await page.getByText("Submit").first().click();
await page.waitForURL("/settings/account");
});
test("Sign in with expired login code fails", async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
await page.goto(`/lc/${token.token}`);
await expect(page.getByRole("paragraph")).toHaveText(
"Token is invalid or expired. Please try again."
);
});
test("Sign in with login code entered manually fails", async ({ page }) => {
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
await page.goto("/lc");
await page.getByPlaceholder("Code").first().fill(token.token);
await page.getByText("Submit").first().click();
await expect(page.getByRole("paragraph")).toHaveText(
"Token is invalid or expired. Please try again."
);
});

View File

@@ -0,0 +1,153 @@
import test, { expect } from "@playwright/test";
import { userGroups, users } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
test.beforeEach(cleanupBackend);
test("Create user group", async ({ page }) => {
await page.goto("/settings/admin/user-groups");
const group = userGroups.humanResources;
await page.getByRole("button", { name: "Add Group" }).click();
await page.getByLabel("Friendly Name").fill(group.friendlyName);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User group created successfully"
);
await page.waitForURL("/settings/admin/user-groups/*");
await expect(page.getByLabel("Friendly Name")).toHaveValue(
group.friendlyName
);
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(
group.name
);
});
test("Edit user group", async ({ page }) => {
await page.goto("/settings/admin/user-groups");
const group = userGroups.developers;
await page
.getByRole("row", { name: group.name })
.locator("#bits-5")
.getByRole("button")
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await page.getByLabel("Friendly Name").fill("Developers updated");
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(
group.name
);
await page.getByLabel("Name", { exact: true }).fill("developers_updated");
await page.getByRole("button", { name: "Save" }).nth(0).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User group updated successfully"
);
await expect(page.getByLabel("Friendly Name")).toHaveValue(
"Developers updated"
);
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(
"developers_updated"
);
});
test("Update user group users", async ({ page }) => {
const group = userGroups.designers;
await page.goto(`/settings/admin/user-groups/${group.id}`);
await page
.getByRole("row", { name: users.tim.email })
.getByRole("checkbox")
.click();
await page
.getByRole("row", { name: users.craig.email })
.getByRole("checkbox")
.click();
await page.getByRole("button", { name: "Save" }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Users updated successfully"
);
await page.reload();
await expect(
page.getByRole("row", { name: users.tim.email }).getByRole("checkbox")
).toHaveAttribute("data-state", "unchecked");
await expect(
page.getByRole("row", { name: users.craig.email }).getByRole("checkbox")
).toHaveAttribute("data-state", "checked");
});
test("Delete user group", async ({ page }) => {
const group = userGroups.developers;
await page.goto("/settings/admin/user-groups");
await page.getByRole("row", { name: group.name }).getByRole("button").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User group deleted successfully"
);
await expect(page.getByRole("row", { name: group.name })).not.toBeVisible();
});
test("Update user group custom claims", async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
await page.getByRole("button", { name: "Expand card" }).click();
// Add two custom claims
await page.getByRole("button", { name: "Add custom claim" }).click();
await page.getByPlaceholder("Key").fill("customClaim1");
await page.getByPlaceholder("Value").fill("customClaim1_value");
await page.getByRole("button", { name: "Add another" }).click();
await page.getByPlaceholder("Key").nth(1).fill("customClaim2");
await page.getByPlaceholder("Value").nth(1).fill("customClaim2_value");
await page.getByRole("button", { name: "Save" }).nth(2).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Custom claims updated successfully"
);
await page.reload();
// Check if custom claims are saved
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
"customClaim1"
);
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
"customClaim1_value"
);
await expect(page.getByPlaceholder("Key").nth(1)).toHaveValue("customClaim2");
await expect(page.getByPlaceholder("Value").nth(1)).toHaveValue(
"customClaim2_value"
);
// Remove one custom claim
await page.getByLabel("Remove custom claim").first().click();
await page.getByRole("button", { name: "Save" }).nth(2).click();
await page.reload();
// Check if custom claim is removed
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
"customClaim2"
);
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
"customClaim2_value"
);
});

View File

@@ -0,0 +1,253 @@
import test, { expect } from "@playwright/test";
import { userGroups, users } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
test.beforeEach(cleanupBackend);
test("Create user", async ({ page }) => {
const user = users.steve;
await page.goto("/settings/admin/users");
await page.getByRole("button", { name: "Add User" }).click();
await page.getByLabel("First name").fill(user.firstname);
await page.getByLabel("Last name").fill(user.lastname);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Username").fill(user.username);
await page.getByRole("button", { name: "Save" }).click();
await expect(
page.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
).toBeVisible();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User created successfully"
);
});
test("Create user fails with already taken email", async ({ page }) => {
const user = users.steve;
await page.goto("/settings/admin/users");
await page.getByRole("button", { name: "Add User" }).click();
await page.getByLabel("First name").fill(user.firstname);
await page.getByLabel("Last name").fill(user.lastname);
await page.getByLabel("Email").fill(users.tim.email);
await page.getByLabel("Username").fill(user.username);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="error"]')).toHaveText(
"Email is already in use"
);
});
test("Create user fails with already taken username", async ({ page }) => {
const user = users.steve;
await page.goto("/settings/admin/users");
await page.getByRole("button", { name: "Add User" }).click();
await page.getByLabel("First name").fill(user.firstname);
await page.getByLabel("Last name").fill(user.lastname);
await page.getByLabel("Email").fill(user.email);
await page.getByLabel("Username").fill(users.tim.username);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator('[data-type="error"]')).toHaveText(
"Username is already in use"
);
});
test("Create one time access token", async ({ page, context }) => {
await page.goto("/settings/admin/users");
await page
.getByRole("row", {
name: `${users.craig.firstname} ${users.craig.lastname}`,
})
.getByRole("button")
.click();
await page.getByRole("menuitem", { name: "Login Code" }).click();
await page.getByLabel("Login Code").getByRole("combobox").click();
await page.getByRole("option", { name: "12 hours" }).click();
await page.getByRole("button", { name: "Show Code" }).click();
const link = await page.getByTestId("login-code-link").textContent();
await context.clearCookies();
await page.goto(link!);
await page.waitForURL("/settings/account");
});
test("Delete user", async ({ page }) => {
await page.goto("/settings/admin/users");
await page
.getByRole("row", {
name: `${users.craig.firstname} ${users.craig.lastname}`,
})
.getByRole("button")
.click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User deleted successfully"
);
await expect(
page.getByRole("row", {
name: `${users.craig.firstname} ${users.craig.lastname}`,
})
).not.toBeVisible();
});
test("Update user", async ({ page }) => {
const user = users.craig;
await page.goto("/settings/admin/users");
await page
.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
.getByRole("button")
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await page.getByLabel("First name").fill("Crack");
await page.getByLabel("Last name").fill("Apple");
await page.getByLabel("Email").fill("crack.apple@test.com");
await page.getByLabel("Username").fill("crack");
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User updated successfully"
);
});
test("Update user fails with already taken email", async ({ page }) => {
const user = users.craig;
await page.goto("/settings/admin/users");
await page
.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
.getByRole("button")
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await page.getByLabel("Email").fill(users.tim.email);
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.locator('[data-type="error"]')).toHaveText(
"Email is already in use"
);
});
test("Update user fails with already taken username", async ({ page }) => {
const user = users.craig;
await page.goto("/settings/admin/users");
await page
.getByRole("row", { name: `${user.firstname} ${user.lastname}` })
.getByRole("button")
.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await page.getByLabel("Username").fill(users.tim.username);
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.locator('[data-type="error"]')).toHaveText(
"Username is already in use"
);
});
test("Update user custom claims", async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole("button", { name: "Expand card" }).nth(1).click();
// Add two custom claims
await page.getByRole("button", { name: "Add custom claim" }).click();
await page.getByPlaceholder("Key").fill("customClaim1");
await page.getByPlaceholder("Value").fill("customClaim1_value");
await page.getByRole("button", { name: "Add another" }).click();
await page.getByPlaceholder("Key").nth(1).fill("customClaim2");
await page.getByPlaceholder("Value").nth(1).fill("customClaim2_value");
await page.getByRole("button", { name: "Save" }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Custom claims updated successfully"
);
await page.reload();
// Check if custom claims are saved
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
"customClaim1"
);
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
"customClaim1_value"
);
await expect(page.getByPlaceholder("Key").nth(1)).toHaveValue("customClaim2");
await expect(page.getByPlaceholder("Value").nth(1)).toHaveValue(
"customClaim2_value"
);
// Remove one custom claim
await page.getByLabel("Remove custom claim").first().click();
await page.getByRole("button", { name: "Save" }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"Custom claims updated successfully"
);
await page.reload();
// Check if custom claim is removed
await expect(page.getByPlaceholder("Key").first()).toHaveValue(
"customClaim2"
);
await expect(page.getByPlaceholder("Value").first()).toHaveValue(
"customClaim2_value"
);
});
test("Update user group assignments", async ({ page }) => {
const user = users.craig;
await page.goto(`/settings/admin/users/${user.id}`);
page.getByRole("button", { name: "Expand card" }).first().click();
await page
.getByRole("row", { name: userGroups.developers.name })
.getByRole("checkbox")
.click();
await page
.getByRole("row", { name: userGroups.designers.name })
.getByRole("checkbox")
.click();
await page.getByRole("button", { name: "Save" }).nth(1).click();
await expect(page.locator('[data-type="success"]')).toHaveText(
"User groups updated successfully"
);
await page.reload();
await expect(
page
.getByRole("row", { name: userGroups.designers.name })
.getByRole("checkbox")
).toHaveAttribute("data-state", "checked");
await expect(
page
.getByRole("row", { name: userGroups.developers.name })
.getByRole("checkbox")
).toHaveAttribute("data-state", "unchecked");
});

12
tests/utils/auth.util.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Page } from '@playwright/test';
import passkeyUtil from './passkey.util';
async function authenticate(page: Page) {
await page.goto('/login');
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authenticate' }).click();
}
export default { authenticate };

View File

@@ -0,0 +1,16 @@
import playwrightConfig from "../playwright.config";
export async function cleanupBackend() {
const response = await fetch(
playwrightConfig.use!.baseURL + "/api/test/reset",
{
method: "POST",
}
);
if (!response.ok) {
throw new Error(
`Failed to reset backend: ${response.status} ${response.statusText}`
);
}
}

64
tests/utils/jwt.util.ts Normal file
View File

@@ -0,0 +1,64 @@
import * as jose from "jose";
import playwrightConfig from "../playwright.config";
const PRIVATE_KEY_STRING = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`;
type User = {
id: string;
email: string;
firstname: string;
lastname: string;
};
const privateKey = JSON.parse(PRIVATE_KEY_STRING);
const privateKeyImported = await jose.importJWK(privateKey, "RS256");
export async function generateIdToken(
user: User,
clientId: string,
expired = false
) {
const now = Math.floor(Date.now() / 1000);
const expiration = expired ? now + 1 : now + 1000000000; // Either expired or valid for a long time
const payload = {
aud: clientId,
email: user.email,
email_verified: true,
exp: expiration,
family_name: user.lastname,
given_name: user.firstname,
iat: now,
iss: playwrightConfig.use!.baseURL,
name: `${user.firstname} ${user.lastname}`,
nonce: "oW1A1O78GQ15D73OsHEx7WQKj7ZqvHLZu_37mdXIqAQ",
sub: user.id,
type: "id-token",
};
return await new jose.SignJWT(payload)
.setProtectedHeader({ alg: "RS256", kid: privateKey.kid, typ: "JWT" })
.sign(privateKeyImported);
}
export async function generateOauthAccessToken(
user: User,
clientId: string,
expired = false
) {
const now = Math.floor(Date.now() / 1000);
const expiration = expired ? now - 1000 : now + 1000000000; // Either expired or valid for a long time
const payload = {
aud: [clientId],
exp: expiration,
iat: now,
iss: playwrightConfig.use!.baseURL,
sub: user.id,
type: "oauth-access-token",
};
return await new jose.SignJWT(payload)
.setProtectedHeader({ alg: "RS256", kid: privateKey.kid, typ: "JWT" })
.sign(privateKeyImported);
}

22
tests/utils/oidc.util.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Page } from '@playwright/test';
async function getUserCode(page: Page, clientId: string, clientSecret: string) {
const response = await page.request
.post('/api/oidc/device/authorize', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: {
client_id: clientId,
client_secret: clientSecret,
scope: 'openid profile email'
}
})
.then((r) => r.json());
return response.user_code;
}
export default {
getUserCode
};

View File

@@ -0,0 +1,70 @@
import type { CDPSession, Page } from '@playwright/test';
// The existing passkeys are already stored in the database
const passkeys = {
tim: {
credentialId: 'test-credential-tim',
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG'
},
craig: {
credentialId: 'test-credential-craig',
userHandle: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL1UaeWG1KYpN+HcxQvXEJysiQjT9Fn7Zif3i5cY+s+yhRANCAASPioDQ+tnODwKjULbufJRvOunwTCOvt46UYjYt+vOZsvmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouH'
},
timNew: {
credentialId: 'new-test-credential-tim',
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFl2lIlRyc2G7O9D8WWrw2N8D7NTlhgWcKFY7jYxrfcmhRANCAASmvbCFrXshUvW7avTIysV9UymbhmUwGb7AonUMQPgqK2Jur7PWp9V0AIe5YMuXYH1oxsqY5CoAbdY2YsPmhYoX'
}
};
async function init(page: Page) {
const client = await page.context().newCDPSession(page);
await client.send('WebAuthn.enable');
const authenticatorId = await addVirtualAuthenticator(client);
return {
addPasskey: async (passkey?: keyof typeof passkeys) => {
await addPasskey(authenticatorId, client, passkey);
}
};
}
async function addVirtualAuthenticator(client: CDPSession): Promise<string> {
const result = await client.send('WebAuthn.addVirtualAuthenticator', {
// config authenticator
options: {
protocol: 'ctap2',
transport: 'internal',
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true
}
});
return result.authenticatorId;
}
async function addPasskey(
authenticatorId: string,
client: CDPSession,
passkeyName: keyof typeof passkeys = 'tim'
): Promise<void> {
const passkey = passkeys[passkeyName];
await client.send('WebAuthn.addCredential', {
authenticatorId,
credential: {
credentialId: btoa(passkey.credentialId),
isResidentCredential: true,
rpId: 'localhost',
privateKey: passkey.privateKey,
userHandle: btoa(passkey.userHandle),
signCount: Math.round((new Date().getTime() - 1704444610871) / 1000 / 2)
}
});
}
export default { init };