refactor: move e2e tests to root of repository
4
.gitignore
vendored
@@ -35,8 +35,8 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Application specific
|
||||
data
|
||||
/frontend/tests/.auth
|
||||
/frontend/tests/.report
|
||||
/tests/.auth
|
||||
/tests/.report
|
||||
pocket-id-backend
|
||||
/backend/GeoLite2-City.mmdb
|
||||
/backend/frontend/dist
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
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('tests/assets/w3-schools-favicon.ico');
|
||||
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
|
||||
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
||||
await page.getByLabel('Background Image').setInputFiles('tests/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));
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
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('tests/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('tests/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();
|
||||
});
|
||||
@@ -1,378 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
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.'
|
||||
);
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import playwrightConfig from '../../playwright.config';
|
||||
|
||||
export async function cleanupBackend() {
|
||||
await axios.post(playwrightConfig.use!.baseURL + '/api/test/reset');
|
||||
}
|
||||
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 528 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
86
tests/package-lock.json
generated
Normal 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
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"jose": "^6.0.11"
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,16 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
outputDir: './tests/.output',
|
||||
outputDir: './.output',
|
||||
timeout: 10000,
|
||||
testDir: './tests',
|
||||
testDir: './specs',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI
|
||||
? [['html', { outputFolder: 'tests/.report' }], ['github']]
|
||||
: [['line'], ['html', { open: 'never', outputFolder: 'tests/.report' }]],
|
||||
? [['html', { outputFolder: './.report' }], ['github']]
|
||||
: [['line'], ['html', { open: 'never', outputFolder: './.report' }]],
|
||||
use: {
|
||||
baseURL: process.env.APP_URL ?? 'http://localhost:1411',
|
||||
video: 'retain-on-failure',
|
||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'tests/.auth/user.json' },
|
||||
use: { ...devices['Desktop Chrome'], storageState: './.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
}
|
||||
]
|
||||
126
tests/specs/account-settings.spec.ts
Normal 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");
|
||||
});
|
||||
76
tests/specs/api-key.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
99
tests/specs/application-configuration.spec.ts
Normal 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));
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { cleanupBackend } from './utils/cleanup.util';
|
||||
import { cleanupBackend } from '../utils/cleanup.util';
|
||||
|
||||
test.beforeEach(cleanupBackend);
|
||||
|
||||
@@ -20,7 +20,7 @@ test.describe('LDAP Integration', () => {
|
||||
|
||||
const syncButton = page.getByRole('button', { name: 'Sync now' });
|
||||
await syncButton.click();
|
||||
await expect(page.getByText('LDAP sync finished')).toBeVisible();
|
||||
await expect(page.locator('[data-type="success"]')).toHaveText('LDAP sync finished');
|
||||
});
|
||||
|
||||
test('LDAP users are synced into PocketID', async ({ page }) => {
|
||||
103
tests/specs/oidc-client-settings.spec.ts
Normal 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
@@ -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();
|
||||
});
|
||||
48
tests/specs/one-time-access-token.spec.ts
Normal 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."
|
||||
);
|
||||
});
|
||||
153
tests/specs/user-group.spec.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
253
tests/specs/user-settings.spec.ts
Normal 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");
|
||||
});
|
||||
16
tests/utils/cleanup.util.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +1,64 @@
|
||||
import * as jose from 'jose';
|
||||
import playwrightConfig from '../../playwright.config';
|
||||
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;
|
||||
id: string;
|
||||
email: string;
|
||||
firstname: string;
|
||||
lastname: string;
|
||||
};
|
||||
|
||||
const privateKey = JSON.parse(PRIVATE_KEY_STRING);
|
||||
const privateKeyImported = await jose.importJWK(privateKey, 'RS256');
|
||||
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
|
||||
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'
|
||||
};
|
||||
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);
|
||||
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
|
||||
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'
|
||||
};
|
||||
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);
|
||||
return await new jose.SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "RS256", kid: privateKey.kid, typ: "JWT" })
|
||||
.sign(privateKeyImported);
|
||||
}
|
||||