@@ -169,10 +169,69 @@ vi.mock('../lib/api', () => {
169169
170170beforeEach ( ( ) => {
171171 vi . mocked ( api . fetchConfig ) . mockResolvedValue ( createDefaultConfigResponse ( ) ) ;
172+ vi . mocked ( api . fetchDecisionsPaginated ) . mockImplementation ( async ( page : number , pageSize = 50 , filters ?: Record < string , string > ) => {
173+ let decisions : DecisionListItem [ ] = [
174+ {
175+ id : 10 ,
176+ created_at : '2026-03-23T10:00:00.000Z' ,
177+ machine : 'host-a' ,
178+ value : '1.2.3.4' ,
179+ expired : false ,
180+ is_duplicate : false ,
181+ simulated : false ,
182+ detail : {
183+ origin : 'manual' ,
184+ reason : 'crowdsecurity/ssh-bf' ,
185+ country : 'DE' ,
186+ as : 'Hetzner' ,
187+ action : 'ban' ,
188+ duration : '4h' ,
189+ alert_id : 1 ,
190+ target : 'ssh' ,
191+ } ,
192+ } ,
193+ {
194+ id : 20 ,
195+ created_at : '2026-03-24T11:00:00.000Z' ,
196+ machine : 'machine-2' ,
197+ value : '5.6.7.8' ,
198+ expired : false ,
199+ is_duplicate : false ,
200+ simulated : true ,
201+ detail : {
202+ origin : 'CAPI' ,
203+ reason : 'crowdsecurity/nginx-bf' ,
204+ country : 'US' ,
205+ as : 'AWS' ,
206+ action : 'ban' ,
207+ duration : '4h' ,
208+ alert_id : 2 ,
209+ target : 'nginx' ,
210+ } ,
211+ } ,
212+ ] ;
213+
214+ if ( filters ?. simulation === 'simulated' ) {
215+ decisions = decisions . filter ( ( decision ) => decision . simulated === true ) ;
216+ }
217+ if ( filters ?. q ) {
218+ const compiledSearch = compileDecisionSearch ( filters . q , {
219+ machineEnabled : true ,
220+ originEnabled : true ,
221+ } ) ;
222+ if ( ! compiledSearch . ok ) {
223+ throw new Error ( compiledSearch . error . message ) ;
224+ }
225+ decisions = decisions . filter ( compiledSearch . predicate ) ;
226+ }
227+
228+ return toPaginatedDecisions ( decisions , page , pageSize , 2 ) ;
229+ } ) ;
172230} ) ;
173231
174232afterEach ( ( ) => {
175233 refreshSignalMock = 0 ;
234+ vi . useRealTimers ( ) ;
176235 window . localStorage . clear ( ) ;
177236 vi . unstubAllGlobals ( ) ;
178237 vi . restoreAllMocks ( ) ;
@@ -302,6 +361,48 @@ describe('Decisions page', () => {
302361 expect ( screen . queryByRole ( 'columnheader' , { name : 'Origin' } ) ) . not . toBeInTheDocument ( ) ;
303362 } ) ;
304363
364+ test ( 'counts decision expiration down live from the absolute stop time' , async ( ) => {
365+ const expiresAt = new Date ( Date . now ( ) + 2_500 ) . toISOString ( ) ;
366+ vi . mocked ( api . fetchDecisionsPaginated ) . mockImplementationOnce ( async ( page , pageSize ) =>
367+ toPaginatedDecisions ( [
368+ {
369+ id : 10 ,
370+ created_at : new Date ( Date . now ( ) - 60_000 ) . toISOString ( ) ,
371+ value : '1.2.3.4' ,
372+ expired : false ,
373+ is_duplicate : false ,
374+ simulated : false ,
375+ detail : {
376+ origin : 'manual' ,
377+ reason : 'crowdsecurity/ssh-bf' ,
378+ country : 'DE' ,
379+ as : 'Hetzner' ,
380+ action : 'ban' ,
381+ duration : '4m10s' ,
382+ expiration : expiresAt ,
383+ alert_id : 1 ,
384+ } ,
385+ } ,
386+ ] , page , pageSize ) ,
387+ ) ;
388+
389+ render (
390+ < MemoryRouter initialEntries = { [ '/decisions' ] } >
391+ < Decisions />
392+ </ MemoryRouter > ,
393+ ) ;
394+
395+ await waitFor ( ( ) => expect ( screen . getByText ( / ^ [ 1 2 3 ] s $ / ) ) . toBeInTheDocument ( ) ) ;
396+ expect ( screen . queryByText ( '4m10s' ) ) . not . toBeInTheDocument ( ) ;
397+
398+ await act ( async ( ) => {
399+ await new Promise ( ( resolve ) => setTimeout ( resolve , 2_600 ) ) ;
400+ } ) ;
401+ await waitFor ( ( ) => expect ( screen . getByText ( '0s' ) ) . toBeInTheDocument ( ) ) ;
402+ await waitFor ( ( ) => expect ( screen . getByText ( / E x p i r e d / ) ) . toBeInTheDocument ( ) ) ;
403+ await waitFor ( ( ) => expect ( screen . getByRole ( 'checkbox' , { name : 'Select decision 10' } ) ) . toBeDisabled ( ) ) ;
404+ } ) ;
405+
305406 test ( 'saves decision table columns from the modal' , async ( ) => {
306407 render (
307408 < MemoryRouter initialEntries = { [ '/decisions' ] } >
@@ -362,7 +463,7 @@ describe('Decisions page', () => {
362463 ) ;
363464
364465 await waitFor ( ( ) => expect ( screen . getByRole ( 'columnheader' , { name : 'Machine' } ) ) . toBeInTheDocument ( ) ) ;
365- expect ( screen . getByText ( 'host-a' ) ) . toBeInTheDocument ( ) ;
466+ await waitFor ( ( ) => expect ( screen . getByText ( 'host-a' ) ) . toBeInTheDocument ( ) ) ;
366467
367468 fireEvent . change ( screen . getByPlaceholderText ( 'Filter decisions...' ) , { target : { value : 'host-a' } } ) ;
368469 await flushDecisionSearchDebounce ( ) ;
@@ -395,7 +496,7 @@ describe('Decisions page', () => {
395496
396497 await waitFor ( ( ) => expect ( screen . getByRole ( 'columnheader' , { name : 'Origin' } ) ) . toBeInTheDocument ( ) ) ;
397498 await waitFor ( ( ) => expect ( screen . getByText ( 'manual' ) ) . toBeInTheDocument ( ) ) ;
398- expect ( screen . getByText ( 'CAPI' ) ) . toBeInTheDocument ( ) ;
499+ await waitFor ( ( ) => expect ( screen . getByText ( 'CAPI' ) ) . toBeInTheDocument ( ) ) ;
399500
400501 fireEvent . change ( screen . getByPlaceholderText ( 'Filter decisions...' ) , { target : { value : 'manual' } } ) ;
401502 await flushDecisionSearchDebounce ( ) ;
0 commit comments