diff --git a/.gitignore b/.gitignore index 108cfba5..9b2a00b6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/frontend/tests/account-settings.spec.ts b/frontend/tests/account-settings.spec.ts deleted file mode 100644 index 31114d15..00000000 --- a/frontend/tests/account-settings.spec.ts +++ /dev/null @@ -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'); -}); diff --git a/frontend/tests/api-key.spec.ts b/frontend/tests/api-key.spec.ts deleted file mode 100644 index 84b61acb..00000000 --- a/frontend/tests/api-key.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/frontend/tests/application-configuration.spec.ts b/frontend/tests/application-configuration.spec.ts deleted file mode 100644 index bc6c9a94..00000000 --- a/frontend/tests/application-configuration.spec.ts +++ /dev/null @@ -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)); -}); diff --git a/frontend/tests/oidc-client-settings.spec.ts b/frontend/tests/oidc-client-settings.spec.ts deleted file mode 100644 index 70d94801..00000000 --- a/frontend/tests/oidc-client-settings.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/frontend/tests/oidc.spec.ts b/frontend/tests/oidc.spec.ts deleted file mode 100644 index 87cb9c6d..00000000 --- a/frontend/tests/oidc.spec.ts +++ /dev/null @@ -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(); -}); diff --git a/frontend/tests/one-time-access-token.spec.ts b/frontend/tests/one-time-access-token.spec.ts deleted file mode 100644 index aa11e968..00000000 --- a/frontend/tests/one-time-access-token.spec.ts +++ /dev/null @@ -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.' - ); -}); diff --git a/frontend/tests/user-group.spec.ts b/frontend/tests/user-group.spec.ts deleted file mode 100644 index 396038be..00000000 --- a/frontend/tests/user-group.spec.ts +++ /dev/null @@ -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'); -}); diff --git a/frontend/tests/user-settings.spec.ts b/frontend/tests/user-settings.spec.ts deleted file mode 100644 index d2ea1cea..00000000 --- a/frontend/tests/user-settings.spec.ts +++ /dev/null @@ -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'); -}); diff --git a/frontend/tests/utils/cleanup.util.ts b/frontend/tests/utils/cleanup.util.ts deleted file mode 100644 index aaf2c82c..00000000 --- a/frontend/tests/utils/cleanup.util.ts +++ /dev/null @@ -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'); -} diff --git a/frontend/tests/assets/clouds.jpg b/tests/assets/clouds.jpg similarity index 100% rename from frontend/tests/assets/clouds.jpg rename to tests/assets/clouds.jpg diff --git a/frontend/tests/assets/nextcloud-logo.png b/tests/assets/nextcloud-logo.png similarity index 100% rename from frontend/tests/assets/nextcloud-logo.png rename to tests/assets/nextcloud-logo.png diff --git a/frontend/tests/assets/pingvin-share-logo.png b/tests/assets/pingvin-share-logo.png similarity index 100% rename from frontend/tests/assets/pingvin-share-logo.png rename to tests/assets/pingvin-share-logo.png diff --git a/frontend/tests/assets/w3-schools-favicon.ico b/tests/assets/w3-schools-favicon.ico similarity index 100% rename from frontend/tests/assets/w3-schools-favicon.ico rename to tests/assets/w3-schools-favicon.ico diff --git a/frontend/tests/auth.setup.ts b/tests/auth.setup.ts similarity index 100% rename from frontend/tests/auth.setup.ts rename to tests/auth.setup.ts diff --git a/frontend/tests/data.ts b/tests/data.ts similarity index 100% rename from frontend/tests/data.ts rename to tests/data.ts diff --git a/tests/package-lock.json b/tests/package-lock.json new file mode 100644 index 00000000..26d609f3 --- /dev/null +++ b/tests/package-lock.json @@ -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" + } + } + } +} diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 00000000..3f8241c7 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,7 @@ +{ + "type": "module", + "devDependencies": { + "@playwright/test": "^1.52.0", + "jose": "^6.0.11" + } +} diff --git a/frontend/playwright.config.ts b/tests/playwright.config.ts similarity index 66% rename from frontend/playwright.config.ts rename to tests/playwright.config.ts index 024e4a3d..90a9e9c5 100644 --- a/frontend/playwright.config.ts +++ b/tests/playwright.config.ts @@ -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'] } ] diff --git a/tests/specs/account-settings.spec.ts b/tests/specs/account-settings.spec.ts new file mode 100644 index 00000000..575dd672 --- /dev/null +++ b/tests/specs/account-settings.spec.ts @@ -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"); +}); diff --git a/tests/specs/api-key.spec.ts b/tests/specs/api-key.spec.ts new file mode 100644 index 00000000..11762a14 --- /dev/null +++ b/tests/specs/api-key.spec.ts @@ -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(); + }); +}); diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts new file mode 100644 index 00000000..882de1f3 --- /dev/null +++ b/tests/specs/application-configuration.spec.ts @@ -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)); +}); diff --git a/frontend/tests/ldap.spec.ts b/tests/specs/ldap.spec.ts similarity index 95% rename from frontend/tests/ldap.spec.ts rename to tests/specs/ldap.spec.ts index b065248c..4fef23f3 100644 --- a/frontend/tests/ldap.spec.ts +++ b/tests/specs/ldap.spec.ts @@ -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 }) => { diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts new file mode 100644 index 00000000..ed8d3756 --- /dev/null +++ b/tests/specs/oidc-client-settings.spec.ts @@ -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(); +}); diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts new file mode 100644 index 00000000..b0dcff07 --- /dev/null +++ b/tests/specs/oidc.spec.ts @@ -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(); +}); diff --git a/tests/specs/one-time-access-token.spec.ts b/tests/specs/one-time-access-token.spec.ts new file mode 100644 index 00000000..3b443092 --- /dev/null +++ b/tests/specs/one-time-access-token.spec.ts @@ -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." + ); +}); diff --git a/tests/specs/user-group.spec.ts b/tests/specs/user-group.spec.ts new file mode 100644 index 00000000..79cf76ae --- /dev/null +++ b/tests/specs/user-group.spec.ts @@ -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" + ); +}); diff --git a/tests/specs/user-settings.spec.ts b/tests/specs/user-settings.spec.ts new file mode 100644 index 00000000..a56c57bc --- /dev/null +++ b/tests/specs/user-settings.spec.ts @@ -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"); +}); diff --git a/frontend/tests/utils/auth.util.ts b/tests/utils/auth.util.ts similarity index 100% rename from frontend/tests/utils/auth.util.ts rename to tests/utils/auth.util.ts diff --git a/tests/utils/cleanup.util.ts b/tests/utils/cleanup.util.ts new file mode 100644 index 00000000..14785b0f --- /dev/null +++ b/tests/utils/cleanup.util.ts @@ -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}` + ); + } +} diff --git a/frontend/tests/utils/jwt.util.ts b/tests/utils/jwt.util.ts similarity index 53% rename from frontend/tests/utils/jwt.util.ts rename to tests/utils/jwt.util.ts index cee096bb..07a2b584 100644 --- a/frontend/tests/utils/jwt.util.ts +++ b/tests/utils/jwt.util.ts @@ -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); } diff --git a/frontend/tests/utils/oidc.util.ts b/tests/utils/oidc.util.ts similarity index 100% rename from frontend/tests/utils/oidc.util.ts rename to tests/utils/oidc.util.ts diff --git a/frontend/tests/utils/passkey.util.ts b/tests/utils/passkey.util.ts similarity index 100% rename from frontend/tests/utils/passkey.util.ts rename to tests/utils/passkey.util.ts