Просмотр исходного кода

add tests for anonymization handlers

Yuki Takei 9 месяцев назад
Родитель
Сommit
ac96ebcdfe

+ 238 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-api-handler.spec.ts

@@ -0,0 +1,238 @@
+import type { IncomingMessage } from 'http';
+
+import {
+  describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { pageApiModule } from './page-api-handler';
+
+describe('pageApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    // No mocks needed - test actual behavior
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                                          | url                                                              | expected
+      ${'pages list endpoint'}                             | ${'/_api/v3/pages/list?path=/home'}                              | ${true}
+      ${'subordinated list endpoint'}                      | ${'/_api/v3/pages/subordinated-list?path=/docs'}                 | ${true}
+      ${'check page existence endpoint'}                   | ${'/_api/v3/page/check-page-existence?path=/wiki'}               | ${true}
+      ${'get page paths with descendant count endpoint'}   | ${'/_api/v3/page/get-page-paths-with-descendant-count?paths=[]'} | ${true}
+      ${'pages list without query'}                        | ${'/_api/v3/pages/list'}                                         | ${true}
+      ${'subordinated list without query'}                 | ${'/_api/v3/pages/subordinated-list'}                            | ${true}
+      ${'check page existence without query'}              | ${'/_api/v3/page/check-page-existence'}                          | ${true}
+      ${'get page paths without query'}                    | ${'/_api/v3/page/get-page-paths-with-descendant-count'}          | ${true}
+      ${'other pages endpoint'}                            | ${'/_api/v3/pages/create'}                                       | ${false}
+      ${'different API version'}                           | ${'/_api/v2/pages/list'}                                         | ${false}
+      ${'non-page API'}                                    | ${'/_api/v3/search'}                                             | ${false}
+      ${'regular page path'}                               | ${'/page/path'}                                                  | ${false}
+      ${'root path'}                                       | ${'/'}                                                           | ${false}
+      ${'empty URL'}                                       | ${''}                                                            | ${false}
+      ${'partial match but different endpoint'}            | ${'/_api/v3/pages-other/list'}                                   | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = pageApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('pages/list endpoint', () => {
+      it('should anonymize path parameter when present', () => {
+        const originalUrl = '/_api/v3/pages/list?path=/sensitive/path&limit=10';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/list?path=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const url = '/_api/v3/pages/list?limit=10&sort=updated';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('pages/subordinated-list endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/pages/subordinated-list?path=/user/documents&offset=0';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&offset=0',
+        });
+      });
+
+      it('should handle encoded path parameters', () => {
+        const originalUrl = '/_api/v3/pages/subordinated-list?path=%2Fuser%2Fdocs&includeEmpty=true';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/subordinated-list?path=%5BANONYMIZED%5D&includeEmpty=true',
+        });
+      });
+    });
+
+    describe('page/check-page-existence endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page/check-page-existence?path=/project/wiki';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+        });
+      });
+
+      it('should handle multiple parameters including path', () => {
+        const originalUrl = '/_api/v3/page/check-page-existence?path=/docs/api&includePrivate=false';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D&includePrivate=false',
+        });
+      });
+    });
+
+    describe('page/get-page-paths-with-descendant-count endpoint', () => {
+      it('should anonymize paths parameter when present', () => {
+        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=["/docs","/wiki"]';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+        });
+      });
+
+      it('should handle encoded paths parameter', () => {
+        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%2Fdocs%22%5D';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5B%22%5BANONYMIZED%5D%22%5D',
+        });
+      });
+
+      it('should return null when no paths parameter is present', () => {
+        const url = '/_api/v3/page/get-page-paths-with-descendant-count?includeEmpty=true';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('mixed endpoint scenarios', () => {
+      it('should handle pages/list endpoint without path parameter', () => {
+        const url = '/_api/v3/pages/list?limit=20&sort=name';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should handle subordinated-list endpoint without path parameter', () => {
+        const url = '/_api/v3/pages/subordinated-list?includeEmpty=false';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should handle check-page-existence endpoint without path parameter', () => {
+        const url = '/_api/v3/page/check-page-existence?includePrivate=true';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(url)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle empty path parameter', () => {
+        const originalUrl = '/_api/v3/pages/list?path=&limit=5';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/pages/list?path=&limit=5',
+        });
+      });
+
+      it('should handle root path parameter', () => {
+        const originalUrl = '/_api/v3/page/check-page-existence?path=/';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/check-page-existence?path=%5BANONYMIZED%5D',
+        });
+      });
+
+      it('should handle empty paths array parameter', () => {
+        const originalUrl = '/_api/v3/page/get-page-paths-with-descendant-count?paths=[]';
+
+        // Verify canHandle returns true for this URL
+        expect(pageApiModule.canHandle(originalUrl)).toBe(true);
+
+        const result = pageApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page/get-page-paths-with-descendant-count?paths=%5BANONYMIZED%5D',
+        });
+      });
+    });
+  });
+});

+ 335 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -0,0 +1,335 @@
+import type { IncomingMessage } from 'http';
+
+import {
+  describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { pageListingApiModule } from './page-listing-api-handler';
+
+describe('pageListingApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    // No mocks needed - test actual behavior
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                           | url                                                    | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}  | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}        | ${true}
+      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}            | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}         | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                   | ${true}
+      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                       | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                      | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                   | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                              | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                       | ${false}
+      ${'root path'}                        | ${'/'}                                                | ${false}
+      ${'empty URL'}                        | ${''}                                                 | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}             | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = pageListingApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('ancestors-children endpoint', () => {
+      it('should anonymize path parameter when present', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should handle encoded path parameters', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=%2Fuser%2Fdocs&includeEmpty=true';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/ancestors-children?path=%5BANONYMIZED%5D&includeEmpty=true',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const url = '/_api/v3/page-listing/ancestors-children?limit=10&sort=name';
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('children endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=/project/docs&offset=0';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&offset=0',
+        });
+      });
+
+      it('should handle root path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=/&includePrivate=false';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&includePrivate=false',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const url = '/_api/v3/page-listing/children?sort=updated&limit=20';
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('info endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/info?path=/wiki/documentation';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/info?path=%5BANONYMIZED%5D',
+        });
+      });
+
+      it('should handle multiple parameters including path', () => {
+        const originalUrl = '/_api/v3/page-listing/info?path=/admin&includeMetadata=true&format=json';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/info?path=%5BANONYMIZED%5D&includeMetadata=true&format=json',
+        });
+      });
+
+      it('should return null when no path parameter is present', () => {
+        const url = '/_api/v3/page-listing/info?includeMetadata=true';
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle empty path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=&limit=5';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/children?path=%5BANONYMIZED%5D&limit=5',
+        });
+      });
+
+      it('should handle URLs without any parameters', () => {
+        const url = '/_api/v3/page-listing/ancestors-children';
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should preserve parameter order while anonymizing path', () => {
+        const originalUrl = '/_api/v3/page-listing/info?format=json&path=/secret&includeMetadata=true';
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/v3/page-listing/info?format=json&path=%5BANONYMIZED%5D&includeMetadata=true',
+        });
+      });
+    });
+  });
+});
+
+const mockAnonymizeQueryParams = vi.fn();
+vi.mock('../utils/anonymize-query-params', () => ({
+  anonymizeQueryParams: mockAnonymizeQueryParams,
+}));
+
+describe('pageListingApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                                          | url                                                          | expected
+      ${'ancestors-children endpoint'}                     | ${'/_api/v3/page-listing/ancestors-children?path=/home'}    | ${true}
+      ${'children endpoint'}                               | ${'/_api/v3/page-listing/children?path=/docs'}              | ${true}
+      ${'info endpoint'}                                   | ${'/_api/v3/page-listing/info?path=/wiki'}                  | ${true}
+      ${'ancestors-children without query'}               | ${'/_api/v3/page-listing/ancestors-children'}               | ${true}
+      ${'children without query'}                          | ${'/_api/v3/page-listing/children'}                         | ${true}
+      ${'info without query'}                              | ${'/_api/v3/page-listing/info'}                             | ${true}
+      ${'other page-listing endpoint'}                     | ${'/_api/v3/page-listing/other'}                            | ${false}
+      ${'different API version'}                           | ${'/_api/v2/page-listing/children'}                         | ${false}
+      ${'non-page-listing API'}                            | ${'/_api/v3/pages/list'}                                     | ${false}
+      ${'regular page path'}                               | ${'/page/path'}                                              | ${false}
+      ${'root path'}                                       | ${'/'}                                                       | ${false}
+      ${'empty URL'}                                       | ${''}                                                        | ${false}
+      ${'partial match but different endpoint'}            | ${'/_api/v3/page-listing-other/children'}                   | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = pageListingApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('ancestors-children endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/sensitive/path&limit=10';
+        const anonymizedUrl = '/_api/v3/page-listing/ancestors-children?path=<ANONYMIZED>&limit=10';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+
+      it('should return anonymized URL when no path parameter is present', () => {
+        const url = '/_api/v3/page-listing/ancestors-children?limit=10';
+
+        mockAnonymizeQueryParams.mockReturnValue(url);
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(url, ['path']);
+        expect(result).toEqual({
+          'http.target': url,
+        });
+      });
+    });
+
+    describe('children endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=/user/documents&offset=0';
+        const anonymizedUrl = '/_api/v3/page-listing/children?path=<ANONYMIZED>&offset=0';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+
+      it('should handle encoded path parameters', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=%2Fuser%2Fdocs&sort=name';
+        const anonymizedUrl = '/_api/v3/page-listing/children?path=<ANONYMIZED>&sort=name';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+    });
+
+    describe('info endpoint', () => {
+      it('should anonymize path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/info?path=/project/wiki';
+        const anonymizedUrl = '/_api/v3/page-listing/info?path=<ANONYMIZED>';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+
+      it('should handle multiple parameters including path', () => {
+        const originalUrl = '/_api/v3/page-listing/info?path=/docs/api&includeEmpty=true&format=json';
+        const anonymizedUrl = '/_api/v3/page-listing/info?path=<ANONYMIZED>&includeEmpty=true&format=json';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+    });
+
+    describe('non-matching URLs', () => {
+      it('should return null for URLs that do not match any endpoint', () => {
+        const url = '/_api/v3/pages/list?path=/some/path';
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(mockAnonymizeQueryParams).not.toHaveBeenCalled();
+        expect(result).toBeNull();
+      });
+
+      it('should return null for URLs without page-listing endpoints', () => {
+        const url = '/_api/v3/search?q=test';
+
+        const result = pageListingApiModule.handle(mockRequest, url);
+
+        expect(mockAnonymizeQueryParams).not.toHaveBeenCalled();
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle empty path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/children?path=&limit=5';
+        const anonymizedUrl = '/_api/v3/page-listing/children?path=<ANONYMIZED>&limit=5';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+
+      it('should handle root path parameter', () => {
+        const originalUrl = '/_api/v3/page-listing/ancestors-children?path=/';
+        const anonymizedUrl = '/_api/v3/page-listing/ancestors-children?path=<ANONYMIZED>';
+
+        mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+        const result = pageListingApiModule.handle(mockRequest, originalUrl);
+
+        expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['path']);
+        expect(result).toEqual({
+          'http.target': anonymizedUrl,
+        });
+      });
+    });
+  });
+});

+ 259 - 0
apps/app/src/features/opentelemetry/server/anonymization/handlers/search-api-handler.spec.ts

@@ -0,0 +1,259 @@
+import type { IncomingMessage } from 'http';
+
+import {
+  describe, it, expect, beforeEach,
+} from 'vitest';
+
+import { searchApiModule } from './search-api-handler';
+
+describe('searchApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    // No mocks needed - test actual behavior
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                     | url                                 | expected
+      ${'search API endpoint'}        | ${'/_api/search?q=test'}            | ${true}
+      ${'search API without query'}   | ${'/_api/search'}                   | ${true}
+      ${'search endpoint'}            | ${'/_search?q=keyword'}             | ${true}
+      ${'search endpoint without q'}  | ${'/_search'}                       | ${true}
+      ${'nested search API'}          | ${'/admin/_api/search?q=admin'}     | ${true}
+      ${'nested search endpoint'}     | ${'/docs/_search?q=documentation'}  | ${true}
+      ${'other API endpoint'}         | ${'/_api/pages'}                    | ${false}
+      ${'regular page path'}          | ${'/search/results'}                | ${false}
+      ${'similar but different'}      | ${'/_api/search-results'}           | ${false}
+      ${'root path'}                  | ${'/'}                              | ${false}
+      ${'empty URL'}                  | ${''}                               | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = searchApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    describe('search API with query parameter', () => {
+      it('should anonymize search query when q parameter is present', () => {
+        const originalUrl = '/_api/search?q=sensitive search term&limit=10';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D&limit=10',
+        });
+      });
+
+      it('should handle encoded query parameters', () => {
+        const originalUrl = '/_search?q=encoded%20search%20term&sort=relevance';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_search?q=%5BANONYMIZED%5D&sort=relevance',
+        });
+      });
+
+      it('should handle empty query parameter', () => {
+        const originalUrl = '/_api/search?q=&page=1';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D&page=1',
+        });
+      });
+
+      it('should handle complex query with special characters', () => {
+        const originalUrl = '/_search?q=user:john+tag:important&format=json';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_search?q=%5BANONYMIZED%5D&format=json',
+        });
+      });
+    });
+
+    describe('search API without query parameter', () => {
+      it('should return null when no q parameter is present', () => {
+        const url = '/_api/search?limit=20&sort=date';
+
+        const result = searchApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should return null for search endpoint without query', () => {
+        const url = '/_search?page=2&format=json';
+
+        const result = searchApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+
+      it('should return null for search API without any parameters', () => {
+        const url = '/_api/search';
+
+        const result = searchApiModule.handle(mockRequest, url);
+
+        expect(result).toBeNull();
+      });
+    });
+
+    describe('edge cases', () => {
+      it('should handle multiple q parameters', () => {
+        const originalUrl = '/_api/search?q=first&q=second&limit=5';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D&limit=5',
+        });
+      });
+
+      it('should preserve other parameters while anonymizing q', () => {
+        const originalUrl = '/_search?category=docs&q=secret&page=1&sort=date';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_search?category=docs&q=%5BANONYMIZED%5D&page=1&sort=date',
+        });
+      });
+
+      it('should handle URLs with fragments', () => {
+        const originalUrl = '/_api/search?q=test#results';
+
+        const result = searchApiModule.handle(mockRequest, originalUrl);
+
+        expect(result).toEqual({
+          'http.target': '/_api/search?q=%5BANONYMIZED%5D#results',
+        });
+      });
+    });
+  });
+});
+
+const mockAnonymizeQueryParams = vi.fn();
+vi.mock('../utils/anonymize-query-params', () => ({
+  anonymizeQueryParams: mockAnonymizeQueryParams,
+}));
+
+describe('searchApiModule', () => {
+  const mockRequest = {} as IncomingMessage;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('canHandle', () => {
+    it.each`
+      description                                  | url                                           | expected
+      ${'search API endpoint with _api prefix'}    | ${'/_api/search?q=test'}                      | ${true}
+      ${'search API endpoint with _search'}        | ${'/_search?q=test'}                          | ${true}
+      ${'search endpoint in URL path'}             | ${'/some/_api/search/endpoint'}               | ${true}
+      ${'search endpoint without query'}           | ${'/_api/search'}                             | ${true}
+      ${'regular page path'}                       | ${'/page/path'}                               | ${false}
+      ${'other API endpoint'}                      | ${'/_api/v3/pages'}                           | ${false}
+      ${'root path'}                               | ${'/'}                                        | ${false}
+      ${'empty URL'}                               | ${''}                                         | ${false}
+      ${'search-like but not exact match'}         | ${'/_api/searchable'}                         | ${false}
+      ${'path containing search but not API'}      | ${'/search/page'}                             | ${false}
+    `('should return $expected for $description: $url', ({ url, expected }) => {
+      const result = searchApiModule.canHandle(url);
+      expect(result).toBe(expected);
+    });
+  });
+
+  describe('handle', () => {
+    it('should anonymize search query parameter when q= is present', () => {
+      const originalUrl = '/_api/search?q=sensitive search term&limit=10';
+      const anonymizedUrl = '/_api/search?q=<ANONYMIZED>&limit=10';
+
+      mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+      const result = searchApiModule.handle(mockRequest, originalUrl);
+
+      expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['q']);
+      expect(result).toEqual({
+        'http.target': anonymizedUrl,
+      });
+    });
+
+    it('should handle multiple query parameters correctly', () => {
+      const originalUrl = '/_api/search?q=test query&offset=0&limit=20&sort=updated';
+      const anonymizedUrl = '/_api/search?q=<ANONYMIZED>&offset=0&limit=20&sort=updated';
+
+      mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+      const result = searchApiModule.handle(mockRequest, originalUrl);
+
+      expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['q']);
+      expect(result).toEqual({
+        'http.target': anonymizedUrl,
+      });
+    });
+
+    it('should return null when no q parameter is present', () => {
+      const url = '/_api/search?limit=10&offset=0';
+
+      const result = searchApiModule.handle(mockRequest, url);
+
+      expect(mockAnonymizeQueryParams).not.toHaveBeenCalled();
+      expect(result).toBeNull();
+    });
+
+    it('should return null for search URLs without query parameters', () => {
+      const url = '/_api/search';
+
+      const result = searchApiModule.handle(mockRequest, url);
+
+      expect(mockAnonymizeQueryParams).not.toHaveBeenCalled();
+      expect(result).toBeNull();
+    });
+
+    it('should handle _search endpoint with query parameter', () => {
+      const originalUrl = '/_search?q=document content';
+      const anonymizedUrl = '/_search?q=<ANONYMIZED>';
+
+      mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+      const result = searchApiModule.handle(mockRequest, originalUrl);
+
+      expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['q']);
+      expect(result).toEqual({
+        'http.target': anonymizedUrl,
+      });
+    });
+
+    it('should handle encoded query parameters', () => {
+      const originalUrl = '/_api/search?q=search%20with%20spaces&other=value';
+      const anonymizedUrl = '/_api/search?q=<ANONYMIZED>&other=value';
+
+      mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+      const result = searchApiModule.handle(mockRequest, originalUrl);
+
+      expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['q']);
+      expect(result).toEqual({
+        'http.target': anonymizedUrl,
+      });
+    });
+
+    it('should handle empty q parameter', () => {
+      const originalUrl = '/_api/search?q=&limit=10';
+      const anonymizedUrl = '/_api/search?q=<ANONYMIZED>&limit=10';
+
+      mockAnonymizeQueryParams.mockReturnValue(anonymizedUrl);
+
+      const result = searchApiModule.handle(mockRequest, originalUrl);
+
+      expect(mockAnonymizeQueryParams).toHaveBeenCalledWith(originalUrl, ['q']);
+      expect(result).toEqual({
+        'http.target': anonymizedUrl,
+      });
+    });
+  });
+});