skillhub/web/e2e/settings-security-capability.spec.ts
XiaoSeS 9557478dd8 feat(auth): add explicit identity link and safe unlink flow
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-07-31 07:02:17 +08:00

205 lines
8.8 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { csrfHeaders } from './helpers/csrf'
import { createFreshSession, loginWithCredentials } from './helpers/session'
function getOptionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim()
return value ? value : undefined
}
function adminCredentials() {
return {
username: getOptionalEnv('E2E_ADMIN_USERNAME') ?? getOptionalEnv('BOOTSTRAP_ADMIN_USERNAME') ?? 'admin',
password: getOptionalEnv('E2E_ADMIN_PASSWORD') ?? getOptionalEnv('BOOTSTRAP_ADMIN_PASSWORD') ?? 'ChangeMe!2026',
}
}
function gitLabIdentityLinkE2EEnabled(): boolean {
return getOptionalEnv('E2E_IDENTITY_LINK_BROWSER_PROVIDER') === 'gitlab'
}
function requireGitLabIdentityLinkE2E(): void {
const enabled = gitLabIdentityLinkE2EEnabled()
if (!enabled && process.env.CI) {
throw new Error(
'E2E_IDENTITY_LINK_BROWSER_PROVIDER=gitlab is required in CI',
)
}
test.skip(!enabled, 'requires the dedicated GitLab identity-link test provider')
}
async function currentDisplayName(page: Page, headers?: Record<string, string>): Promise<string> {
const response = await page.context().request.get('/api/v1/auth/me', { headers })
expect(response.ok()).toBeTruthy()
const body = await response.json() as { data: { displayName: string } }
return body.data.displayName
}
test.describe('Security Settings capability (Real API)', () => {
test.use({
baseURL: getOptionalEnv('E2E_BASE_URL') ?? 'http://127.0.0.1:3000',
})
test('shows the security menu entry and password form for local admin accounts', async ({ page }, testInfo) => {
await setEnglishLocale(page)
await loginWithCredentials(page, adminCredentials(), testInfo)
const displayName = await currentDisplayName(page)
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible()
await expect(page.getByText('Local password', { exact: true }).first()).toBeVisible()
await expect(page.getByLabel('Current Password')).toBeVisible()
await expect(page.getByLabel('New Password')).toBeVisible()
await page.getByRole('button', { name: displayName }).click()
await expect(page.getByRole('link', { name: 'Security Settings' })).toBeVisible()
})
test('requires fresh local reauthentication before linking an external provider', async ({ page }, testInfo) => {
requireGitLabIdentityLinkE2E()
await setEnglishLocale(page)
const credentials = await createFreshSession(page, testInfo)
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Login Methods', exact: true })).toBeVisible()
const addButton = page.getByRole('button', { name: 'Add' }).first()
await expect(addButton).toBeVisible()
await addButton.click()
const dialog = page.getByRole('dialog', { name: 'Verify account control' })
await expect(dialog).toBeVisible()
await dialog.getByLabel('Local password').fill(credentials.password)
await dialog.getByRole('button', { name: 'Verify password' }).click()
await expect(dialog.getByText(
'Current account verified. Now authenticate the login method you want to link.',
)).toBeVisible()
await expect(dialog.getByRole('button', { name: /^Continue with / })).toBeVisible()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toBeHidden()
})
test('links, unlinks, and relinks a browser identity through the deployed stack', async ({ page }, testInfo) => {
requireGitLabIdentityLinkE2E()
await setEnglishLocale(page)
const credentials = await createFreshSession(page, testInfo)
await page.goto('/settings/security')
const availableMethods = page.locator(
'section[aria-labelledby="available-login-methods"]',
)
const linkedMethods = page.locator(
'section[aria-labelledby="linked-login-methods"]',
)
async function linkGitLab() {
await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible()
await availableMethods.getByRole('button', { name: 'Add' }).click()
const dialog = page.getByRole('dialog', { name: 'Verify account control' })
await dialog.getByLabel('Local password').fill(credentials.password)
await dialog.getByRole('button', { name: 'Verify password' }).click()
await expect(dialog.getByRole('button', { name: 'Continue with GitLab' })).toBeVisible()
await dialog.getByRole('button', { name: 'Continue with GitLab' }).click()
await page.waitForURL(/identityLink=linked/)
await expect(page.getByText('The login method was linked successfully.')).toBeVisible()
await expect(linkedMethods.getByText('GitLab', { exact: true })).toBeVisible()
}
await linkGitLab()
await linkedMethods.getByRole('button', { name: 'Remove' }).click()
const unlinkDialog = page.getByRole('dialog', { name: 'Verify account control' })
await unlinkDialog.getByLabel('Local password').fill(credentials.password)
await unlinkDialog.getByRole('button', { name: 'Verify password' }).click()
await expect(
unlinkDialog.getByRole('button', { name: 'Remove login method' }),
).toBeVisible()
await unlinkDialog.getByRole('button', { name: 'Remove login method' }).click()
await expect(unlinkDialog).toBeHidden()
await expect(availableMethods.getByText('GitLab', { exact: true })).toBeVisible()
await linkGitLab()
})
test('redirects an unavailable identity-link provider with a stable reason code', async ({ page }, testInfo) => {
requireGitLabIdentityLinkE2E()
await setEnglishLocale(page)
const credentials = await createFreshSession(page, testInfo)
const request = page.context().request
const createIntent = await request.post('/api/v1/auth/identity-link-intents/link', {
data: { providerCode: 'gitlab' },
headers: await csrfHeaders(page),
})
expect(createIntent.ok()).toBeTruthy()
const createBody = await createIntent.json() as { data: { id: string } }
const intentId = createBody.data.id
const reauthenticate = await request.post(
`/api/v1/auth/identity-link-intents/${intentId}/reauthenticate/local`,
{
data: { password: credentials.password },
headers: await csrfHeaders(page),
},
)
expect(reauthenticate.ok()).toBeTruthy()
const prepareLink = await request.post(
`/api/v1/auth/identity-link-intents/${intentId}/link/browser`,
{ headers: await csrfHeaders(page) },
)
expect(prepareLink.ok()).toBeTruthy()
const prepareBody = await prepareLink.json() as { data: { actionUrl: string } }
const unavailableActionUrl = prepareBody.data.actionUrl.replace(
'/oauth2/authorization/gitlab',
'/oauth2/authorization/missing-provider',
)
expect(unavailableActionUrl).not.toBe(prepareBody.data.actionUrl)
const failure = await request.get(unavailableActionUrl, { maxRedirects: 0 })
expect(failure.status()).toBe(302)
const location = failure.headers().location
expect(location).toBeTruthy()
const redirect = new URL(location, 'http://127.0.0.1')
expect(redirect.pathname).toBe('/settings/security')
expect(redirect.searchParams.get('identityLink')).toBe('failed')
expect(redirect.searchParams.get('intentId')).toBe(intentId)
expect(redirect.searchParams.get('reasonCode')).toBe('PROVIDER_UNAVAILABLE')
const ordinaryOAuth = await request.get(
'/oauth2/authorization/missing-provider',
{ maxRedirects: 0 },
)
expect(ordinaryOAuth.status()).toBe(403)
})
test('hides the security menu entry and rejects password changes without a local credential', async ({ page }) => {
await setEnglishLocale(page)
await page.context().setExtraHTTPHeaders({
'X-Mock-User-Id': 'local-user',
})
const displayName = await currentDisplayName(page, { 'X-Mock-User-Id': 'local-user' })
await page.goto('/settings/security')
await expect(page.getByRole('heading', { name: 'Security Settings' })).toBeVisible()
await expect(page.getByText('Password changes are unavailable for this account.')).toBeVisible()
await expect(page.getByLabel('Current Password')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Update Password' })).toHaveCount(0)
await page.getByRole('button', { name: displayName }).click()
await expect(page.getByRole('link', { name: 'Security Settings' })).toHaveCount(0)
const response = await page.context().request.post('/api/v1/auth/local/change-password', {
data: {
currentPassword: 'Passw0rd!123',
newPassword: 'N3wPassw0rd!123',
},
headers: await csrfHeaders(page, { 'X-Mock-User-Id': 'local-user' }),
})
expect(response.status()).toBe(400)
})
})