11# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
22"""Tests for OutgoingApiLogService"""
33
4+ import json
5+
46from odoo .tests .common import TransactionCase
57
68from ..services .outgoing_api_log_service import OutgoingApiLogService
@@ -150,8 +152,6 @@ def test_truncate_payload_exact_boundary(self):
150152 )
151153
152154 # Build a payload whose JSON serialization is exactly max_length
153- import json
154-
155155 max_length = 50
156156 # {"k": "..."} — adjust value to hit exact length
157157 base = json .dumps ({"k" : "" }) # '{"k": ""}' = 10 chars
@@ -173,8 +173,6 @@ def test_truncate_payload_one_over_boundary(self):
173173 service_code = "test" ,
174174 )
175175
176- import json
177-
178176 max_length = 50
179177 base = json .dumps ({"k" : "" })
180178 filler = "x" * (max_length - len (base ) + 1 )
@@ -186,3 +184,194 @@ def test_truncate_payload_one_over_boundary(self):
186184 self .assertTrue (result ["_truncated" ])
187185 self .assertEqual (result ["_original_length" ], max_length + 1 )
188186 self .assertEqual (len (result ["_preview" ]), max_length )
187+
188+ def test_mask_sensitive_keys_top_level (self ):
189+ """_mask_sensitive_keys masks known sensitive keys at top level"""
190+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
191+
192+ payload = {
193+ "Authorization" : "Bearer secret-token-123" ,
194+ "Content-Type" : "application/json" ,
195+ "data" : "visible" ,
196+ }
197+
198+ result = service ._mask_sensitive_keys (payload )
199+ self .assertEqual (result ["Authorization" ], "***MASKED***" )
200+ self .assertEqual (result ["Content-Type" ], "application/json" )
201+ self .assertEqual (result ["data" ], "visible" )
202+
203+ def test_mask_sensitive_keys_nested (self ):
204+ """_mask_sensitive_keys masks sensitive keys in nested dicts"""
205+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
206+
207+ payload = {
208+ "header" : {
209+ "authorization" : "Bearer xyz" ,
210+ "action" : "search" ,
211+ },
212+ "body" : {"password" : "s3cret" , "username" : "admin" },
213+ }
214+
215+ result = service ._mask_sensitive_keys (payload )
216+ self .assertEqual (result ["header" ]["authorization" ], "***MASKED***" )
217+ self .assertEqual (result ["header" ]["action" ], "search" )
218+ self .assertEqual (result ["body" ]["password" ], "***MASKED***" )
219+ self .assertEqual (result ["body" ]["username" ], "admin" )
220+
221+ def test_mask_sensitive_keys_case_insensitive (self ):
222+ """_mask_sensitive_keys matches key names case-insensitively"""
223+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
224+
225+ payload = {
226+ "API_KEY" : "key123" ,
227+ "api_key" : "key456" ,
228+ "Api_Key" : "key789" ,
229+ }
230+
231+ result = service ._mask_sensitive_keys (payload )
232+ for key in payload :
233+ self .assertEqual (result [key ], "***MASKED***" )
234+
235+ def test_mask_sensitive_keys_various_sensitive_names (self ):
236+ """_mask_sensitive_keys masks all common sensitive key names"""
237+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
238+
239+ sensitive_keys = [
240+ "authorization" ,
241+ "password" ,
242+ "token" ,
243+ "access_token" ,
244+ "refresh_token" ,
245+ "api_key" ,
246+ "apikey" ,
247+ "secret" ,
248+ "client_secret" ,
249+ "credential" ,
250+ "private_key" ,
251+ ]
252+
253+ payload = {key : f"value_{ key } " for key in sensitive_keys }
254+ result = service ._mask_sensitive_keys (payload )
255+
256+ for key in sensitive_keys :
257+ self .assertEqual (
258+ result [key ],
259+ "***MASKED***" ,
260+ f"Key '{ key } ' should be masked" ,
261+ )
262+
263+ def test_mask_sensitive_keys_preserves_non_dict_values (self ):
264+ """_mask_sensitive_keys handles lists and non-dict nested structures"""
265+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
266+
267+ payload = {
268+ "items" : [
269+ {"password" : "secret" , "name" : "item1" },
270+ {"token" : "abc" , "name" : "item2" },
271+ ],
272+ "count" : 2 ,
273+ }
274+
275+ result = service ._mask_sensitive_keys (payload )
276+ self .assertEqual (result ["items" ][0 ]["password" ], "***MASKED***" )
277+ self .assertEqual (result ["items" ][0 ]["name" ], "item1" )
278+ self .assertEqual (result ["items" ][1 ]["token" ], "***MASKED***" )
279+ self .assertEqual (result ["items" ][1 ]["name" ], "item2" )
280+ self .assertEqual (result ["count" ], 2 )
281+
282+ def test_mask_sensitive_keys_none_input (self ):
283+ """_mask_sensitive_keys returns None for None input"""
284+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
285+
286+ self .assertIsNone (service ._mask_sensitive_keys (None ))
287+
288+ def test_mask_sensitive_keys_does_not_mutate_original (self ):
289+ """_mask_sensitive_keys returns a new dict without mutating the input"""
290+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
291+
292+ payload = {"password" : "secret" , "data" : "visible" }
293+ result = service ._mask_sensitive_keys (payload )
294+
295+ self .assertEqual (payload ["password" ], "secret" )
296+ self .assertEqual (result ["password" ], "***MASKED***" )
297+
298+ def test_log_call_masks_sensitive_keys_in_payloads (self ):
299+ """log_call applies masking to request and response payloads"""
300+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
301+
302+ result = service .log_call (
303+ url = "https://example.org/api/test" ,
304+ request_summary = {"authorization" : "Bearer xyz" , "action" : "search" },
305+ response_summary = {"token" : "resp-token" , "status" : "ok" },
306+ )
307+
308+ self .assertTrue (result )
309+ self .assertEqual (result .request_summary ["authorization" ], "***MASKED***" )
310+ self .assertEqual (result .request_summary ["action" ], "search" )
311+ self .assertEqual (result .response_summary ["token" ], "***MASKED***" )
312+ self .assertEqual (result .response_summary ["status" ], "ok" )
313+
314+ def test_sanitize_url_no_sensitive_params (self ):
315+ """_sanitize_url leaves URLs without sensitive params unchanged"""
316+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
317+
318+ url = "https://example.org/api/search?q=hello&limit=10"
319+ result = service ._sanitize_url (url )
320+ self .assertIn ("q=hello" , result )
321+ self .assertIn ("limit=10" , result )
322+
323+ def test_sanitize_url_masks_sensitive_params (self ):
324+ """_sanitize_url replaces sensitive query parameter values with MASKED"""
325+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
326+
327+ url = "https://example.org/api/search?api_key=secret123&q=hello"
328+ result = service ._sanitize_url (url )
329+ self .assertNotIn ("secret123" , result )
330+ self .assertIn ("***MASKED***" , result )
331+ self .assertIn ("q=hello" , result )
332+
333+ def test_sanitize_url_masks_multiple_sensitive_params (self ):
334+ """_sanitize_url masks all sensitive params in a single URL"""
335+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
336+
337+ url = "https://example.org/api?token=abc&api_key=xyz&page=1"
338+ result = service ._sanitize_url (url )
339+ self .assertNotIn ("abc" , result )
340+ self .assertNotIn ("xyz" , result )
341+ self .assertIn ("page=1" , result )
342+
343+ def test_sanitize_url_case_insensitive_params (self ):
344+ """_sanitize_url matches sensitive param names case-insensitively"""
345+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
346+
347+ url = "https://example.org/api?API_KEY=secret&Access_Token=tok"
348+ result = service ._sanitize_url (url )
349+ self .assertNotIn ("secret" , result )
350+ self .assertNotIn ("tok" , result )
351+
352+ def test_sanitize_url_no_query_string (self ):
353+ """_sanitize_url returns URL unchanged when there is no query string"""
354+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
355+
356+ url = "https://example.org/api/search"
357+ result = service ._sanitize_url (url )
358+ self .assertEqual (result , url )
359+
360+ def test_sanitize_url_none_input (self ):
361+ """_sanitize_url returns None for None input"""
362+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
363+
364+ self .assertIsNone (service ._sanitize_url (None ))
365+
366+ def test_log_call_sanitizes_url (self ):
367+ """log_call strips sensitive query parameters from URL before storing"""
368+ service = OutgoingApiLogService (self .env , service_name = "Test" , service_code = "test" )
369+
370+ result = service .log_call (
371+ url = "https://example.org/api/search?api_key=supersecret&q=hello" ,
372+ )
373+
374+ self .assertTrue (result )
375+ self .assertNotIn ("supersecret" , result .url )
376+ self .assertIn ("***MASKED***" , result .url )
377+ self .assertIn ("q=hello" , result .url )
0 commit comments