Skip to content

Commit 153b7fd

Browse files
committed
refactor(captcha): unify script injection via shared captcha.utilities
1 parent e870cef commit 153b7fd

8 files changed

Lines changed: 286 additions & 198 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
import { beforeEach, describe, expect, it, vi } from 'vitest';
11+
12+
import { loadCaptchaScript, resolveGrecaptcha } from './captcha.utilities';
13+
14+
vi.stubGlobal('window', globalThis);
15+
16+
describe('resolveGrecaptcha', () => {
17+
beforeEach(() => {
18+
vi.stubGlobal('grecaptcha', undefined);
19+
});
20+
21+
it('returns grecaptcha.enterprise when enterprise namespace is present', () => {
22+
const enterprise = { ready: vi.fn(), render: vi.fn(), execute: vi.fn() };
23+
vi.stubGlobal('grecaptcha', { enterprise });
24+
expect(resolveGrecaptcha()).toBe(enterprise);
25+
});
26+
27+
it('falls back to window.grecaptcha when enterprise namespace is absent', () => {
28+
const classic = { ready: vi.fn(), render: vi.fn(), execute: vi.fn() };
29+
vi.stubGlobal('grecaptcha', classic);
30+
expect(resolveGrecaptcha()).toBe(classic);
31+
});
32+
});
33+
34+
describe('loadCaptchaScript', () => {
35+
let appendedScript: {
36+
src: string;
37+
async: boolean;
38+
onload: (() => void) | null;
39+
onerror: (() => void) | null;
40+
};
41+
let mockQuerySelector: ReturnType<typeof vi.fn>;
42+
let mockAppendChild: ReturnType<typeof vi.fn>;
43+
44+
beforeEach(() => {
45+
vi.stubGlobal('grecaptcha', undefined);
46+
appendedScript = { src: '', async: false, onload: null, onerror: null };
47+
mockAppendChild = vi.fn();
48+
mockQuerySelector = vi.fn().mockReturnValue(null);
49+
vi.stubGlobal('document', {
50+
querySelector: mockQuerySelector,
51+
createElement: () => appendedScript,
52+
head: { appendChild: mockAppendChild },
53+
});
54+
});
55+
56+
it('injects a new script tag and resolves on load for hcaptcha', async () => {
57+
const promise = loadCaptchaScript({
58+
src: 'https://js.hcaptcha.com/1/api.js',
59+
provider: 'hcaptcha',
60+
});
61+
expect(appendedScript.src).toBe('https://js.hcaptcha.com/1/api.js');
62+
expect(mockAppendChild).toHaveBeenCalledOnce();
63+
appendedScript.onload?.();
64+
await promise;
65+
});
66+
67+
it('injects a new script tag and waits for grecaptcha.ready on load', async () => {
68+
const mockReady = vi.fn((fn: () => void) => fn());
69+
const promise = loadCaptchaScript({
70+
src: 'https://www.google.com/recaptcha/api.js',
71+
provider: 'grecaptcha',
72+
});
73+
expect(appendedScript.src).toBe('https://www.google.com/recaptcha/api.js');
74+
vi.stubGlobal('grecaptcha', { ready: mockReady });
75+
appendedScript.onload?.();
76+
await promise;
77+
expect(mockReady).toHaveBeenCalledOnce();
78+
});
79+
80+
it('reuses existing script and resolves immediately for hcaptcha', async () => {
81+
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
82+
await loadCaptchaScript({ src: 'https://js.hcaptcha.com/1/api.js', provider: 'hcaptcha' });
83+
expect(mockAppendChild).not.toHaveBeenCalled();
84+
});
85+
86+
it('reuses existing script and calls grecaptcha.ready when already present', async () => {
87+
const mockReady = vi.fn((fn: () => void) => fn());
88+
vi.stubGlobal('grecaptcha', { ready: mockReady });
89+
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
90+
await loadCaptchaScript({
91+
src: 'https://www.google.com/recaptcha/api.js',
92+
provider: 'grecaptcha',
93+
});
94+
expect(mockAppendChild).not.toHaveBeenCalled();
95+
expect(mockReady).toHaveBeenCalledOnce();
96+
});
97+
98+
it('rejects when the script fails to load', async () => {
99+
const promise = loadCaptchaScript({
100+
src: 'https://bad.example.com/api.js',
101+
provider: 'hcaptcha',
102+
});
103+
appendedScript.onerror?.();
104+
await expect(promise).rejects.toThrow('Failed to load CAPTCHA script');
105+
});
106+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
*
3+
* Copyright © 2026 Ping Identity Corporation. All right reserved.
4+
*
5+
* This software may be modified and distributed under the terms
6+
* of the MIT license. See the LICENSE file for details.
7+
*
8+
**/
9+
10+
/**
11+
* Resolves the active reCAPTCHA namespace. Prefers `grecaptcha.enterprise`
12+
* (loaded by enterprise.js) and falls back to the classic `grecaptcha` global
13+
* (loaded by api.js or auto-migrated keys). This makes all call sites
14+
* transparent to which script the consumer loaded.
15+
*/
16+
export function resolveGrecaptcha(): ReCaptchaV2.ReCaptcha {
17+
const grecaptcha = window.grecaptcha as ReCaptchaV2.ReCaptcha & {
18+
enterprise?: ReCaptchaV2.ReCaptcha;
19+
};
20+
return grecaptcha?.enterprise ?? window.grecaptcha;
21+
}
22+
23+
/**
24+
* Injects a CAPTCHA script tag into <head> and resolves when the provider API
25+
* is ready to use. No-ops if the provider API is already present on window (e.g.
26+
* pre-loaded by consumer or stubbed in tests), or if a script with the same src
27+
* is already in the document. For grecaptcha, waits for grecaptcha.ready().
28+
*/
29+
export function loadCaptchaScript({
30+
src,
31+
provider,
32+
}: {
33+
src: string;
34+
provider: 'grecaptcha' | 'hcaptcha';
35+
}): Promise<void> {
36+
if (provider === 'hcaptcha') {
37+
const hc = (window as Window & { hcaptcha?: unknown }).hcaptcha;
38+
if (hc) return Promise.resolve();
39+
} else {
40+
const grc = resolveGrecaptcha();
41+
if (grc) return new Promise((resolve) => grc.ready(() => resolve()));
42+
}
43+
44+
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
45+
if (existing) {
46+
return new Promise((resolve) => {
47+
if (provider === 'hcaptcha') {
48+
resolve();
49+
} else {
50+
const grc = resolveGrecaptcha();
51+
if (grc) {
52+
grc.ready(() => resolve());
53+
} else {
54+
existing.addEventListener('load', () => resolve(), { once: true });
55+
}
56+
}
57+
});
58+
}
59+
return new Promise((resolve, reject) => {
60+
const script = document.createElement('script');
61+
script.src = src;
62+
script.async = true;
63+
script.onerror = () => reject(new Error(`Failed to load CAPTCHA script: ${src}`));
64+
script.onload = () => {
65+
if (provider === 'hcaptcha') {
66+
resolve();
67+
} else {
68+
const grc = resolveGrecaptcha();
69+
grc ? grc.ready(() => resolve()) : resolve();
70+
}
71+
};
72+
document.head.appendChild(script);
73+
});
74+
}

core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@
6262
apiUrl,
6363
siteKey,
6464
mode: mountMode,
65-
nameOfCaptcha: captchaProvider,
6665
});
6766
} catch (err) {
6867
console.error('[recaptcha-enterprise] script load failed:', err);
@@ -109,8 +108,9 @@
109108
});
110109
111110
function runInvisibleExecute() {
112-
if (!recaptchaAction || captchaMode !== 'invisible' || !callback || executed || !scriptReady)
111+
if (!recaptchaAction || captchaMode !== 'invisible' || !callback || executed || !scriptReady) {
113112
return;
113+
}
114114
executed = true;
115115
executeEnterpriseCaptcha({
116116
siteKey,

core/journey/callbacks/recaptcha-enterprise/recaptcha-enterprise.utilities.test.ts

Lines changed: 18 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
import { beforeEach, describe, expect, it, vi } from 'vitest';
1111

1212
import {
13+
buildEnterpriseScriptSrc,
1314
detectEnterpriseProvider,
1415
executeEnterpriseCaptcha,
15-
loadEnterpriseScript,
1616
renderEnterpriseCaptcha,
1717
} from './recaptcha-enterprise.utilities';
1818

@@ -40,93 +40,25 @@ describe('detectEnterpriseProvider', () => {
4040
});
4141
});
4242

43-
describe('loadEnterpriseScript', () => {
44-
let appendedScript: {
45-
src: string;
46-
async: boolean;
47-
onload: (() => void) | null;
48-
onerror: (() => void) | null;
49-
};
50-
let mockQuerySelector: ReturnType<typeof vi.fn>;
51-
let mockAppendChild: ReturnType<typeof vi.fn>;
52-
53-
beforeEach(() => {
54-
vi.stubGlobal('grecaptcha', undefined);
55-
56-
appendedScript = { src: '', async: false, onload: null, onerror: null };
57-
mockAppendChild = vi.fn();
58-
mockQuerySelector = vi.fn().mockReturnValue(null);
59-
60-
vi.stubGlobal('document', {
61-
querySelector: mockQuerySelector,
62-
createElement: () => appendedScript,
63-
head: { appendChild: mockAppendChild, querySelector: mockQuerySelector },
64-
});
65-
});
66-
67-
it('appends ?render=siteKey for grecaptcha invisible mode', async () => {
68-
const promise = loadEnterpriseScript({
69-
apiUrl: 'https://www.google.com/recaptcha/enterprise.js',
70-
siteKey: 'test-site-key',
71-
mode: 'invisible',
72-
nameOfCaptcha: 'grecaptcha',
73-
});
74-
expect(appendedScript.src).toBe(
75-
'https://www.google.com/recaptcha/enterprise.js?render=test-site-key',
76-
);
77-
appendedScript.onload?.();
78-
await promise;
79-
});
80-
81-
it('does not append ?render for grecaptcha normal (checkbox) mode', async () => {
82-
const promise = loadEnterpriseScript({
83-
apiUrl: 'https://www.google.com/recaptcha/enterprise.js',
84-
siteKey: 'test-site-key',
85-
mode: 'normal',
86-
nameOfCaptcha: 'grecaptcha',
87-
});
88-
expect(appendedScript.src).toBe('https://www.google.com/recaptcha/enterprise.js');
89-
appendedScript.onload?.();
90-
await promise;
91-
});
92-
93-
it('does not append ?render for hcaptcha invisible mode', async () => {
94-
const promise = loadEnterpriseScript({
95-
apiUrl: 'https://js.hcaptcha.com/1/api.js',
96-
siteKey: 'test-site-key',
97-
mode: 'invisible',
98-
nameOfCaptcha: 'hcaptcha',
99-
});
100-
expect(appendedScript.src).toBe('https://js.hcaptcha.com/1/api.js');
101-
appendedScript.onload?.();
102-
await promise;
103-
});
104-
105-
it('reuses existing script and calls grecaptcha.ready when script already present', async () => {
106-
const mockReady = vi.fn((fn: () => void) => fn());
107-
vi.stubGlobal('grecaptcha', { ready: mockReady });
108-
mockQuerySelector.mockReturnValue({ addEventListener: vi.fn() });
109-
110-
await loadEnterpriseScript({
111-
apiUrl: 'https://www.google.com/recaptcha/enterprise.js',
112-
siteKey: 'my-key',
113-
mode: 'invisible',
114-
nameOfCaptcha: 'grecaptcha',
115-
});
116-
117-
expect(mockAppendChild).not.toHaveBeenCalled();
118-
expect(mockReady).toHaveBeenCalledOnce();
43+
describe('buildEnterpriseScriptSrc', () => {
44+
it('appends ?render=siteKey for invisible mode', () => {
45+
expect(
46+
buildEnterpriseScriptSrc({
47+
apiUrl: 'https://www.google.com/recaptcha/enterprise.js',
48+
siteKey: 'test-site-key',
49+
mode: 'invisible',
50+
}),
51+
).toBe('https://www.google.com/recaptcha/enterprise.js?render=test-site-key');
11952
});
12053

121-
it('rejects when the script fails to load', async () => {
122-
const promise = loadEnterpriseScript({
123-
apiUrl: 'https://www.google.com/recaptcha/enterprise.js',
124-
siteKey: 'bad-key',
125-
mode: 'invisible',
126-
nameOfCaptcha: 'grecaptcha',
127-
});
128-
appendedScript.onerror?.();
129-
await expect(promise).rejects.toThrow('Failed to load CAPTCHA script');
54+
it('does not append ?render for normal (checkbox) mode', () => {
55+
expect(
56+
buildEnterpriseScriptSrc({
57+
apiUrl: 'https://www.google.com/recaptcha/enterprise.js',
58+
siteKey: 'test-site-key',
59+
mode: 'normal',
60+
}),
61+
).toBe('https://www.google.com/recaptcha/enterprise.js');
13062
});
13163
});
13264

0 commit comments

Comments
 (0)