Skip to content

Commit 6336b78

Browse files
authored
feat #127: acid test security settings — apiConfig wiring, improved errors, expanded tests (#163)
1 parent be5954a commit 6336b78

File tree

2 files changed

+353
-21
lines changed

2 files changed

+353
-21
lines changed

packages/core/src/__tests__/bloomreachSecuritySettings.test.ts

Lines changed: 258 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
2+
import type { BloomreachApiConfig } from '../bloomreachApiClient.js';
23
import {
34
CREATE_SSH_TUNNEL_ACTION_TYPE,
45
UPDATE_SSH_TUNNEL_ACTION_TYPE,
@@ -20,6 +21,17 @@ import {
2021
BloomreachSecuritySettingsService,
2122
} from '../index.js';
2223

24+
const TEST_API_CONFIG: BloomreachApiConfig = {
25+
projectToken: 'test-token-123',
26+
apiKeyId: 'key-id',
27+
apiSecret: 'key-secret',
28+
baseUrl: 'https://api.test.com',
29+
};
30+
31+
afterEach(() => {
32+
vi.restoreAllMocks();
33+
});
34+
2335
describe('action type constants', () => {
2436
it('exports CREATE_SSH_TUNNEL_ACTION_TYPE', () => {
2537
expect(CREATE_SSH_TUNNEL_ACTION_TYPE).toBe('security_settings.create_ssh_tunnel');
@@ -82,6 +94,26 @@ describe('validateTunnelName', () => {
8294
const name = 'x'.repeat(201);
8395
expect(() => validateTunnelName(name)).toThrow('must not exceed 200 characters');
8496
});
97+
98+
it('returns trimmed value with tabs and newlines', () => {
99+
expect(validateTunnelName('\n\tPrimary Tunnel\t\n')).toBe('Primary Tunnel');
100+
});
101+
102+
it('accepts single-character name', () => {
103+
expect(validateTunnelName('T')).toBe('T');
104+
});
105+
106+
it('accepts unicode tunnel name', () => {
107+
expect(validateTunnelName('Produkční tunel')).toBe('Produkční tunel');
108+
});
109+
110+
it('throws for tab-only string', () => {
111+
expect(() => validateTunnelName('\t\t')).toThrow('must not be empty');
112+
});
113+
114+
it('throws for newline-only string', () => {
115+
expect(() => validateTunnelName('\n\n')).toThrow('must not be empty');
116+
});
85117
});
86118

87119
describe('validateHost', () => {
@@ -106,6 +138,26 @@ describe('validateHost', () => {
106138
const host = 'x'.repeat(254);
107139
expect(() => validateHost(host)).toThrow('must not exceed 253 characters');
108140
});
141+
142+
it('returns trimmed value with tabs and newlines', () => {
143+
expect(validateHost('\n\tssh.example.com\t\n')).toBe('ssh.example.com');
144+
});
145+
146+
it('accepts single-character host', () => {
147+
expect(validateHost('x')).toBe('x');
148+
});
149+
150+
it('accepts unicode host', () => {
151+
expect(validateHost('ssh.ëxample.com')).toBe('ssh.ëxample.com');
152+
});
153+
154+
it('throws for tab-only string', () => {
155+
expect(() => validateHost('\t\t')).toThrow('must not be empty');
156+
});
157+
158+
it('throws for newline-only string', () => {
159+
expect(() => validateHost('\n\n')).toThrow('must not be empty');
160+
});
109161
});
110162

111163
describe('validatePort', () => {
@@ -136,6 +188,10 @@ describe('validatePort', () => {
136188
it('accepts typical SSH port', () => {
137189
expect(validatePort(22)).toBe(22);
138190
});
191+
192+
it('throws for NaN', () => {
193+
expect(() => validatePort(NaN)).toThrow();
194+
});
139195
});
140196

141197
describe('validateUsername', () => {
@@ -160,6 +216,26 @@ describe('validateUsername', () => {
160216
const username = 'x'.repeat(201);
161217
expect(() => validateUsername(username)).toThrow('must not exceed 200 characters');
162218
});
219+
220+
it('returns trimmed value with tabs and newlines', () => {
221+
expect(validateUsername('\n\tdeploy\t\n')).toBe('deploy');
222+
});
223+
224+
it('accepts single-character username', () => {
225+
expect(validateUsername('d')).toBe('d');
226+
});
227+
228+
it('accepts unicode username', () => {
229+
expect(validateUsername('přístup')).toBe('přístup');
230+
});
231+
232+
it('throws for tab-only string', () => {
233+
expect(() => validateUsername('\t\t')).toThrow('must not be empty');
234+
});
235+
236+
it('throws for newline-only string', () => {
237+
expect(() => validateUsername('\n\n')).toThrow('must not be empty');
238+
});
163239
});
164240

165241
describe('validateTunnelId', () => {
@@ -174,6 +250,22 @@ describe('validateTunnelId', () => {
174250
it('returns trimmed tunnel ID for valid input', () => {
175251
expect(validateTunnelId(' tunnel-123 ')).toBe('tunnel-123');
176252
});
253+
254+
it('returns trimmed value with tabs and newlines', () => {
255+
expect(validateTunnelId('\n\ttunnel-abc\t\n')).toBe('tunnel-abc');
256+
});
257+
258+
it('accepts unicode tunnel ID', () => {
259+
expect(validateTunnelId('tunel-č123')).toBe('tunel-č123');
260+
});
261+
262+
it('throws for tab-only string', () => {
263+
expect(() => validateTunnelId('\t\t')).toThrow('must not be empty');
264+
});
265+
266+
it('throws for newline-only string', () => {
267+
expect(() => validateTunnelId('\n\n')).toThrow('must not be empty');
268+
});
177269
});
178270

179271
describe('URL builders', () => {
@@ -199,6 +291,18 @@ describe('URL builders', () => {
199291
'/p/org%2Fproject/project-settings/project-two-step',
200292
);
201293
});
294+
295+
it('encodes unicode project names in URLs', () => {
296+
expect(buildSshTunnelsUrl('projekt åäö')).toContain('%C3%A5');
297+
expect(buildTwoStepVerificationUrl('projekt åäö')).toContain('%C3%A5');
298+
});
299+
300+
it('encodes hash in URLs', () => {
301+
expect(buildSshTunnelsUrl('my#project')).toBe('/p/my%23project/project-settings/ssh-tunnels');
302+
expect(buildTwoStepVerificationUrl('my#project')).toBe(
303+
'/p/my%23project/project-settings/project-two-step',
304+
);
305+
});
202306
});
203307

204308
describe('createSecuritySettingsActionExecutors', () => {
@@ -226,6 +330,79 @@ describe('createSecuritySettingsActionExecutors', () => {
226330
await expect(executor.execute({})).rejects.toThrow('not yet implemented');
227331
}
228332
});
333+
334+
it('executors throw UI-only availability message on execute', async () => {
335+
const executors = createSecuritySettingsActionExecutors();
336+
for (const executor of Object.values(executors)) {
337+
await expect(executor.execute({})).rejects.toThrow(
338+
'only available through the Bloomreach Engagement UI',
339+
);
340+
}
341+
});
342+
343+
it('CreateSshTunnelExecutor mentions UI-only availability', async () => {
344+
const executors = createSecuritySettingsActionExecutors();
345+
await expect(executors[CREATE_SSH_TUNNEL_ACTION_TYPE].execute({})).rejects.toThrow(
346+
'only available through the Bloomreach Engagement UI',
347+
);
348+
});
349+
350+
it('UpdateSshTunnelExecutor mentions UI-only availability', async () => {
351+
const executors = createSecuritySettingsActionExecutors();
352+
await expect(executors[UPDATE_SSH_TUNNEL_ACTION_TYPE].execute({})).rejects.toThrow(
353+
'only available through the Bloomreach Engagement UI',
354+
);
355+
});
356+
357+
it('DeleteSshTunnelExecutor mentions UI-only availability', async () => {
358+
const executors = createSecuritySettingsActionExecutors();
359+
await expect(executors[DELETE_SSH_TUNNEL_ACTION_TYPE].execute({})).rejects.toThrow(
360+
'only available through the Bloomreach Engagement UI',
361+
);
362+
});
363+
364+
it('EnableTwoStepExecutor mentions UI-only availability', async () => {
365+
const executors = createSecuritySettingsActionExecutors();
366+
await expect(executors[ENABLE_TWO_STEP_ACTION_TYPE].execute({})).rejects.toThrow(
367+
'only available through the Bloomreach Engagement UI',
368+
);
369+
});
370+
371+
it('DisableTwoStepExecutor mentions UI-only availability', async () => {
372+
const executors = createSecuritySettingsActionExecutors();
373+
await expect(executors[DISABLE_TWO_STEP_ACTION_TYPE].execute({})).rejects.toThrow(
374+
'only available through the Bloomreach Engagement UI',
375+
);
376+
});
377+
378+
it('UpdateTwoStepExecutor mentions UI-only availability', async () => {
379+
const executors = createSecuritySettingsActionExecutors();
380+
await expect(executors[UPDATE_TWO_STEP_ACTION_TYPE].execute({})).rejects.toThrow(
381+
'only available through the Bloomreach Engagement UI',
382+
);
383+
});
384+
});
385+
386+
describe('apiConfig acceptance', () => {
387+
it('createSecuritySettingsActionExecutors accepts apiConfig', () => {
388+
const executors = createSecuritySettingsActionExecutors(TEST_API_CONFIG);
389+
expect(Object.keys(executors)).toHaveLength(6);
390+
});
391+
392+
it('createSecuritySettingsActionExecutors works without apiConfig', () => {
393+
const executors = createSecuritySettingsActionExecutors();
394+
expect(Object.keys(executors)).toHaveLength(6);
395+
});
396+
397+
it('BloomreachSecuritySettingsService accepts apiConfig', () => {
398+
const service = new BloomreachSecuritySettingsService('test', TEST_API_CONFIG);
399+
expect(service.sshTunnelsUrl).toBe('/p/test/project-settings/ssh-tunnels');
400+
});
401+
402+
it('BloomreachSecuritySettingsService works without apiConfig', () => {
403+
const service = new BloomreachSecuritySettingsService('test');
404+
expect(service.sshTunnelsUrl).toBe('/p/test/project-settings/ssh-tunnels');
405+
});
229406
});
230407

231408
describe('BloomreachSecuritySettingsService', () => {
@@ -243,6 +420,21 @@ describe('BloomreachSecuritySettingsService', () => {
243420
it('throws for empty project', () => {
244421
expect(() => new BloomreachSecuritySettingsService('')).toThrow('must not be empty');
245422
});
423+
424+
it('encodes spaces in URL', () => {
425+
const service = new BloomreachSecuritySettingsService('my project');
426+
expect(service.sshTunnelsUrl).toBe('/p/my%20project/project-settings/ssh-tunnels');
427+
});
428+
429+
it('encodes unicode in URL', () => {
430+
const service = new BloomreachSecuritySettingsService('projekt åäö');
431+
expect(service.sshTunnelsUrl).toContain('%C3%A5');
432+
});
433+
434+
it('encodes hash in URL', () => {
435+
const service = new BloomreachSecuritySettingsService('my#project');
436+
expect(service.sshTunnelsUrl).toBe('/p/my%23project/project-settings/ssh-tunnels');
437+
});
246438
});
247439

248440
describe('URL getters', () => {
@@ -270,6 +462,40 @@ describe('BloomreachSecuritySettingsService', () => {
270462
const service = new BloomreachSecuritySettingsService('test');
271463
await expect(service.viewTwoStepVerification()).rejects.toThrow('not yet implemented');
272464
});
465+
466+
it('listSshTunnels throws descriptive UI-only error', async () => {
467+
const service = new BloomreachSecuritySettingsService('test');
468+
await expect(service.listSshTunnels()).rejects.toThrow('Bloomreach Engagement UI');
469+
});
470+
471+
it('viewSshTunnel throws descriptive UI-only error', async () => {
472+
const service = new BloomreachSecuritySettingsService('test');
473+
await expect(service.viewSshTunnel()).rejects.toThrow('Bloomreach Engagement UI');
474+
});
475+
476+
it('viewTwoStepVerification throws descriptive UI-only error', async () => {
477+
const service = new BloomreachSecuritySettingsService('test');
478+
await expect(service.viewTwoStepVerification()).rejects.toThrow('Bloomreach Engagement UI');
479+
});
480+
481+
it('listSshTunnels validates project when input provided', async () => {
482+
const service = new BloomreachSecuritySettingsService('test');
483+
await expect(service.listSshTunnels({ project: '' })).rejects.toThrow('must not be empty');
484+
});
485+
486+
it('viewSshTunnel validates project when input provided', async () => {
487+
const service = new BloomreachSecuritySettingsService('test');
488+
await expect(
489+
service.viewSshTunnel({ project: '', tunnelId: 'x' }),
490+
).rejects.toThrow('must not be empty');
491+
});
492+
493+
it('viewTwoStepVerification validates project when input provided', async () => {
494+
const service = new BloomreachSecuritySettingsService('test');
495+
await expect(
496+
service.viewTwoStepVerification({ project: '' }),
497+
).rejects.toThrow('must not be empty');
498+
});
273499
});
274500

275501
describe('prepareCreateSshTunnel', () => {
@@ -517,4 +743,35 @@ describe('BloomreachSecuritySettingsService', () => {
517743
expect(() => service.prepareDisableTwoStep({ project: '' })).toThrow('must not be empty');
518744
});
519745
});
746+
747+
describe('token expiry consistency', () => {
748+
it('all prepare methods set expiry ~30 minutes in the future', () => {
749+
const service = new BloomreachSecuritySettingsService('test');
750+
const now = Date.now();
751+
const thirtyMinMs = 30 * 60 * 1000;
752+
753+
const results = [
754+
service.prepareCreateSshTunnel({
755+
project: 'test',
756+
name: 'T',
757+
host: 'h',
758+
port: 22,
759+
username: 'u',
760+
}),
761+
service.prepareUpdateSshTunnel({
762+
project: 'test',
763+
tunnelId: 't-1',
764+
name: 'T2',
765+
}),
766+
service.prepareDeleteSshTunnel({ project: 'test', tunnelId: 't-1' }),
767+
service.prepareEnableTwoStep({ project: 'test' }),
768+
service.prepareDisableTwoStep({ project: 'test' }),
769+
];
770+
771+
for (const result of results) {
772+
expect(result.expiresAtMs).toBeGreaterThanOrEqual(now + thirtyMinMs - 1000);
773+
expect(result.expiresAtMs).toBeLessThanOrEqual(now + thirtyMinMs + 5000);
774+
}
775+
});
776+
});
520777
});

0 commit comments

Comments
 (0)