jojo/tests/e2e/user-settings.test.e2e.ts
Mathieu Fenniak cf087a2f12 feat: ability to edit authorized integration in web UI (#12601)
Extends the UI introduced in #12558 to have edit capabilities.  (not in scope: "Add" for a new Authorized Integration will be the next update to this UI; `create-authorized-integration` CLI is still the only way to create a new record)

This PR includes a few refactoring steps.  The goal of these steps is to have `services/auth` be a single entrypoint for validating, inserting, or updating an authorized integration.  Some logic is moved out of `services/authz` because it is not authorization related, and some is moved out of `services/auth/method` to allow it to be reused during validation without creating a cyclical module dependency.

This PR also adds comprehensive validation to the more complex fields in the authorized integration, such as the issuer and claim rules.  This validation applies to the `forgejo admin user create-authorized-integration` CLI as well.

The visible UI is the same as #12558, but with a "Save" button, and the ability to display errors:

![Screenshot 2026-05-16 at 15-43-20 Authorized Integrations - Forgejo Beyond coding. We Forge](/attachments/ffaf60e2-3652-429b-a815-b339100f05f8)

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [x] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Tests for JavaScript changes

- I added test coverage for JavaScript changes...
  - [ ] in `web_src/js/*.test.js` if it can be unit tested.
  - [x] in `tests/e2e/*.test.e2e.js` if it requires interactions with a live Forgejo server (see also the [developer guide for JavaScript testing](https://codeberg.org/forgejo/forgejo/src/branch/forgejo/tests/e2e/README.md#end-to-end-tests)).

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.
    - Documentation is on my TODO list and will be completed before release.

### Release notes

- [x] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [ ] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12601
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
2026-05-17 18:33:39 +02:00

427 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @watch start
// templates/user/settings/**.tmpl
// web_src/css/{form,user}.css
// @watch end
import {expect, type Page} from '@playwright/test';
import {test, login_user, login} from './utils_e2e.ts';
import {screenshot} from './shared/screenshots.ts';
import {validate_form} from './shared/forms.ts';
test.beforeAll(async ({browser}, workerInfo) => {
await login_user(browser, workerInfo, 'user2');
});
test('User: Profile settings', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings');
await page.getByLabel('Full name').fill('SecondUser');
const pronounsInput = page.locator('input[list="pronouns"]');
await expect(pronounsInput).toHaveAttribute('placeholder', 'Unspecified');
await pronounsInput.click();
const pronounsList = page.locator('datalist#pronouns');
const pronounsOptions = pronounsList.locator('option');
const pronounsValues = await pronounsOptions.evaluateAll((opts) => opts.map((opt: HTMLOptionElement) => opt.value));
expect(pronounsValues).toEqual(['he/him', 'she/her', 'they/them', 'it/its', 'any pronouns']);
await pronounsInput.fill('she/her');
await page.getByPlaceholder('Tell others a little bit').fill('I am a playwright test running for several seconds.');
await page.getByPlaceholder('Tell others a little bit').press('Tab');
await page.getByLabel('Website').fill('https://forgejo.org');
await page.getByPlaceholder('Share your approximate').fill('on a computer chip');
await page.getByLabel('User visibility').click();
await page.getByLabel('Visible only to signed-in').click();
await page.getByLabel('Hide email address Email address will').uncheck();
await page.getByLabel('Hide activity from profile').check();
await validate_form({page}, 'fieldset');
await screenshot(page);
await page.getByRole('button', {name: 'Update profile'}).click();
await expect(page.getByText('Your profile has been updated.')).toBeVisible();
await page.getByRole('link', {name: 'public activity'}).click();
await expect(page.getByText('Your activity is only visible')).toBeVisible();
await screenshot(page);
await page.goto('/user2');
await expect(page.getByText('SecondUser')).toBeVisible();
await expect(page.getByText('on a computer chip')).toBeVisible();
await expect(page.locator('li').filter({hasText: 'user2@example.com'})).toBeVisible();
await expect(page.locator('li').filter({hasText: 'https://forgejo.org'})).toBeVisible();
await expect(page.getByText('I am a playwright test')).toBeVisible();
await screenshot(page);
await page.goto('/user/settings');
await page.locator('input[list="pronouns"]').fill('rob/ot');
await page.getByLabel('User visibility').click();
await page.getByLabel('Visible to everyone').click();
await page.getByLabel('Hide email address Email address will').check();
await page.getByLabel('Hide activity from profile').uncheck();
await expect(page.getByText('Your profile has been updated.')).toBeHidden();
await validate_form({page}, 'fieldset');
await screenshot(page);
await page.getByRole('button', {name: 'Update profile'}).click();
await expect(page.getByText('Your profile has been updated.')).toBeVisible();
await page.goto('/user2');
await expect(page.getByText('SecondUser')).toBeVisible();
await expect(page.locator('li').filter({hasText: 'user2@example.com'})).toBeHidden();
await page.goto('/user2?tab=activity');
await expect(page.getByText('Your activity is visible to everyone')).toBeVisible();
});
test('User: Storage overview', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/storage_overview');
await page.waitForLoadState();
await page.getByLabel('Git LFS 8 KiB').nth(1).hover({position: {x: 250, y: 2}});
await expect(page.getByText('Git LFS 8 KiB')).toBeVisible();
// Show/hide legend by clicking on the bar
await expect(page.locator('.stats ul').nth(1)).toBeHidden();
await expect(page.getByText('Git LFS 8 KiB').nth(1)).toBeHidden();
await page.locator('.stats summary').nth(1).click();
await expect(page.locator('.stats ul').nth(1)).toBeVisible();
await expect(page.getByText('Git LFS 8 KiB').nth(1)).toBeVisible();
await screenshot(page);
await page.locator('.stats summary').nth(1).click();
await expect(page.locator('.stats ul').nth(1)).toBeHidden();
await expect(page.getByText('Git LFS 8 KiB').nth(1)).toBeHidden();
await screenshot(page);
});
test('User: Canceling adding SSH key clears inputs', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/keys');
await page.locator('#add-ssh-button').click();
await page.getByLabel('Key name').fill('MyAwesomeKey');
await page.locator('#ssh-key-content').fill('Wront key material');
await page.getByRole('button', {name: 'Cancel'}).click();
await page.locator('#add-ssh-button').click();
const keyName = page.getByLabel('Key name');
await expect(keyName).toHaveValue('');
const content = page.locator('#ssh-key-content');
await expect(content).toHaveValue('');
});
test('User: Canceling adding GPG key clears input', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/keys');
await page.locator('.show-panel[data-panel="#add-gpg-key-panel"]').click();
const gpgKeyContent = page.locator('#gpg-key-content');
await gpgKeyContent.fill('Wront key material');
await page.locator('.hide-panel[data-panel="#add-gpg-key-panel"]').click();
await expect(gpgKeyContent).toHaveValue('');
});
test('User: Add access token', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/applications');
await page.getByRole('link', {name: 'New access token'}).click();
await page.locator('#scoped-access-submit').click();
await page.locator('#name:invalid').isVisible();
await page.selectOption('#access-token-scope-activitypub', 'read:activitypub');
await page.locator('#scoped-access-submit').click();
await page.locator('#name:invalid').isVisible();
await expect(page.locator('#access-token-scope-activitypub')).toHaveValue('read:activitypub');
const tokenName = globalThis.crypto.randomUUID();
await page.locator('#name').fill(tokenName);
await page.getByRole('radio', {name: /^All /}).click();
await page.locator('#scoped-access-submit').click();
await expect(page.locator('.ui.info.message.flash-info')).toBeVisible();
const flashText = await page.locator('.ui.info.message.flash-info').textContent();
expect(flashText?.trim()).toMatch(/^[0-9a-f]{40}$/);
await page.getByText(tokenName).isVisible();
});
test('User: Add access token validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/applications');
await page.getByRole('link', {name: 'New access token'}).click();
await page.getByRole('button', {name: 'Generate token'}).click();
await page.locator('#name:invalid').isVisible();
await page.getByRole('textbox', {name: 'Token name *'}).fill('Token A');
await page.getByRole('combobox', {name: 'activitypub'}).selectOption('read:activitypub');
await page.getByRole('radio', {name: 'Public only'}).click();
await page.getByRole('button', {name: 'Generate token'}).click();
await page.getByText('has been used as an application name already.').isVisible();
// validate that selected options (public-only, activitypub) are still selected after the validation error.
await expect(page.getByRole('radio', {name: 'Public only'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'activitypub'})).toHaveValue('read:activitypub');
});
test('User: Add specific repo access token', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/applications');
await page.getByRole('link', {name: 'New access token'}).click();
const tokenName = globalThis.crypto.randomUUID();
await page.getByRole('textbox', {name: /^Token name/}).fill(tokenName);
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
// clicking specific repositories will display currently available repositories:
await expect(page.getByText('org17/big_test_private_4')).toBeHidden();
await page.getByRole('radio', {name: 'Specific repositories'}).click();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await expect(page.getByText('user2/commits_search_test')).toBeVisible(); // another repo, will be used to verify search worked
await page.getByPlaceholder('Search repos…').fill('big_test_private_4');
await page.getByRole('button', {name: 'Search…'}).click();
// verify search results visible:
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await expect(page.getByText('user2/commits_search_test')).toBeHidden();
// after performing a search, verify that the token name, 'selected repositories', and selected permissions are maintained
await expect(page.getByRole('textbox', {name: /^Token name/})).toHaveValue(tokenName);
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
// Add the big_test_private_4 repo.
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
await expect(page.getByText('Selected repository (1)')).toBeVisible();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
// Remove it to test remove, and then re-add
await page.getByRole('button', {name: 'Remove org17/big_test_private_4'}).click();
await expect(page.getByText('Selected repositories (0)')).toBeVisible();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
// Create the token and check for success.
await page.getByRole('button', {name: 'Generate token'}).click();
await expect(page.locator('.ui.info.message.flash-info')).toBeVisible();
const flashText = await page.locator('.ui.info.message.flash-info').textContent();
expect(flashText?.trim()).toMatch(/^[0-9a-f]{40}$/);
await page.getByText(tokenName).isVisible();
});
// Test that validation errors on the repo-specific access token page retain all the entered field values when the
// error is displayed.
test('User: Add specific repo access token error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/applications');
await page.getByRole('link', {name: 'New access token'}).click();
await page.getByRole('textbox', {name: /^Token name/}).fill('Token A');
await page.getByRole('combobox', {name: 'repository'}).selectOption('read:repository');
await page.getByRole('radio', {name: 'Specific repositories'}).click();
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
// Create the token, verify error, then check all the fields for retained values.
await page.getByRole('button', {name: 'Generate token'}).click();
await page.getByText('has been used as an application name already.').isVisible();
await expect(page.getByRole('textbox', {name: /^Token name/})).toHaveValue('Token A');
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('read:repository');
await expect(page.getByRole('button', {name: 'Remove org17/big_test_private_4'})).toBeVisible();
});
test('User: List authorized integrations', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await expect(page.locator('.flex-item-title')).toContainText('Example AI');
await expect(page.locator('.flex-item-body')).toContainText('Added on 2026-05-16');
await expect(page.locator('.flex-item-body')).toContainText('No recent activity');
});
async function validateClaimRules(page: Page, expected: string) {
await expect(async () => {
const internal = await page.evaluate(() => Array.from(window.codeEditors)[0].state.doc.toString());
expect(internal).toStrictEqual(expected);
}).toPass({timeout: 3000});
await expect(page.locator('#claim_rules')).toHaveValue(expected);
}
test('User: View authorized integration', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('Example AI');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('This is an authorized integration.\nThis example is just for viewing and editing.');
await expect(page.getByRole('textbox', {name: 'Audience (aud Claim)'})).toHaveValue('u:2:7a6a47fb-6252-48b2-b0bb-e39158b11a36');
await expect(page.getByRole('textbox', {name: 'Issuer (iss Claim)'})).toHaveValue('urn:forgejo:authorized-integrations:actions');
// Claim rules JSON codemirror editor:
const editor = page.locator('.cm-content');
await expect(editor).toHaveAttribute('data-language', 'json', {timeout: 3000});
await validateClaimRules(page, '{\n "rules": null\n}');
await expect(page.getByRole('radio', {name: 'All (public, private, and limited)'})).toBeChecked();
await expect(page.getByRole('radio', {name: 'Public only'})).not.toBeChecked();
await expect(page.getByRole('radio', {name: 'Specific repositories'})).not.toBeChecked();
await expect(page.getByRole('combobox', {name: 'issue'})).toHaveValue('read:issue');
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('write:repository');
await expect(page.getByRole('combobox', {name: 'user'})).toHaveValue('');
await expect(page.getByRole('combobox', {name: 'admin'})).toBeHidden(); // not an admin user
});
test('User: Edit authorized integration basic fields', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await page.getByRole('textbox', {name: 'Name'}).fill('Example AI (Updated!)');
await page.getByRole('textbox', {name: 'Description'}).fill('Updated by Edit authorized integration basic field test');
await page.getByRole('button', {name: 'Save authorized integration'}).click();
// Returns to the list page; validate the updated name is present, and that it isn't marked
// as "used" just because it was edited:
await expect(page.locator('.flex-item-title')).toContainText('Example AI (Updated!)');
await expect(page.locator('.flex-item-body')).toContainText('Added on 2026-05-16');
await expect(page.locator('.flex-item-body')).toContainText('No recent activity');
// Reopen to check description:
await page.getByRole('link', {name: 'Edit'}).click();
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue('Example AI (Updated!)');
await expect(page.getByRole('textbox', {name: 'Description'})).toHaveValue('Updated by Edit authorized integration basic field test');
// Restore values to avoid affecting other tests and other platforms:
await page.getByRole('textbox', {name: 'Name'}).fill('Example AI');
await page.getByRole('textbox', {name: 'Description'}).fill('This is an authorized integration.\nThis example is just for viewing and editing.');
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
});
test('User: Edit authorized integration basic fields validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await page.getByRole('textbox', {name: 'Name'}).fill('\t'); // trims to empty
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flash-error')).toContainText('Authorized integration name is required.');
await expect(page.getByRole('textbox', {name: 'Name'}).locator('..')).toHaveClass('required field error');
});
test('User: Edit authorized integration issuer validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
await page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).fill('ftp://example.org'); // designed to hit "unsupported URL scheme" error, no external traffic involved
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flash-error')).toContainText(/Issuer validation failed:/);
await expect(page.getByRole('textbox', {name: 'Issuer (iss Claim)'}).locator('..')).toHaveClass('required field error');
});
test('User: Edit authorized integration claim rules', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
const editor = page.locator('.cm-content');
await editor.click(); // Focus codemirror editor
await page.keyboard.press('ControlOrMeta+A'); // select all
await page.keyboard.press('Backspace'); // delete
await page.keyboard.type('{"rules": [{"claim": "sub", "compare": "eq", "value": "a subject"}]}', {delay: 10});
await page.getByRole('button', {name: 'Save authorized integration'}).click();
// Reopen to check claim rules saved:
await page.getByRole('link', {name: 'Edit'}).click();
await validateClaimRules(page, '{\n "rules": [\n {\n "claim": "sub",\n "compare": "eq",\n "value": "a subject"\n }\n ]\n}');
// Restore values to avoid affecting other tests and other platforms:
await editor.click(); // Focus codemirror editor
await page.keyboard.press('ControlOrMeta+A'); // select all
await page.keyboard.press('Backspace'); // delete
await page.keyboard.type('{"rules": null}', {delay: 10});
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
});
test('User: Edit authorized integration claim rules validation error', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
const editor = page.locator('.cm-content');
await editor.click(); // Focus codemirror editor
await page.keyboard.type('{{{{{{', {delay: 10}); // type some incomplete garbage at the end
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flash-error')).toContainText(/Claim Rules validation failed:/);
});
test('User: Edit authorized integration specific repo', async ({browser}, workerInfo) => {
const page = await login({browser}, workerInfo);
await page.goto('/user/settings/authorized-integrations');
await page.getByRole('link', {name: 'Edit'}).click();
// clicking specific repositories will display currently available repositories:
await expect(page.getByText('org17/big_test_private_4')).toBeHidden();
await page.getByRole('radio', {name: 'Specific repositories'}).click();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await expect(page.getByText('user2/commits_search_test')).toBeVisible(); // another repo, will be used to verify search worked
await page.getByPlaceholder('Search repos…').fill('big_test_private_4');
await page.getByRole('button', {name: 'Search…'}).click();
// verify search results visible:
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await expect(page.getByText('user2/commits_search_test')).toBeHidden();
// after performing a search, verify that the name, 'selected repositories', and selected permissions are maintained
await expect(page.getByRole('textbox', {name: 'Name'})).toHaveValue(/^Example AI/);
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('combobox', {name: 'repository'})).toHaveValue('write:repository');
// Add the big_test_private_4 repo.
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
await expect(page.getByText('Selected repository (1)')).toBeVisible();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
// Remove it to test remove, and then re-add
await page.getByRole('button', {name: 'Remove org17/big_test_private_4'}).click();
await expect(page.getByText('Selected repositories (0)')).toBeVisible();
await expect(page.getByText('org17/big_test_private_4')).toBeVisible();
await page.getByRole('button', {name: 'Add org17/big_test_private_4'}).click();
// Save authorized integration
await page.getByRole('button', {name: 'Save authorized integration'}).click();
// Reopen to check change to repo-specific was saved:
await page.getByRole('link', {name: 'Edit'}).click();
await expect(page.getByRole('radio', {name: 'All (public, private, and limited)'})).not.toBeChecked();
await expect(page.getByRole('radio', {name: 'Public only'})).not.toBeChecked();
await expect(page.getByRole('radio', {name: 'Specific repositories'})).toBeChecked();
await expect(page.getByRole('button', {name: 'Remove org17/big_test_private_4'})).toBeVisible();
// Restore values to avoid affecting other tests and other platforms:
await page.getByRole('radio', {name: 'All (public, private, and limited)'}).click();
await page.getByRole('button', {name: 'Save authorized integration'}).click();
await expect(page.locator('.flex-item-title')).toContainText('Example AI'); // ensure save completes and we land on list page
});