Compare commits

...

7 Commits

Author SHA1 Message Date
taylanbakircioglu 02667bbda4 test(acme): make the wizard regression suite pass under the default jest timeout
Follow-up to #57. The contributed tests render the whole ACMEAutomation tree
(antd Steps + Form + Select) and drive it through all three wizard steps, which
takes 4-9 seconds per test. Under jest's default 5s per-test limit two of them
failed, so `npm test` did not pass as shipped:

  ✕ an explicitly picked HTTP-01 account survives the step change and is what
    gets submitted        -> Exceeded timeout of 5000 ms
  ✕ the wildcard guard still applies on the Review step, where Submit lives
                          -> Exceeded timeout of 5000 ms

The PR's reported 5/5 holds only when the runner is invoked with an explicit
--testTimeout. Setting it in the file instead means the suite passes however it
is invoked, which matters because the frontend image build runs `npm run build`
and never the tests, so nothing else would have caught this.

Verified with the default runner (no flags) after the change: 5/5 pass.

Test-only. No production code touched.
2026-08-09 03:17:15 +03:00
Taylan Bakırcıoğlu c97df53da8 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.
2026-08-09 03:11:10 +03:00
mustafa.ulukaya be01ddd119 test(acme): drive the certificate wizard to pin multi-account account resolution
Renders the real component and walks it through all three wizard steps, because
the bug these cover was invisible to any unit test: it only appeared once the
wizard advanced PAST the step that owns the account Select, since Form.useWatch
reports only currently-rendered fields.

Assertions describe behaviour rather than markup - the primary evidence is the
POST body (account_id paired with challenge_type), compared against a fixture
whose DNS-01 account is deliberately both the lower id and the older account,
which is the exact shape that made the UI default and the backend default
disagree.

Verified by running the suite against the pre-fix component: the picked-account
test reports challenge_type "dns-01" where "http-01" is expected, the Review
test shows "Active (dns@example.com) / Challenge Method: DNS-01 / DNS provider:
godaddy" for a chosen HTTP-01 account - the reported symptom reproduced - and
the wildcard-guard test finds Submit enabled. Four fail, one passes: the DNS-01
selection path, kept as a positive control because it worked before (the
default the wizard fell back to happened to be the DNS-01 account) and must
keep working after.

Adds src/setupTests.js with the ResizeObserver and matchMedia polyfills jsdom
lacks and Ant Design 5 needs before any Select can open.
2026-08-08 12:41:52 +03:00
mustafa.ulukaya bbd8359f50 docs(v1.10.3): document the multi-account ACME wizard fix
Release note covering the three compounding faults, and upgrade notes stating
that this is frontend-only with nothing to do on upgrade. Two points are called
out for operators rather than glossed: installations that never picked an
account explicitly were already using the newest valid account, so only the
preview was wrong; and the wildcard guard that stopped applying on Review was a
lost warning, not a correctness hole, since the backend still rejected those
requests.
2026-08-08 12:21:02 +03:00
mustafa.ulukaya dd7b7822bf chore(version): bump to 1.10.3 - multi-account ACME wizard fix 2026-08-08 12:21:02 +03:00
mustafa.ulukaya c4139bb11a fix(acme): honour the selected ACME account in the certificate wizard
With more than one account registered, picking an HTTP-01 account in Request
ACME Certificate still submitted a DNS-01 request, which the API rejected with
"The selected ACME account has no DNS provider configured for DNS-01."

Three faults compounded:

1. Form.useWatch reports only fields that are currently rendered. The account
   Select lives on the Configuration step, so the moment the wizard advanced to
   Review the watch read undefined and the wizard fell back to the default
   account - even though the value was still in the form store. Both wizard
   watches now pass preserve: true. The same fault silently 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 first valid entry of a list ordered by id, i.e. the oldest - the
   opposite account whenever the two differ. The wizard now resolves the same
   account 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. Both are now derived from one resolved account, so
   the pair can no longer describe two different accounts.

Also: the Review step showed the default account's address instead of the
chosen one, and Submit stayed enabled when the resolved account was
deactivated (the fallback can land on a non-valid account, and the Select
lists deactivated accounts).

Single-account installations are unaffected.
2026-08-08 12:21:02 +03:00
taylanbakircioglu dbb9189f16 fix(ui): make Apply Management readable in dark mode (v1.10.2)
Three dark-mode defects reported on the Apply Management page, all the same
class of bug: light-mode colour literals hardcoded where theme tokens belong.

1. The "Pending Changes" box was painted background #fffbe6 with border
   #ffe58f. In dark mode the text on top is light, so the version name,
   timestamp and "View Change" link sat on a cream panel and were unreadable.
   Measured contrast was 1.03:1; it is now 11.50:1 (secondary text 1.03:1 ->
   7.02:1).

2. The added/removed rows in the View Change diff used #f6ffed/#52c41a and
   #fff2f0/#ff4d4f, which stayed near-white inside the otherwise dark diff
   panel. Now 5.49:1 (added) and 4.01:1 (removed), from 2.21:1 and 2.99:1.

3. The "Apply All Configuration Changes" confirm dialog came up white. This one
   is not a colour literal: 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 fall back to the light algorithm. Registering
   ConfigProvider.config({ holderRender }) once at the app root wraps that
   detached root in the same ConfigProvider. Verified against the installed antd
   5.29.3 source rather than assumed: config-provider/index.js sets
   globalHolderRender, and modal/confirm.js wraps the dialog with it. This fixes
   EVERY static dialog in the application — 12 components call Modal.confirm —
   not just this page.

While in the file, six more instances of the same bug were fixed: the error
Alert border, the VIP pending-delete row, two ACME/pending version panels, the
applied-version panel and the agent-error recommendation box.

Light mode is byte-identical. Each token resolves under the default algorithm
to exactly the literal it replaced (colorWarningBg -> #fffbe6, colorSuccessBg ->
#f6ffed, colorErrorBg -> #fff2f0, colorInfoBg, colorErrorBorder, ...), so this
release can only change dark mode. Contrast was measured by resolving the real
design tokens under both algorithms and computing WCAG ratios, not by eye.

Note the diff rows were already low-contrast in LIGHT mode (2.21:1 and 2.99:1)
and remain so; that is the design system's own success/error pair and changing
it would alter the established light-mode appearance, so it is left alone.

holderRender is registered in an effect rather than during render, since
ConfigProvider.config() mutates antd module state; effects still run long
before a user can click anything that opens a static dialog.

Frontend only: no schema, no SCHEMA_VERSION bump, no API change, no environment
variable, zero agent impact. Backend suite unchanged at 1243 passed.
2026-08-08 02:39:23 +03:00
9 changed files with 367 additions and 31 deletions
+2
View File
@@ -2474,6 +2474,8 @@ 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.
- **v1.9.0** (2026-08-04) — **CSR creation** (in-app key + CSR generation and signed-certificate import): a new **CSR tab** on the SSL Certificates page generates a private key and Certificate Signing Request server-side (RSA 2048/4096 or ECDSA P-256/P-384; full subject — O/OU/L/ST/C/email — plus DNS SANs with wildcard support), for certificates signed by an **external or corporate CA**. The operator downloads/copies the CSR PEM, has it signed, then imports the signed certificate (+ optional chain): the backend verifies the certificate against the stored key (hard gate), rejects expired certs, warns on SAN drift, and creates a normal SSL certificate entry (source `CSR`) that flows through the standard **PENDING → Apply Management → agent pull** pipeline. The private key **never leaves the server** — no CSR endpoint returns it, and after import the CSR row's key copy is destroyed (the key then lives only on the certificate, like every other key). Additive schema change: one new table `ssl_csrs` (SCHEMA_VERSION 9 → 10, auto-migrated, no existing table altered); key generation runs off the event loop and is rate-limited per user; existing `ssl.*` permissions govern all new endpoints. No agent or rendered-config changes.
+48
View File
@@ -1,3 +1,51 @@
# 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,
no environment variable, zero agent impact. Light mode is byte-identical: every colour swapped in
this release resolves, under the default algorithm, to exactly the literal it replaced
(`colorWarningBg` → `#fffbe6`, `colorSuccessBg` → `#f6ffed`, `colorErrorBg` → `#fff2f0`, …), so
only dark mode changes.
- **Apply Management panels** 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 was
**1.03:1** — effectively invisible. It is now **11.50:1**. The same class of bug affected the
diff rows in *View Change* (2.21:1 and 2.99:1, now 5.49:1 and 4.01:1), the ACME/pending version
panels, the VIP pending-delete row and the agent-error recommendation box.
- **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. This release registers
`ConfigProvider.config({ holderRender })` once at the app root, which fixes **every** static
dialog in the app (12 components use them), not just Apply Management.
- **Rollback:** downgrade freely. This release changes rendering only.
---
# Upgrade Notes — v1.10.1 (CSR private key encrypted at rest)
**Backward compatible.** Nothing to do on upgrade, and nothing changes for existing clusters,
+2 -2
View File
@@ -1,5 +1,5 @@
{
"version": "1.10.1",
"releaseName": "CSR private key encrypted at rest",
"version": "1.10.3",
"releaseName": "Multi-account ACME — the wizard honours the selected account",
"releaseDate": "2026-08-08"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "haproxy-openmanager-frontend",
"version": "1.10.1",
"version": "1.10.3",
"description": "HAProxy Load Balancer Management UI",
"license": "AGPL-3.0-or-later",
"dependencies": {
+23 -5
View File
@@ -484,12 +484,30 @@ function AppContent() {
function ThemedApp() {
const { isDarkMode } = useTheme();
const antdTheme = {
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
};
// Ant Design 5: the STATIC message/notification/Modal.confirm APIs render into their own
// detached root, so they do not see this ConfigProvider and always fall back to the light
// algorithm — a confirm dialog came up white while the app was in dark mode. `holderRender`
// wraps that detached root in the same ConfigProvider, which fixes every static call in the
// app at once (12 components use Modal.confirm) instead of migrating each one to
// App.useApp(). In an effect rather than during render: ConfigProvider.config() mutates
// antd module state, and effects still run long before a user can click anything that opens
// a static modal. Re-registered on theme change so the toggle takes effect immediately.
React.useEffect(() => {
ConfigProvider.config({
holderRender: (children) => (
<ConfigProvider theme={{ algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
{children}
</ConfigProvider>
),
});
}, [isDarkMode]);
return (
<ConfigProvider
theme={{
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
<ConfigProvider theme={antdTheme}>
<AuthProvider>
<ClusterProvider>
<ProgressProvider>
+47 -11
View File
@@ -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>
)}
+20 -12
View File
@@ -1092,7 +1092,7 @@ const ApplyManagement = () => {
style={{
marginBottom: 24,
borderRadius: 8,
border: '1px solid #ffccc7',
border: `1px solid ${token.colorErrorBorder}`,
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.15)'
}}
message={
@@ -1348,7 +1348,7 @@ const ApplyManagement = () => {
HA / VIP Changes ({pendingChanges.vips.length})
</Title>
{pendingChanges.vips.map(item => (
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: item.pending_delete ? '1px solid #ffccc7' : '1px solid #f0f0f0', borderRadius: 6, background: item.pending_delete ? '#fff1f0' : undefined }}>
<div key={`vip-${item.id}`} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', marginBottom: 6, border: `1px solid ${item.pending_delete ? token.colorErrorBorder : token.colorBorderSecondary}`, borderRadius: 6, background: item.pending_delete ? token.colorErrorBg : undefined }}>
<CloudServerOutlined style={{ color: item.pending_delete ? '#cf1322' : '#13c2c2' }} />
<span style={{ fontWeight: 500 }}>{item.name}</span>
<Tag>{item.virtual_ip}/{item.prefix_length}</Tag>
@@ -1374,8 +1374,8 @@ const ApplyManagement = () => {
const isEnable = /^cluster-\d+-acme-enable-/.test(v.version_name);
return (
<div key={v.id} style={{
padding: 10, border: '1px dashed #1890ff', borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: '#f0f8ff'
padding: 10, border: `1px dashed ${token.colorPrimary}`, borderRadius: 6, marginBottom: 8,
display: 'flex', alignItems: 'center', justifyContent: 'space-between', backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<span>
@@ -1419,7 +1419,7 @@ const ApplyManagement = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f0f8ff'
backgroundColor: token.colorInfoBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<Tag color="orange">PENDING</Tag>
@@ -1443,7 +1443,7 @@ const ApplyManagement = () => {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#f6ffed'
backgroundColor: token.colorSuccessBg
}}>
<span style={{ fontFamily: 'monospace' }}>{v.version_name}</span>
<Tag color="orange">PENDING</Tag>
@@ -1518,9 +1518,13 @@ const ApplyManagement = () => {
</Descriptions>
</div>
{/* Pending Versions Section */}
{/* Pending Versions Section.
Theme tokens, not the light-mode literals #fffbe6/#ffe58f: in dark mode those
produced a cream panel with light text on it, so the version name, timestamp
and "View Change" link were unreadable. colorWarningBg/Border track the
algorithm, so the "pending" tint survives in both themes. */}
{pendingVersions.length > 0 && (
<div style={{ marginBottom: 24, background: '#fffbe6', borderRadius: 8, border: '1px solid #ffe58f', padding: '16px 16px 8px' }}>
<div style={{ marginBottom: 24, background: token.colorWarningBg, borderRadius: 8, border: `1px solid ${token.colorWarningBorder}`, padding: '16px 16px 8px' }}>
<Title level={5} style={{ marginTop: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#faad14' }} />
Pending Changes ({pendingVersions.length})
@@ -1765,8 +1769,8 @@ const ApplyManagement = () => {
<div>
{/* Parsed suggestion */}
{agentSync.parsed_error?.suggestion && (
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fff7e6', borderRadius: 4, border: '1px solid #ffd591' }}>
<Text strong style={{ color: '#ad4e00' }}>Recommendation: </Text>
<div style={{ marginBottom: 12, padding: '8px 12px', background: token.colorWarningBg, borderRadius: 4, border: `1px solid ${token.colorWarningBorder}` }}>
<Text strong style={{ color: token.colorWarningText }}>Recommendation: </Text>
<Text>{agentSync.parsed_error.suggestion}</Text>
</div>
)}
@@ -1951,13 +1955,17 @@ const ApplyManagement = () => {
{diffData.changes && diffData.changes.length > 0 ? (
diffData.changes.map((change, index) => (
<div key={index} style={{ marginBottom: '4px' }}>
{/* Token-based, not the light literals #f6ffed/#fff2f0: those stayed
near-white in dark mode, so the added/removed rows glared against
the dark diff panel around them. colorSuccessBg/colorErrorBg darken
with the algorithm while keeping the green/red semantics. */}
{change.type === 'added' && (
<div style={{ backgroundColor: '#f6ffed', color: '#52c41a', padding: '2px 8px', borderLeft: '3px solid #52c41a' }}>
<div style={{ backgroundColor: token.colorSuccessBg, color: token.colorSuccessText, padding: '2px 8px', borderLeft: `3px solid ${token.colorSuccess}` }}>
+ {change.line}
</div>
)}
{change.type === 'removed' && (
<div style={{ backgroundColor: '#fff2f0', color: '#ff4d4f', padding: '2px 8px', borderLeft: '3px solid #ff4d4f' }}>
<div style={{ backgroundColor: token.colorErrorBg, color: token.colorErrorText, padding: '2px 8px', borderLeft: `3px solid ${token.colorError}` }}>
- {change.line}
</div>
)}
@@ -0,0 +1,198 @@
/**
* 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';
// These render the whole ACMEAutomation tree (antd Steps + Form + Select) three times per test and
// take 4-7s each, so jest's default 5s per-test limit fails two of them. Raise it here rather than
// relying on the runner being invoked with --testTimeout, so `npm test` passes as shipped.
jest.setTimeout(30000);
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();
});
});
+26
View File
@@ -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,
});
}