web/admin: Unify the forms for providers between the ./admin/providers and ./admin/applications/wizard

## 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)
```

🎉
This commit is contained in:
Ken Sternberg
2024-10-29 15:06:32 -07:00
parent 5bd7cedaba
commit 807e2a9fb0
21 changed files with 567 additions and 645 deletions

View File

@ -10,7 +10,7 @@ export type LocalTypeCreate = TypeCreate & {
renderer: ProviderRenderer;
};
export const providerTypeRenderers = {
export const providerTypeRenderers: Record<string, () => TemplateResult> = {
oauth2provider: () =>
html`<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>`,
ldapprovider: () =>

View File

@ -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(

View File

@ -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<ProviderModelEnum, "11184809">;
@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<TransactionApplicationResponse | void> {
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}
</h1>
${this.renderErrors(this.errors)}
${this.commitState === errorState
? this.renderErrors(this.errors ?? {})
: nothing}
</div>
</div>
</div>

View File

@ -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(

View File

@ -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` <ak-wizard-title>${msg("Configure Proxy Provider")}</ak-wizard-title>
<form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
${renderForm(this.wizard.provider ?? {}, this.wizard.errors.provider ?? [], {
mode: this.wizard.proxyMode,
mode: this.wizard.proxyMode ?? ProxyMode.Proxy,
onSetMode,
showHttpBasic: this.showHttpBasic,
onSetShowHttpBasic,

View File

@ -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";

View File

@ -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`
<ak-search-select
name=${ifDefined(this.name ?? undefined)}
.fetchObjects=${this.fetchObjects}
.renderElement=${renderElement}
.value=${renderValue}

View File

@ -134,7 +134,7 @@ export class FlowSearch<T extends Flow> 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}
>

View File

@ -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(
</ak-radio-input>
<ak-switch-input
name="openInNewTab"
name="mfaSupport"
label=${msg("Code-based MFA Support")}
?checked=${provider?.mfaSupport ?? true}
help=${mfaSupportHelp}
@ -108,7 +109,7 @@ export function renderForm(
<ak-branded-flow-search
flowType=${FlowsInstancesListDesignationEnum.Invalidation}
.currentFlow=${provider?.invalidationFlow}
.brandFlow=${brand.flowInvalidation}
.brandFlow=${brand?.flowInvalidation}
.errorMessages=${errors?.invalidationFlow ?? []}
required
></ak-branded-flow-search>

View File

@ -24,8 +24,8 @@ export class ProxyProviderFormPage extends BaseProviderForm<ProxyProvider> {
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;
}

View File

@ -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<ProvxyModeValue>) => void;
export type SetMode = (ev: CustomEvent<ProxyModeValue>) => 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<ProxyProvider>) {
return html`<ak-text-input
name="basicAuthUserAttribute"
label=${msg("HTTP-Basic Username Key")}
@ -67,78 +72,59 @@ function renderModeSelector(mode: ProxyMode, onSet: SetMode) {
</ak-toggle-group>`;
}
function renderProxySettings(provider: ProxyProvider) {
function renderProxySettings(provider: Partial<ProxyProvider>, errors?: ValidationError) {
return html`<p class="pf-u-mb-xl">
${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.",
)}
</p>
<ak-form-element-horizontal label=${msg("External host")} required name="externalHost">
<input
type="text"
<ak-text-input
name="externalHost"
label=${msg("External host")}
value="${ifDefined(provider?.externalHost)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
.errorMessages=${errors?.externalHost ?? []}
help=${msg(
"The external URL you'll access the application at. Include any non-standard port.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Internal host")} required name="internalHost">
<input
type="text"
></ak-text-input>
<ak-text-input
name="internalHost"
label=${msg("Internal host")}
value="${ifDefined(provider?.internalHost)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Upstream host that the requests are forwarded to.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="internalHostSslValidation">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
.errorMessages=${errors?.internalHost ?? []}
help=${msg("Upstream host that the requests are forwarded to.")}
></ak-text-input>
<ak-switch-input
name="internalHostSslValidation"
label=${msg("Internal host SSL Validation")}
?checked=${provider?.internalHostSslValidation ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Internal host SSL Validation")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg("Validate SSL Certificates of upstream servers.")}
</p>
</ak-form-element-horizontal>`;
help=${msg("Validate SSL Certificates of upstream servers.")}
>
</ak-switch-input>`;
}
function renderForwardSingleSettings(provider: ProxyProvider) {
function renderForwardSingleSettings(provider: Partial<ProxyProvider>, errors?: ValidationError) {
return html`<p class="pf-u-mb-xl">
${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).",
)}
</p>
<ak-form-element-horizontal label=${msg("External host")} required name="externalHost">
<input
type="text"
<ak-text-input
name="externalHost"
label=${msg("External host")}
value="${ifDefined(provider?.externalHost)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
.errorMessages=${errors?.externalHost ?? []}
help=${msg(
"The external URL you'll access the application at. Include any non-standard port.",
)}
</p>
</ak-form-element-horizontal>`;
></ak-text-input>`;
}
function renderForwardDomainSettings(provider: ProxyProvider) {
function renderForwardDomainSettings(provider: Partial<ProxyProvider>, errors?: ValidationError) {
return html`<p class="pf-u-mb-xl">
${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.",
)}
</div>
<ak-form-element-horizontal label=${msg("Authentication URL")} required name="externalHost">
<input
type="text"
<ak-text-input
name="externalHost"
label=${msg("Authentication URL")}
value="${provider?.externalHost ?? window.location.origin}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
.errorMessages=${errors?.externalHost ?? []}
help=${msg(
"The external URL you'll authenticate at. The authentik core server should be reachable under this URL.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Cookie domain")} name="cookieDomain" required>
<input
type="text"
></ak-text-input>
<ak-text-input
label=${msg("Cookie domain")}
name="cookieDomain"
value="${ifDefined(provider?.cookieDomain)}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg(
.errorMessages=${errors?.cookieDomain ?? []}
help=${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'.",
)}
</p>
</ak-form-element-horizontal>`;
></ak-text-input> `;
}
function renderSettings(provider: ProxyProvider, mode: ProxyMode) {
return match(mode)
type StrictProxyMode = Omit<ProxyMode, "11184809">;
function renderSettings(provider: Partial<ProxyProvider>, 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<ProxyProvider>,
errors: ValidationError,
provider: Partial<ProxyProvider> = {},
errors: ValidationError = {},
args: ProxyModeExtraArgs,
) {
const { mode, onSetMode, showHttpBasic, onSetShowHttpBasic } = args;
return html`
<ak-form-element-horizontal label=${msg("Name")} required name="name">
<input
type="text"
value="${ifDefined(provider?.name)}"
class="pf-c-form-control"
<ak-text-input
name="name"
value=${ifDefined(provider?.name)}
label=${msg("Name")}
.errorMessages=${errors?.name ?? []}
required
/>
</ak-form-element-horizontal>
></ak-text-input>
<ak-form-element-horizontal
label=${msg("Authorization flow")}
required
@ -225,15 +211,15 @@ export function renderForm(
<div class="pf-c-card__body">${renderModeSelector(mode, onSetMode)}</div>
<div class="pf-c-card__footer">${renderSettings(provider, mode)}</div>
</div>
<ak-form-element-horizontal label=${msg("Token validity")} name="accessTokenValidity">
<input
type="text"
<ak-text-input
label=${msg("Token validity")}
name="accessTokenValidity"
value="${provider?.accessTokenValidity ?? "hours=24"}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">${msg("Configure how long tokens are valid for.")}</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
.errorMessages=${errors?.accessTokenValidity ?? []}
required
.help=${msg("Configure how long tokens are valid for.")}
></ak-text-input>
<ak-form-group>
<span slot="header">${msg("Advanced protocol settings")}</span>
@ -281,51 +267,27 @@ export function renderForm(
<ak-form-group>
<span slot="header">${msg("Authentication settings")}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="interceptHeaderAuth">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
<ak-switch-input
name="interceptHeaderAuth"
label=${msg("Intercept header authentication")}
?checked=${provider?.interceptHeaderAuth ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Intercept header authentication")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
help=${msg(
"When enabled, authentik will intercept the Authorization header to authenticate the request.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="basicAuthEnabled">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${provider?.basicAuthEnabled ?? false}
@change=${onSetShowHttpBasic}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Send HTTP-Basic Authentication")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
</ak-switch-input>
<ak-switch-input
name="basicAuthEnabled"
label=${msg("Send HTTP-Basic Authentication")}
?checked=${provider?.basicAuthEnabled ?? false}
help=${msg(
"Send a custom HTTP-Basic Authentication header based on values from authentik.",
)}
</p>
</ak-form-element-horizontal>
@change=${onSetShowHttpBasic}
>
</ak-switch-input>
${showHttpBasic ? renderHttpBasic(provider) : nothing}
<ak-form-element-horizontal label=${msg("Trusted OIDC Sources")} name="jwksSources">
<ak-dual-select-dynamic-selected

View File

@ -12,8 +12,10 @@ import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import {
CurrentBrand,
FlowsInstancesListDesignationEnum,
PropertymappingsApi,
RadiusProvider,
RadiusProviderPropertyMapping,
ValidationError,
} from "@goauthentik/api";
@ -40,7 +42,7 @@ export function makeRadiusPropertyMappingsSelector(instanceMappings?: string[])
: ([_0, _1, _2, _]: DualSelectPair<RadiusProviderPropertyMapping>) => [];
}
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(
<p class="pf-c-form__helper-text">${msg("Flow used for users to authenticate.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="mfaSupport">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
<ak-switch-input
name="mfaSupport"
label=${msg("Code-based MFA Support")}
?checked=${provider?.mfaSupport ?? true}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Code-based MFA Support")}</span>
</label>
<p class="pf-c-form__helper-text">${mfaHelp}</p>
</ak-form-element-horizontal>
help=${mfaSupportHelp}
>
</ak-switch-input>
<ak-form-group expanded>
<span slot="header"> ${msg("Protocol settings")} </span>

View File

@ -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";

View File

@ -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,62 +50,7 @@ export function makeSAMLPropertyMappingsSelector(instanceMappings?: string[]) {
mapping?.managed?.startsWith("goauthentik.io/providers/saml");
}
export function renderForm(
provider?: Partial<SAMLProvider>,
errors: ValidationError,
setHasSigningKp: (ev: InputEvent) => void,
hasSigningKp: boolean,
) {
return html` <ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(provider?.name)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Authorization flow")}
required
name="authorizationFlow"
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${provider?.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("ACS URL")} ?required=${true} name="acsUrl">
<input
type="text"
value="${ifDefined(provider?.acsUrl)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Issuer")} ?required=${true} name="issuer">
<input
type="text"
value="${provider?.issuer || "authentik"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">${msg("Also known as EntityID.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Service Provider Binding")}
?required=${true}
name="spBinding"
>
<ak-radio
.options=${[
const serviceProviderBindingOptions = [
{
label: msg("Redirect"),
value: SpBindingEnum.Redirect,
@ -116,23 +60,90 @@ export function renderForm(
label: msg("Post"),
value: SpBindingEnum.Post,
},
]}
.value=${provider?.spBinding}
];
function renderHasSigningKp(provider?: Partial<SAMLProvider>) {
return html` <ak-switch-input
name="signAssertion"
label=${msg("Sign assertions")}
?checked=${provider?.signAssertion ?? true}
help=${msg("When enabled, the assertion element of the SAML response will be signed.")}
>
</ak-radio>
</ak-switch-input>
<ak-switch-input
name="signResponse"
label=${msg("Sign responses")}
?checked=${provider?.signResponse ?? false}
help=${msg("When enabled, the assertion element of the SAML response will be signed.")}
>
</ak-switch-input>`;
}
export function renderForm(
provider: Partial<SAMLProvider> = {},
errors: ValidationError,
setHasSigningKp: (ev: InputEvent) => void,
hasSigningKp: boolean,
) {
return html` <ak-text-input
name="name"
value=${ifDefined(provider?.name)}
label=${msg("Name")}
required
.errorMessages=${errors?.name ?? []}
></ak-text-input>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
required
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${provider?.authorizationFlow}
required
.errorMessages=${errors?.authorizationFlow ?? []}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg(
"Determines how authentik sends the response back to the Service Provider.",
)}
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Audience")} name="audience">
<input
type="text"
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="acsUrl"
label=${msg("ACS URL")}
value="${ifDefined(provider?.acsUrl)}"
required
.errorMessages=${errors?.acsUrl ?? []}
></ak-text-input>
<ak-text-input
label=${msg("Issuer")}
name="issuer"
value="${provider?.issuer || "authentik"}"
required
.errorMessages=${errors?.issuer ?? []}
help=${msg("Also known as EntityID.")}
></ak-text-input>
<ak-radio-input
label=${msg("Service Provider Binding")}
name="spBinding"
required
.options=${serviceProviderBindingOptions}
.value=${provider?.spBinding}
help=${msg(
"Determines how authentik sends the response back to the Service Provider.",
)}
>
</ak-radio-input>
<ak-text-input
name="audience"
label=${msg("Audience")}
value="${ifDefined(provider?.audience)}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
.errorMessages=${errors?.audience ?? []}
></ak-text-input>
</div>
</ak-form-group>
@ -186,48 +197,8 @@ export function renderForm(
)}
</p>
</ak-form-element-horizontal>
${hasSigningKp
? html` <ak-form-element-horizontal name="signAssertion">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(provider?.signAssertion, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Sign assertions")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When enabled, the assertion element of the SAML response will be signed.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="signResponse">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(provider?.signResponse, false)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Sign responses")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When enabled, the assertion element of the SAML response will be signed.",
)}
</p>
</ak-form-element-horizontal>`
: nothing}
${hasSigningKp ? renderHasSigningKp(provider) : nothing}
<ak-form-element-horizontal
label=${msg("Verification Certificate")}
name="verificationKp"
@ -300,93 +271,60 @@ export function renderForm(
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Assertion valid not before")}
?required=${true}
<ak-text-input
name="assertionValidNotBefore"
>
<input
type="text"
label=${msg("Assertion valid not before")}
value="${provider?.assertionValidNotBefore || "minutes=-5"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Configure the maximum allowed time drift for an assertion.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Assertion valid not on or after")}
?required=${true}
.errorMessages=${errors?.assertionValidNotBefore ?? []}
help=${msg("Configure the maximum allowed time drift for an assertion.")}
></ak-text-input>
<ak-text-input
name="assertionValidNotOnOrAfter"
>
<input
type="text"
label=${msg("Assertion valid not on or after")}
value="${provider?.assertionValidNotOnOrAfter || "minutes=5"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Assertion not valid on or after current time + this value.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Session valid not on or after")}
?required=${true}
.errorMessages=${errors?.assertionValidNotBefore ?? []}
help=${msg("Assertion not valid on or after current time + this value.")}
></ak-text-input>
<ak-text-input
name="sessionValidNotOnOrAfter"
>
<input
type="text"
label=${msg("Session valid not on or after")}
value="${provider?.sessionValidNotOnOrAfter || "minutes=86400"}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Session not valid on or after current time + this value.")}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Default relay state")}
.errorMessages=${errors?.sessionValidNotOnOrAfter ?? []}
help=${msg("Session not valid on or after current time + this value.")}
></ak-text-input>
<ak-text-input
name="defaultRelayState"
>
<input
type="text"
label=${msg("Default relay state")}
value="${provider?.defaultRelayState || ""}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
.errorMessages=${errors?.sessionValidNotOnOrAfter ?? []}
help=${msg(
"When using IDP-initiated logins, the relay state will be set to this value.",
)}
</p>
<ak-utils-time-delta-help></ak-utils-time-delta-help>
</ak-form-element-horizontal>
></ak-text-input>
<ak-form-element-horizontal
label=${msg("Digest algorithm")}
?required=${true}
<ak-radio-input
name="digestAlgorithm"
>
<ak-radio
label=${msg("Digest algorithm")}
.options=${digestAlgorithmOptions}
.value=${provider?.digestAlgorithm}
required
>
</ak-radio>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Signature algorithm")}
?required=${true}
</ak-radio-input>
<ak-radio-input
name="signatureAlgorithm"
>
<ak-radio
label=${msg("Signature algorithm")}
.options=${signatureAlgorithmOptions}
.value=${provider?.signatureAlgorithm}
required
>
</ak-radio>
</ak-form-element-horizontal>
</ak-radio-input>
</div>
</ak-form-group>`;
}

View File

@ -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<SCIMProvider>, errors: ValidationError = {}) {
return html`
<ak-form-element-horizontal label=${msg("Name")} required name="name">
<input
type="text"
value="${ifDefined(provider?.name)}"
class="pf-c-form-control"
<ak-text-input
name="name"
value=${ifDefined(provider?.name)}
label=${msg("Name")}
.errorMessages=${errors?.name ?? []}
required
/>
</ak-form-element-horizontal>
help=${msg("Method's display Name.")}
></ak-text-input>
<ak-form-group expanded>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${msg("URL")} required name="url">
<input
type="text"
<ak-text-input
name="url"
label=${msg("URL")}
value="${first(provider?.url, "")}"
class="pf-c-form-control"
.errorMessages=${errors?.url ?? []}
required
/>
<p class="pf-c-form__helper-text">
${msg("SCIM base url, usually ends in /v2.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="verifyCertificates">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(provider?.verifyCertificates, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Verify SCIM server's certificates")}</span
help=${msg("SCIM base url, usually ends in /v2.")}
></ak-text-input>
<ak-switch-input
name="verifyCertificates"
label=${msg("Verify SCIM server's certificates")}
?checked=${provider?.verifyCertificates ?? true}
>
</label>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Token")} required name="token">
<input
type="text"
value="${first(provider?.token, "")}"
class="pf-c-form-control"
</ak-switch-input>
<ak-text-input
name="token"
label=${msg("Token")}
value="${provider?.token ?? ""}"
.errorMessages=${errors?.token ?? []}
required
/>
<p class="pf-c-form__helper-text">
${msg(
help=${msg(
"Token to authenticate with. Currently only bearer authentication is supported.",
)}
</p>
</ak-form-element-horizontal>
></ak-text-input>
</div>
</ak-form-group>
<ak-form-group expanded>
<span slot="header">${msg("User filtering")}</span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="excludeUsersServiceAccount">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
<ak-switch-input
name="excludeUsersServiceAccount"
label=${msg("Exclude service accounts")}
?checked=${first(provider?.excludeUsersServiceAccount, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Exclude service accounts")}</span>
</label>
</ak-form-element-horizontal>
>
</ak-switch-input>
<ak-form-element-horizontal label=${msg("Group")} name="filterGroup">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Group[]> => {

View File

@ -4,90 +4,65 @@ 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) {
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;
export function tap<A>(a: A) {
console.log("TAP:", a);
return a;
}
})();
// 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();
const makeComparator = (value: string | RegExp) =>
typeof value === "string"
? (sample: string) => sample === value
: (sample: string) => value.test(sample);
// @ts-expect-error "Types break on shadow$$"
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 $(`div[data-managed-for*="${name}"]`)
.$("ak-list-select")
.$$("button")) {
if ((await button.getText()).includes(value)) {
for await (const button of context.$$("button")) {
if ((await button.isDisplayed()) && (await button.getText()).indexOf(name) !== -1) {
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}`);
}
await (await button).click();
await browser.keys(Key.Tab);
await doBlur(control);
if (!(button && (await button.isDisplayed()))) {
throw new Error(`Unable to find button '${name}'`);
}
export async function setTextInput(name: string, value: string) {
const control = await $(`input[name="${name}"]`);
await control.scrollIntoView();
await control.setValue(value);
await doBlur(control);
await button.scrollIntoView();
await button.click();
await doBlur(button);
}
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();
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 card = await (async () => {
for await (const card of $("ak-wizard-page-type-create").$$(
'[data-ouid-component-type="ak-type-create-grid-card"]',
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 card.$(".pf-c-card__title").getText())) {
return card;
if (comparator(await button.$(".pf-c-toggle-group__text").getText())) {
return button;
}
}
})();
await card.scrollIntoView();
await card.click();
await doBlur(control);
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 =
typeof name === "string" ? (sample) => sample === name : (sample) => name.test(sample);
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*,
@ -103,6 +78,10 @@ export async function setFormGroup(name: string | RegExp, setting: "open" | "clo
}
})();
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])
@ -112,35 +91,135 @@ export async function setFormGroup(name: string | RegExp, setting: "open" | "clo
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;
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;
}
}
await button.scrollIntoView();
await button.click();
await doBlur(button);
})();
if (!(item && (await item.isDisplayed()))) {
throw new Error(`Unable to find a radio that matches ${name}:${value.toString()}`);
}
export async function clickToggleGroup(name: string, value: string | RegExp) {
const comparator =
typeof name === "string" ? (sample) => sample === value : (sample) => value.test(sample);
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}"]`);
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;
}
})();
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();
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())) {
for await (const button of $(`div[data-managed-for*="${name}"]`)
.$("ak-list-select")
.$$("button")) {
if (comparator(await button.getText())) {
return button;
}
}
})();
await button.scrollIntoView();
await button.click();
await doBlur(button);
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);
}
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 setTextareaInput(name: string, value: string) {
const control = await $(`textarea[name="${name}"]`);
await control.scrollIntoView();
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 = 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"]',
)) {
if (comparator(await card.$(".pf-c-card__title").getText())) {
return card;
}
}
})();
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 type TestInteraction =
| [typeof checkIsPresent, ...Parameters<typeof checkIsPresent>]
| [typeof clickButton, ...Parameters<typeof clickButton>]
| [typeof clickToggleGroup, ...Parameters<typeof clickToggleGroup>]
| [typeof setFormGroup, ...Parameters<typeof setFormGroup>]
| [typeof setRadio, ...Parameters<typeof setRadio>]
| [typeof setSearchSelect, ...Parameters<typeof setSearchSelect>]
| [typeof setTextInput, ...Parameters<typeof setTextInput>]
| [typeof setTextareaInput, ...Parameters<typeof setTextareaInput>]
| [typeof setToggle, ...Parameters<typeof setToggle>]
| [typeof setTypeCreate, ...Parameters<typeof setTypeCreate>];
export type TestSequence = TestInteraction[];
export type TestProvider = () => TestSequence;

View File

@ -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 () => {});

View File

@ -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", () => {

View File

@ -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) {

View File

@ -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 clickButton>]
| [typeof clickToggleGroup, ...Parameters<typeof clickToggleGroup>]
| [typeof setFormGroup, ...Parameters<typeof setFormGroup>]
| [typeof setSearchSelect, ...Parameters<typeof setSearchSelect>]
| [typeof setTextInput, ...Parameters<typeof setTextInput>]
| [typeof setTypeCreate, ...Parameters<typeof setTypeCreate>];
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"],
];

View File

@ -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) => {