Files
payloadcms/test/auth/e2e.spec.ts
Jacob Fletcher 8113d3bdef fix(next): exclude permissions from page response when unauthenticated (#13796)
Similar spirit as #13714.

Permissions are embedded into the page response, exposing some field
names to unauthenticated users.

For example, when setting `read: () => false` on a field, that field's
name is now included in the response due to its presence in the
permissions object.

We now search the HTML source directly in the test, similar to "view
source" in the browser, which will be much effective at preventing
regression going forward.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211347942663256
2025-09-12 20:57:03 +00:00

492 lines
17 KiB
TypeScript

import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
import { login } from 'helpers/e2e/auth/login.js'
import { logout } from 'helpers/e2e/auth/logout.js'
import { openNav } from 'helpers/e2e/toggleNav.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { v4 as uuid } from 'uuid'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config } from './payload-types.js'
import {
ensureCompilationIsDone,
exactText,
getRoutes,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { apiKeysSlug, slug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
let payload: PayloadTestSDK<Config>
const { beforeAll, afterAll, describe } = test
const headers = {
'Content-Type': 'application/json',
}
describe('Auth', () => {
let page: Page
let context: BrowserContext
let url: AdminUrlUtil
let serverURL: string
let apiURL: string
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
apiURL = `${serverURL}/api`
url = new AdminUrlUtil(serverURL, slug)
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
describe('create first user', () => {
beforeAll(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'create-first-user',
deleteOnly: true,
})
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
await payload.delete({
collection: slug,
where: {
email: {
exists: true,
},
},
})
})
async function waitForVisibleAuthFields() {
await expect(page.locator('#field-email')).toBeVisible()
await expect(page.locator('#field-password')).toBeVisible()
await expect(page.locator('#field-confirm-password')).toBeVisible()
}
test('should create first user and redirect to admin', async () => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
await expect(page.locator('.create-first-user')).toBeVisible()
await waitForVisibleAuthFields()
// forget to fill out confirm password
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.confirm-password .field-error')).toHaveText(
'This field is required.',
)
// make them match, but does not pass password validation
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill('12')
await page.locator('#field-confirm-password').fill('12')
await page.locator('.form-submit > button').click()
await expect(page.locator('.field-type.password .field-error')).toHaveText(
'This value must be longer than the minimum length of 3 characters.',
)
// should fill out all fields correctly
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await page.locator('#field-custom').fill('Hello, world!')
await page.locator('.form-submit > button').click()
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.not.toContain('create-first-user')
})
test('richText field should should not be readOnly in create first user view', async () => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getRoutes({})
// wait for create first user route
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
await expect(page.locator('.create-first-user')).toBeVisible()
await waitForVisibleAuthFields()
const richTextRoot = page
.locator('.rich-text-lexical .ContentEditable__root[data-lexical-editor="true"]')
.first()
// ensure editor is present
await expect(richTextRoot).toBeVisible()
// core read-only checks
await expect(richTextRoot).toHaveAttribute('contenteditable', 'true')
await expect(richTextRoot).not.toHaveAttribute('aria-readonly', 'true')
})
})
describe('non create first user', () => {
beforeAll(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'auth',
deleteOnly: false,
})
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
await login({ page, serverURL })
})
describe('passwords', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
afterAll(async () => {
// reset password to original password
await page.goto(url.account)
await page.locator('#change-password').click()
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)
await saveDocAndAssert(page, '#action-save')
})
test('should protect field schemas behind authentication', async () => {
await logout(page, serverURL)
// Inspect the page source (before authentication)
const loginPageRes = await page.goto(`${serverURL}/admin/login`)
const loginPageSource = await loginPageRes?.text()
expect(loginPageSource).not.toContain('shouldNotShowInClientConfigUnlessAuthenticated')
// Inspect the client config (before authentication)
await expect(page.locator('#unauthenticated-client-config')).toBeAttached()
await expect(
page.locator('#unauthenticated-client-config', {
hasText: 'shouldNotShowInClientConfigUnlessAuthenticated',
}),
).toHaveCount(0)
await login({ page, serverURL })
await page.goto(serverURL + '/admin')
// Inspect the client config (after authentication)
await expect(page.locator('#authenticated-client-config')).toBeAttached()
await expect(
page.locator('#authenticated-client-config', {
hasText: 'shouldNotShowInClientConfigUnlessAuthenticated',
}),
).toHaveCount(1)
// Inspect the page source (after authentication)
const dashboardPageRes = await page.goto(`${serverURL}/admin`)
const dashboardPageSource = await dashboardPageRes?.text()
expect(dashboardPageSource).toContain('shouldNotShowInClientConfigUnlessAuthenticated')
})
test('should allow change password', async () => {
await page.goto(url.account)
const emailBeforeSave = await page.locator('#field-email').inputValue()
await page.locator('#change-password').click()
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should fail to save with incorrect confirm password
await page.locator('#field-confirm-password').fill('wrong password')
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('Passwords do not match.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page, '#action-save')
// should still have the same email
await expect(page.locator('#field-email')).toHaveValue(emailBeforeSave)
})
test('should prevent new user creation without confirm password', async () => {
await page.goto(url.create)
await page.locator('#field-email').fill('dev2@payloadcms.com')
await page.locator('#field-password').fill('password')
// should fail to save without confirm password
await page.locator('#action-save').click()
await expect(
page.locator('.field-type.confirm-password .tooltip--show', {
hasText: exactText('This field is required.'),
}),
).toBeVisible()
// should succeed with matching confirm password
await page.locator('#field-confirm-password').fill('password')
await saveDocAndAssert(page, '#action-save')
})
})
describe('authenticated users', () => {
beforeAll(() => {
url = new AdminUrlUtil(serverURL, slug)
})
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account)
await expect(page.locator('#users-api-result')).toHaveText('Hello, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom')
await field.fill('Goodbye, world!')
await saveDocAndAssert(page)
await expect(page.locator('#users-api-result')).toHaveText('Goodbye, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Goodbye, world!')
})
// Need to test unlocking documents on logout here as this test suite does not auto login users
test('should unlock document on logout after editing without saving', async () => {
await page.goto(url.list)
await page.locator('.table .row-1 .cell-custom a').click()
const textInput = page.locator('#field-namedSaveToJWT')
await expect(textInput).toBeVisible()
const docID = (await page.locator('.render-title').getAttribute('data-doc-id')) as string
const lockDocRequest = page.waitForResponse(
(response) =>
response.request().method() === 'POST' && response.request().url() === url.edit(docID),
)
await textInput.fill('some text')
await lockDocRequest
const lockedDocs = await payload.find({
collection: 'payload-locked-documents',
limit: 1,
pagination: false,
})
await expect.poll(() => lockedDocs.docs.length).toBe(1)
await openNav(page)
await page.locator('.nav .nav__controls a[href="/admin/logout"]').click()
// Locate the modal container
const modalContainer = page.locator('.payload__modal-container')
await expect(modalContainer).toBeVisible()
// Click the "Leave anyway" button
await page
.locator('#leave-without-saving .confirmation-modal__controls .btn--style-primary')
.click()
await expect(page.locator('.login')).toBeVisible()
const unlockedDocs = await payload.find({
collection: 'payload-locked-documents',
limit: 1,
pagination: false,
})
await expect.poll(() => unlockedDocs.docs.length).toBe(0)
// added so tests after this do not need to re-login
await login({ page, serverURL })
})
})
describe('api-keys', () => {
let user
beforeAll(async () => {
url = new AdminUrlUtil(serverURL, apiKeysSlug)
user = await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
})
test('should enable api key', async () => {
await page.goto(url.create)
// click enable api key checkbox
await page.locator('#field-enableAPIKey').click()
// assert that the value is set
const apiKeyLocator = page.locator('#apiKey')
await expect
.poll(async () => await apiKeyLocator.inputValue(), { timeout: POLL_TOPASS_TIMEOUT })
.toBeDefined()
const apiKey = await apiKeyLocator.inputValue()
await saveDocAndAssert(page)
await expect(async () => {
const apiKeyAfterSave = await apiKeyLocator.inputValue()
expect(apiKey).toStrictEqual(apiKeyAfterSave)
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('should disable api key', async () => {
await page.goto(url.edit(user.id))
// click enable api key checkbox
await page.locator('#field-enableAPIKey').click()
// assert that the apiKey field is hidden
await expect(page.locator('#apiKey')).toBeHidden()
await saveDocAndAssert(page)
// use the api key in a fetch to assert that it is disabled
await expect(async () => {
const response = await fetch(`${apiURL}/${apiKeysSlug}/me`, {
headers: {
...headers,
Authorization: `${apiKeysSlug} API-Key ${user.apiKey}`,
},
}).then((res) => res.json())
expect(response.user).toBeNull()
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
})
describe('api-keys-with-field-read-access', () => {
let user
beforeAll(async () => {
url = new AdminUrlUtil(serverURL, 'api-keys-with-field-read-access')
user = await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
})
test('should hide auth parent container if api keys enabled but no read access', async () => {
await page.goto(url.create)
// assert that the auth parent container is hidden
await expect(page.locator('.auth-fields')).toBeHidden()
await saveDocAndAssert(page)
})
test('ensure `?redirect=` param is injected into the URL and handled properly after login', async () => {
const users = await payload.find({
collection: slug,
limit: 1,
})
const userDocumentRoute = `${serverURL}/admin/collections/users/${users?.docs?.[0]?.id}`
await logout(page, serverURL)
// This will send the user back to the login page with a `?redirect=` param
await page.goto(userDocumentRoute)
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.toContain('/admin/login?redirect=')
// Important: do not use the login helper here, as this may clear the redirect param
await expect(page.locator('#field-email')).toBeVisible()
await expect(page.locator('#field-password')).toBeVisible()
await page.locator('.form-submit > button').click()
// Expect to be redirected to the correct page
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.toBe(userDocumentRoute)
// Previously, this would crash the page with a "Cannot read properties of undefined (reading 'match')" error
await expect(page.locator('#field-roles')).toBeVisible()
// Now do this again, only with a page that is not in the user's collection
const notInUserCollection = await payload.create({
collection: 'relationsCollection',
data: {},
})
await logout(page, serverURL)
await page.goto(
`${serverURL}/admin/collections/relationsCollection/${notInUserCollection.id}`,
)
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.toContain('/admin/login?redirect=')
// Important: do not use the login helper here, as this may clear the redirect param
await expect(page.locator('#field-email')).toBeVisible()
await expect(page.locator('#field-password')).toBeVisible()
await page.locator('.form-submit > button').click()
// Expect to be redirected to the correct page
await expect
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
.toBe(`${serverURL}/admin/collections/relationsCollection/${notInUserCollection.id}`)
// Previously, this would crash the page with a "Cannot read properties of null (reading 'fields')" error
await expect(page.locator('#field-rel')).toBeVisible()
})
})
})
})