From 4af6ecf629c6747a84966e93cefaf56a364f4ff8 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 22 Oct 2024 07:13:04 -0700 Subject: [PATCH 1/8] web: Isolate the OAuth2 Provider Form into a reusable rendering function - Pull the OAuth2 Provider Form `render()` method out into a standalone function. - Why: So it can be shared by both the Wizard and the Provider function. The renderer is (or at least, can be) a pure function: you give it input and it produces HTML, *and then it stops*. - Provide a test harness that can test the OAuth2 provider form. --- ...lication-wizard-authentication-by-oauth.ts | 257 +------------ web/src/admin/providers/ProviderWizard.ts | 1 + .../providers/oauth2/OAuth2ProviderForm.ts | 341 +---------------- .../oauth2/OAuth2ProviderFormForm.ts | 354 ++++++++++++++++++ web/src/elements/forms/FormGroup.ts | 58 +-- web/tests/pageobjects/controls.ts | 102 +++++ web/tests/pageobjects/page.ts | 74 +++- web/tests/specs/oauth-provider.ts | 45 ++- 8 files changed, 602 insertions(+), 630 deletions(-) create mode 100644 web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts create mode 100644 web/tests/pageobjects/controls.ts diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts index 14700b1506ad..09422aabe417 100644 --- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts +++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -1,37 +1,9 @@ -import "@goauthentik/admin/applications/wizard/ak-wizard-title"; -import "@goauthentik/admin/common/ak-crypto-certificate-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; -import { - makeOAuth2PropertyMappingsSelector, - oauth2PropertyMappingsProvider, -} from "@goauthentik/admin/providers/oauth2/OAuth2PropertyMappings.js"; -import { - clientTypeOptions, - issuerModeOptions, - redirectUriHelp, - subjectModeOptions, -} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; -import { - makeSourceSelector, - oauth2SourcesProvider, -} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js"; +import { renderForm } from "@goauthentik/admin/providers/oauth2/OAuth2ProviderFormForm.js"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; -import "@goauthentik/components/ak-number-input"; -import "@goauthentik/components/ak-radio-input"; -import "@goauthentik/components/ak-switch-input"; -import "@goauthentik/components/ak-text-input"; -import "@goauthentik/components/ak-textarea-input"; -import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; -import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; -import { html, nothing } from "lit"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { ClientTypeEnum, FlowsInstancesListDesignationEnum, SourcesApi } from "@goauthentik/api"; +import { SourcesApi } from "@goauthentik/api"; import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @@ -59,227 +31,10 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { render() { const provider = this.wizard.provider as OAuth2Provider | undefined; const errors = this.wizard.errors.provider; - - return html`${msg("Configure OAuth2/OpenId Provider")} -
- - - - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
- - - ${msg("Protocol settings")} -
- ) => { - this.showClientSecret = ev.detail.value !== ClientTypeEnum.Public; - }} - .options=${clientTypeOptions} - > - - - - - - - - - - - - - - -

- ${msg("Key used to sign the tokens.")} -

-
-
-
- - - ${msg("Advanced protocol settings")} -
- - ${msg("Configure how long access codes are valid for.")} -

- `} - > -
- - - ${msg("Configure how long access tokens are valid for.")} -

- `} - > -
- - - ${msg("Configure how long refresh tokens are valid for.")} -

- `} - > -
- - - -

- ${msg( - "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", - )} -

-
- - - - - - -
-
- - - ${msg("Machine-to-Machine authentication settings")} -
- - -

- ${msg( - "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", - )} -

-
-
-
-
`; + const showClientSecretCallback = (show: boolean) => { + this.showClientSecret = show; + }; + return renderForm(provider ?? {}, errors, this.showClientSecret, showClientSecretCallback); } } diff --git a/web/src/admin/providers/ProviderWizard.ts b/web/src/admin/providers/ProviderWizard.ts index 51e159c671c8..2c7ba266b97c 100644 --- a/web/src/admin/providers/ProviderWizard.ts +++ b/web/src/admin/providers/ProviderWizard.ts @@ -58,6 +58,7 @@ export class ProviderWizard extends AKElement { }} > html`

${m}

`, -)}`; +import { renderForm } from "./OAuth2ProviderFormForm.js"; /** * Form page for OAuth2 Authentication Method @@ -145,233 +40,11 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { } } - renderForm(): TemplateResult { - const provider = this.instance; - - return html` - - - ${msg("Protocol settings")} -
- ) => { - this.showClientSecret = ev.detail.value !== ClientTypeEnum.Public; - }} - .options=${clientTypeOptions} - > - - - - - - - - - - - -

${msg("Key used to sign the tokens.")}

-
- - - -

- ${msg("Key used to encrypt the tokens.")} -

-
-
-
- - - ${msg("Flow settings")} -
- - -

- ${msg( - "Flow used when a user access this provider and is not authenticated.", - )} -

-
- - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
-
-
- - - ${msg("Advanced protocol settings")} -
- - ${msg("Configure how long access codes are valid for.")} -

- `} - > -
- - ${msg("Configure how long access tokens are valid for.")} -

- `} - > -
- - - ${msg("Configure how long refresh tokens are valid for.")} -

- `} - > -
- - - -

- ${msg( - "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", - )} -

-
- - - - - -
-
- - - ${msg("Machine-to-Machine authentication settings")} -
- - -

- ${msg( - "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", - )} -

-
-
-
`; + renderForm() { + const showClientSecretCallback = (show: boolean) => { + this.showClientSecret = show; + }; + return renderForm(this.instance ?? {}, [], this.showClientSecret, showClientSecretCallback); } } diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts new file mode 100644 index 000000000000..35a2ac4cea0e --- /dev/null +++ b/web/src/admin/providers/oauth2/OAuth2ProviderFormForm.ts @@ -0,0 +1,354 @@ +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/components/ak-textarea-input"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + ClientTypeEnum, + FlowsInstancesListDesignationEnum, + IssuerModeEnum, + OAuth2Provider, + SubModeEnum, + ValidationError, +} from "@goauthentik/api"; + +import { + makeOAuth2PropertyMappingsSelector, + oauth2PropertyMappingsProvider, +} from "./OAuth2PropertyMappings.js"; +import { makeSourceSelector, oauth2SourcesProvider } from "./OAuth2Sources.js"; + +export const clientTypeOptions = [ + { + label: msg("Confidential"), + value: ClientTypeEnum.Confidential, + default: true, + description: html`${msg( + "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets", + )}`, + }, + { + label: msg("Public"), + value: ClientTypeEnum.Public, + description: html`${msg( + "Public clients are incapable of maintaining the confidentiality and should use methods like PKCE. ", + )}`, + }, +]; + +export const subjectModeOptions = [ + { + label: msg("Based on the User's hashed ID"), + value: SubModeEnum.HashedUserId, + default: true, + }, + { + label: msg("Based on the User's ID"), + value: SubModeEnum.UserId, + }, + { + label: msg("Based on the User's UUID"), + value: SubModeEnum.UserUuid, + }, + { + label: msg("Based on the User's username"), + value: SubModeEnum.UserUsername, + }, + { + label: msg("Based on the User's Email"), + value: SubModeEnum.UserEmail, + description: html`${msg("This is recommended over the UPN mode.")}`, + }, + { + label: msg("Based on the User's UPN"), + value: SubModeEnum.UserUpn, + description: html`${msg( + "Requires the user to have a 'upn' attribute set, and falls back to hashed user ID. Use this mode only if you have different UPN and Mail domains.", + )}`, + }, +]; + +export const issuerModeOptions = [ + { + label: msg("Each provider has a different issuer, based on the application slug"), + value: IssuerModeEnum.PerProvider, + default: true, + }, + { + label: msg("Same identifier is used for all providers"), + value: IssuerModeEnum.Global, + }, +]; + +const redirectUriHelpMessages = [ + msg( + "Valid redirect URLs after a successful authorization flow. Also specify any origins here for Implicit flows.", + ), + msg( + "If no explicit redirect URIs are specified, the first successfully used redirect URI will be saved.", + ), + msg( + 'To allow any redirect URI, set this value to ".*". Be aware of the possible security implications this can have.', + ), +]; + +export const redirectUriHelp = html`${redirectUriHelpMessages.map( + (m) => html`

${m}

`, +)}`; + +type ShowClientSecret = (show: boolean) => void; +const defaultShowClientSecret: ShowClientSecret = (_show) => undefined; + +export function renderForm( + provider: Partial, + errors: ValidationError, + showClientSecret = false, + showClientSecretCallback: ShowClientSecret = defaultShowClientSecret, +) { + return html` + + + ${msg("Protocol settings")} +
+ ) => { + showClientSecretCallback(ev.detail.value !== ClientTypeEnum.Public); + }} + .options=${clientTypeOptions} + > + + + + + + + + + + + +

${msg("Key used to sign the tokens.")}

+
+ + + +

${msg("Key used to encrypt the tokens.")}

+
+
+
+ + + ${msg("Flow settings")} +
+ + +

+ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +

+
+ + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+
+ + + ${msg("Advanced protocol settings")} +
+ + ${msg("Configure how long access codes are valid for.")} +

+ `} + > +
+ + ${msg("Configure how long access tokens are valid for.")} +

+ `} + > +
+ + + ${msg("Configure how long refresh tokens are valid for.")} +

+ `} + > +
+ + + +

+ ${msg( + "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", + )} +

+
+ + + + + +
+
+ + + ${msg("Machine-to-Machine authentication settings")} +
+ + +

+ ${msg( + "JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.", + )} +

+
+
+
`; +} diff --git a/web/src/elements/forms/FormGroup.ts b/web/src/elements/forms/FormGroup.ts index 285bc5acf0f2..92986eaecf4a 100644 --- a/web/src/elements/forms/FormGroup.ts +++ b/web/src/elements/forms/FormGroup.ts @@ -44,37 +44,43 @@ export class FormGroup extends AKElement { } render(): TemplateResult { - return html`
-
-
- + return html`
+
+
+
+ +
-
-
-
-
-
- +
+
+
+
+ +
+
+
+
-
-
-
+
-
`; } } diff --git a/web/tests/pageobjects/controls.ts b/web/tests/pageobjects/controls.ts new file mode 100644 index 000000000000..cb14cd17a584 --- /dev/null +++ b/web/tests/pageobjects/controls.ts @@ -0,0 +1,102 @@ +import { browser } from "@wdio/globals"; +import { match } from "ts-pattern"; +import { ChainablePromiseArray, Key } from "webdriverio"; + +browser.addCommand('findByText', async function(items: ChainablePromiseArray, text: string) { + let item: WebdriverIO.Element | undefined = undefined; + for (const i of items) { + const label = await i.getText(); + if (label.indexOf(text) !== -1) { + item = i; + break; + } + } + return item; +}, true); + +export async function setSearchSelect(name: string, value: string) { + const control = await (async () => { + try { + const control = await $(`ak-search-select[name="${name}"]`); + await control.waitForExist({ timeout: 500 }); + return control; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + } catch (_e: any) { + const control = await $(`ak-search-selects-ez[name="${name}"]`); + return control; + } + })(); + + // Find the search select input control and activate it. + const view = await control.$("ak-search-select-view"); + const input = await view.$('input[type="text"]'); + await input.scrollIntoView(); + await input.click(); + + // Weirdly necessary because it's portals! + const searchBlock = await ( + await $(`div[data-managed-for*="${name}"]`).$("ak-list-select") + ).shadow$$("button"); + + // @ts-expect-error "Types break on shadow$$" + for (const button of searchBlock) { + if ((await button.getText()).includes(value)) { + target = button; + break; + } + } + // @ts-expect-error "TSC cannot tell if the `for` loop actually performs the assignment." + if (!target) { + throw new Error(`Expected to find an entry matching the spec ${value}`); + } + await (await target).click(); + await browser.keys(Key.Tab); +} + +export async function setTextInput(name: string, value: string) { + const control = await $(`input[name="${name}"]`); + await control.scrollIntoView(); + await control.setValue(value); +} + +export async function setRadio(name: string, value: string) { + const control = await $(`ak-radio[name="${name}"]`); + await control.scrollIntoView(); + const item = await control.$(`label.*=${value}`).parentElement(); + await item.scrollIntoView(); + await item.click(); +} + +export async function setTypeCreate(name: string, value: string) { + const control = await $(`ak-wizard-page-type-create[name="${name}"]`); + await control.scrollIntoView(); + const cards = ; + const selection = await findByText(await control.$$("div.pf-c-card__title"), value); + await selection.scrollIntoView(); + await selection.click(); +} + +export async function setFormGroup(name: string, setting: "open" | "closed") { + const formGroup = await $(`.//span[contains(., "${name}")]`); + await formGroup.scrollIntoView(); + const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); + await match([toggle.getAttribute("expanded"), setting]) + .with(["false", "open"], async () => await toggle.click()) + .with(["true", "closed"], async () => await toggle.click()) + .otherwise(async () => {}); +} + +export async function clickButton(name: string, ctx?: WebdriverIO.Element) { + const context = ctx ?? browser; + const buttons = await context.$$("button"); + let button: WebdriverIO.Element; + for (const b of buttons) { + const label = await b.getText(); + if (label.indexOf(name) !== -1) { + button = b; + break; + } + } + await button.scrollIntoView(); + await button.click(); +} diff --git a/web/tests/pageobjects/page.ts b/web/tests/pageobjects/page.ts index a5b5f15a0292..ae225ce06c1a 100644 --- a/web/tests/pageobjects/page.ts +++ b/web/tests/pageobjects/page.ts @@ -1,4 +1,5 @@ import { browser } from "@wdio/globals"; +import { match } from "ts-pattern"; import { Key } from "webdriverio"; const CLICK_TIME_DELAY = 250; @@ -7,6 +8,7 @@ const CLICK_TIME_DELAY = 250; * Main page object containing all methods, selectors and functionality that is shared across all * page objects */ + export default class Page { /** * Opens a sub page of the page @@ -31,7 +33,6 @@ export default class Page { * why it would be hard to simplify this further (`flow` vs `tentanted-flow` vs a straight-up * SearchSelect each have different a `searchSelector`). */ - async searchSelect(searchSelector: string, managedSelector: string, buttonSelector: string) { const inputBind = await $(searchSelector); const inputMain = await inputBind.$('input[type="text"]'); @@ -55,6 +56,77 @@ export default class Page { await browser.keys(Key.Tab); } + async setSearchSelect(name: string, value: string) { + const control = await (async () => { + try { + const control = await $(`ak-search-select[name="${name}"]`); + await control.waitForExist({ timeout: 500 }); + return control; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars + } catch (_e: any) { + const control = await $(`ak-search-selects-ez[name="${name}"]`); + return control; + } + })(); + + // Find the search select input control and activate it. + const view = await control.$("ak-search-select-view"); + const input = await view.$('input[type="text"]'); + await input.scrollIntoView(); + await input.click(); + + // Weirdly necessary because it's portals! + const searchBlock = await ( + await $(`div[data-managed-for="${name}"]`).$("ak-list-select") + ).shadow$$("button"); + + // @ts-expect-error "Types break on shadow$$" + for (const button of searchBlock) { + if ((await button.getText()).includes(value)) { + target = button; + break; + } + } + // @ts-expect-error "TSC cannot tell if the `for` loop actually performs the assignment." + if (!target) { + throw new Error(`Expected to find an entry matching the spec ${value}`); + } + await (await target).click(); + await browser.keys(Key.Tab); + } + + async setTextInput(name: string, value: string) { + const control = await $(`input[name="${name}"}`); + await control.scrollIntoView(); + await control.setValue(value); + } + + async setRadio(name: string, value: string) { + const control = await $(`ak-radio[name="${name}"]`); + await control.scrollIntoView(); + const item = await control.$(`label.*=${value}`).parentElement(); + await item.scrollIntoView(); + await item.click(); + } + + async setTypeCreate(name: string, value: string) { + const control = await $(`ak-wizard-page-type-create[name="${name}"]`); + await control.scrollIntoView(); + const selection = await $(`.pf-c-card__.*=${value}`); + await selection.scrollIntoView(); + await selection.click(); + } + + async setFormGroup(name: string, setting: "open" | "closed") { + const formGroup = await $(`ak-form-group span[slot="header"].*=${name}`).parentElement(); + await formGroup.scrollIntoView(); + const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); + await match([toggle.getAttribute("expanded"), setting]) + .with(["false", "open"], async () => await toggle.click()) + .with(["true", "closed"], async () => await toggle.click()) + .otherwise(async () => {}); + } + public async logout() { await browser.url("http://localhost:9000/flows/-/default/invalidation/"); return await this.pause(); diff --git a/web/tests/specs/oauth-provider.ts b/web/tests/specs/oauth-provider.ts index 69557e989150..0cb5393986d5 100644 --- a/web/tests/specs/oauth-provider.ts +++ b/web/tests/specs/oauth-provider.ts @@ -1,4 +1,11 @@ import { expect } from "@wdio/globals"; +import { + clickButton, + setFormGroup, + setSearchSelect, + setTextInput, + setTypeCreate, +} from "pageobjects/controls.js"; import ProviderWizardView from "../pageobjects/provider-wizard.page.js"; import ProvidersListPage from "../pageobjects/providers-list.page.js"; @@ -16,6 +23,14 @@ async function reachTheProvider() { await expect(await ProviderWizardView.wizardTitle).toHaveText("New provider"); } +async function fillOutFields(fields: FieldDesc[]) { + for (const field of fields) { + const thefunc = field[0]; + const args = field.slice(1); + await thefunc.apply($, args); + } +} + describe("Configure Oauth2 Providers", () => { it("Should configure a simple LDAP Application", async () => { const newProviderName = `New OAuth2 Provider - ${randomId()}`; @@ -23,25 +38,19 @@ describe("Configure Oauth2 Providers", () => { await reachTheProvider(); await $("ak-wizard-page-type-create").waitForDisplayed(); - await $('div[data-ouid-component-name="oauth2provider"]').scrollIntoView(); - await $('div[data-ouid-component-name="oauth2provider"]').click(); - await ProviderWizardView.nextButton.click(); - await ProviderWizardView.pause(); + await setTypeCreate("selectProviderType", "OAuth2/OpenID Provider"); + await clickButton("Next"); + + // prettier-ignore + await fillOutFields([ + [setTextInput, "name", newProviderName], + [setFormGroup, "Flow settings", "open"], + [setSearchSelect, "authenticationFlow", "default-authentication-flow"], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], + [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], + ]); - return await $('ak-form-element-horizontal[name="name"]').$("input"); - await ProviderWizardView.oauth.setAuthorizationFlow( - "default-provider-authorization-explicit-consent", - ); - await ProviderWizardView.nextButton.click(); await ProviderWizardView.pause(); - - await ProvidersListPage.searchInput.setValue(newProviderName); - await ProvidersListPage.clickSearchButton(); - await ProvidersListPage.pause(); - - const newProvider = await ProvidersListPage.findProviderRow(); - await newProvider.waitForDisplayed(); - expect(newProvider).toExist(); - expect(await newProvider.getText()).toHaveText(newProviderName); + await ProviderWizardView.nextButton.click(); }); }); From 4439b298bd483f5dd3427696e0ff1ad74997963f Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 22 Oct 2024 14:21:16 -0700 Subject: [PATCH 2/8] Still trying to find components by internal text. Still not working. --- web/package-lock.json | 8 ++++---- web/package.json | 2 +- web/tests/pageobjects/controls.ts | 31 +++++++++++++++++-------------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 7137bca49ec6..60f6721a70b6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -81,7 +81,7 @@ "@wdio/cli": "^9.1.2", "@wdio/spec-reporter": "^9.1.2", "chokidar": "^4.0.1", - "chromedriver": "^129.0.2", + "chromedriver": "^130.0.0", "esbuild": "^0.24.0", "eslint": "^9.11.1", "eslint-plugin-lit": "^1.15.0", @@ -8642,9 +8642,9 @@ } }, "node_modules/chromedriver": { - "version": "129.0.2", - "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-129.0.2.tgz", - "integrity": "sha512-rUEFCJAmAwOdFfaDFtveT97fFeA7NOxlkgyPyN+G09Ws4qGW39aLDxMQBbS9cxQQHhTihqZZobgF5CLVYXnmGA==", + "version": "130.0.0", + "resolved": "https://registry.npmjs.org/chromedriver/-/chromedriver-130.0.0.tgz", + "integrity": "sha512-1g1eMoKF22Uh6l8DTFOPvWLovINPrkAMw7yDHlF6Rx+4W4JI9aGdCZ2Cx7c181hUgALU1oSKGH3uKNryYM5DaQ==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/web/package.json b/web/package.json index a0ca9b58df22..11d23a54b46d 100644 --- a/web/package.json +++ b/web/package.json @@ -69,7 +69,7 @@ "@wdio/cli": "^9.1.2", "@wdio/spec-reporter": "^9.1.2", "chokidar": "^4.0.1", - "chromedriver": "^129.0.2", + "chromedriver": "^130.0.0", "esbuild": "^0.24.0", "eslint": "^9.11.1", "eslint-plugin-lit": "^1.15.0", diff --git a/web/tests/pageobjects/controls.ts b/web/tests/pageobjects/controls.ts index cb14cd17a584..ba1dcafce759 100644 --- a/web/tests/pageobjects/controls.ts +++ b/web/tests/pageobjects/controls.ts @@ -2,18 +2,6 @@ import { browser } from "@wdio/globals"; import { match } from "ts-pattern"; import { ChainablePromiseArray, Key } from "webdriverio"; -browser.addCommand('findByText', async function(items: ChainablePromiseArray, text: string) { - let item: WebdriverIO.Element | undefined = undefined; - for (const i of items) { - const label = await i.getText(); - if (label.indexOf(text) !== -1) { - item = i; - break; - } - } - return item; -}, true); - export async function setSearchSelect(name: string, value: string) { const control = await (async () => { try { @@ -67,11 +55,26 @@ export async function setRadio(name: string, value: string) { await item.click(); } +browser.addCommand( + "findInside$", + async function (these: WebdriverIO.ElementArray, selector: string) { + // prettier-ignore + console.log("HERE!!!!!!!!!"); + for await (const item of these) { + const wanted = item.$(selector); + if (wanted.isExisting()) { + return wanted; + } + } + return undefined; + }, + true, +); + export async function setTypeCreate(name: string, value: string) { const control = await $(`ak-wizard-page-type-create[name="${name}"]`); await control.scrollIntoView(); - const cards = ; - const selection = await findByText(await control.$$("div.pf-c-card__title"), value); + const selection = await $$("ak-type-create-grid-card").findInside$(`div*=${value}`); await selection.scrollIntoView(); await selection.click(); } From f9f849574b32243a0b49fcc7a8707b73ec745ffc Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 23 Oct 2024 10:50:27 -0700 Subject: [PATCH 3/8] We have working tests!!!!!! --- .../admin/common/ak-flow-search/FlowSearch.ts | 3 +- web/tests/pageobjects/controls.ts | 78 ++++++++++--------- web/tests/specs/oauth-provider.ts | 31 +++++++- 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/web/src/admin/common/ak-flow-search/FlowSearch.ts b/web/src/admin/common/ak-flow-search/FlowSearch.ts index 960c0f80991a..74ccd212ac3c 100644 --- a/web/src/admin/common/ak-flow-search/FlowSearch.ts +++ b/web/src/admin/common/ak-flow-search/FlowSearch.ts @@ -7,6 +7,7 @@ import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter" import { html } from "lit"; import { property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { FlowsApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api"; import type { Flow, FlowsInstancesListRequest } from "@goauthentik/api"; @@ -122,7 +123,7 @@ export class FlowSearch extends CustomListenerElement(AKElement) .renderElement=${renderElement} .renderDescription=${renderDescription} .value=${getFlowValue} - .name=${this.name} + name=${ifDefined(this.name)} @ak-change=${this.handleSearchUpdate} ?blankable=${!this.required} > diff --git a/web/tests/pageobjects/controls.ts b/web/tests/pageobjects/controls.ts index ba1dcafce759..d84710a236ef 100644 --- a/web/tests/pageobjects/controls.ts +++ b/web/tests/pageobjects/controls.ts @@ -1,6 +1,6 @@ import { browser } from "@wdio/globals"; import { match } from "ts-pattern"; -import { ChainablePromiseArray, Key } from "webdriverio"; +import { Key } from "webdriverio"; export async function setSearchSelect(name: string, value: string) { const control = await (async () => { @@ -21,23 +21,22 @@ export async function setSearchSelect(name: string, value: string) { await input.scrollIntoView(); await input.click(); - // Weirdly necessary because it's portals! - const searchBlock = await ( - await $(`div[data-managed-for*="${name}"]`).$("ak-list-select") - ).shadow$$("button"); - // @ts-expect-error "Types break on shadow$$" - for (const button of searchBlock) { - if ((await button.getText()).includes(value)) { - target = button; - break; + const button = await (async () => { + for await (const button of $(`div[data-managed-for*="${name}"]`) + .$("ak-list-select") + .$$("button")) { + if ((await button.getText()).includes(value)) { + return button; + } } - } + })(); + // @ts-expect-error "TSC cannot tell if the `for` loop actually performs the assignment." - if (!target) { + if (!button.isExisting()) { throw new Error(`Expected to find an entry matching the spec ${value}`); } - await (await target).click(); + await (await button).click(); await browser.keys(Key.Tab); } @@ -55,35 +54,44 @@ export async function setRadio(name: string, value: string) { await item.click(); } -browser.addCommand( - "findInside$", - async function (these: WebdriverIO.ElementArray, selector: string) { - // prettier-ignore - console.log("HERE!!!!!!!!!"); - for await (const item of these) { - const wanted = item.$(selector); - if (wanted.isExisting()) { - return wanted; +export async function setTypeCreate(name: string, value: string | RegExp) { + const control = await $(`ak-wizard-page-type-create[name="${name}"]`); + await control.scrollIntoView(); + + const comparator = + typeof value === "string" ? (sample) => sample === value : (sample) => value.test(sample); + + const card = await (async () => { + for await (const card of $("ak-wizard-page-type-create").$$( + '[data-ouid-component-type="ak-type-create-grid-card"]', + )) { + if (comparator(await card.$(".pf-c-card__title").getText())) { + return card; } } - return undefined; - }, - true, -); + })(); -export async function setTypeCreate(name: string, value: string) { - const control = await $(`ak-wizard-page-type-create[name="${name}"]`); - await control.scrollIntoView(); - const selection = await $$("ak-type-create-grid-card").findInside$(`div*=${value}`); - await selection.scrollIntoView(); - await selection.click(); + await card.scrollIntoView(); + await card.click(); } -export async function setFormGroup(name: string, setting: "open" | "closed") { - const formGroup = await $(`.//span[contains(., "${name}")]`); +export async function setFormGroup(name: string | RegExp, setting: "open" | "closed") { + const comparator = + typeof name === "string" ? (sample) => sample === name : (sample) => name.test(sample); + + const formGroup = await (async () => { + for await (const group of $$("ak-form-group")) { + if ( + comparator(await group.$("div.pf-c-form__field-group-header-title-text").getText()) + ) { + return group; + } + } + })(); + await formGroup.scrollIntoView(); const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); - await match([toggle.getAttribute("expanded"), setting]) + await match([await toggle.getAttribute("aria-expanded"), setting]) .with(["false", "open"], async () => await toggle.click()) .with(["true", "closed"], async () => await toggle.click()) .otherwise(async () => {}); diff --git a/web/tests/specs/oauth-provider.ts b/web/tests/specs/oauth-provider.ts index 0cb5393986d5..9465959ff448 100644 --- a/web/tests/specs/oauth-provider.ts +++ b/web/tests/specs/oauth-provider.ts @@ -32,19 +32,19 @@ async function fillOutFields(fields: FieldDesc[]) { } describe("Configure Oauth2 Providers", () => { - it("Should configure a simple LDAP Application", async () => { + it("Should configure a simple OAuth2 Provider", async () => { const newProviderName = `New OAuth2 Provider - ${randomId()}`; await reachTheProvider(); await $("ak-wizard-page-type-create").waitForDisplayed(); - await setTypeCreate("selectProviderType", "OAuth2/OpenID Provider"); - await clickButton("Next"); // prettier-ignore await fillOutFields([ + [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], + [clickButton, "Next"], [setTextInput, "name", newProviderName], - [setFormGroup, "Flow settings", "open"], + [setFormGroup, /Flow settings/, "open"], [setSearchSelect, "authenticationFlow", "default-authentication-flow"], [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], @@ -54,3 +54,26 @@ describe("Configure Oauth2 Providers", () => { await ProviderWizardView.nextButton.click(); }); }); + +describe("Configure LDAP Providers", () => { + it("Should configure a simple LDAP Provider", async () => { + const newProviderName = `New LDAP Provider - ${randomId()}`; + + await reachTheProvider(); + await $("ak-wizard-page-type-create").waitForDisplayed(); + + // prettier-ignore + await fillOutFields([ + [setTypeCreate, "selectProviderType", "LDAP Provider"], + [clickButton, "Next"], + [setTextInput, "name", newProviderName], + [setFormGroup, /Flow settings/, "open"], + // This will never not weird me out. + [setSearchSelect, "authorizationFlow", "default-authentication-flow"], + [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], + ]); + + await ProviderWizardView.pause(); + await ProviderWizardView.nextButton.click(); + }); +}); From a36cc820bdd33ce39b487e43acf7f9b131e79fca Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 23 Oct 2024 15:24:47 -0700 Subject: [PATCH 4/8] Radius form has been isolated. --- ...ication-wizard-authentication-by-radius.ts | 83 +-------- .../providers/radius/RadiusProviderForm.ts | 163 +----------------- .../radius/RadiusProviderFormForm.ts | 161 +++++++++++++++++ 3 files changed, 172 insertions(+), 235 deletions(-) create mode 100644 web/src/admin/providers/radius/RadiusProviderFormForm.ts diff --git a/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts b/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts index fca1666d813e..5df32e02d693 100644 --- a/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts +++ b/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts @@ -1,6 +1,7 @@ import "@goauthentik/admin/applications/wizard/ak-wizard-title"; import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; +import { renderForm } from "@goauthentik/admin/providers/radius/RadiusProviderFormForm.js"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-text-input"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; @@ -10,91 +11,21 @@ import "@goauthentik/elements/forms/HorizontalFormElement"; import { msg } from "@lit/localize"; import { customElement } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { FlowsInstancesListDesignationEnum, RadiusProvider } from "@goauthentik/api"; +import { RadiusProvider } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @customElement("ak-application-wizard-authentication-by-radius") export class ApplicationWizardAuthenticationByRadius extends WithBrandConfig(BaseProviderPanel) { render() { - const provider = this.wizard.provider as RadiusProvider | undefined; - const errors = this.wizard.errors.provider; - return html`${msg("Configure Radius Provider")}
- - - - - -

- ${msg("Flow used for users to authenticate.")} -

-
- - - ${msg("Protocol settings")} -
- - -
-
- - ${msg("Advanced flow settings")} -
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
-
+ ${renderForm( + this.wizard.provider as RadiusProvider | undefined, + this.wizard.errors.provider, + this.brand, + )}
`; } } diff --git a/web/src/admin/providers/radius/RadiusProviderForm.ts b/web/src/admin/providers/radius/RadiusProviderForm.ts index d3280d8013c7..e1832f7fc7ed 100644 --- a/web/src/admin/providers/radius/RadiusProviderForm.ts +++ b/web/src/admin/providers/radius/RadiusProviderForm.ts @@ -1,48 +1,12 @@ -import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; -import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; -import "@goauthentik/elements/forms/SearchSelect"; -import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; -import { ifDefined } from "lit-html/directives/if-defined.js"; import { customElement } from "lit/decorators.js"; -import { - FlowsInstancesListDesignationEnum, - PropertymappingsApi, - ProvidersApi, - RadiusProvider, - RadiusProviderPropertyMapping, -} from "@goauthentik/api"; +import { ProvidersApi, RadiusProvider } from "@goauthentik/api"; -export async function radiusPropertyMappingsProvider(page = 1, search = "") { - const propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsProviderRadiusList({ - ordering: "name", - pageSize: 20, - search: search.trim(), - page, - }); - return { - pagination: propertyMappings.pagination, - options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), - }; -} - -export function makeRadiusPropertyMappingsSelector(instanceMappings?: string[]) { - const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; - return localMappings - ? ([pk, _]: DualSelectPair) => localMappings.has(pk) - : ([_0, _1, _2, _]: DualSelectPair) => []; -} +import { renderForm } from "./RadiusProviderFormForm.js"; @customElement("ak-provider-radius-form") export class RadiusProviderFormPage extends WithBrandConfig(BaseProviderForm) { @@ -65,127 +29,8 @@ export class RadiusProviderFormPage extends WithBrandConfig(BaseProviderForm - - - - -

${msg("Flow used for users to authenticate.")}

-
- - -

- ${msg( - "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.", - )} -

-
- - - ${msg("Protocol settings")} -
- - - - - -

- ${msg(`List of CIDRs (comma-seperated) that clients can connect from. A more specific - CIDR will match before a looser one. Clients connecting from a non-specified CIDR - will be dropped.`)} -

-
- - - -
-
- - ${msg("Advanced flow settings")} -
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
-
- `; + renderForm() { + return renderForm(this.instance ?? {}, [], this.brand); } } diff --git a/web/src/admin/providers/radius/RadiusProviderFormForm.ts b/web/src/admin/providers/radius/RadiusProviderFormForm.ts new file mode 100644 index 000000000000..a88be56afebe --- /dev/null +++ b/web/src/admin/providers/radius/RadiusProviderFormForm.ts @@ -0,0 +1,161 @@ +import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + FlowsInstancesListDesignationEnum, + PropertymappingsApi, + RadiusProviderPropertyMapping, + ValidationError, +} from "@goauthentik/api"; + +export async function radiusPropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderRadiusList({ + ordering: "name", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +export function makeRadiusPropertyMappingsSelector(instanceMappings?: string[]) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, _]: DualSelectPair) => []; +} + +const mfaHelp = msg( + "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.", +); + +const clientNetworksHelp = msg( + "List of CIDRs (comma-seperated) that clients can connect from. A more specific CIDR will match before a looser one. Clients connecting from a non-specified CIDR will be dropped.", +); + +// All Provider objects have an Authorization flow, but not all providers have an Authentication +// flow. Radius needs only one field, but it is not the Authorization field, it is an +// Authentication field. So, yeah, we're using the authorization field to store the +// authentication information, which is why the ak-branded-flow-search call down there looks so +// weird-- we're looking up Authentication flows, but we're storing them in the Authorization +// field of the target Provider. + +export function renderForm( + provider?: Partial, + errors: ValidationError = {}, + brand?: CurrentBrand, +) { + return html` + + + + + +

${msg("Flow used for users to authenticate.")}

+
+ + + +

${mfaHelp}

+
+ + + ${msg("Protocol settings")} +
+ + + + + +
+
+ + ${msg("Advanced flow settings")} +
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+ `; +} From 99af95b10cfa397458b21919868ccebb16d361f9 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 24 Oct 2024 09:35:31 -0700 Subject: [PATCH 5/8] Committed harmony on SAML. Streamlined the tests even further. --- ...rd-authentication-by-saml-configuration.ts | 355 +-------------- .../admin/providers/saml/SAMLProviderForm.ts | 419 +----------------- .../providers/saml/SAMLProviderFormForm.ts | 394 ++++++++++++++++ web/tests/specs/providers.ts | 83 ++-- web/tests/specs/shared-sequences.ts | 35 ++ 5 files changed, 490 insertions(+), 796 deletions(-) create mode 100644 web/src/admin/providers/saml/SAMLProviderFormForm.ts create mode 100644 web/tests/specs/shared-sequences.ts diff --git a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts index 61c1f6403df3..9d419d8b0717 100644 --- a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts +++ b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts @@ -1,363 +1,34 @@ -import "@goauthentik/admin/applications/wizard/ak-wizard-title"; -import "@goauthentik/admin/applications/wizard/ak-wizard-title"; -import "@goauthentik/admin/common/ak-crypto-certificate-search"; import AkCryptoCertificateSearch from "@goauthentik/admin/common/ak-crypto-certificate-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { first } from "@goauthentik/common/utils"; -import "@goauthentik/components/ak-multi-select"; -import "@goauthentik/components/ak-number-input"; -import "@goauthentik/components/ak-radio-input"; -import "@goauthentik/components/ak-switch-input"; -import "@goauthentik/components/ak-text-input"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; -import { html, nothing } from "lit"; -import { ifDefined } from "lit/directives/if-defined.js"; +import { html } from "lit"; -import { - FlowsInstancesListDesignationEnum, - PaginatedSAMLPropertyMappingList, - PropertymappingsApi, - SAMLProvider, -} from "@goauthentik/api"; +import { SAMLProvider } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; -import { - digestAlgorithmOptions, - signatureAlgorithmOptions, - spBindingOptions, -} from "./SamlProviderOptions"; import "./saml-property-mappings-search"; @customElement("ak-application-wizard-authentication-by-saml-configuration") export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPanel { - @state() - propertyMappings?: PaginatedSAMLPropertyMappingList; - @state() hasSigningKp = false; - constructor() { - super(); - new PropertymappingsApi(DEFAULT_CONFIG) - .propertymappingsProviderSamlList({ - ordering: "saml_name", - }) - .then((propertyMappings: PaginatedSAMLPropertyMappingList) => { - this.propertyMappings = propertyMappings; - }); - } - - propertyMappingConfiguration(provider?: SAMLProvider) { - const propertyMappings = this.propertyMappings?.results ?? []; - - const configuredMappings = (providerMappings: string[]) => - propertyMappings.map((pm) => pm.pk).filter((pmpk) => providerMappings.includes(pmpk)); - - const managedMappings = () => - propertyMappings - .filter((pm) => (pm?.managed ?? "").startsWith("goauthentik.io/providers/saml")) - .map((pm) => pm.pk); - - const pmValues = provider?.propertyMappings - ? configuredMappings(provider?.propertyMappings ?? []) - : managedMappings(); - - const propertyPairs = propertyMappings.map((pm) => [pm.pk, pm.name]); - - return { pmValues, propertyPairs }; - } - render() { - const provider = this.wizard.provider as SAMLProvider | undefined; - const errors = this.wizard.errors.provider; - - const { pmValues, propertyPairs } = this.propertyMappingConfiguration(provider); + const setHasSigningKp = (ev: InputEvent) => { + const target = ev.target as AkCryptoCertificateSearch; + if (!target) return; + this.hasSigningKp = !!target.selectedKeypair; + }; return html` ${msg("Configure SAML Provider")}
- - - - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - - ${msg("Protocol settings")} -
- - - - - - - - -
-
- - - ${msg("Advanced flow settings")} - - -

- ${msg( - "Flow used when a user access this provider and is not authenticated.", - )} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
-
- - - ${msg("Advanced protocol settings")} -
- - { - const target = ev.target as AkCryptoCertificateSearch; - if (!target) return; - this.hasSigningKp = !!target.selectedKeypair; - }} - > -

- ${msg( - "Certificate used to sign outgoing Responses going to the Service Provider.", - )} -

-
- ${ - this.hasSigningKp - ? html` - -

- ${msg( - "When enabled, the assertion element of the SAML response will be signed.", - )} -

-
- - -

- ${msg( - "When enabled, the assertion element of the SAML response will be signed.", - )} -

-
` - : nothing - } - - - -

- ${msg( - "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", - )} -

-
- - - -

- ${msg( - "When selected, encrypted assertions will be decrypted using this keypair.", - )} -

-
- - - ${msg("Property mappings used for user mapping.")} -

`} - >
- - - -

- ${msg( - "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", - )} -

-
- - - - - - - - - - - - -
-
+ ${renderForm( + (this.wizard.provider as SAMLProvider) ?? {}, + this.wizard.errors.provider, + setHasSigningKp, + this.hasSigningKp, + )} `; } } diff --git a/web/src/admin/providers/saml/SAMLProviderForm.ts b/web/src/admin/providers/saml/SAMLProviderForm.ts index ef35d2960b3f..f54ead0c90d2 100644 --- a/web/src/admin/providers/saml/SAMLProviderForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderForm.ts @@ -1,58 +1,11 @@ -import { - digestAlgorithmOptions, - signatureAlgorithmOptions, -} from "@goauthentik/admin/applications/wizard/methods/saml/SamlProviderOptions"; -import "@goauthentik/admin/common/ak-crypto-certificate-search"; -import AkCryptoCertificateSearch from "@goauthentik/admin/common/ak-crypto-certificate-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { first } from "@goauthentik/common/utils"; -import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; -import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; -import "@goauthentik/elements/forms/Radio"; -import "@goauthentik/elements/forms/SearchSelect"; -import "@goauthentik/elements/utils/TimeDeltaHelp"; -import { msg } from "@lit/localize"; -import { TemplateResult, html, nothing } from "lit"; import { customElement, state } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { - FlowsInstancesListDesignationEnum, - PropertymappingsApi, - PropertymappingsProviderSamlListRequest, - ProvidersApi, - SAMLPropertyMapping, - SAMLProvider, - SpBindingEnum, -} from "@goauthentik/api"; +import { ProvidersApi, SAMLProvider } from "@goauthentik/api"; -export async function samlPropertyMappingsProvider(page = 1, search = "") { - const propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsProviderSamlList({ - ordering: "saml_name", - pageSize: 20, - search: search.trim(), - page, - }); - return { - pagination: propertyMappings.pagination, - options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), - }; -} - -export function makeSAMLPropertyMappingsSelector(instanceMappings?: string[]) { - const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; - return localMappings - ? ([pk, _]: DualSelectPair) => localMappings.has(pk) - : ([_0, _1, _2, mapping]: DualSelectPair) => - mapping?.managed?.startsWith("goauthentik.io/providers/saml"); -} +import { renderForm } from "./SAMLProviderFormForm.js"; @customElement("ak-provider-saml-form") export class SAMLProviderFormPage extends BaseProviderForm { @@ -80,368 +33,14 @@ export class SAMLProviderFormPage extends BaseProviderForm { } } - renderForm(): TemplateResult { - return html` - - - - -

- ${msg("Flow used when authorizing this provider.")} -

-
- - - ${msg("Protocol settings")} -
- - - - - -

${msg("Also known as EntityID.")}

-
- - - -

- ${msg( - "Determines how authentik sends the response back to the Service Provider.", - )} -

-
- - - -
-
- - - ${msg("Advanced flow settings")} -
- - -

- ${msg( - "Flow used when a user access this provider and is not authenticated.", - )} -

-
- - -

- ${msg("Flow used when logging out of this provider.")} -

-
-
-
- - - ${msg("Advanced protocol settings")} -
- - { - const target = ev.target as AkCryptoCertificateSearch; - if (!target) return; - this.hasSigningKp = !!target.selectedKeypair; - }} - > -

- ${msg( - "Certificate used to sign outgoing Responses going to the Service Provider.", - )} -

-
- ${this.hasSigningKp - ? html` - -

- ${msg( - "When enabled, the assertion element of the SAML response will be signed.", - )} -

-
- - -

- ${msg( - "When enabled, the assertion element of the SAML response will be signed.", - )} -

-
` - : nothing} - - -

- ${msg( - "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", - )} -

-
- - -

- ${msg( - "When selected, assertions will be encrypted using this keypair.", - )} -

-
- - - - - => { - const args: PropertymappingsProviderSamlListRequest = { - ordering: "saml_name", - }; - if (query !== undefined) { - args.search = query; - } - const items = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsProviderSamlList(args); - return items.results; - }} - .renderElement=${(item: SAMLPropertyMapping): string => { - return item.name; - }} - .value=${( - item: SAMLPropertyMapping | undefined, - ): string | undefined => { - return item?.pk; - }} - .selected=${(item: SAMLPropertyMapping): boolean => { - return this.instance?.nameIdMapping === item.pk; - }} - ?blankable=${true} - > - -

- ${msg( - "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", - )} -

-
- - - -

- ${msg("Configure the maximum allowed time drift for an assertion.")} -

- -
- - -

- ${msg("Assertion not valid on or after current time + this value.")} -

- -
- - -

- ${msg("Session not valid on or after current time + this value.")} -

- -
- - -

- ${msg( - "When using IDP-initiated logins, the relay state will be set to this value.", - )} -

- -
+ renderForm() { + const setHasSigningKp = (ev: InputEvent) => { + const target = ev.target as AkCryptoCertificateSearch; + if (!target) return; + this.hasSigningKp = !!target.selectedKeypair; + }; - - - - - - - - -
-
`; + return renderForm(this.instance ?? {}, [], setHasSigningKp, this.hasSigningKp); } } diff --git a/web/src/admin/providers/saml/SAMLProviderFormForm.ts b/web/src/admin/providers/saml/SAMLProviderFormForm.ts new file mode 100644 index 000000000000..5fe9d9b8737a --- /dev/null +++ b/web/src/admin/providers/saml/SAMLProviderFormForm.ts @@ -0,0 +1,394 @@ +import { + digestAlgorithmOptions, + signatureAlgorithmOptions, +} from "@goauthentik/admin/applications/wizard/methods/saml/SamlProviderOptions"; +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; +import "@goauthentik/elements/utils/TimeDeltaHelp"; + +import { msg } from "@lit/localize"; +import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + FlowsInstancesListDesignationEnum, + PropertymappingsApi, + PropertymappingsProviderSamlListRequest, + SAMLPropertyMapping, + SAMLProvider, + SpBindingEnum, + ValidationError, +} from "@goauthentik/api"; + +export async function samlPropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderSamlList({ + ordering: "saml_name", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +export function makeSAMLPropertyMappingsSelector(instanceMappings?: string[]) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, mapping]: DualSelectPair) => + mapping?.managed?.startsWith("goauthentik.io/providers/saml"); +} + +export function renderForm( + provider?: Partial, + errors: ValidationError, + setHasSigningKp: (ev: InputEvent) => void, + hasSigningKp: boolean, +) { + return html` + + + + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + + ${msg("Protocol settings")} +
+ + + + + +

${msg("Also known as EntityID.")}

+
+ + + +

+ ${msg( + "Determines how authentik sends the response back to the Service Provider.", + )} +

+
+ + + +
+
+ + + ${msg("Advanced flow settings")} +
+ + +

+ ${msg( + "Flow used when a user access this provider and is not authenticated.", + )} +

+
+ + +

+ ${msg("Flow used when logging out of this provider.")} +

+
+
+
+ + + ${msg("Advanced protocol settings")} +
+ + +

+ ${msg( + "Certificate used to sign outgoing Responses going to the Service Provider.", + )} +

+
+ ${hasSigningKp + ? html` + +

+ ${msg( + "When enabled, the assertion element of the SAML response will be signed.", + )} +

+
+ + +

+ ${msg( + "When enabled, the assertion element of the SAML response will be signed.", + )} +

+
` + : nothing} + + +

+ ${msg( + "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.", + )} +

+
+ + +

+ ${msg("When selected, assertions will be encrypted using this keypair.")} +

+
+ + + + + => { + const args: PropertymappingsProviderSamlListRequest = { + ordering: "saml_name", + }; + if (query !== undefined) { + args.search = query; + } + const items = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderSamlList(args); + return items.results; + }} + .renderElement=${(item: SAMLPropertyMapping): string => { + return item.name; + }} + .value=${(item: SAMLPropertyMapping | undefined): string | undefined => { + return item?.pk; + }} + .selected=${(item: SAMLPropertyMapping): boolean => { + return provider?.nameIdMapping === item.pk; + }} + ?blankable=${true} + > + +

+ ${msg( + "Configure how the NameID value will be created. When left empty, the NameIDPolicy of the incoming request will be respected.", + )} +

+
+ + + +

+ ${msg("Configure the maximum allowed time drift for an assertion.")} +

+ +
+ + +

+ ${msg("Assertion not valid on or after current time + this value.")} +

+ +
+ + +

+ ${msg("Session not valid on or after current time + this value.")} +

+ +
+ + +

+ ${msg( + "When using IDP-initiated logins, the relay state will be set to this value.", + )} +

+ +
+ + + + + + + + + +
+
`; +} diff --git a/web/tests/specs/providers.ts b/web/tests/specs/providers.ts index 8582eae4a27c..4a69453835ce 100644 --- a/web/tests/specs/providers.ts +++ b/web/tests/specs/providers.ts @@ -1,28 +1,43 @@ import { expect } from "@wdio/globals"; -import { - clickButton, - setFormGroup, - setSearchSelect, - setTextInput, - setTypeCreate, -} from "pageobjects/controls.js"; import ProviderWizardView from "../pageobjects/provider-wizard.page.js"; import ProvidersListPage from "../pageobjects/providers-list.page.js"; -import { randomId } from "../utils/index.js"; import { login } from "../utils/login.js"; +import { + simpleLDAPProviderForm, + simpleOAuth2ProviderForm, + simpleRadiusProviderForm, +} from "./shared-sequences.js"; async function reachTheProvider() { await ProvidersListPage.logout(); await login(); await ProvidersListPage.open(); await expect(await ProvidersListPage.pageHeader()).toHaveText("Providers"); + await expect(await containedMessages()).not.toContain("Successfully created provider."); await ProvidersListPage.startWizardButton.click(); await ProviderWizardView.wizardTitle.waitForDisplayed(); await expect(await ProviderWizardView.wizardTitle).toHaveText("New provider"); } +const containedMessages = async () => + await (async () => { + const messages = []; + for await (const alert of $("ak-message-container").$$("ak-message")) { + messages.push(await alert.$("p.pf-c-alert__title").getText()); + } + return messages; + })(); + +const hasProviderSuccessMessage = async () => + await browser.waitUntil( + async () => (await containedMessages()).includes("Successfully created provider."), + { timeout: 1000, timeoutMsg: "Expected to see provider success message." }, + ); + +type FieldDesc = [(..._: unknown) => Promise, ...unknown]; + async function fillOutFields(fields: FieldDesc[]) { for (const field of fields) { const thefunc = field[0]; @@ -33,64 +48,44 @@ async function fillOutFields(fields: FieldDesc[]) { describe("Configure Oauth2 Providers", () => { it("Should configure a simple OAuth2 Provider", async () => { - const newProviderName = `New OAuth2 Provider - ${randomId()}`; - await reachTheProvider(); - await $("ak-wizard-page-type-create").waitForDisplayed(); - - // prettier-ignore - await fillOutFields([ - [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], - [clickButton, "Next"], - [setTextInput, "name", newProviderName], - [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], - ]); - + await fillOutFields(simpleOAuth2ProviderForm()); await ProviderWizardView.pause(); await ProviderWizardView.nextButton.click(); + await hasProviderSuccessMessage(); }); }); describe("Configure LDAP Providers", () => { it("Should configure a simple LDAP Provider", async () => { - const newProviderName = `New LDAP Provider - ${randomId()}`; - await reachTheProvider(); await $("ak-wizard-page-type-create").waitForDisplayed(); - - // prettier-ignore - await fillOutFields([ - [setTypeCreate, "selectProviderType", "LDAP Provider"], - [clickButton, "Next"], - [setTextInput, "name", newProviderName], - [setFormGroup, /Flow settings/, "open"], - // This will never not weird me out. - [setSearchSelect, "authorizationFlow", "default-authentication-flow"], - [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], - ]); - + await fillOutFields(simpleLDAPProviderForm()); await ProviderWizardView.pause(); await ProviderWizardView.nextButton.click(); + await hasProviderSuccessMessage(); }); }); describe("Configure Radius Providers", () => { it("Should configure a simple Radius Provider", async () => { - const newProviderName = `New Radius Provider - ${randomId()}`; - await reachTheProvider(); await $("ak-wizard-page-type-create").waitForDisplayed(); + await fillOutFields(simpleRadiusProviderForm()); + await ProviderWizardView.pause(); + await ProviderWizardView.nextButton.click(); + await hasProviderSuccessMessage(); + }); +}); - // prettier-ignore - await fillOutFields([ - [setTypeCreate, "selectProviderType", "Radius Provider"], - [clickButton, "Next"], - [setTextInput, "name", newProviderName], - [setSearchSelect, "authorizationFlow", "default-authentication-flow"], - ]); - +describe("Configure SAML Providers", () => { + it("Should configure a simple Radius Provider", async () => { + await reachTheProvider(); + await $("ak-wizard-page-type-create").waitForDisplayed(); + await fillOutFields(simpleRadiusProviderForm()); await ProviderWizardView.pause(); await ProviderWizardView.nextButton.click(); + await hasProviderSuccessMessage(); }); }); diff --git a/web/tests/specs/shared-sequences.ts b/web/tests/specs/shared-sequences.ts new file mode 100644 index 000000000000..77504672ed7b --- /dev/null +++ b/web/tests/specs/shared-sequences.ts @@ -0,0 +1,35 @@ +import { + clickButton, + setFormGroup, + setSearchSelect, + setTextInput, + setTypeCreate, +} from "pageobjects/controls.js"; + +import { randomId } from "../utils/index.js"; + +const newObjectName = (prefix: string) => `${prefix} - ${randomId()}`; + +export const simpleOAuth2ProviderForm = () => [ + [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Oauth2 Provider")], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], +]; + +export const simpleLDAPProviderForm = () => [ + [setTypeCreate, "selectProviderType", "LDAP Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New LDAP Provider")], + // This will never not weird me out. + [setSearchSelect, "authorizationFlow", "default-authentication-flow"], + [setFormGroup, /Flow settings/, "open"], + [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], +]; + +export const simpleRadiusProviderForm = () => [ + [setTypeCreate, "selectProviderType", "Radius Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Radius Provider")], + [setSearchSelect, "authorizationFlow", "default-authentication-flow"], +]; From c0814ad279661d121e5d82703bc868b6460045f3 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Fri, 25 Oct 2024 10:27:06 -0700 Subject: [PATCH 6/8] Almost there! --- ...rd-authentication-method-choice.choices.ts | 10 +- ...ion-wizard-authentication-method-choice.ts | 1 + ...lication-wizard-authentication-by-oauth.ts | 12 +- ...rd-authentication-by-saml-configuration.ts | 1 + ...plication-wizard-authentication-by-scim.ts | 133 +----------- .../providers/proxy/ProxyProviderForm.ts | 2 +- .../admin/providers/scim/SCIMProviderForm.ts | 198 +----------------- .../providers/scim/SCIMProviderFormForm.ts | 196 +++++++++++++++++ web/tests/pageobjects/controls.ts | 36 ++++ web/tests/specs/new-application-by-wizard.ts | 190 +++++------------ web/tests/specs/providers.ts | 63 +++--- web/tests/specs/shared-sequences.ts | 64 +++++- 12 files changed, 403 insertions(+), 503 deletions(-) create mode 100644 web/src/admin/providers/scim/SCIMProviderFormForm.ts diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts index b0f1dfa83ab7..fc7ed3659763 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts @@ -36,7 +36,7 @@ export type LocalTypeCreate = TypeCreate & { export const providerModelsList: LocalTypeCreate[] = [ { formName: "oauth2provider", - name: msg("OAuth2/OIDC (Open Authorization/OpenID Connect)"), + name: msg("OAuth2/OpenID Provider"), description: msg("Modern applications, APIs and Single-page applications."), renderer: () => html``, @@ -50,7 +50,7 @@ export const providerModelsList: LocalTypeCreate[] = [ }, { formName: "ldapprovider", - name: msg("LDAP (Lightweight Directory Access Protocol)"), + name: msg("LDAP Provider"), description: msg( "Provide an LDAP interface for applications and users to authenticate against.", ), @@ -127,7 +127,7 @@ export const providerModelsList: LocalTypeCreate[] = [ }, { formName: "samlprovider", - name: msg("SAML (Security Assertion Markup Language)"), + name: msg("SAML Provider"), description: msg("Configure SAML provider manually"), renderer: () => html``, @@ -141,7 +141,7 @@ export const providerModelsList: LocalTypeCreate[] = [ }, { formName: "radiusprovider", - name: msg("RADIUS (Remote Authentication Dial-In User Service)"), + name: msg("Radius Provider"), description: msg("Configure RADIUS provider manually"), renderer: () => html``, @@ -155,7 +155,7 @@ export const providerModelsList: LocalTypeCreate[] = [ }, { formName: "scimprovider", - name: msg("SCIM (System for Cross-domain Identity Management)"), + name: msg("SCIM Provider"), description: msg("Configure SCIM provider manually"), renderer: () => html``, diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts index c4625f24202b..eac762f3f9f5 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts @@ -35,6 +35,7 @@ export class ApplicationWizardAuthenticationMethodChoice extends WithLicenseSumm ? html`
0 ? selectedTypes[0] : undefined} @select=${(ev: CustomEvent) => { diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts index 09422aabe417..9b97efb8c8db 100644 --- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts +++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -1,7 +1,9 @@ import { renderForm } from "@goauthentik/admin/providers/oauth2/OAuth2ProviderFormForm.js"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; +import { html } from "lit"; import { SourcesApi } from "@goauthentik/api"; import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api"; @@ -34,7 +36,15 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { const showClientSecretCallback = (show: boolean) => { this.showClientSecret = show; }; - return renderForm(provider ?? {}, errors, this.showClientSecret, showClientSecretCallback); + return html` ${msg("Configure LDAP Provider")} + + ${renderForm( + provider ?? {}, + errors, + this.showClientSecret, + showClientSecretCallback, + )} + `; } } diff --git a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts index 9d419d8b0717..cbb6845105ad 100644 --- a/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts +++ b/web/src/admin/applications/wizard/methods/saml/ak-application-wizard-authentication-by-saml-configuration.ts @@ -1,4 +1,5 @@ import AkCryptoCertificateSearch from "@goauthentik/admin/common/ak-crypto-certificate-search"; +import { renderForm } from "@goauthentik/admin/providers/saml/SAMLProviderFormForm.js"; import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; diff --git a/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts b/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts index f8036d1bccec..75d853408431 100644 --- a/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts +++ b/web/src/admin/applications/wizard/methods/scim/ak-application-wizard-authentication-by-scim.ts @@ -1,21 +1,8 @@ -import "@goauthentik/admin/applications/wizard/ak-wizard-title"; -import "@goauthentik/admin/common/ak-core-group-search"; -import "@goauthentik/admin/common/ak-crypto-certificate-search"; -import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { first } from "@goauthentik/common/utils"; -import "@goauthentik/components/ak-multi-select"; -import "@goauthentik/components/ak-switch-input"; -import "@goauthentik/components/ak-text-input"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; - import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { PaginatedSCIMMappingList, PropertymappingsApi, type SCIMProvider } from "@goauthentik/api"; +import { PaginatedSCIMMappingList, type SCIMProvider } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @@ -26,125 +13,15 @@ export class ApplicationWizardAuthenticationBySCIM extends BaseProviderPanel { constructor() { super(); - new PropertymappingsApi(DEFAULT_CONFIG) - .propertymappingsProviderScimList({ - ordering: "managed", - }) - .then((propertyMappings: PaginatedSCIMMappingList) => { - this.propertyMappings = propertyMappings; - }); - } - - propertyMappingConfiguration(provider?: SCIMProvider) { - const propertyMappings = this.propertyMappings?.results ?? []; - - const configuredMappings = (providerMappings: string[]) => - propertyMappings.map((pm) => pm.pk).filter((pmpk) => providerMappings.includes(pmpk)); - - const managedMappings = (key: string) => - propertyMappings - .filter((pm) => pm.managed === `goauthentik.io/providers/scim/${key}`) - .map((pm) => pm.pk); - - const pmUserValues = provider?.propertyMappings - ? configuredMappings(provider?.propertyMappings ?? []) - : managedMappings("user"); - - const pmGroupValues = provider?.propertyMappingsGroup - ? configuredMappings(provider?.propertyMappingsGroup ?? []) - : managedMappings("group"); - - const propertyPairs = propertyMappings.map((pm) => [pm.pk, pm.name]); - - return { pmUserValues, pmGroupValues, propertyPairs }; } render() { - const provider = this.wizard.provider as SCIMProvider | undefined; - const errors = this.wizard.errors.provider; - - const { pmUserValues, pmGroupValues, propertyPairs } = - this.propertyMappingConfiguration(provider); - return html`${msg("Configure SCIM Provider")}
- - - ${msg("Protocol settings")} -
- - - - -
-
- - ${msg("User filtering")} -
- - - -

- ${msg("Only sync users within the selected group.")} -

-
-
-
- - ${msg("Attribute mapping")} -
- - ${msg("Property mappings used for user mapping.")} -

- `} - >
- - ${msg("Property mappings used for group creation.")} -

- `} - >
-
-
+ ${renderForm( + (this.wizard.provider as SCIMProvider) ?? {}, + this.wizard.errors.provider, + )}
`; } } diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 86c34969f171..b39e010f1843 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -102,7 +102,7 @@ export class ProxyProviderFormPage extends BaseProviderForm { // prettier-ignore return html` - + diff --git a/web/src/admin/providers/scim/SCIMProviderForm.ts b/web/src/admin/providers/scim/SCIMProviderForm.ts index 5bc15c2bf8cf..bfdcd3736dd4 100644 --- a/web/src/admin/providers/scim/SCIMProviderForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderForm.ts @@ -1,53 +1,11 @@ import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { first } from "@goauthentik/common/utils"; -import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; -import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; -import "@goauthentik/elements/forms/FormGroup"; -import "@goauthentik/elements/forms/HorizontalFormElement"; -import "@goauthentik/elements/forms/Radio"; -import "@goauthentik/elements/forms/SearchSelect"; -import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { ifDefined } from "lit/directives/if-defined.js"; -import { - CoreApi, - CoreGroupsListRequest, - Group, - PropertymappingsApi, - ProvidersApi, - SCIMMapping, - SCIMProvider, -} from "@goauthentik/api"; +import { ProvidersApi, SCIMProvider } from "@goauthentik/api"; -export async function scimPropertyMappingsProvider(page = 1, search = "") { - const propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsProviderScimList({ - ordering: "managed", - pageSize: 20, - search: search.trim(), - page, - }); - return { - pagination: propertyMappings.pagination, - options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), - }; -} - -export function makeSCIMPropertyMappingsSelector( - instanceMappings: string[] | undefined, - defaultSelected: string, -) { - const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; - return localMappings - ? ([pk, _]: DualSelectPair) => localMappings.has(pk) - : ([_0, _1, _2, mapping]: DualSelectPair) => - mapping?.managed === defaultSelected; -} +import { renderForm } from "./SCIMProviderFormForm.js"; @customElement("ak-provider-scim-form") export class SCIMProviderFormPage extends BaseProviderForm { @@ -70,156 +28,8 @@ export class SCIMProviderFormPage extends BaseProviderForm { } } - renderForm(): TemplateResult { - return html` - - - - ${msg("Protocol settings")} -
- - -

- ${msg("SCIM base url, usually ends in /v2.")} -

-
- - - - - -

- ${msg( - "Token to authenticate with. Currently only bearer authentication is supported.", - )} -

-
-
-
- - ${msg("User filtering")} -
- - - - - => { - const args: CoreGroupsListRequest = { - ordering: "name", - includeUsers: false, - }; - if (query !== undefined) { - args.search = query; - } - const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList( - args, - ); - return groups.results; - }} - .renderElement=${(group: Group): string => { - return group.name; - }} - .value=${(group: Group | undefined): string | undefined => { - return group ? group.pk : undefined; - }} - .selected=${(group: Group): boolean => { - return group.pk === this.instance?.filterGroup; - }} - ?blankable=${true} - > - -

- ${msg("Only sync users within the selected group.")} -

-
-
-
- - ${msg("Attribute mapping")} -
- - - -

- ${msg("Property mappings used to user mapping.")} -

-
- - -

- ${msg("Property mappings used to group creation.")} -

-
-
-
`; + renderForm() { + return renderForm(this.instance ?? {}, []); } } diff --git a/web/src/admin/providers/scim/SCIMProviderFormForm.ts b/web/src/admin/providers/scim/SCIMProviderFormForm.ts new file mode 100644 index 000000000000..f6ea6bb43408 --- /dev/null +++ b/web/src/admin/providers/scim/SCIMProviderFormForm.ts @@ -0,0 +1,196 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; +import "@goauthentik/elements/forms/SearchSelect"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + CoreApi, + CoreGroupsListRequest, + Group, + PropertymappingsApi, + SCIMMapping, + SCIMProvider, +} from "@goauthentik/api"; + +export async function scimPropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi( + DEFAULT_CONFIG, + ).propertymappingsProviderScimList({ + ordering: "managed", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +export function makeSCIMPropertyMappingsSelector( + instanceMappings: string[] | undefined, + defaultSelected: string, +) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, mapping]: DualSelectPair) => + mapping?.managed === defaultSelected; +} + +export function renderForm(provider?: Partial, errors: ValidationError = {}) { + return html` + + + + + + ${msg("Protocol settings")} +
+ + +

+ ${msg("SCIM base url, usually ends in /v2.")} +

+
+ + + + + +

+ ${msg( + "Token to authenticate with. Currently only bearer authentication is supported.", + )} +

+
+
+
+ + ${msg("User filtering")} +
+ + + + + => { + const args: CoreGroupsListRequest = { + ordering: "name", + includeUsers: false, + }; + if (query !== undefined) { + args.search = query; + } + const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(args); + return groups.results; + }} + .renderElement=${(group: Group): string => { + return group.name; + }} + .value=${(group: Group | undefined): string | undefined => { + return group ? group.pk : undefined; + }} + .selected=${(group: Group): boolean => { + return group.pk === provider?.filterGroup; + }} + blankable + > + +

+ ${msg("Only sync users within the selected group.")} +

+
+
+
+ + + ${msg("Attribute mapping")} +
+ + +

+ ${msg("Property mappings used to user mapping.")} +

+
+ + +

+ ${msg("Property mappings used to group creation.")} +

+
+
+
+ `; +} diff --git a/web/tests/pageobjects/controls.ts b/web/tests/pageobjects/controls.ts index d84710a236ef..5e746fe3d71d 100644 --- a/web/tests/pageobjects/controls.ts +++ b/web/tests/pageobjects/controls.ts @@ -2,6 +2,11 @@ import { browser } from "@wdio/globals"; import { match } from "ts-pattern"; import { Key } from "webdriverio"; +export async function doBlur(el: WebdriverIO.Element | ChainablePromiseElement) { + const element = await el; + browser.execute((element) => element.blur()); +} + export async function setSearchSelect(name: string, value: string) { const control = await (async () => { try { @@ -38,12 +43,14 @@ export async function setSearchSelect(name: string, value: string) { } await (await button).click(); await browser.keys(Key.Tab); + await doBlur(control); } export async function setTextInput(name: string, value: string) { const control = await $(`input[name="${name}"]`); await control.scrollIntoView(); await control.setValue(value); + await doBlur(control); } export async function setRadio(name: string, value: string) { @@ -52,6 +59,7 @@ export async function setRadio(name: string, value: string) { const item = await control.$(`label.*=${value}`).parentElement(); await item.scrollIntoView(); await item.click(); + await doBlur(control); } export async function setTypeCreate(name: string, value: string | RegExp) { @@ -73,6 +81,7 @@ export async function setTypeCreate(name: string, value: string | RegExp) { await card.scrollIntoView(); await card.click(); + await doBlur(control); } export async function setFormGroup(name: string | RegExp, setting: "open" | "closed") { @@ -95,6 +104,7 @@ export async function setFormGroup(name: string | RegExp, setting: "open" | "clo .with(["false", "open"], async () => await toggle.click()) .with(["true", "closed"], async () => await toggle.click()) .otherwise(async () => {}); + await doBlur(formGroup); } export async function clickButton(name: string, ctx?: WebdriverIO.Element) { @@ -110,4 +120,30 @@ export async function clickButton(name: string, ctx?: WebdriverIO.Element) { } await button.scrollIntoView(); await button.click(); + await doBlur(button); +} + +const tap = (a: T): T => { + console.log(a); + return a; +}; + +export async function clickToggleGroup(name: string, value: string | RegExp) { + const comparator = + typeof name === "string" + ? (sample) => tap(sample) === tap(value) + : (sample) => value.test(sample); + + const button = await (async () => { + for await (const button of $(`[data-ouid-component-name=${name}]`).$$( + ".pf-c-toggle-group__button", + )) { + if (comparator(await button.$(".pf-c-toggle-group__text").getText())) { + return button; + } + } + })(); + await button.scrollIntoView(); + await button.click(); + await doBlur(button); } diff --git a/web/tests/specs/new-application-by-wizard.ts b/web/tests/specs/new-application-by-wizard.ts index a43d84b9d78f..c636fda21fad 100644 --- a/web/tests/specs/new-application-by-wizard.ts +++ b/web/tests/specs/new-application-by-wizard.ts @@ -9,29 +9,42 @@ import ApplicationWizardView from "../pageobjects/application-wizard.page.js"; import ApplicationsListPage from "../pageobjects/applications-list.page.js"; import { randomId } from "../utils/index.js"; import { login } from "../utils/login.js"; +import { type TestSequence } from "./shared-sequences"; +import { + simpleForwardAuthDomainProxyProviderForm, + simpleForwardAuthProxyProviderForm, + simpleLDAPProviderForm, + simpleOAuth2ProviderForm, + simpleProxyProviderForm, + simpleRadiusProviderForm, + simpleSAMLProviderForm, + simpleSCIMProviderForm, +} from "./shared-sequences.js"; -async function reachTheProvider(title: string) { - const newPrefix = randomId(); +const SUCCESS_MESSAGE = "Your application has been saved"; +async function reachTheApplicationsPage() { await ApplicationsListPage.logout(); await login(); await ApplicationsListPage.open(); await ApplicationsListPage.pause("ak-page-header"); await expect(await ApplicationsListPage.pageHeader()).toBeDisplayed(); await expect(await ApplicationsListPage.pageHeader()).toHaveText("Applications"); +} + +async function fillOutTheApplication(title: string) { + const newPrefix = randomId(); await (await ApplicationsListPage.startWizardButton()).click(); await (await ApplicationWizardView.wizardTitle()).waitForDisplayed(); await expect(await ApplicationWizardView.wizardTitle()).toHaveText("New application"); - await (await ApplicationWizardView.app.name()).setValue(`${title} - ${newPrefix}`); await (await ApplicationWizardView.app.uiSettings()).scrollIntoView(); await (await ApplicationWizardView.app.uiSettings()).click(); await (await ApplicationWizardView.app.launchUrl()).scrollIntoView(); await (await ApplicationWizardView.app.launchUrl()).setValue("http://example.goauthentik.io"); - await (await ApplicationWizardView.nextButton()).click(); - return await ApplicationWizardView.pause(); + await ApplicationWizardView.pause(); } async function getCommitMessage() { @@ -39,136 +52,45 @@ async function getCommitMessage() { return await ApplicationWizardView.successMessage(); } -const SUCCESS_MESSAGE = "Your application has been saved"; -const EXPLICIT_CONSENT = "default-provider-authorization-explicit-consent"; - -describe("Configure Applications with the Application Wizard", () => { - it("Should configure a simple LDAP Application", async () => { - await reachTheProvider("New LDAP Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.ldapProvider).scrollIntoView(); - await (await ApplicationWizardView.ldapProvider).click(); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.ldap.setBindFlow("default-authentication-flow"); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); - }); - - it("Should configure a simple Oauth2 Application", async () => { - await reachTheProvider("New Oauth2 Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.oauth2Provider).scrollIntoView(); - await (await ApplicationWizardView.oauth2Provider).click(); - - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.oauth.setAuthorizationFlow(EXPLICIT_CONSENT); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); - }); - - it("Should configure a simple SAML Application", async () => { - await reachTheProvider("New SAML Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.samlProvider).scrollIntoView(); - await (await ApplicationWizardView.samlProvider).click(); - - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.saml.setAuthorizationFlow(EXPLICIT_CONSENT); - await ApplicationWizardView.saml.acsUrl.setValue("http://example.com:8000/"); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); - }); - - it("Should configure a simple SCIM Application", async () => { - await reachTheProvider("New SCIM Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.scimProvider).scrollIntoView(); - await (await ApplicationWizardView.scimProvider).click(); - - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.scim.url.setValue("http://example.com:8000/"); - await ApplicationWizardView.scim.token.setValue("a-very-basic-token"); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); - }); - - it("Should configure a simple Radius Application", async () => { - await reachTheProvider("New Radius Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.radiusProvider).scrollIntoView(); - await (await ApplicationWizardView.radiusProvider).click(); - - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.radius.setAuthenticationFlow("default-authentication-flow"); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); - }); - - it("Should configure a simple Transparent Proxy Application", async () => { - await reachTheProvider("New Transparent Proxy Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.proxyProviderProxy).scrollIntoView(); - await (await ApplicationWizardView.proxyProviderProxy).click(); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.transparentProxy.setAuthorizationFlow(EXPLICIT_CONSENT); - await ApplicationWizardView.transparentProxy.externalHost.setValue( - "http://external.example.com", - ); - await ApplicationWizardView.transparentProxy.internalHost.setValue( - "http://internal.example.com", - ); - - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); +async function fillOutTheProviderAndCommit(provider: TestSequence) { + // The wizard automagically provides a name. If it doesn't, that's a bug. + const wizardProvider = provider.filter((p) => p.length < 2 || p[1] !== "name"); + await $("ak-wizard-page-type-create").waitForDisplayed(); + for await (const field of wizardProvider) { + const thefunc = field[0]; + const args = field.slice(1); + console.log(`Running ${args.join(", ")}`); + // @ts-expect-error "This is a pretty alien call; I'm not surprised Typescript hates it." + await thefunc.apply($, args); + await browser.pause(1000); + } + + await $("ak-wizard-frame").$("footer button.pf-m-primary").click(); + await ApplicationWizardView.pause(); + await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); +} - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); +async function itShouldConfigureApplicationsViaTheWizard(name: string, provider: TestSequence) { + it(`Should successfully configure an application with a ${name} provider`, async () => { + await reachTheApplicationsPage(); + await fillOutTheApplication(name); + await fillOutTheProviderAndCommit(provider); }); +} - it("Should configure a simple Forward Proxy Application", async () => { - await reachTheProvider("New Forward Proxy Application"); - - await (await ApplicationWizardView.providerList()).waitForDisplayed(); - await (await ApplicationWizardView.proxyProviderForwardsingle).scrollIntoView(); - await (await ApplicationWizardView.proxyProviderForwardsingle).click(); - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await ApplicationWizardView.forwardProxy.setAuthorizationFlow(EXPLICIT_CONSENT); - await ApplicationWizardView.forwardProxy.externalHost.setValue( - "http://external.example.com", - ); - - await (await ApplicationWizardView.nextButton()).click(); - await ApplicationWizardView.pause(); - - await expect(await getCommitMessage()).toHaveText(SUCCESS_MESSAGE); - }); +const providers = [ + ["LDAP", simpleLDAPProviderForm], + ["OAuth2", simpleOAuth2ProviderForm], + ["Radius", simpleRadiusProviderForm], + ["SAML", simpleSAMLProviderForm], + ["SCIM", simpleSCIMProviderForm], + ["Proxy", simpleProxyProviderForm], + ["Forward Auth (single application)", simpleForwardAuthProxyProviderForm], + ["Forward Auth (domain level)", simpleForwardAuthDomainProxyProviderForm], +]; + +describe("Configuring Applications Via the Wizard", () => { + for (const [name, provider] of providers) { + itShouldConfigureApplicationsViaTheWizard(name, provider()); + } }); diff --git a/web/tests/specs/providers.ts b/web/tests/specs/providers.ts index 4a69453835ce..2e6299cd0ca3 100644 --- a/web/tests/specs/providers.ts +++ b/web/tests/specs/providers.ts @@ -3,10 +3,16 @@ import { expect } from "@wdio/globals"; import ProviderWizardView from "../pageobjects/provider-wizard.page.js"; import ProvidersListPage from "../pageobjects/providers-list.page.js"; import { login } from "../utils/login.js"; +import { type TestSequence } from "./shared-sequences"; import { + simpleForwardAuthDomainProxyProviderForm, + simpleForwardAuthProxyProviderForm, simpleLDAPProviderForm, simpleOAuth2ProviderForm, + simpleProxyProviderForm, simpleRadiusProviderForm, + simpleSAMLProviderForm, + simpleSCIMProviderForm, } from "./shared-sequences.js"; async function reachTheProvider() { @@ -36,56 +42,39 @@ const hasProviderSuccessMessage = async () => { timeout: 1000, timeoutMsg: "Expected to see provider success message." }, ); -type FieldDesc = [(..._: unknown) => Promise, ...unknown]; - -async function fillOutFields(fields: FieldDesc[]) { +async function fillOutFields(fields: TestSequence) { for (const field of fields) { const thefunc = field[0]; const args = field.slice(1); + // @ts-expect-error "This is a pretty alien call; I'm not surprised Typescript hates it." await thefunc.apply($, args); } } -describe("Configure Oauth2 Providers", () => { - it("Should configure a simple OAuth2 Provider", async () => { +async function itShouldConfigureASimpleProvider(name: string, provider: TestSequence) { + it(`Should successfully configure a ${name} provider`, async () => { await reachTheProvider(); await $("ak-wizard-page-type-create").waitForDisplayed(); - await fillOutFields(simpleOAuth2ProviderForm()); + await fillOutFields(provider); await ProviderWizardView.pause(); await ProviderWizardView.nextButton.click(); await hasProviderSuccessMessage(); }); -}); +} -describe("Configure LDAP Providers", () => { - it("Should configure a simple LDAP Provider", async () => { - await reachTheProvider(); - await $("ak-wizard-page-type-create").waitForDisplayed(); - await fillOutFields(simpleLDAPProviderForm()); - await ProviderWizardView.pause(); - await ProviderWizardView.nextButton.click(); - await hasProviderSuccessMessage(); - }); -}); +describe("Configuring Providers", () => { + const providers = [ + ["LDAP", simpleLDAPProviderForm], + ["OAuth2", simpleOAuth2ProviderForm], + ["Radius", simpleRadiusProviderForm], + ["SAML", simpleSAMLProviderForm], + ["SCIM", simpleSCIMProviderForm], + ["Proxy", simpleProxyProviderForm], + ["Forward Auth (single application)", simpleForwardAuthProxyProviderForm], + ["Forward Auth (domain level)", simpleForwardAuthDomainProxyProviderForm], + ]; -describe("Configure Radius Providers", () => { - it("Should configure a simple Radius Provider", async () => { - await reachTheProvider(); - await $("ak-wizard-page-type-create").waitForDisplayed(); - await fillOutFields(simpleRadiusProviderForm()); - await ProviderWizardView.pause(); - await ProviderWizardView.nextButton.click(); - await hasProviderSuccessMessage(); - }); -}); - -describe("Configure SAML Providers", () => { - it("Should configure a simple Radius Provider", async () => { - await reachTheProvider(); - await $("ak-wizard-page-type-create").waitForDisplayed(); - await fillOutFields(simpleRadiusProviderForm()); - await ProviderWizardView.pause(); - await ProviderWizardView.nextButton.click(); - await hasProviderSuccessMessage(); - }); + for (const [name, provider] of providers) { + itShouldConfigureASimpleProvider(name, provider()); + } }); diff --git a/web/tests/specs/shared-sequences.ts b/web/tests/specs/shared-sequences.ts index 77504672ed7b..814a8c755c55 100644 --- a/web/tests/specs/shared-sequences.ts +++ b/web/tests/specs/shared-sequences.ts @@ -1,5 +1,6 @@ import { clickButton, + clickToggleGroup, setFormGroup, setSearchSelect, setTextInput, @@ -10,14 +11,26 @@ import { randomId } from "../utils/index.js"; const newObjectName = (prefix: string) => `${prefix} - ${randomId()}`; -export const simpleOAuth2ProviderForm = () => [ +export type TestInteraction = + | [typeof clickButton, ...Parameters] + | [typeof clickToggleGroup, ...Parameters] + | [typeof setFormGroup, ...Parameters] + | [typeof setSearchSelect, ...Parameters] + | [typeof setTextInput, ...Parameters] + | [typeof setTypeCreate, ...Parameters]; + +export type TestSequence = TestInteraction[]; + +export type TestProvider = () => TestSequence; + +export const simpleOAuth2ProviderForm: TestProvider = () => [ [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], [clickButton, "Next"], [setTextInput, "name", newObjectName("New Oauth2 Provider")], [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], ]; -export const simpleLDAPProviderForm = () => [ +export const simpleLDAPProviderForm: TestProvider = () => [ [setTypeCreate, "selectProviderType", "LDAP Provider"], [clickButton, "Next"], [setTextInput, "name", newObjectName("New LDAP Provider")], @@ -27,9 +40,54 @@ export const simpleLDAPProviderForm = () => [ [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], ]; -export const simpleRadiusProviderForm = () => [ +export const simpleRadiusProviderForm: TestProvider = () => [ [setTypeCreate, "selectProviderType", "Radius Provider"], [clickButton, "Next"], [setTextInput, "name", newObjectName("New Radius Provider")], [setSearchSelect, "authorizationFlow", "default-authentication-flow"], ]; + +export const simpleSAMLProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "SAML Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New SAML Provider")], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], + [setTextInput, "acsUrl", "http://example.com:8000/"], +]; + +export const simpleSCIMProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "SCIM Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New SCIM Provider")], + [setTextInput, "url", "http://example.com:8000/"], + [setTextInput, "token", "insert-real-token-here"], +]; + +export const simpleProxyProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Proxy Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Proxy Provider")], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], + [clickToggleGroup, "proxy-type-toggle", "Proxy"], + [setTextInput, "externalHost", "http://example.com:8000/"], + [setTextInput, "internalHost", "http://example.com:8001/"], +]; + +export const simpleForwardAuthProxyProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Proxy Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Forward Auth Provider")], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], + [clickToggleGroup, "proxy-type-toggle", "Forward auth (single application)"], + [setTextInput, "externalHost", "http://example.com:8000/"], +]; + +export const simpleForwardAuthDomainProxyProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Proxy Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Forward Auth Domain Level Provider")], + [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], + [clickToggleGroup, "proxy-type-toggle", "Forward auth (domain level)"], + [setTextInput, "externalHost", "http://example.com:8000/"], + [setTextInput, "cookieDomain", "somedomain.tld"], +]; From 807e2a9fb0a60bec0224257afc3014cdba1ffeac Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 29 Oct 2024 15:06:32 -0700 Subject: [PATCH 7/8] web/admin: Unify the forms for providers between the ./admin/providers and ./admin/applications/wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What - For LDAP, OAuth2, Radius, SAML, SCIM, and Proxy providers, extract the literal form rendering component of each provider into a function. After all, that's what they are: they take input (the render state) and produce output (HTML with event handlers). - Rip out all of the forms in the wizard and replace them with ☝️ - Write E2E tests that exercise *all* of the components in *all* of the forms mentioned. See test results. These tests come in two flavors, "simple" (minimum amount needed to make the provider "pass" the backend's parsers) and "complete" (touches every legal field in the form according to the authentik `./schema.yml` file). As a result, every field is validated against the schema (although the schema is currently ported into the test by hand. - Fixed some serious bugginess in the way the wizard `commit` phase handles errors. ## Details ### Providers In some cases, I broke up the forms into smaller units: - Proxy, especially, with standalone units now for `renderHttpBasic`, `renderModeSelector`, `renderSettings`, and the differing modes) - SAML now has a `renderHasSigningKp` object, which makes that part of the code much more readable. I also extracted a few of static `options` collections into static const objects, so that the form object itself would be a bit more readable. ### Wizard Just ripped out all of the Provider forms. All of them. They weren't going to be needed in our glorious new future. Using the information provided by the `providerTypes` object, it was easy to extract all of the information that had once been in `ak-application-wizard-authentication-method-choice.choices`. The only thing left now is the renderers, one for each of the forms ripped out. Everything else is just gone. As a result, though, that's no longer a static list. It has to be derived from information sent via the API. So now it's in a context that's built when the wizard is initialized, and accessed by the `createTypes` pass as well as the specific provider. The error handling in the `commit` pass was just broken. I have improved it quite a bit, and now it actually displays helpful messages when things go wrong. ### Tests Wrote a simple test runner that iterates through a collection of fields, setting their values via field-type instructions contained in each line. For example, the "simple" OAuth2 Provider test looks like this: ``` export const simpleOAuth2ProviderForm: TestProvider = () => [ [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], [clickButton, "Next"], [setTextInput, "name", newObjectName("New Oauth2 Provider")], [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], ]; ``` Each control checks for the existence of the object, and in most cases its current `display`. (SearchSelect only checks existence, due to the oddness of the portaled popup.) Where a field can't reasonably be modified and still pass, we at least verify that the name provided in `schema.yml` corresponds to an existing, available control on the form or wizard panel. Combined with a routine for logging in and navigating to the Provider page, and another one to validate that a new and uniqute "Successfully Created Provider" notification appeared, this makes testing each provider a simple message of filling out the table of fields you want populated. Equally simple: these *exact same tests* can be incorporated into a wrapper for logging in, navigating to the Application page, and filling out an Application, and then a new and unique Provider for that Application, by Provider Type. As a special case, the Wizard variant checks the `TestSequence` object returned by the `TestProvider` function and removes the `name` field, since the Wizard pre-populates that automatically. As a result of this, the contents of `./web/src` has lost 1,504 lines of code. And results like these, where the behavior has been cross-checked three ways (the forms, the tests (and so the back-end), *and the schema* all agree on field names and behaviors, gives me much more confidence that the refactor works as expected: ``` [chrome 130.0.6723.70 mac #0-1] Running: chrome (v130.0.6723.70) on mac [chrome 130.0.6723.70 mac #0-1] Session ID: 039c70690eebc83ffbc2eef97043c774 [chrome 130.0.6723.70 mac #0-1] [chrome 130.0.6723.70 mac #0-1] » /tests/specs/providers.ts [chrome 130.0.6723.70 mac #0-1] Configuring Providers [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple LDAP provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple OAuth2 provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple Radius provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple SAML provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple SCIM provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple Proxy provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple Forward Auth (single application) provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Simple Forward Auth (domain level) provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete OAuth2 provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete LDAP provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete Radius provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete SAML provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete SCIM provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete Proxy provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete Forward Auth (single application) provider [chrome 130.0.6723.70 mac #0-1] ✓ Should successfully configure a Complete Forward Auth (domain level) provider [chrome 130.0.6723.70 mac #0-1] [chrome 130.0.6723.70 mac #0-1] 16 passing (1m 48.5s) ------------------------------------------------------------------ [chrome 130.0.6723.70 mac #0-2] Running: chrome (v130.0.6723.70) on mac [chrome 130.0.6723.70 mac #0-2] Session ID: 5a3ae12c851eff8fffd2686096759146 [chrome 130.0.6723.70 mac #0-2] [chrome 130.0.6723.70 mac #0-2] » /tests/specs/new-application-by-wizard.ts [chrome 130.0.6723.70 mac #0-2] Configuring Applications Via the Wizard [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple LDAP provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple OAuth2 provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple Radius provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple SAML provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple SCIM provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple Proxy provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple Forward Auth (single) provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Simple Forward Auth (domain) provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete OAuth2 provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete LDAP provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete Radius provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete SAML provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete SCIM provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete Proxy provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete Forward Auth (single) provider [chrome 130.0.6723.70 mac #0-2] ✓ Should successfully configure an application with a Complete Forward Auth (domain) provider [chrome 130.0.6723.70 mac #0-2] [chrome 130.0.6723.70 mac #0-2] 16 passing (2m 3s) ``` 🎉 --- ...rd-authentication-method-choice.choices.ts | 2 +- ...ion-wizard-authentication-method-choice.ts | 2 +- ...k-application-wizard-commit-application.ts | 34 +- ...pplication-wizard-authentication-method.ts | 4 +- ...wizard-authentication-for-reverse-proxy.ts | 6 +- ...ication-wizard-authentication-by-radius.ts | 1 - .../common/ak-crypto-certificate-search.ts | 5 +- .../admin/common/ak-flow-search/FlowSearch.ts | 2 +- .../providers/ldap/LDAPProviderFormForm.ts | 5 +- .../providers/proxy/ProxyProviderForm.ts | 4 +- .../providers/proxy/ProxyProviderFormForm.ts | 270 +++++++--------- .../radius/RadiusProviderFormForm.ts | 27 +- .../admin/providers/saml/SAMLProviderForm.ts | 1 + .../providers/saml/SAMLProviderFormForm.ts | 294 +++++++----------- .../providers/scim/SCIMProviderFormForm.ts | 107 +++---- web/tests/pageobjects/controls.ts | 229 +++++++++----- web/tests/pageobjects/page.ts | 4 +- web/tests/specs/new-application-by-wizard.ts | 37 ++- web/tests/specs/providers.ts | 42 ++- web/tests/specs/shared-sequences.ts | 93 ------ web/tests/utils/index.ts | 19 ++ 21 files changed, 555 insertions(+), 633 deletions(-) delete mode 100644 web/tests/specs/shared-sequences.ts diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts index c4b25a56c409..337787692d5c 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.choices.ts @@ -10,7 +10,7 @@ export type LocalTypeCreate = TypeCreate & { renderer: ProviderRenderer; }; -export const providerTypeRenderers = { +export const providerTypeRenderers: Record TemplateResult> = { oauth2provider: () => html``, ldapprovider: () => diff --git a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts index f647b268d94c..ef2dac6595a9 100644 --- a/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts +++ b/web/src/admin/applications/wizard/auth-method-choice/ak-application-wizard-authentication-method-choice.ts @@ -19,7 +19,7 @@ import type { LocalTypeCreate } from "./ak-application-wizard-authentication-met @customElement("ak-application-wizard-authentication-method-choice") export class ApplicationWizardAuthenticationMethodChoice extends WithLicenseSummary(BasePanel) { @consume({ context: applicationWizardProvidersContext }) - public providerModelsList: LocalTypeCreate[]; + public providerModelsList!: LocalTypeCreate[]; render() { const selectedTypes = this.providerModelsList.filter( diff --git a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts index 1e032f2d7e31..9eac8ae98857 100644 --- a/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts +++ b/web/src/admin/applications/wizard/commit/ak-application-wizard-commit-application.ts @@ -21,8 +21,10 @@ import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css"; import { type ApplicationRequest, CoreApi, + type ModelRequest, ProviderModelEnum, ProxyMode, + type ProxyProviderRequest, type TransactionApplicationRequest, type TransactionApplicationResponse, ValidationError, @@ -74,6 +76,8 @@ const successState: State = { icon: ["fa-check-circle", "pf-m-success"], }; +type StrictProviderModelEnum = Exclude; + @customElement("ak-application-wizard-commit-application") export class ApplicationWizardCommitApplication extends BasePanel { static get styles() { @@ -106,15 +110,18 @@ export class ApplicationWizardCommitApplication extends BasePanel { // Stringly-based API. Not the best, but it works. Just be aware that it is // stringly-based. - const providerModel = providerMap.get(this.wizard.providerModel); - const provider = this.wizard.provider; + + const providerModel = providerMap.get( + this.wizard.providerModel, + ) as StrictProviderModelEnum; + const provider = this.wizard.provider as ModelRequest; provider.providerModel = providerModel; - // Special case for providers. + // Special case for the Proxy provider. if (this.wizard.providerModel === "proxyprovider") { - provider.mode = this.wizard.proxyMode; - if (provider.model !== ProxyMode.ForwardDomain) { - provider.cookieDomain = ""; + (provider as ProxyProviderRequest).mode = this.wizard.proxyMode; + if ((provider as ProxyProviderRequest).mode !== ProxyMode.ForwardDomain) { + (provider as ProxyProviderRequest).cookieDomain = ""; } } @@ -132,6 +139,7 @@ export class ApplicationWizardCommitApplication extends BasePanel { data: TransactionApplicationRequest, ): Promise { this.errors = undefined; + this.commitState = idleState; new CoreApi(DEFAULT_CONFIG) .coreTransactionalApplicationsUpdate({ transactionApplicationRequest: data, @@ -144,11 +152,11 @@ export class ApplicationWizardCommitApplication extends BasePanel { }) // eslint-disable-next-line @typescript-eslint/no-explicit-any .catch(async (resolution: any) => { - const errors = await parseAPIError(resolution); + this.errors = await parseAPIError(resolution); this.dispatchWizardUpdate({ update: { ...this.wizard, - errors, + errors: this.errors, }, status: "failed", }); @@ -156,11 +164,7 @@ export class ApplicationWizardCommitApplication extends BasePanel { }); } - renderErrors(errors?: ValidationError) { - if (!errors) { - return nothing; - } - + renderErrors(errors: ValidationError) { const navTo = (step: number) => () => this.dispatchCustomEvent("ak-wizard-nav", { command: "goto", @@ -211,7 +215,9 @@ export class ApplicationWizardCommitApplication extends BasePanel { > ${this.commitState.label} - ${this.renderErrors(this.errors)} + ${this.commitState === errorState + ? this.renderErrors(this.errors ?? {}) + : nothing}
diff --git a/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts b/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts index 8ec5344bb868..a9ba93fad656 100644 --- a/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts +++ b/web/src/admin/applications/wizard/methods/ak-application-wizard-authentication-method.ts @@ -3,7 +3,7 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j import BasePanel from "../BasePanel"; import { applicationWizardProvidersContext } from "../ContextIdentity"; -import type { LocalTypeCreate } from "./ak-application-wizard-authentication-method-choice.choices"; +import type { LocalTypeCreate } from "../auth-method-choice/ak-application-wizard-authentication-method-choice.choices"; import "./ldap/ak-application-wizard-authentication-by-ldap"; import "./oauth/ak-application-wizard-authentication-by-oauth"; import "./proxy/ak-application-wizard-authentication-for-reverse-proxy"; @@ -15,7 +15,7 @@ import "./scim/ak-application-wizard-authentication-by-scim"; @customElement("ak-application-wizard-authentication-method") export class ApplicationWizardApplicationDetails extends BasePanel { @consume({ context: applicationWizardProvidersContext }) - public providerModelsList: LocalTypeCreate[]; + public providerModelsList!: LocalTypeCreate[]; render() { const handler: LocalTypeCreate | undefined = this.providerModelsList.find( diff --git a/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-reverse-proxy.ts b/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-reverse-proxy.ts index 5487aa48026a..1f111357de0c 100644 --- a/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-reverse-proxy.ts +++ b/web/src/admin/applications/wizard/methods/proxy/ak-application-wizard-authentication-for-reverse-proxy.ts @@ -1,5 +1,7 @@ import { ProxyModeValue, + type SetMode, + type SetShowHttpBasic, renderForm, } from "@goauthentik/admin/providers/proxy/ProxyProviderFormForm.js"; @@ -7,6 +9,8 @@ import { msg } from "@lit/localize"; import { customElement, state } from "@lit/reactive-element/decorators.js"; import { html } from "lit"; +import { ProxyMode } from "@goauthentik/api"; + import BaseProviderPanel from "../BaseProviderPanel.js"; @customElement("ak-application-wizard-authentication-for-reverse-proxy") @@ -35,7 +39,7 @@ export class AkReverseProxyApplicationWizardPage extends BaseProviderPanel { return html` ${msg("Configure Proxy Provider")}
${renderForm(this.wizard.provider ?? {}, this.wizard.errors.provider ?? [], { - mode: this.wizard.proxyMode, + mode: this.wizard.proxyMode ?? ProxyMode.Proxy, onSetMode, showHttpBasic: this.showHttpBasic, onSetShowHttpBasic, diff --git a/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts b/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts index 5df32e02d693..5b46db415bdc 100644 --- a/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts +++ b/web/src/admin/applications/wizard/methods/radius/ak-application-wizard-authentication-by-radius.ts @@ -2,7 +2,6 @@ import "@goauthentik/admin/applications/wizard/ak-wizard-title"; import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; import { renderForm } from "@goauthentik/admin/providers/radius/RadiusProviderFormForm.js"; -import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-text-input"; import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; import "@goauthentik/elements/forms/FormGroup"; diff --git a/web/src/admin/common/ak-crypto-certificate-search.ts b/web/src/admin/common/ak-crypto-certificate-search.ts index c2227168219c..45926c101e76 100644 --- a/web/src/admin/common/ak-crypto-certificate-search.ts +++ b/web/src/admin/common/ak-crypto-certificate-search.ts @@ -5,8 +5,8 @@ import "@goauthentik/elements/forms/SearchSelect"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { html } from "lit"; -import { customElement } from "lit/decorators.js"; -import { property, query } from "lit/decorators.js"; +import { customElement, property, query } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; import { CertificateKeyPair, @@ -114,6 +114,7 @@ export class AkCryptoCertificateSearch extends CustomListenerElement(AKElement) render() { return html` extends CustomListenerElement(AKElement) .renderElement=${renderElement} .renderDescription=${renderDescription} .value=${getFlowValue} - name=${ifDefined(this.name)} + name=${ifDefined(this.name ?? undefined)} @ak-change=${this.handleSearchUpdate} ?blankable=${!this.required} > diff --git a/web/src/admin/providers/ldap/LDAPProviderFormForm.ts b/web/src/admin/providers/ldap/LDAPProviderFormForm.ts index d12cb43b051a..6e436e507709 100644 --- a/web/src/admin/providers/ldap/LDAPProviderFormForm.ts +++ b/web/src/admin/providers/ldap/LDAPProviderFormForm.ts @@ -1,6 +1,7 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; +import "@goauthentik/components/ak-number-input"; import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; @@ -72,7 +73,7 @@ export function renderForm( diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 4cdee45371b9..31a2c1105baa 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -24,8 +24,8 @@ export class ProxyProviderFormPage extends BaseProviderForm { const provider = await new ProvidersApi(DEFAULT_CONFIG).providersProxyRetrieve({ id: pk, }); - this.showHttpBasic = first(provider.basicAuthEnabled, true); - this.mode = first(provider.mode, ProxyMode.Proxy); + this.showHttpBasic = provider.basicAuthEnabled ?? true; + this.mode = provider.mode ?? ProxyMode.Proxy; return provider; } diff --git a/web/src/admin/providers/proxy/ProxyProviderFormForm.ts b/web/src/admin/providers/proxy/ProxyProviderFormForm.ts index f27ea019a4a9..6bc162e42eaf 100644 --- a/web/src/admin/providers/proxy/ProxyProviderFormForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderFormForm.ts @@ -16,7 +16,12 @@ import { msg } from "@lit/localize"; import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; -import { FlowsInstancesListDesignationEnum, ProxyMode, ProxyProvider } from "@goauthentik/api"; +import { + FlowsInstancesListDesignationEnum, + ProxyMode, + ProxyProvider, + ValidationError, +} from "@goauthentik/api"; import { makeProxyPropertyMappingsSelector, @@ -24,7 +29,7 @@ import { } from "./ProxyProviderPropertyMappings.js"; export type ProxyModeValue = { value: ProxyMode }; -export type SetMode = (ev: CustomEvent) => void; +export type SetMode = (ev: CustomEvent) => void; export type SetShowHttpBasic = (ev: Event) => void; export interface ProxyModeExtraArgs { @@ -34,7 +39,7 @@ export interface ProxyModeExtraArgs { onSetShowHttpBasic: SetShowHttpBasic; } -function renderHttpBasic(provider: ProxyProvider) { +function renderHttpBasic(provider: Partial) { return html``; } -function renderProxySettings(provider: ProxyProvider) { +function renderProxySettings(provider: Partial, errors?: ValidationError) { return html`

${msg( "This provider will behave like a transparent reverse-proxy, except requests must be authenticated. If your upstream application uses HTTPS, make sure to connect to the outpost using HTTPS as well.", )}

- - -

- ${msg( - "The external URL you'll access the application at. Include any non-standard port.", - )} -

-
- - -

- ${msg("Upstream host that the requests are forwarded to.")} -

-
- - -

- ${msg("Validate SSL Certificates of upstream servers.")} -

-
`; + + + + + `; } -function renderForwardSingleSettings(provider: ProxyProvider) { +function renderForwardSingleSettings(provider: Partial, errors?: ValidationError) { return html`

${msg( "Use this provider with nginx's auth_request or traefik's forwardAuth. Each application/domain needs its own provider. Additionally, on each domain, /outpost.goauthentik.io must be routed to the outpost (when using a managed outpost, this is done for you).", )}

- - -

- ${msg( - "The external URL you'll access the application at. Include any non-standard port.", - )} -

-
`; + `; } -function renderForwardDomainSettings(provider: ProxyProvider) { +function renderForwardDomainSettings(provider: Partial, errors?: ValidationError) { return html`

${msg( "Use this provider with nginx's auth_request or traefik's forwardAuth. Only a single provider is required per root domain. You can't do per-application authorization, but you don't have to create a provider for each application.", @@ -154,58 +140,58 @@ function renderForwardDomainSettings(provider: ProxyProvider) { "In this case, you'd set the Authentication URL to auth.example.com and Cookie domain to example.com.", )}

- - -

- ${msg( - "The external URL you'll authenticate at. The authentik core server should be reachable under this URL.", - )} -

-
- - -

- ${msg( - "Set this to the domain you wish the authentication to be valid for. Must be a parent domain of the URL above. If you're running applications as app1.domain.tld, app2.domain.tld, set this to 'domain.tld'.", - )} -

-
`; + + + + `; } -function renderSettings(provider: ProxyProvider, mode: ProxyMode) { - return match(mode) +type StrictProxyMode = Omit; + +function renderSettings(provider: Partial, mode: ProxyMode) { + return match(mode as StrictProxyMode) .with(ProxyMode.Proxy, () => renderProxySettings(provider)) .with(ProxyMode.ForwardSingle, () => renderForwardSingleSettings(provider)) .with(ProxyMode.ForwardDomain, () => renderForwardDomainSettings(provider)) - .exhaustive(); + .otherwise(() => { + throw new Error("Unrecognized proxy mode"); + }); } export function renderForm( - provider?: Partial, - errors: ValidationError, + provider: Partial = {}, + errors: ValidationError = {}, args: ProxyModeExtraArgs, ) { const { mode, onSetMode, showHttpBasic, onSetShowHttpBasic } = args; return html` - - - + + ${renderModeSelector(mode, onSetMode)}
- - -

${msg("Configure how long tokens are valid for.")}

- -
+ + ${msg("Advanced protocol settings")} @@ -281,51 +267,27 @@ export function renderForm( ${msg("Authentication settings")}
- - -

- ${msg( - "When enabled, authentik will intercept the Authorization header to authenticate the request.", - )} -

-
- - -

- ${msg( - "Send a custom HTTP-Basic Authentication header based on values from authentik.", - )} -

-
+ + + + + + ${showHttpBasic ? renderHttpBasic(provider) : nothing} ) => []; } -const mfaHelp = msg( +const mfaSupportHelp = msg( "When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.", ); @@ -85,22 +87,13 @@ export function renderForm(

${msg("Flow used for users to authenticate.")}

- - -

${mfaHelp}

-
+ + ${msg("Protocol settings")} diff --git a/web/src/admin/providers/saml/SAMLProviderForm.ts b/web/src/admin/providers/saml/SAMLProviderForm.ts index f54ead0c90d2..72525b2128b5 100644 --- a/web/src/admin/providers/saml/SAMLProviderForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderForm.ts @@ -1,3 +1,4 @@ +import { type AkCryptoCertificateSearch } from "@goauthentik/admin/common/ak-crypto-certificate-search"; import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; diff --git a/web/src/admin/providers/saml/SAMLProviderFormForm.ts b/web/src/admin/providers/saml/SAMLProviderFormForm.ts index 760ddf24e89d..ffa4ebc24804 100644 --- a/web/src/admin/providers/saml/SAMLProviderFormForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderFormForm.ts @@ -5,7 +5,6 @@ import { import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; import "@goauthentik/elements/forms/FormGroup"; @@ -51,29 +50,59 @@ export function makeSAMLPropertyMappingsSelector(instanceMappings?: string[]) { mapping?.managed?.startsWith("goauthentik.io/providers/saml"); } +const serviceProviderBindingOptions = [ + { + label: msg("Redirect"), + value: SpBindingEnum.Redirect, + default: true, + }, + { + label: msg("Post"), + value: SpBindingEnum.Post, + }, +]; + +function renderHasSigningKp(provider?: Partial) { + return html` + + + + `; +} + export function renderForm( - provider?: Partial, + provider: Partial = {}, errors: ValidationError, setHasSigningKp: (ev: InputEvent) => void, hasSigningKp: boolean, ) { - return html` - - + return html`

${msg("Flow used when authorizing this provider.")} @@ -83,56 +112,38 @@ export function renderForm( ${msg("Protocol settings")}

- - - - - -

${msg("Also known as EntityID.")}

-
- + + - - -

- ${msg( - "Determines how authentik sends the response back to the Service Provider.", - )} -

-
- - - + +
@@ -186,48 +197,8 @@ export function renderForm( )}

- ${hasSigningKp - ? html` - -

- ${msg( - "When enabled, the assertion element of the SAML response will be signed.", - )} -

-
- - -

- ${msg( - "When enabled, the assertion element of the SAML response will be signed.", - )} -

-
` - : nothing} + ${hasSigningKp ? renderHasSigningKp(provider) : nothing} + - - -

- ${msg("Configure the maximum allowed time drift for an assertion.")} -

- -
- + + - -

- ${msg("Assertion not valid on or after current time + this value.")} -

- -
- + + - -

- ${msg("Session not valid on or after current time + this value.")} -

- -
- + + - -

- ${msg( - "When using IDP-initiated logins, the relay state will be set to this value.", - )} -

- -
+ label=${msg("Default relay state")} + value="${provider?.defaultRelayState || ""}" + .errorMessages=${errors?.sessionValidNotOnOrAfter ?? []} + help=${msg( + "When using IDP-initiated logins, the relay state will be set to this value.", + )} + > - - - - - + + - - - +
`; } diff --git a/web/src/admin/providers/scim/SCIMProviderFormForm.ts b/web/src/admin/providers/scim/SCIMProviderFormForm.ts index f6ea6bb43408..229406ee695a 100644 --- a/web/src/admin/providers/scim/SCIMProviderFormForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderFormForm.ts @@ -18,6 +18,7 @@ import { PropertymappingsApi, SCIMMapping, SCIMProvider, + ValidationError, } from "@goauthentik/api"; export async function scimPropertyMappingsProvider(page = 1, search = "") { @@ -48,79 +49,55 @@ export function makeSCIMPropertyMappingsSelector( export function renderForm(provider?: Partial, errors: ValidationError = {}) { return html` - - - - + ${msg("Protocol settings")}
- - -

- ${msg("SCIM base url, usually ends in /v2.")} -

-
- - - - - -

- ${msg( - "Token to authenticate with. Currently only bearer authentication is supported.", - )} -

-
+ + + + + +
${msg("User filtering")}
- - - + + + => { diff --git a/web/tests/pageobjects/controls.ts b/web/tests/pageobjects/controls.ts index df70e00f547b..50ac159ac69a 100644 --- a/web/tests/pageobjects/controls.ts +++ b/web/tests/pageobjects/controls.ts @@ -4,10 +4,116 @@ import { Key } from "webdriverio"; export async function doBlur(el: WebdriverIO.Element | ChainablePromiseElement) { const element = await el; - browser.execute((element) => element.blur()); + browser.execute((element) => element.blur(), element); } -export async function setSearchSelect(name: string, value: string) { +export function tap(a: A) { + console.log("TAP:", a); + return a; +} + +const makeComparator = (value: string | RegExp) => + typeof value === "string" + ? (sample: string) => sample === value + : (sample: string) => value.test(sample); + +export async function checkIsPresent(name: string) { + await expect(await $(name)).toBeDisplayed(); +} + +export async function clickButton(name: string, ctx?: WebdriverIO.Element) { + const context = ctx ?? browser; + const button = await (async () => { + for await (const button of context.$$("button")) { + if ((await button.isDisplayed()) && (await button.getText()).indexOf(name) !== -1) { + return button; + } + } + })(); + + if (!(button && (await button.isDisplayed()))) { + throw new Error(`Unable to find button '${name}'`); + } + + await button.scrollIntoView(); + await button.click(); + await doBlur(button); +} + +export async function clickToggleGroup(name: string, value: string | RegExp) { + const comparator = makeComparator(value); + const button = await (async () => { + for await (const button of $(`[data-ouid-component-name=${name}]`).$$( + ".pf-c-toggle-group__button", + )) { + if (comparator(await button.$(".pf-c-toggle-group__text").getText())) { + return button; + } + } + })(); + + if (!(button && (await button?.isDisplayed()))) { + throw new Error(`Unable to locate toggle button ${name}:${value.toString()}`); + } + + await button.scrollIntoView(); + await button.click(); + await doBlur(button); +} + +export async function setFormGroup(name: string | RegExp, setting: "open" | "closed") { + const comparator = makeComparator(name); + const formGroup = await (async () => { + for await (const group of browser.$$("ak-form-group")) { + // Delightfully, wizards may have slotted elements that *exist* but are not *attached*, + // and this can break the damn tests. + if (!(await group.isDisplayed())) { + continue; + } + if ( + comparator(await group.$("div.pf-c-form__field-group-header-title-text").getText()) + ) { + return group; + } + } + })(); + + if (!(formGroup && (await formGroup.isDisplayed()))) { + throw new Error(`Unable to find ak-form-group[name="${name}"]`); + } + + await formGroup.scrollIntoView(); + const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); + await match([await toggle.getAttribute("aria-expanded"), setting]) + .with(["false", "open"], async () => await toggle.click()) + .with(["true", "closed"], async () => await toggle.click()) + .otherwise(async () => {}); + await doBlur(formGroup); +} + +export async function setRadio(name: string, value: string | RegExp) { + const control = await $(`ak-radio[name="${name}"]`); + await control.scrollIntoView(); + + const comparator = makeComparator(value); + const item = await (async () => { + for await (const item of control.$$("div.pf-c-radio")) { + if (comparator(await item.$(".pf-c-radio__label").getText())) { + return item; + } + } + })(); + + if (!(item && (await item.isDisplayed()))) { + throw new Error(`Unable to find a radio that matches ${name}:${value.toString()}`); + } + + await item.scrollIntoView(); + await item.click(); + await doBlur(control); +} + +export async function setSearchSelect(name: string, value: string | RegExp) { const control = await (async () => { try { const control = await $(`ak-search-select[name="${name}"]`); @@ -20,27 +126,33 @@ export async function setSearchSelect(name: string, value: string) { } })(); + if (!(control && (await control.isExisting()))) { + throw new Error(`Unable to find an ak-search-select variant matching ${name}}`); + } + // Find the search select input control and activate it. const view = await control.$("ak-search-select-view"); const input = await view.$('input[type="text"]'); await input.scrollIntoView(); await input.click(); - // @ts-expect-error "Types break on shadow$$" + const comparator = makeComparator(value); const button = await (async () => { for await (const button of $(`div[data-managed-for*="${name}"]`) .$("ak-list-select") .$$("button")) { - if ((await button.getText()).includes(value)) { + if (comparator(await button.getText())) { return button; } } })(); - // @ts-expect-error "TSC cannot tell if the `for` loop actually performs the assignment." - if (!button.isExisting()) { - throw new Error(`Expected to find an entry matching the spec ${value}`); + if (!(button && (await button.isDisplayed()))) { + throw new Error( + `Unable to find an ak-search-select entry matching ${name}:${value.toString()}`, + ); } + await (await button).click(); await browser.keys(Key.Tab); await doBlur(control); @@ -53,22 +165,30 @@ export async function setTextInput(name: string, value: string) { await doBlur(control); } -export async function setRadio(name: string, value: string) { - const control = await $(`ak-radio[name="${name}"]`); +export async function setTextareaInput(name: string, value: string) { + const control = await $(`textarea[name="${name}"]`); await control.scrollIntoView(); - const item = await control.$(`label.*=${value}`).parentElement(); - await item.scrollIntoView(); - await item.click(); + await control.setValue(value); await doBlur(control); } +export async function setToggle(name: string, set: boolean) { + const toggle = await $(`input[name="${name}"]`); + await toggle.scrollIntoView(); + await expect(await toggle.getAttribute("type")).toBe("checkbox"); + const state = await toggle.isSelected(); + if (set !== state) { + const control = await (await toggle.parentElement()).$(".pf-c-switch__toggle"); + await control.click(); + await doBlur(control); + } +} + export async function setTypeCreate(name: string, value: string | RegExp) { const control = await $(`ak-wizard-page-type-create[name="${name}"]`); await control.scrollIntoView(); - const comparator = - typeof value === "string" ? (sample) => sample === value : (sample) => value.test(sample); - + const comparator = makeComparator(value); const card = await (async () => { for await (const card of $("ak-wizard-page-type-create").$$( '[data-ouid-component-type="ak-type-create-grid-card"]', @@ -79,68 +199,27 @@ export async function setTypeCreate(name: string, value: string | RegExp) { } })(); + if (!(card && (await card.isDisplayed()))) { + throw new Error(`Unable to locate radio card ${name}:${value.toString()}`); + } + await card.scrollIntoView(); await card.click(); await doBlur(control); } -export async function setFormGroup(name: string | RegExp, setting: "open" | "closed") { - const comparator = - typeof name === "string" ? (sample) => sample === name : (sample) => name.test(sample); - - const formGroup = await (async () => { - for await (const group of browser.$$("ak-form-group")) { - // Delightfully, wizards may have slotted elements that *exist* but are not *attached*, - // and this can break the damn tests. - if (!(await group.isDisplayed())) { - continue; - } - if ( - comparator(await group.$("div.pf-c-form__field-group-header-title-text").getText()) - ) { - return group; - } - } - })(); - - await formGroup.scrollIntoView(); - const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); - await match([await toggle.getAttribute("aria-expanded"), setting]) - .with(["false", "open"], async () => await toggle.click()) - .with(["true", "closed"], async () => await toggle.click()) - .otherwise(async () => {}); - await doBlur(formGroup); -} - -export async function clickButton(name: string, ctx?: WebdriverIO.Element) { - const context = ctx ?? browser; - const buttons = await context.$$("button"); - let button: WebdriverIO.Element; - for (const b of buttons) { - if (b.isDisplayed() && (await b.getText()).indexOf(name) !== -1) { - button = b; - break; - } - } - await button.scrollIntoView(); - await button.click(); - await doBlur(button); -} - -export async function clickToggleGroup(name: string, value: string | RegExp) { - const comparator = - typeof name === "string" ? (sample) => sample === value : (sample) => value.test(sample); - - const button = await (async () => { - for await (const button of $(`[data-ouid-component-name=${name}]`).$$( - ".pf-c-toggle-group__button", - )) { - if (comparator(await button.$(".pf-c-toggle-group__text").getText())) { - return button; - } - } - })(); - await button.scrollIntoView(); - await button.click(); - await doBlur(button); -} +export type TestInteraction = + | [typeof checkIsPresent, ...Parameters] + | [typeof clickButton, ...Parameters] + | [typeof clickToggleGroup, ...Parameters] + | [typeof setFormGroup, ...Parameters] + | [typeof setRadio, ...Parameters] + | [typeof setSearchSelect, ...Parameters] + | [typeof setTextInput, ...Parameters] + | [typeof setTextareaInput, ...Parameters] + | [typeof setToggle, ...Parameters] + | [typeof setTypeCreate, ...Parameters]; + +export type TestSequence = TestInteraction[]; + +export type TestProvider = () => TestSequence; diff --git a/web/tests/pageobjects/page.ts b/web/tests/pageobjects/page.ts index ae225ce06c1a..63a26cd6c4d4 100644 --- a/web/tests/pageobjects/page.ts +++ b/web/tests/pageobjects/page.ts @@ -80,6 +80,7 @@ export default class Page { await $(`div[data-managed-for="${name}"]`).$("ak-list-select") ).shadow$$("button"); + let target: WebdriverIO.Element; // @ts-expect-error "Types break on shadow$$" for (const button of searchBlock) { if ((await button.getText()).includes(value)) { @@ -91,6 +92,7 @@ export default class Page { if (!target) { throw new Error(`Expected to find an entry matching the spec ${value}`); } + await (await target).click(); await browser.keys(Key.Tab); } @@ -121,7 +123,7 @@ export default class Page { const formGroup = await $(`ak-form-group span[slot="header"].*=${name}`).parentElement(); await formGroup.scrollIntoView(); const toggle = await formGroup.$("div.pf-c-form__field-group-toggle-button button"); - await match([toggle.getAttribute("expanded"), setting]) + await match([await toggle.getAttribute("expanded"), setting]) .with(["false", "open"], async () => await toggle.click()) .with(["true", "closed"], async () => await toggle.click()) .otherwise(async () => {}); diff --git a/web/tests/specs/new-application-by-wizard.ts b/web/tests/specs/new-application-by-wizard.ts index c636fda21fad..50c3e5fd1692 100644 --- a/web/tests/specs/new-application-by-wizard.ts +++ b/web/tests/specs/new-application-by-wizard.ts @@ -9,8 +9,15 @@ import ApplicationWizardView from "../pageobjects/application-wizard.page.js"; import ApplicationsListPage from "../pageobjects/applications-list.page.js"; import { randomId } from "../utils/index.js"; import { login } from "../utils/login.js"; -import { type TestSequence } from "./shared-sequences"; import { + completeForwardAuthDomainProxyProviderForm, + completeForwardAuthProxyProviderForm, + completeLDAPProviderForm, + completeOAuth2ProviderForm, + completeProxyProviderForm, + completeRadiusProviderForm, + completeSAMLProviderForm, + completeSCIMProviderForm, simpleForwardAuthDomainProxyProviderForm, simpleForwardAuthProxyProviderForm, simpleLDAPProviderForm, @@ -19,7 +26,8 @@ import { simpleRadiusProviderForm, simpleSAMLProviderForm, simpleSCIMProviderForm, -} from "./shared-sequences.js"; +} from "./provider-shared-sequences.js"; +import { type TestSequence } from "./shared-sequences"; const SUCCESS_MESSAGE = "Your application has been saved"; @@ -62,7 +70,6 @@ async function fillOutTheProviderAndCommit(provider: TestSequence) { console.log(`Running ${args.join(", ")}`); // @ts-expect-error "This is a pretty alien call; I'm not surprised Typescript hates it." await thefunc.apply($, args); - await browser.pause(1000); } await $("ak-wizard-frame").$("footer button.pf-m-primary").click(); @@ -79,14 +86,22 @@ async function itShouldConfigureApplicationsViaTheWizard(name: string, provider: } const providers = [ - ["LDAP", simpleLDAPProviderForm], - ["OAuth2", simpleOAuth2ProviderForm], - ["Radius", simpleRadiusProviderForm], - ["SAML", simpleSAMLProviderForm], - ["SCIM", simpleSCIMProviderForm], - ["Proxy", simpleProxyProviderForm], - ["Forward Auth (single application)", simpleForwardAuthProxyProviderForm], - ["Forward Auth (domain level)", simpleForwardAuthDomainProxyProviderForm], + ["Simple LDAP", simpleLDAPProviderForm], + ["Simple OAuth2", simpleOAuth2ProviderForm], + ["Simple Radius", simpleRadiusProviderForm], + ["Simple SAML", simpleSAMLProviderForm], + ["Simple SCIM", simpleSCIMProviderForm], + ["Simple Proxy", simpleProxyProviderForm], + ["Simple Forward Auth (single)", simpleForwardAuthProxyProviderForm], + ["Simple Forward Auth (domain)", simpleForwardAuthDomainProxyProviderForm], + ["Complete OAuth2", completeOAuth2ProviderForm], + ["Complete LDAP", completeLDAPProviderForm], + ["Complete Radius", completeRadiusProviderForm], + ["Complete SAML", completeSAMLProviderForm], + ["Complete SCIM", completeSCIMProviderForm], + ["Complete Proxy", completeProxyProviderForm], + ["Complete Forward Auth (single)", completeForwardAuthProxyProviderForm], + ["Complete Forward Auth (domain)", completeForwardAuthDomainProxyProviderForm], ]; describe("Configuring Applications Via the Wizard", () => { diff --git a/web/tests/specs/providers.ts b/web/tests/specs/providers.ts index 2e6299cd0ca3..5d25ed198de9 100644 --- a/web/tests/specs/providers.ts +++ b/web/tests/specs/providers.ts @@ -1,10 +1,18 @@ import { expect } from "@wdio/globals"; +import { type TestProvider, type TestSequence } from "../pageobjects/controls"; import ProviderWizardView from "../pageobjects/provider-wizard.page.js"; import ProvidersListPage from "../pageobjects/providers-list.page.js"; import { login } from "../utils/login.js"; -import { type TestSequence } from "./shared-sequences"; import { + completeForwardAuthDomainProxyProviderForm, + completeForwardAuthProxyProviderForm, + completeLDAPProviderForm, + completeOAuth2ProviderForm, + completeProxyProviderForm, + completeRadiusProviderForm, + completeSAMLProviderForm, + completeSCIMProviderForm, simpleForwardAuthDomainProxyProviderForm, simpleForwardAuthProxyProviderForm, simpleLDAPProviderForm, @@ -13,7 +21,7 @@ import { simpleRadiusProviderForm, simpleSAMLProviderForm, simpleSCIMProviderForm, -} from "./shared-sequences.js"; +} from "./provider-shared-sequences.js"; async function reachTheProvider() { await ProvidersListPage.logout(); @@ -46,7 +54,7 @@ async function fillOutFields(fields: TestSequence) { for (const field of fields) { const thefunc = field[0]; const args = field.slice(1); - // @ts-expect-error "This is a pretty alien call; I'm not surprised Typescript hates it." + // @ts-expect-error "This is a pretty alien call, so I'm not surprised Typescript doesn't like it." await thefunc.apply($, args); } } @@ -62,16 +70,26 @@ async function itShouldConfigureASimpleProvider(name: string, provider: TestSequ }); } +type ProviderTest = [string, TestProvider]; + describe("Configuring Providers", () => { - const providers = [ - ["LDAP", simpleLDAPProviderForm], - ["OAuth2", simpleOAuth2ProviderForm], - ["Radius", simpleRadiusProviderForm], - ["SAML", simpleSAMLProviderForm], - ["SCIM", simpleSCIMProviderForm], - ["Proxy", simpleProxyProviderForm], - ["Forward Auth (single application)", simpleForwardAuthProxyProviderForm], - ["Forward Auth (domain level)", simpleForwardAuthDomainProxyProviderForm], + const providers: ProviderTest[] = [ + ["Simple LDAP", simpleLDAPProviderForm], + ["Simple OAuth2", simpleOAuth2ProviderForm], + ["Simple Radius", simpleRadiusProviderForm], + ["Simple SAML", simpleSAMLProviderForm], + ["Simple SCIM", simpleSCIMProviderForm], + ["Simple Proxy", simpleProxyProviderForm], + ["Simple Forward Auth (single application)", simpleForwardAuthProxyProviderForm], + ["Simple Forward Auth (domain level)", simpleForwardAuthDomainProxyProviderForm], + ["Complete OAuth2", completeOAuth2ProviderForm], + ["Complete LDAP", completeLDAPProviderForm], + ["Complete Radius", completeRadiusProviderForm], + ["Complete SAML", completeSAMLProviderForm], + ["Complete SCIM", completeSCIMProviderForm], + ["Complete Proxy", completeProxyProviderForm], + ["Complete Forward Auth (single application)", completeForwardAuthProxyProviderForm], + ["Complete Forward Auth (domain level)", completeForwardAuthDomainProxyProviderForm], ]; for (const [name, provider] of providers) { diff --git a/web/tests/specs/shared-sequences.ts b/web/tests/specs/shared-sequences.ts deleted file mode 100644 index 5dd8be8c4bad..000000000000 --- a/web/tests/specs/shared-sequences.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - clickButton, - clickToggleGroup, - setFormGroup, - setSearchSelect, - setTextInput, - setTypeCreate, -} from "pageobjects/controls.js"; - -import { randomId } from "../utils/index.js"; - -const newObjectName = (prefix: string) => `${prefix} - ${randomId()}`; - -export type TestInteraction = - | [typeof clickButton, ...Parameters] - | [typeof clickToggleGroup, ...Parameters] - | [typeof setFormGroup, ...Parameters] - | [typeof setSearchSelect, ...Parameters] - | [typeof setTextInput, ...Parameters] - | [typeof setTypeCreate, ...Parameters]; - -export type TestSequence = TestInteraction[]; - -export type TestProvider = () => TestSequence; - -export const simpleOAuth2ProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New Oauth2 Provider")], - [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], -]; - -export const simpleLDAPProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "LDAP Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New LDAP Provider")], - // This will never not weird me out. - [setFormGroup, /Flow settings/, "open"], - [setSearchSelect, "authorizationFlow", "default-authentication-flow"], - [setSearchSelect, "invalidationFlow", "default-invalidation-flow"], -]; - -export const simpleRadiusProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "Radius Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New Radius Provider")], - [setSearchSelect, "authorizationFlow", "default-authentication-flow"], -]; - -export const simpleSAMLProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "SAML Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New SAML Provider")], - [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], - [setTextInput, "acsUrl", "http://example.com:8000/"], -]; - -export const simpleSCIMProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "SCIM Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New SCIM Provider")], - [setTextInput, "url", "http://example.com:8000/"], - [setTextInput, "token", "insert-real-token-here"], -]; - -export const simpleProxyProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "Proxy Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New Proxy Provider")], - [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], - [clickToggleGroup, "proxy-type-toggle", "Proxy"], - [setTextInput, "externalHost", "http://example.com:8000/"], - [setTextInput, "internalHost", "http://example.com:8001/"], -]; - -export const simpleForwardAuthProxyProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "Proxy Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New Forward Auth Provider")], - [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], - [clickToggleGroup, "proxy-type-toggle", "Forward auth (single application)"], - [setTextInput, "externalHost", "http://example.com:8000/"], -]; - -export const simpleForwardAuthDomainProxyProviderForm: TestProvider = () => [ - [setTypeCreate, "selectProviderType", "Proxy Provider"], - [clickButton, "Next"], - [setTextInput, "name", newObjectName("New Forward Auth Domain Level Provider")], - [setSearchSelect, "authorizationFlow", "default-provider-authorization-explicit-consent"], - [clickToggleGroup, "proxy-type-toggle", "Forward auth (domain level)"], - [setTextInput, "externalHost", "http://example.com:8000/"], - [setTextInput, "cookieDomain", "somedomain.tld"], -]; diff --git a/web/tests/utils/index.ts b/web/tests/utils/index.ts index 73e10a876da6..edd10f312762 100644 --- a/web/tests/utils/index.ts +++ b/web/tests/utils/index.ts @@ -1,3 +1,22 @@ +// Taken from python's string module +export const ascii_lowercase = "abcdefghijklmnopqrstuvwxyz"; +export const ascii_uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +export const ascii_letters = ascii_lowercase + ascii_uppercase; +export const digits = "0123456789"; +export const hexdigits = digits + "abcdef" + "ABCDEF"; +export const octdigits = "01234567"; +export const punctuation = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"; + +export function randomString(len: number, charset: string): string { + const chars = []; + const array = new Uint8Array(len); + globalThis.crypto.getRandomValues(array); + for (let index = 0; index < len; index++) { + chars.push(charset[Math.floor(charset.length * (array[index] / Math.pow(2, 8)))]); + } + return chars.join(""); +} + export function randomId() { let dt = new Date().getTime(); return "xxxxxxxx".replace(/x/g, (c) => { From 11bc9b80418e10ea6e6a58778663435493de6bd7 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Tue, 29 Oct 2024 15:51:19 -0700 Subject: [PATCH 8/8] Not sure how *that* got lost, but... --- web/tests/specs/provider-shared-sequences.ts | 323 +++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 web/tests/specs/provider-shared-sequences.ts diff --git a/web/tests/specs/provider-shared-sequences.ts b/web/tests/specs/provider-shared-sequences.ts new file mode 100644 index 000000000000..de44ce514657 --- /dev/null +++ b/web/tests/specs/provider-shared-sequences.ts @@ -0,0 +1,323 @@ +import { + type TestProvider, + type TestSequence, + checkIsPresent, + clickButton, + clickToggleGroup, + setFormGroup, + setRadio, + setSearchSelect, + setTextInput, + setTextareaInput, + setToggle, + setTypeCreate, +} from "pageobjects/controls.js"; + +import { ascii_letters, digits, randomString } from "../utils"; +import { randomId } from "../utils/index.js"; + +const newObjectName = (prefix: string) => `${prefix} - ${randomId()}`; + +// components.schemas.OAuth2ProviderRequest +// +// - name +// - authentication_flow +// - authorization_flow +// - invalidation_flow +// - property_mappings +// - client_type +// - client_id +// - client_secret +// - access_code_validity +// - access_token_validity +// - refresh_token_validity +// - include_claims_in_id_token +// - signing_key +// - encryption_key +// - redirect_uris +// - sub_mode +// - issuer_mode +// - jwks_sources +// +export const simpleOAuth2ProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Oauth2 Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], +]; + +export const completeOAuth2ProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "OAuth2/OpenID Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Oauth2 Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], + [setFormGroup, /Protocol settings/, "open"], + [setRadio, "clientType", "Public"], + // Switch back so we can make sure `clientSecret` is available. + [setRadio, "clientType", "Confidential"], + [checkIsPresent, '[name="clientId"]'], + [checkIsPresent, '[name="clientSecret"]'], + [setSearchSelect, "signingKey", /authentik Self-signed Certificate/], + [setSearchSelect, "encryptionKey", /authentik Self-signed Certificate/], + [setFormGroup, /Advanced flow settings/, "open"], + [setSearchSelect, "authenticationFlow", /default-source-authentication/], + [setSearchSelect, "invalidationFlow", /default-invalidation-flow/], + [setFormGroup, /Advanced protocol settings/, "open"], + [setTextInput, "accessCodeValidity", "minutes=2"], + [setTextInput, "accessTokenValidity", "minutes=10"], + [setTextInput, "refreshTokenValidity", "days=40"], + [setToggle, "includeClaimsInIdToken", false], + [checkIsPresent, '[name="redirectUris"]'], + [setRadio, "subMode", "Based on the User's username"], + [setRadio, "issuerMode", "Same identifier is used for all providers"], + [setFormGroup, /Machine-to-Machine authentication settings/, "open"], + [checkIsPresent, '[name="jwksSources"]'], +]; + +// components.schemas.LDAPProviderRequest +// +// - name +// - authentication_flow +// - authorization_flow +// - invalidation_flow +// - base_dn +// - certificate +// - tls_server_name +// - uid_start_number +// - gid_start_number +// - search_mode +// - bind_mode +// - mfa_support +// +export const simpleLDAPProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "LDAP Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New LDAP Provider")], + // This will never not weird me out. + [setFormGroup, /Flow settings/, "open"], + [setSearchSelect, "authorizationFlow", /default-authentication-flow/], + [setSearchSelect, "invalidationFlow", /default-invalidation-flow/], +]; + +export const completeLDAPProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "LDAP Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New LDAP Provider")], + [setFormGroup, /Flow settings/, "open"], + [setFormGroup, /Protocol settings/, "open"], + [setSearchSelect, "authorizationFlow", /default-authentication-flow/], + [setSearchSelect, "invalidationFlow", /default-invalidation-flow/], + [setTextInput, "baseDn", "DC=ldap-2,DC=goauthentik,DC=io"], + [setSearchSelect, "certificate", /authentik Self-signed Certificate/], + [checkIsPresent, '[name="tlsServerName"]'], + [setTextInput, "uidStartNumber", "2001"], + [setTextInput, "gidStartNumber", "4001"], + [setRadio, "searchMode", "Direct querying"], + [setRadio, "bindMode", "Direct binding"], + [setToggle, "mfaSupport", false], +]; + +export const simpleRadiusProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Radius Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Radius Provider")], + [setSearchSelect, "authorizationFlow", /default-authentication-flow/], +]; + +export const completeRadiusProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Radius Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Radius Provider")], + [setSearchSelect, "authorizationFlow", /default-authentication-flow/], + [setFormGroup, /Advanced flow settings/, "open"], + [setSearchSelect, "invalidationFlow", /default-invalidation-flow/], + [setFormGroup, /Protocol settings/, "open"], + [setToggle, "mfaSupport", false], + [setTextInput, "clientNetworks", ""], + [setTextInput, "clientNetworks", "0.0.0.0/0, ::/0"], + [setTextInput, "sharedSecret", randomString(128, ascii_letters + digits)], + [checkIsPresent, '[name="propertyMappings"]'], +]; + +// provider_components.schemas.SAMLProviderRequest.yml +// +// - name +// - authentication_flow +// - authorization_flow +// - invalidation_flow +// - property_mappings +// - acs_url +// - audience +// - issuer +// - assertion_valid_not_before +// - assertion_valid_not_on_or_after +// - session_valid_not_on_or_after +// - name_id_mapping +// - digest_algorithm +// - signature_algorithm +// - signing_kp +// - verification_kp +// - encryption_kp +// - sign_assertion +// - sign_response +// - sp_binding +// - default_relay_state +// +export const simpleSAMLProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "SAML Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New SAML Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], + [setTextInput, "acsUrl", "http://example.com:8000/"], +]; + +export const completeSAMLProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "SAML Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New SAML Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], + [setTextInput, "acsUrl", "http://example.com:8000/"], + [setTextInput, "issuer", "someone-else"], + [setRadio, "spBinding", "Post"], + [setTextInput, "audience", ""], + [setFormGroup, /Advanced flow settings/, "open"], + [setSearchSelect, "invalidationFlow", /default-invalidation-flow/], + [setSearchSelect, "authenticationFlow", /default-source-authentication/], + [setFormGroup, /Advanced protocol settings/, "open"], + [checkIsPresent, '[name="propertyMappings"]'], + [setSearchSelect, "signingKp", /authentik Self-signed Certificate/], + [setSearchSelect, "verificationKp", /authentik Self-signed Certificate/], + [setSearchSelect, "encryptionKp", /authentik Self-signed Certificate/], + [setSearchSelect, "nameIdMapping", /authentik default SAML Mapping. Username/], + [setTextInput, "assertionValidNotBefore", "minutes=-10"], + [setTextInput, "assertionValidNotOnOrAfter", "minutes=10"], + [setTextInput, "sessionValidNotOnOrAfter", "minutes=172800"], + [checkIsPresent, '[name="defaultRelayState"]'], + [setRadio, "digestAlgorithm", "SHA512"], + [setRadio, "signatureAlgorithm", "RSA-SHA512"], + // These are only available after the signingKp is defined. + [setToggle, "signAssertion", true], + [setToggle, "signResponse", true], +]; + +// provider_components.schemas.SCIMProviderRequest.yml +// +// - name +// - property_mappings +// - property_mappings_group +// - url +// - verify_certificates +// - token +// - exclude_users_service_account +// - filter_group +// +export const simpleSCIMProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "SCIM Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New SCIM Provider")], + [setTextInput, "url", "http://example.com:8000/"], + [setTextInput, "token", "insert-real-token-here"], +]; + +export const completeSCIMProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "SCIM Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New SCIM Provider")], + [setTextInput, "url", "http://example.com:8000/"], + [setToggle, "verifyCertificates", false], + [setTextInput, "token", "insert-real-token-here"], + [setFormGroup, /Protocol settings/, "open"], + [setFormGroup, /User filtering/, "open"], + [setToggle, "excludeUsersServiceAccount", false], + [setSearchSelect, "filterGroup", /authentik Admins/], + [setFormGroup, /Attribute mapping/, "open"], + [checkIsPresent, '[name="propertyMappings"]'], + [checkIsPresent, '[name="propertyMappingsGroup"]'], +]; + +// provider_components.schemas.ProxyProviderRequest.yml +// +// - name +// - authentication_flow +// - authorization_flow +// - invalidation_flow +// - property_mappings +// - internal_host +// - external_host +// - internal_host_ssl_validation +// - certificate +// - skip_path_regex +// - basic_auth_enabled +// - basic_auth_password_attribute +// - basic_auth_user_attribute +// - mode +// - intercept_header_auth +// - cookie_domain +// - jwks_sources +// - access_token_validity +// - refresh_token_validity +// - refresh_token_validity is not handled in any of our forms. On purpose. +// - internal_host_ssl_validation +// - only on ProxyMode + +export const simpleProxyProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Proxy Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Proxy Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], + [clickToggleGroup, "proxy-type-toggle", "Proxy"], + [setTextInput, "externalHost", "http://example.com:8000/"], + [setTextInput, "internalHost", "http://example.com:8001/"], +]; + +export const simpleForwardAuthProxyProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Proxy Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Forward Auth Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], + [clickToggleGroup, "proxy-type-toggle", "Forward auth (single application)"], + [setTextInput, "externalHost", "http://example.com:8000/"], +]; + +export const simpleForwardAuthDomainProxyProviderForm: TestProvider = () => [ + [setTypeCreate, "selectProviderType", "Proxy Provider"], + [clickButton, "Next"], + [setTextInput, "name", newObjectName("New Forward Auth Domain Level Provider")], + [setSearchSelect, "authorizationFlow", /default-provider-authorization-explicit-consent/], + [clickToggleGroup, "proxy-type-toggle", "Forward auth (domain level)"], + [setTextInput, "externalHost", "http://example.com:8000/"], + [setTextInput, "cookieDomain", "somedomain.tld"], +]; + +const proxyModeCompletions: TestSequence = [ + [setTextInput, "accessTokenValidity", "hours=36"], + [setFormGroup, /Advanced protocol settings/, "open"], + [setSearchSelect, "certificate", /authentik Self-signed Certificate/], + [checkIsPresent, '[name="propertyMappings"]'], + [setTextareaInput, "skipPathRegex", "."], + [setFormGroup, /Authentication settings/, "open"], + [setToggle, "interceptHeaderAuth", false], + [setToggle, "basicAuthEnabled", true], + [setTextInput, "basicAuthUserAttribute", "authorized-user"], + [setTextInput, "basicAuthPasswordAttribute", "authorized-user-password"], + [setFormGroup, /Advanced flow settings/, "open"], + [setSearchSelect, "authenticationFlow", /default-source-authentication/], + [setSearchSelect, "invalidationFlow", /default-invalidation-flow/], + [checkIsPresent, '[name="jwksSources"]'], +]; + +export const completeProxyProviderForm: TestProvider = () => [ + ...simpleProxyProviderForm(), + [setToggle, "internalHostSslValidation", false], + ...proxyModeCompletions, +]; + +export const completeForwardAuthProxyProviderForm: TestProvider = () => [ + ...simpleForwardAuthProxyProviderForm(), + ...proxyModeCompletions, +]; + +export const completeForwardAuthDomainProxyProviderForm: TestProvider = () => [ + ...simpleForwardAuthProxyProviderForm(), + ...proxyModeCompletions, +];