1- import { describe , it , expect } from 'vitest' ;
1+ import { describe , it , expect , vi , afterEach } from 'vitest' ;
2+ import type { BloomreachApiConfig } from '../bloomreachApiClient.js' ;
23import {
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+
2335describe ( '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
87119describe ( '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
111163describe ( '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
141197describe ( '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
165241describe ( '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
179271describe ( '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
204308describe ( '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
231408describe ( '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