mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-09-12 05:48:58 +00:00
Merge pull request #57 from appouse/feature/multiple-account-acme-automation
Multi-account ACME: the certificate wizard honours the selected account (v1.10.3). Verified before merge. The contributed regression tests were run against the PRE-FIX component to confirm they actually catch the bug: 4 failed / 1 passed, reproducing the reported symptom exactly (Expected "http-01" / Received "dns-01", Review rendering the default account's email instead of the picked one, and the wildcard warning absent). With the fix: 5/5 pass. The three root causes were each confirmed against the tree — rc-field-form's useWatch honours options.preserve (es/useWatch.js:66), the backend default is ORDER BY created_at DESC (routers/letsencrypt.py:505) while the list is served ORDER BY id (:213), and the null-dns_provider normalization matches the backend's own at :473. Frontend production build succeeds; backend suite unchanged at 1243 passed.
This commit is contained in:
@@ -2474,6 +2474,7 @@ Developed with ❤️ for the HAProxy community
|
||||
|
||||
## Release Notes
|
||||
|
||||
- **v1.10.3** (2026-08-08) — **Multi-account ACME: the certificate wizard honours the account you pick**: with more than one ACME account registered, picking an **HTTP-01** account in *Request ACME Certificate* still produced a **DNS-01** request. Three faults compounded. (1) `Form.useWatch` reports only fields that are currently **rendered**, and the account `Select` lives on the *Configuration* step — so as soon as the wizard advanced to *Review* the watch read `undefined` and the wizard silently reverted to the default account, even though the value was still in the form store; the watches now pass `preserve: true`. The same fault disabled the **wildcard guard** on *Review*, the one step where Submit lives. (2) The UI and the backend disagreed on which account is the *default*: the backend takes the **newest** valid account (`ORDER BY created_at DESC`), the UI took the **oldest** entry of a list ordered by id — the opposite account whenever the two differ. The wizard now resolves the same one, and sends `account_id` **explicitly** so there is no guess left to disagree about. (3) `account_id` was read from the form store while `challenge_type` came from the reverted account object, so the request asked for DNS-01 validation on an HTTP-01 account and the API answered `The selected ACME account has no DNS provider configured for DNS-01.` — both are now derived from one resolved account. The *Review* step also showed the default account's address instead of the chosen one, and Submit stayed enabled for a deactivated account; both fixed. Frontend only — no schema, API-shape, agent or rendered-config changes, and single-account installations behave exactly as before.
|
||||
- **v1.10.2** (2026-08-08) — **Dark mode fixes on Apply Management**: several panels on the Apply Management page were painted with light-mode colour literals, so in dark mode the **Pending Changes** box rendered as a cream panel with light text on it — measured contrast **1.03:1**, effectively unreadable, now **11.50:1**. The same bug affected the added/removed rows in the *View Change* diff (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME and pending-version panels, the VIP pending-delete row, and the agent-error recommendation box; all now derive from theme tokens. Separately, **static confirm dialogs came up white in dark mode**: in Ant Design 5 the static `Modal.confirm` / `message` / `notification` APIs render into their own detached root and never see the app's `ConfigProvider`, so they always used the light algorithm. Registering `ConfigProvider.config({ holderRender })` once at the app root fixes **every** static dialog in the application (12 components use them), not only this page. Light mode is byte-identical — each token resolves under the default algorithm to exactly the literal it replaced. Frontend only: no schema, API, environment or agent change.
|
||||
- **v1.10.1** (2026-08-08) — **CSR private key encrypted at rest** (Issue #53): the private key of a **pending** CSR is now Fernet-encrypted in the database instead of stored as PEM. It is the one key in the system worth protecting this way — it sits idle for the entire signing window (days to weeks), is never transmitted to an agent, and is destroyed the moment the signed certificate is imported; `ssl_certificates.private_key_content` and the ACME order keys are unchanged, because agents must receive those in plaintext on every poll. The token replaces the PEM in the **same column**, so there is **no schema change and no `SCHEMA_VERSION` bump** (and therefore no re-seed of the built-in roles). CSRs created before this release keep a raw PEM and are still read transparently, so anything already out for signature imports normally with no data migration. The key derives from `SECRET_KEY` via HKDF with its own info string, independent of the VIP/MFA/DNS keys, and an optional `CSR_ENCRYPTION_KEY` enables independent rotation — rotating `SECRET_KEY` without it makes pending CSR keys unrecoverable, which now fails with an explicit "delete and re-create this CSR" error rather than a misleading key-mismatch. `.env.template` now documents all four per-purpose encryption keys. No API, UI or agent change.
|
||||
- **v1.10.0** (2026-08-07) — **GoDaddy DNS provider for DNS-01** (Issue #35 follow-up): DNS-01 challenges can now be published and cleaned up automatically through **GoDaddy**, alongside the existing Manual and Cloudflare providers, so wildcard and internal-cluster certificates on GoDaddy-hosted zones **renew unattended**. Credentials are a **Production API Key + Secret** pair from `developer.godaddy.com/keys` (a **Personal Access Token** also works — paste it as the Key and leave the Secret blank, which is the forward path as GoDaddy retires `sso-key`); they are **verified against the GoDaddy API before being saved** and **encrypted at rest** (Fernet, the same path as Cloudflare), and are never returned by the API, logged, or written to an order event. GoDaddy's v1 API has **no per-value TXT write** — `PUT` replaces an entire RRset — so add/remove are read-modify-write with sibling values merged back, empty-`data` tombstones filtered out, and `DELETE` used for the last value (`PUT []` is rejected); this is what keeps the **apex + wildcard** case (two TXT values at one `_acme-challenge` name) working, and the record path is hard-gated so it can never collapse onto the zone-wide endpoint that would wipe SPF/DKIM/DMARC. Zone lookup probes the records API rather than the domain listing, so **delegated sub-zones** resolve and small accounts are not falsely rejected. Registry-only addition: one new provider module plus one registry line — no frontend change (the credential form is schema-driven). No schema, API-shape, agent, or rendered-config changes; Manual, Cloudflare and HTTP-01 are unaffected.
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
# Upgrade Notes — v1.10.3 (Multi-account ACME wizard fix)
|
||||
|
||||
**Frontend only. Nothing to do on upgrade.** No schema, no `SCHEMA_VERSION` bump, no API change, no
|
||||
environment variable, zero agent impact. Installations with a single ACME account behave exactly as
|
||||
before.
|
||||
|
||||
- **What was broken:** with **more than one** ACME account registered, the *Request ACME
|
||||
Certificate* wizard did not honour the account you selected. Choosing an HTTP-01 account still
|
||||
submitted a DNS-01 request, which the API rejected with
|
||||
`The selected ACME account has no DNS provider configured for DNS-01.` The *Review* step also
|
||||
named the default account rather than the chosen one, so the mismatch was invisible before
|
||||
submitting.
|
||||
- **Default account:** the wizard previously previewed the **oldest** valid account while the
|
||||
backend uses the **newest** (`ORDER BY created_at DESC`). If you never picked an account
|
||||
explicitly and have several, requests were already going to the newest one — only the preview was
|
||||
wrong. The wizard now previews that same account, marks it `(default)`, and sends `account_id`
|
||||
explicitly so the two can no longer diverge.
|
||||
- **Wildcard guard:** the client-side "wildcard requires a DNS-01 account" block silently stopped
|
||||
applying on the *Review* step. Requests were still rejected by the backend, so nothing incorrect
|
||||
was ever issued — you now get the warning before submitting instead of an error after.
|
||||
- **No action needed on existing certificates or orders.** Nothing about issuance, renewal or the
|
||||
stored accounts changes; only how the wizard resolves which account a new request uses.
|
||||
- **Rollback:** downgrade freely. This release changes frontend behaviour only.
|
||||
|
||||
---
|
||||
|
||||
# Upgrade Notes — v1.10.2 (Dark mode fixes on Apply Management)
|
||||
|
||||
**Frontend only. Nothing to do on upgrade.** No schema, no `SCHEMA_VERSION` bump, no API change,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.10.2",
|
||||
"releaseName": "Dark mode fixes on Apply Management",
|
||||
"version": "1.10.3",
|
||||
"releaseName": "Multi-account ACME — the wizard honours the selected account",
|
||||
"releaseDate": "2026-08-08"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "haproxy-openmanager-frontend",
|
||||
"version": "1.10.2",
|
||||
"version": "1.10.3",
|
||||
"description": "HAProxy Load Balancer Management UI",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -107,8 +107,14 @@ const ACMEAutomation = () => {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const regChallengeType = Form.useWatch('challenge_type', registerForm);
|
||||
const regDnsProvider = Form.useWatch('dns_provider', registerForm);
|
||||
const wizardAccountId = Form.useWatch('account_id', wizardForm);
|
||||
const wizardDomains = Form.useWatch('domains', wizardForm);
|
||||
// `preserve: true` is load-bearing, not a nicety. Each wizard step renders only its own fields —
|
||||
// the domain list on Domains, the account Select on Configuration — and a plain useWatch reports
|
||||
// only fields that are currently REGISTERED, so every one of these read `undefined` from the
|
||||
// Review step onward even though the values were still in the form store. That is what made
|
||||
// Review (and the request it submits) silently fall back to the default ACME account, and it
|
||||
// disabled the wildcard guard at exactly the step where Submit lives.
|
||||
const wizardAccountId = Form.useWatch('account_id', { form: wizardForm, preserve: true });
|
||||
const wizardDomains = Form.useWatch('domains', { form: wizardForm, preserve: true });
|
||||
const selectedDnsProvider = dnsProviders.find(p => p.name === regDnsProvider) || null;
|
||||
|
||||
// Issue #35: per-account DNS credential management (view/replace/clear after creation).
|
||||
@@ -217,7 +223,16 @@ const ACMEAutomation = () => {
|
||||
const pendingOrders = orders.filter(o =>
|
||||
o.status === 'pending' || o.status === 'processing' || o.status === 'ready' || isOrderStuck(o)
|
||||
);
|
||||
const activeAccount = accounts.find(a => a.status === 'valid') || null;
|
||||
// The account a request lands on when it carries no explicit account_id. This MUST match the
|
||||
// backend, which takes `ORDER BY created_at DESC LIMIT 1` (routers/letsencrypt.py). The list
|
||||
// arrives ORDER BY id, so picking the first valid entry would preview the OLDEST account — the
|
||||
// opposite one. With two accounts of different challenge methods that made the wizard describe
|
||||
// DNS-01 while the request would actually have gone to an HTTP-01 account.
|
||||
const activeAccount = accounts.filter(a => a.status === 'valid').reduce((best, a) => {
|
||||
if (!best) return a;
|
||||
const delta = new Date(a.created_at) - new Date(best.created_at);
|
||||
return delta > 0 || (delta === 0 && a.id > best.id) ? a : best;
|
||||
}, null);
|
||||
const acmeAccount = activeAccount || (accounts.length > 0 ? accounts[accounts.length - 1] : null);
|
||||
const acmeEnabledClusters = clusters.filter(c => c.acme_enabled && c.is_active);
|
||||
// Issue #35: the cert wizard adapts to the selected account's challenge method.
|
||||
@@ -260,18 +275,26 @@ const ACMEAutomation = () => {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
// Resolve the chosen account's challenge method so DNS-01/wildcard requests are explicit.
|
||||
// Use the same resolution as the wizard description (wizardAccount) so what the user reviewed
|
||||
// matches what is sent.
|
||||
const challengeType = wizardAccount?.challenge_type; // 'http-01' | 'dns-01' | undefined
|
||||
// Resolve the account ONCE and derive everything else from that single object. The id and the
|
||||
// challenge method used to come from different places — account_id from the form store,
|
||||
// challenge_type from wizardAccount — so whenever those two disagreed the request asked for
|
||||
// DNS-01 validation on an HTTP-01 account and the backend answered "The selected ACME account
|
||||
// has no DNS provider configured for DNS-01."
|
||||
const selectedAccountId = values.account_id ?? wizardAccount?.id ?? null;
|
||||
const account = accounts.find(a => a.id === selectedAccountId) || wizardAccount || null;
|
||||
const challengeType = account?.challenge_type; // 'http-01' | 'dns-01' | undefined
|
||||
// Manual DNS-01 can't auto-renew (the wizard shows the switch off+disabled). Send false to
|
||||
// match the displayed state rather than relying only on the backend to override it.
|
||||
const autoRenew = wizardDnsManual ? false : (values.auto_renew !== false);
|
||||
const isManualDns01 = challengeType === 'dns-01' && (account?.dns_provider || 'manual') === 'manual';
|
||||
const autoRenew = isManualDns01 ? false : (values.auto_renew !== false);
|
||||
const res = await axios.post('/api/letsencrypt/certificates', {
|
||||
domains: values.domains,
|
||||
cluster_ids: values.cluster_ids || [],
|
||||
auto_renew: autoRenew,
|
||||
account_id: values.account_id || null,
|
||||
// Always explicit: sending the resolved id removes the frontend/backend "default account"
|
||||
// guess, which disagreed (the UI previewed the oldest valid account, the backend used the
|
||||
// newest) and made the Review step describe an account the request never went to.
|
||||
account_id: account?.id ?? null,
|
||||
challenge_type: challengeType || undefined,
|
||||
});
|
||||
message.success(res.data?.message || 'Certificate request submitted');
|
||||
@@ -1092,7 +1115,17 @@ const ACMEAutomation = () => {
|
||||
message="Prerequisite Check"
|
||||
description={
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
<li>ACME Account: {activeAccount ? <Tag color="success">Active ({activeAccount.email})</Tag> : <Tag color="error">No active account</Tag>}</li>
|
||||
{/* The account the request will actually use — NOT `activeAccount`, which is only
|
||||
the default and would name a different account whenever the user picked one. */}
|
||||
<li>ACME Account: {wizardAccount
|
||||
? <Tag color={wizardAccount.status === 'valid' ? 'success' : 'error'}>
|
||||
{wizardAccount.email}{wizardAccount.status !== 'valid' ? ` (${wizardAccount.status})` : ''}
|
||||
</Tag>
|
||||
: <Tag color="error">No active account</Tag>}
|
||||
{wizardAccount && accounts.length > 1 && !wizardAccountId && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}> (default)</Typography.Text>
|
||||
)}
|
||||
</li>
|
||||
{wizardIsDns01 ? (
|
||||
<>
|
||||
<li>Challenge Method: <Tag>DNS-01</Tag> (TXT record; no port 80 / ACME routing needed)</li>
|
||||
@@ -1324,8 +1357,11 @@ const ACMEAutomation = () => {
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{/* Gate on the account the request will actually use, and on its status: with no valid
|
||||
account wizardAccount falls back to the newest (deactivated) one, and the Select lists
|
||||
deactivated accounts too, so a bare null-check would leave Submit enabled. */}
|
||||
{wizardStep === wizardSteps.length - 1 && (
|
||||
<Button type="primary" onClick={handleRequestCert} loading={submitting} disabled={!activeAccount || wizardWildcardBlocked || wizardDns01Disabled || (!wizardIsDns01 && acmeEnabledClusters.length === 0)}>
|
||||
<Button type="primary" onClick={handleRequestCert} loading={submitting} disabled={wizardAccount?.status !== 'valid' || wizardWildcardBlocked || wizardDns01Disabled || (!wizardIsDns01 && acmeEnabledClusters.length === 0)}>
|
||||
Submit Request
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* v1.10.3 regression tests: with more than one ACME account, the certificate wizard must honour the
|
||||
* account the operator picked — on the Review step AND in the request it submits.
|
||||
*
|
||||
* These drive the real component through all three wizard steps rather than testing a helper,
|
||||
* because the bug was invisible until the wizard ADVANCED PAST the step that owns the account
|
||||
* Select: Form.useWatch reports only fields that are currently rendered, so on Review the selection
|
||||
* read `undefined` and the wizard silently reverted to the default account. A unit test of any
|
||||
* single function would have passed the whole time.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import axios from 'axios';
|
||||
import ACMEAutomation from '../ACMEAutomation';
|
||||
|
||||
jest.mock('axios');
|
||||
jest.mock('react-router-dom', () => ({ useNavigate: () => jest.fn() }));
|
||||
jest.mock('../../contexts/ClusterContext', () => ({
|
||||
useCluster: () => ({ clusters: [], selectCluster: jest.fn() }),
|
||||
}));
|
||||
|
||||
// The account list arrives ORDER BY id while the backend's default is ORDER BY created_at DESC, so
|
||||
// this fixture is deliberately the shape that made the two disagree: the DNS-01 account is BOTH the
|
||||
// lower id and the older account, the HTTP-01 account is the newest. Picking the first valid entry
|
||||
// (as the UI used to) yields the DNS-01 account; the backend would have used the HTTP-01 one.
|
||||
const DNS_ACCOUNT = {
|
||||
id: 1, email: 'dns@example.com', status: 'valid',
|
||||
challenge_type: 'dns-01', dns_provider: 'godaddy',
|
||||
created_at: '2026-05-06T00:00:00Z', directory_url: 'https://acme.zerossl.com/v2/DV90',
|
||||
};
|
||||
const HTTP_ACCOUNT = {
|
||||
id: 2, email: 'http@example.com', status: 'valid',
|
||||
challenge_type: 'http-01', dns_provider: null,
|
||||
created_at: '2026-08-08T00:00:00Z', directory_url: 'https://acme.zerossl.com/v2/DV90',
|
||||
};
|
||||
|
||||
const GET_ROUTES = {
|
||||
'/api/letsencrypt/orders': [],
|
||||
'/api/letsencrypt/accounts': [DNS_ACCOUNT, HTTP_ACCOUNT],
|
||||
'/api/letsencrypt/renewal-schedule': [],
|
||||
'/api/clusters': { clusters: [{ id: 10, name: 'cluster-a', acme_enabled: true, is_active: true }] },
|
||||
'/api/letsencrypt/prerequisites': { steps: [] },
|
||||
'/api/letsencrypt/dns-providers': {
|
||||
dns01_enabled: true,
|
||||
providers: [
|
||||
{ name: 'manual', label: 'Manual', automated: false, credential_fields: [] },
|
||||
{
|
||||
name: 'godaddy', label: 'GoDaddy', automated: true,
|
||||
credential_fields: [
|
||||
{ key: 'api_key', label: 'API Key', type: 'password', required: true, max_length: 200, help: 'Production key' },
|
||||
{ key: 'api_secret', label: 'API Secret', type: 'password', required: false, max_length: 200, help: 'Blank for a PAT' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axios.get.mockImplementation((url) =>
|
||||
Promise.resolve({ data: Object.prototype.hasOwnProperty.call(GET_ROUTES, url) ? GET_ROUTES[url] : {} })
|
||||
);
|
||||
axios.post.mockResolvedValue({ data: { message: 'ok', order_id: 99 } });
|
||||
});
|
||||
|
||||
const modal = () => document.querySelector('.ant-modal-content');
|
||||
|
||||
/** Open the wizard and wait for the Domains step. */
|
||||
async function openWizard() {
|
||||
render(<ACMEAutomation />);
|
||||
// Settle the initial fetch on a signal that does NOT depend on which account the component picks
|
||||
// as its default — that choice is one of the things under test, so waiting on an account address
|
||||
// here would make every test fail at the same early point instead of at its own assertion.
|
||||
await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/letsencrypt/accounts'));
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: /Request Certificate/i }));
|
||||
await screen.findByText('Domain Names');
|
||||
}
|
||||
|
||||
/** antd wires the Form.Item name onto the inner input's id, which is the only stable handle. */
|
||||
function typeDomain(domain) {
|
||||
const input = document.getElementById('domains');
|
||||
fireEvent.change(input, { target: { value: domain } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 });
|
||||
}
|
||||
|
||||
async function selectAccount(email) {
|
||||
await act(async () => {
|
||||
fireEvent.mouseDown(document.getElementById('account_id'));
|
||||
});
|
||||
const option = [...document.querySelectorAll('.ant-select-item-option')]
|
||||
.find((o) => o.textContent.includes(email));
|
||||
if (!option) throw new Error(`account option not found: ${email}`);
|
||||
await act(async () => {
|
||||
fireEvent.click(option);
|
||||
});
|
||||
}
|
||||
|
||||
const next = async () => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Next$/ }));
|
||||
});
|
||||
};
|
||||
const submitButton = () => screen.getByRole('button', { name: /Submit Request/i });
|
||||
|
||||
async function gotoReview({ domain = 'example.com', account } = {}) {
|
||||
await openWizard();
|
||||
typeDomain(domain);
|
||||
await next();
|
||||
// Wait for the Configuration step to actually MOUNT. The transition is async (validateFields
|
||||
// returns a promise) and the text "ACME Account" also appears on the dashboard card behind the
|
||||
// modal, so waiting on that text can resolve while the wizard is still on step 1.
|
||||
await waitFor(() => expect(document.getElementById('account_id')).toBeTruthy());
|
||||
if (account) await selectAccount(account);
|
||||
await next();
|
||||
await screen.findByText('Prerequisite Check');
|
||||
}
|
||||
|
||||
/** The Review step's rendered text. Compared as a whole string on purpose: the assertions must
|
||||
* describe BEHAVIOUR, not the markup this change happens to use, so that a failure means the
|
||||
* wizard resolved the wrong account rather than that a wrapper element moved. */
|
||||
const reviewText = () => modal().textContent;
|
||||
|
||||
async function submitAndGetBody() {
|
||||
fireEvent.click(submitButton());
|
||||
await waitFor(() => expect(axios.post).toHaveBeenCalled());
|
||||
const [url, body] = axios.post.mock.calls[0];
|
||||
expect(url).toBe('/api/letsencrypt/certificates');
|
||||
return body;
|
||||
}
|
||||
|
||||
describe('certificate wizard with multiple ACME accounts', () => {
|
||||
test('an explicitly picked HTTP-01 account survives the step change and is what gets submitted', async () => {
|
||||
await gotoReview({ account: HTTP_ACCOUNT.email });
|
||||
|
||||
// The payload is the real evidence. account_id used to come from the form store while
|
||||
// challenge_type came from an account object that had reverted to the default, so the API got
|
||||
// "HTTP-01 account + dns-01" and answered 422 "no DNS provider configured for DNS-01".
|
||||
const body = await submitAndGetBody();
|
||||
expect(body.account_id).toBe(HTTP_ACCOUNT.id);
|
||||
expect(body.challenge_type).toBe('http-01');
|
||||
});
|
||||
|
||||
test('the Review step describes the picked HTTP-01 account, not the default', async () => {
|
||||
await gotoReview({ account: HTTP_ACCOUNT.email });
|
||||
|
||||
expect(reviewText()).toContain(HTTP_ACCOUNT.email);
|
||||
expect(reviewText()).not.toContain(DNS_ACCOUNT.email);
|
||||
// "Challenge Method" and the provider name only render on the DNS-01 branch.
|
||||
expect(reviewText()).not.toContain('Challenge Method');
|
||||
expect(reviewText()).not.toContain('godaddy');
|
||||
});
|
||||
|
||||
test('an explicitly picked DNS-01 account is described and submitted as DNS-01', async () => {
|
||||
// Positive control: the DNS-01 path must keep working. This one passed before the fix too,
|
||||
// because the default the wizard fell back to happened to be the DNS-01 account.
|
||||
await gotoReview({ account: DNS_ACCOUNT.email });
|
||||
|
||||
expect(reviewText()).toContain(DNS_ACCOUNT.email);
|
||||
expect(reviewText()).toContain('Challenge Method');
|
||||
expect(reviewText()).toContain('godaddy');
|
||||
|
||||
const body = await submitAndGetBody();
|
||||
expect(body.account_id).toBe(DNS_ACCOUNT.id);
|
||||
expect(body.challenge_type).toBe('dns-01');
|
||||
});
|
||||
|
||||
test('with no explicit pick the wizard previews and sends the same default the backend would use', async () => {
|
||||
// The backend takes the NEWEST valid account; the UI used to preview the oldest entry of a
|
||||
// list ordered by id, so Review described an account the request never went to.
|
||||
await gotoReview();
|
||||
|
||||
expect(reviewText()).toContain(HTTP_ACCOUNT.email);
|
||||
expect(reviewText()).not.toContain(DNS_ACCOUNT.email);
|
||||
|
||||
const body = await submitAndGetBody();
|
||||
// Sent explicitly rather than left to the backend to guess a second time.
|
||||
expect(body.account_id).toBe(HTTP_ACCOUNT.id);
|
||||
expect(body.challenge_type).toBe('http-01');
|
||||
});
|
||||
|
||||
test('the wildcard guard still applies on the Review step, where Submit lives', async () => {
|
||||
// Same root cause as the account bug: `domains` is entered on the first step, so a
|
||||
// non-preserving useWatch read undefined from Review onward and the guard evaporated at exactly
|
||||
// the point it had to hold.
|
||||
await gotoReview({ domain: '*.example.com', account: HTTP_ACCOUNT.email });
|
||||
|
||||
expect(reviewText()).toContain('Wildcard requires a DNS-01 account');
|
||||
expect(submitButton()).toBeDisabled();
|
||||
fireEvent.click(submitButton());
|
||||
expect(axios.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Loaded automatically by react-scripts test (CRA convention).
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// jsdom implements neither of these, and Ant Design 5 needs both: rc-select renders its dropdown
|
||||
// through rc-virtual-list (ResizeObserver) and the responsive Grid reads matchMedia. Without the
|
||||
// polyfills any test that opens a Select throws before it can assert anything.
|
||||
if (!global.ResizeObserver) {
|
||||
global.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user