@@ -266,3 +266,222 @@ func TestSlidingWindowIntegration(t *testing.T) {
266266 assert .NotContains (t , windowMap , "endpoint3" )
267267 })
268268}
269+
270+ func TestAddSample (t * testing.T ) {
271+ t .Run ("adds sample to empty sliding window" , func (t * testing.T ) {
272+ sw := NewSlidingWindow ()
273+ sw .AddSample ("GET" , "/api/users" , 15 )
274+
275+ assert .Equal (t , 1 , len (sw .Samples ))
276+ assert .Equal (t , "GET" , sw .Samples [0 ].Method )
277+ assert .Equal (t , "/api/users" , sw .Samples [0 ].Url )
278+ })
279+
280+ t .Run ("adds multiple different samples" , func (t * testing.T ) {
281+ sw := NewSlidingWindow ()
282+ sw .AddSample ("GET" , "/api/users" , 15 )
283+ sw .AddSample ("POST" , "/api/login" , 15 )
284+ sw .AddSample ("DELETE" , "/api/users/123" , 15 )
285+
286+ assert .Equal (t , 3 , len (sw .Samples ))
287+ assert .Equal (t , "GET" , sw .Samples [0 ].Method )
288+ assert .Equal (t , "/api/users" , sw .Samples [0 ].Url )
289+ assert .Equal (t , "POST" , sw .Samples [1 ].Method )
290+ assert .Equal (t , "/api/login" , sw .Samples [1 ].Url )
291+ assert .Equal (t , "DELETE" , sw .Samples [2 ].Method )
292+ assert .Equal (t , "/api/users/123" , sw .Samples [2 ].Url )
293+ })
294+
295+ t .Run ("prevents duplicate samples with same method and URL" , func (t * testing.T ) {
296+ sw := NewSlidingWindow ()
297+ sw .AddSample ("GET" , "/api/users" , 15 )
298+ sw .AddSample ("GET" , "/api/users" , 15 ) // duplicate
299+ sw .AddSample ("GET" , "/api/users" , 15 ) // duplicate
300+
301+ assert .Equal (t , 1 , len (sw .Samples ))
302+ assert .Equal (t , "GET" , sw .Samples [0 ].Method )
303+ assert .Equal (t , "/api/users" , sw .Samples [0 ].Url )
304+ })
305+
306+ t .Run ("allows same URL with different methods" , func (t * testing.T ) {
307+ sw := NewSlidingWindow ()
308+ sw .AddSample ("GET" , "/api/users" , 15 )
309+ sw .AddSample ("POST" , "/api/users" , 15 )
310+ sw .AddSample ("DELETE" , "/api/users" , 15 )
311+
312+ assert .Equal (t , 3 , len (sw .Samples ))
313+ assert .Equal (t , "GET" , sw .Samples [0 ].Method )
314+ assert .Equal (t , "POST" , sw .Samples [1 ].Method )
315+ assert .Equal (t , "DELETE" , sw .Samples [2 ].Method )
316+ })
317+
318+ t .Run ("allows same method with different URLs" , func (t * testing.T ) {
319+ sw := NewSlidingWindow ()
320+ sw .AddSample ("GET" , "/api/users" , 15 )
321+ sw .AddSample ("GET" , "/api/posts" , 15 )
322+ sw .AddSample ("GET" , "/api/comments" , 15 )
323+
324+ assert .Equal (t , 3 , len (sw .Samples ))
325+ assert .Equal (t , "/api/users" , sw .Samples [0 ].Url )
326+ assert .Equal (t , "/api/posts" , sw .Samples [1 ].Url )
327+ assert .Equal (t , "/api/comments" , sw .Samples [2 ].Url )
328+ })
329+
330+ t .Run ("enforces maximum of 10 samples" , func (t * testing.T ) {
331+ sw := NewSlidingWindow ()
332+
333+ // Add 12 unique samples
334+ for i := 0 ; i < 12 ; i ++ {
335+ sw .AddSample ("GET" , "/api/endpoint" + string (rune ('0' + i )), 10 )
336+ }
337+
338+ assert .Equal (t , 10 , len (sw .Samples ))
339+ })
340+
341+ t .Run ("does not add 11th sample even if unique" , func (t * testing.T ) {
342+ sw := NewSlidingWindow ()
343+
344+ // Add exactly 10 samples
345+ for i := 0 ; i < 10 ; i ++ {
346+ sw .AddSample ("GET" , "/api/endpoint" + string (rune ('0' + i )), 10 )
347+ }
348+ assert .Equal (t , 10 , len (sw .Samples ))
349+
350+ // Try to add an 11th unique sample
351+ sw .AddSample ("POST" , "/api/new-endpoint" , 10 )
352+ assert .Equal (t , 10 , len (sw .Samples ))
353+
354+ // Verify the 11th sample was not added
355+ found := false
356+ for _ , sample := range sw .Samples {
357+ if sample .Method == "POST" && sample .Url == "/api/new-endpoint" {
358+ found = true
359+ break
360+ }
361+ }
362+ assert .False (t , found )
363+ })
364+
365+ t .Run ("duplicates do not count toward 10 sample limit" , func (t * testing.T ) {
366+ sw := NewSlidingWindow ()
367+
368+ // Add 5 unique samples
369+ for i := 0 ; i < 5 ; i ++ {
370+ sw .AddSample ("GET" , "/api/endpoint" + string (rune ('0' + i )), 15 )
371+ }
372+
373+ // Try to add duplicates
374+ sw .AddSample ("GET" , "/api/endpoint0" , 15 ) // duplicate
375+ sw .AddSample ("GET" , "/api/endpoint1" , 15 ) // duplicate
376+ sw .AddSample ("GET" , "/api/endpoint2" , 15 ) // duplicate
377+
378+ assert .Equal (t , 5 , len (sw .Samples ))
379+
380+ // Add 5 more unique samples to reach 10
381+ for i := 5 ; i < 10 ; i ++ {
382+ sw .AddSample ("GET" , "/api/endpoint" + string (rune ('0' + i )), 15 )
383+ }
384+ assert .Equal (t , 10 , len (sw .Samples ))
385+
386+ // Try to add more duplicates - should still be 10
387+ sw .AddSample ("GET" , "/api/endpoint5" , 15 )
388+ sw .AddSample ("GET" , "/api/endpoint9" , 15 )
389+ assert .Equal (t , 10 , len (sw .Samples ))
390+ })
391+
392+ t .Run ("preserves samples during window operations" , func (t * testing.T ) {
393+ sw := NewSlidingWindow ()
394+ sw .AddSample ("GET" , "/api/users" , 15 )
395+ sw .AddSample ("POST" , "/api/login" , 15 )
396+ sw .Increment ()
397+
398+ // Advance the window
399+ sw .Advance (5 )
400+
401+ // Samples should still be present
402+ assert .Equal (t , 2 , len (sw .Samples ))
403+ assert .Equal (t , "GET" , sw .Samples [0 ].Method )
404+ assert .Equal (t , "/api/users" , sw .Samples [0 ].Url )
405+ assert .Equal (t , "POST" , sw .Samples [1 ].Method )
406+ assert .Equal (t , "/api/login" , sw .Samples [1 ].Url )
407+ })
408+
409+ t .Run ("empty method and URL are valid samples" , func (t * testing.T ) {
410+ sw := NewSlidingWindow ()
411+ sw .AddSample ("" , "" , 15 )
412+ sw .AddSample ("GET" , "" , 15 )
413+ sw .AddSample ("" , "/api/users" , 15 )
414+
415+ assert .Equal (t , 3 , len (sw .Samples ))
416+ })
417+ }
418+
419+ func TestSlidingWindowSamplesIntegration (t * testing.T ) {
420+ t .Run ("simulates attack wave detection with samples" , func (t * testing.T ) {
421+ // Create a map simulating per-IP tracking
422+ ipMap := map [string ]* SlidingWindow {
423+ "192.168.1.100" : NewSlidingWindow (),
424+ }
425+
426+ ip := "192.168.1.100"
427+ sw := ipMap [ip ]
428+
429+ // Simulate suspicious requests
430+ requests := []struct {
431+ method string
432+ url string
433+ }{
434+ {"GET" , "/admin" },
435+ {"GET" , "/admin" }, // duplicate
436+ {"POST" , "/admin" },
437+ {"GET" , "/wp-admin" },
438+ {"GET" , "/.env" },
439+ {"GET" , "/config.php" },
440+ {"POST" , "/login" },
441+ {"GET" , "/admin" }, // duplicate
442+ }
443+
444+ for _ , req := range requests {
445+ sw .Increment ()
446+ sw .AddSample (req .method , req .url , 15 )
447+ }
448+
449+ // Should have 6 unique samples (2 duplicates removed)
450+ assert .Equal (t , 6 , len (sw .Samples ))
451+ assert .Equal (t , 8 , sw .Total ) // But total count should be 8
452+
453+ // Verify samples are unique
454+ uniqueCheck := make (map [string ]bool )
455+ for _ , sample := range sw .Samples {
456+ key := sample .Method + ":" + sample .Url
457+ assert .False (t , uniqueCheck [key ], "Found duplicate sample: " + key )
458+ uniqueCheck [key ] = true
459+ }
460+ })
461+
462+ t .Run ("samples persist across window advances until removal" , func (t * testing.T ) {
463+ windowMap := map [string ]* SlidingWindow {
464+ "10.0.0.1" : NewSlidingWindow (),
465+ }
466+
467+ sw := windowMap ["10.0.0.1" ]
468+ sw .AddSample ("GET" , "/api/v1/users" , 15 )
469+ sw .AddSample ("POST" , "/api/v1/login" , 15 )
470+ sw .Increment ()
471+ sw .Increment ()
472+
473+ // Advance window multiple times
474+ AdvanceSlidingWindowMap (windowMap , 3 )
475+ AdvanceSlidingWindowMap (windowMap , 3 )
476+
477+ // Samples should still be there
478+ assert .Contains (t , windowMap , "10.0.0.1" )
479+ assert .Equal (t , 2 , len (windowMap ["10.0.0.1" ].Samples ))
480+
481+ // Advance until window is empty
482+ AdvanceSlidingWindowMap (windowMap , 3 )
483+
484+ // Window should be removed when total reaches 0
485+ assert .NotContains (t , windowMap , "10.0.0.1" )
486+ })
487+ }
0 commit comments