Approval-based snapshot testing library for Go (mirror)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip: feat: ignore patterns and scrubber

+1748 -411
+130
README.md
··· 14 14 15 15 ## Usage 16 16 17 + ### Basic Usage 18 + 17 19 ```go 18 20 package package_test 19 21 ··· 22 24 freeze.Snap(t, result) 23 25 } 24 26 ``` 27 + 28 + ### Advanced Usage: Scrubbers and Ignore Patterns 29 + 30 + Freeze supports data scrubbing and field filtering to handle dynamic or sensitive data in snapshots. 31 + 32 + #### Scrubbers 33 + 34 + Scrubbers transform content before snapshotting, typically to replace dynamic or sensitive data with placeholders: 35 + 36 + ```go 37 + func TestUserAPI(t *testing.T) { 38 + user := api.GetUser("123") 39 + 40 + // Replace UUIDs and timestamps with placeholders 41 + freeze.SnapWithOptions(t, "user", []freeze.SnapshotOption{ 42 + freeze.ScrubUUIDs(), 43 + freeze.ScrubTimestamps(), 44 + }, user) 45 + } 46 + ``` 47 + 48 + **Built-in Scrubbers:** 49 + - `ScrubUUIDs()` - Replaces UUIDs with `<UUID>` 50 + - `ScrubTimestamps()` - Replaces ISO8601 timestamps with `<TIMESTAMP>` 51 + - `ScrubEmails()` - Replaces email addresses with `<EMAIL>` 52 + - `ScrubIPAddresses()` - Replaces IPv4 addresses with `<IP>` 53 + - `ScrubJWTs()` - Replaces JWT tokens with `<JWT>` 54 + - `ScrubCreditCards()` - Replaces credit card numbers with `<CREDIT_CARD>` 55 + - `ScrubAPIKeys()` - Replaces API keys with `<API_KEY>` 56 + - `ScrubDates()` - Replaces various date formats with `<DATE>` 57 + - `ScrubUnixTimestamps()` - Replaces Unix timestamps with `<UNIX_TS>` 58 + 59 + **Custom Scrubbers:** 60 + 61 + ```go 62 + // Using regex patterns 63 + freeze.RegexScrubber(`user-\d+`, "<USER_ID>") 64 + 65 + // Using exact string matching 66 + freeze.ExactMatchScrubber("secret_value", "<REDACTED>") 67 + 68 + // Using custom functions 69 + freeze.CustomScrubber(func(content string) string { 70 + return strings.ReplaceAll(content, "localhost", "<HOST>") 71 + }) 72 + ``` 73 + 74 + #### Ignore Patterns 75 + 76 + Ignore patterns remove specific fields from JSON structures before snapshotting: 77 + 78 + ```go 79 + func TestAPIResponse(t *testing.T) { 80 + response := api.GetData() 81 + 82 + // Ignore sensitive fields and null values 83 + freeze.SnapJSONWithOptions(t, "response", response, []freeze.SnapshotOption{ 84 + freeze.IgnoreSensitiveKeys(), 85 + freeze.IgnoreNullValues(), 86 + freeze.IgnoreKeys("created_at", "updated_at"), 87 + }) 88 + } 89 + ``` 90 + 91 + **Built-in Ignore Patterns:** 92 + - `IgnoreSensitiveKeys()` - Ignores common sensitive keys (password, token, api_key, etc.) 93 + - `IgnoreEmptyValues()` - Ignores fields with empty string values 94 + - `IgnoreNullValues()` - Ignores fields with null values 95 + 96 + **Custom Ignore Patterns:** 97 + 98 + ```go 99 + // Ignore specific keys 100 + freeze.IgnoreKeys("id", "timestamp", "version") 101 + 102 + // Ignore key-value pairs 103 + freeze.IgnoreKeyValue("status", "pending") 104 + 105 + // Ignore keys matching a regex pattern 106 + freeze.IgnoreKeysMatching(`^_.*`) // Ignore all keys starting with underscore 107 + 108 + // Ignore specific values 109 + freeze.IgnoreValues("null", "undefined", "") 110 + 111 + // Using custom functions 112 + freeze.CustomIgnore(func(key, value string) bool { 113 + return strings.HasPrefix(key, "temp_") 114 + }) 115 + ``` 116 + 117 + #### Combining Options 118 + 119 + You can combine multiple scrubbers and ignore patterns: 120 + 121 + ```go 122 + func TestComplexData(t *testing.T) { 123 + data := generateTestData() 124 + 125 + freeze.SnapWithOptions(t, "data", []freeze.SnapshotOption{ 126 + // Scrubbers 127 + freeze.ScrubUUIDs(), 128 + freeze.ScrubTimestamps(), 129 + freeze.ScrubEmails(), 130 + 131 + // Ignore patterns 132 + freeze.IgnoreSensitiveKeys(), 133 + freeze.IgnoreKeys("debug_info"), 134 + freeze.IgnoreNullValues(), 135 + }, data) 136 + } 137 + ``` 138 + 139 + #### API Reference 140 + 141 + Three snapshot functions support options: 142 + 143 + ```go 144 + // For general values (structs, maps, slices, etc.) 145 + freeze.SnapWithOptions(t, "title", []freeze.SnapshotOption{...}, value) 146 + 147 + // For JSON strings 148 + freeze.SnapJSONWithOptions(t, "title", jsonString, []freeze.SnapshotOption{...}) 149 + 150 + // For plain strings 151 + freeze.SnapStringWithOptions(t, "title", content, []freeze.SnapshotOption{...}) 152 + ``` 153 + 154 + ### Reviewing Snapshots 25 155 26 156 To review a set of snapshots, run: 27 157
+14
__snapshots__/test_combined_ignore_and_scrub.snap
··· 1 + --- 2 + title: Combined Ignore and Scrub 3 + test_name: TestCombinedIgnoreAndScrub 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "created_at": "<TIMESTAMP>", 10 + "email": "<EMAIL>", 11 + "ip_address": "<IP>", 12 + "name": "John Doe", 13 + "user_id": "<UUID>" 14 + }
+29
__snapshots__/test_complex_real_world_example.snap
··· 1 + --- 2 + title: Real World API Response 3 + test_name: TestComplexRealWorldExample 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "metadata": { 10 + "server_ip": "<IP>", 11 + "session_token": "<JWT>", 12 + "user_agent": "Mozilla/5.0" 13 + }, 14 + "request_id": "<UUID>", 15 + "timestamp": "<TIMESTAMP>", 16 + "transaction": { 17 + "amount": 99.99, 18 + "currency": "USD", 19 + "id": "txn_abc123", 20 + "timestamp": "<TIMESTAMP>" 21 + }, 22 + "user": { 23 + "created_at": "<TIMESTAMP>", 24 + "email": "<EMAIL>", 25 + "id": "<UUID>", 26 + "ip_address": "<IP>", 27 + "name": "John Doe" 28 + } 29 + }
+13
__snapshots__/test_credit_card_scrubbing.snap
··· 1 + --- 2 + title: Scrubbed Credit Cards 3 + test_name: TestCreditCardScrubbing 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "another_card": "<CREDIT_CARD>", 10 + "backup_card": "<CREDIT_CARD>", 11 + "card_number": "<CREDIT_CARD>", 12 + "name": "John Doe" 13 + }
+11
__snapshots__/test_custom_ignore.snap
··· 1 + --- 2 + title: Custom Ignore Function 3 + test_name: TestCustomIgnore 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "grade": "A", 10 + "name": "John Doe" 11 + }
+8
__snapshots__/test_custom_scrubber.snap
··· 1 + --- 2 + title: Custom Scrubber 3 + test_name: TestCustomScrubber 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + hello world! this is a test.
+8
__snapshots__/test_exact_match_scrubber.snap
··· 1 + --- 2 + title: Exact Match Scrubber 3 + test_name: TestExactMatchScrubber 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + The secret password is '<PASSWORD>' and should be hidden.
+11
__snapshots__/test_ignore_empty_values.snap
··· 1 + --- 2 + title: Ignore Empty Values 3 + test_name: TestIgnoreEmptyValues 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "email": "john@example.com", 10 + "name": "John Doe" 11 + }
+21
__snapshots__/test_ignore_in_arrays.snap
··· 1 + --- 2 + title: Ignore in Arrays 3 + test_name: TestIgnoreInArrays 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "users": [ 10 + { 11 + "email": "alice@example.com", 12 + "id": 1, 13 + "name": "Alice" 14 + }, 15 + { 16 + "email": "bob@example.com", 17 + "id": 2, 18 + "name": "Bob" 19 + } 20 + ] 21 + }
+11
__snapshots__/test_ignore_key_pattern.snap
··· 1 + --- 2 + title: Ignore Key Pattern 3 + test_name: TestIgnoreKeyPattern 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "email": "john@example.com", 10 + "username": "john_doe" 11 + }
+11
__snapshots__/test_ignore_key_value.snap
··· 1 + --- 2 + title: Ignore Password Field 3 + test_name: TestIgnoreKeyValue 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "email": "john@example.com", 10 + "username": "john_doe" 11 + }
+12
__snapshots__/test_ignore_keys.snap
··· 1 + --- 2 + title: Ignore Multiple Keys 3 + test_name: TestIgnoreKeys 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "email": "john@example.com", 10 + "id": 1, 11 + "name": "John Doe" 12 + }
+11
__snapshots__/test_ignore_keys_matching.snap
··· 1 + --- 2 + title: Ignore Keys Matching Pattern 3 + test_name: TestIgnoreKeysMatching 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "product_id": 100, 10 + "product_name": "Widget" 11 + }
+12
__snapshots__/test_ignore_null_values.snap
··· 1 + --- 2 + title: Ignore Null Values 3 + test_name: TestIgnoreNullValues 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "age": 30, 10 + "email": "john@example.com", 11 + "name": "John Doe" 12 + }
+12
__snapshots__/test_ignore_sensitive_keys.snap
··· 1 + --- 2 + title: Ignore Sensitive Keys 3 + test_name: TestIgnoreSensitiveKeys 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "email": "john@example.com", 10 + "name": "John Doe", 11 + "username": "john_doe" 12 + }
+10
__snapshots__/test_ignore_values.snap
··· 1 + --- 2 + title: Ignore Specific Values 3 + test_name: TestIgnoreValues 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "message": "Processing" 10 + }
+14
__snapshots__/test_multiple_scrubbers.snap
··· 1 + --- 2 + title: Multiple Scrubbers 3 + test_name: TestMultipleScrubbers 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "created_at": "<TIMESTAMP>", 10 + "email": "<EMAIL>", 11 + "ip_address": "<IP>", 12 + "name": "John Doe", 13 + "user_id": "<UUID>" 14 + }
+19
__snapshots__/test_nested_ignore_patterns.snap
··· 1 + --- 2 + title: Nested Ignore Patterns 3 + test_name: TestNestedIgnorePatterns 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "admin": {}, 10 + "user": { 11 + "email": "john@example.com", 12 + "id": 1, 13 + "name": "John Doe", 14 + "profile": { 15 + "bio": "Developer", 16 + "website": "https://example.com" 17 + } 18 + } 19 + }
+12
__snapshots__/test_regex_scrubber.snap
··· 1 + --- 2 + title: Custom Regex Scrubber 3 + test_name: TestRegexScrubber 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "api_key": "<API_KEY>", 10 + "name": "Test User", 11 + "secret_key": "<API_KEY>" 12 + }
+13
__snapshots__/test_scrub_a_p_i_keys.snap
··· 1 + --- 2 + title: Scrubbed API Keys 3 + test_name: TestScrubAPIKeys 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "api_key_prod": "<API_KEY>", 10 + "name": "Test Config", 11 + "stripe_key": "<API_KEY>", 12 + "test_key": "<API_KEY>" 13 + }
+13
__snapshots__/test_scrub_dates.snap
··· 1 + --- 2 + title: Scrubbed Dates 3 + test_name: TestScrubDates 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "birth_date": "<DATE>", 10 + "hire_date": "<DATE>", 11 + "name": "John Doe", 12 + "us_format": "<DATE>" 13 + }
+12
__snapshots__/test_scrub_emails.snap
··· 1 + --- 2 + title: Scrubbed Emails 3 + test_name: TestScrubEmails 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "backup_email": "<EMAIL>", 10 + "email": "<EMAIL>", 11 + "name": "John Doe" 12 + }
+12
__snapshots__/test_scrub_i_p_addresses.snap
··· 1 + --- 2 + title: Scrubbed IPs 3 + test_name: TestScrubIPAddresses 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "client_ip": "<IP>", 10 + "message": "Connection from <IP>", 11 + "server_ip": "<IP>" 12 + }
+11
__snapshots__/test_scrub_j_w_ts.snap
··· 1 + --- 2 + title: Scrubbed JWTs 3 + test_name: TestScrubJWTs 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "refresh_token": "<JWT>", 10 + "token": "<JWT>" 11 + }
+13
__snapshots__/test_scrub_timestamps.snap
··· 1 + --- 2 + title: Scrubbed Timestamps 3 + test_name: TestScrubTimestamps 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "created_at": "<TIMESTAMP>", 10 + "deleted_at": "<TIMESTAMP>", 11 + "name": "Test Event", 12 + "updated_at": "<TIMESTAMP>" 13 + }
+12
__snapshots__/test_scrub_u_u_i_ds.snap
··· 1 + --- 2 + title: Scrubbed UUIDs 3 + test_name: TestScrubUUIDs 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "name": "John Doe", 10 + "session_id": "<UUID>", 11 + "user_id": "<UUID>" 12 + }
+13
__snapshots__/test_scrub_with_snap_function.snap
··· 1 + --- 2 + title: Scrub With Snap 3 + test_name: TestScrubWithSnapFunction 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + map[string]interface{}{ 9 + "created_at": "<TIMESTAMP>", 10 + "email": "<EMAIL>", 11 + "name": "John Doe", 12 + "user_id": "<UUID>", 13 + }
+22 -22
__snapshots__/test_snap_json_array_of_objects.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 [ 9 - { 10 - "id": 1, 11 - "likes": 42, 12 - "title": "First Post", 13 - "type": "post", 14 - "views": 150 15 - }, 16 - { 17 - "id": 2, 18 - "likes": 75, 19 - "title": "Second Post", 20 - "type": "post", 21 - "views": 280 22 - }, 23 - { 24 - "id": 3, 25 - "likes": 120, 26 - "title": "Third Post", 27 - "type": "post", 28 - "views": 450 29 - } 30 - ] 9 + { 10 + "id": 1, 11 + "type": "post", 12 + "title": "First Post", 13 + "views": 150, 14 + "likes": 42 15 + }, 16 + { 17 + "id": 2, 18 + "type": "post", 19 + "title": "Second Post", 20 + "views": 280, 21 + "likes": 75 22 + }, 23 + { 24 + "id": 3, 25 + "type": "post", 26 + "title": "Third Post", 27 + "views": 450, 28 + "likes": 120 29 + } 30 + ]
+5 -5
__snapshots__/test_snap_json_basic.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "age": 30, 10 - "email": "john@example.com", 11 - "name": "John Doe", 12 - "verified": true 13 - } 9 + "name": "John Doe", 10 + "email": "john@example.com", 11 + "age": 30, 12 + "verified": true 13 + }
+1 -10
__snapshots__/test_snap_json_compact_format.snap
··· 5 5 func_name: 6 6 version: 0.1.0 7 7 --- 8 - { 9 - "id": 1, 10 - "in_stock": true, 11 - "name": "Product", 12 - "price": 99.99, 13 - "tags": [ 14 - "electronics", 15 - "gadgets" 16 - ] 17 - } 8 + {"id":1,"name":"Product","price":99.99,"in_stock":true,"tags":["electronics","gadgets"]}
+35 -35
__snapshots__/test_snap_json_complex_a_p_i.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "code": 200, 10 - "data": { 11 - "pagination": { 12 - "page": 1, 13 - "per_page": 10, 14 - "total": 3, 15 - "total_pages": 1 16 - }, 17 - "users": [ 18 - { 19 - "active": true, 20 - "department": "Engineering", 21 - "id": 1, 22 - "name": "Alice", 23 - "role": "admin" 24 - }, 25 - { 26 - "active": true, 27 - "department": "Sales", 28 - "id": 2, 29 - "name": "Bob", 30 - "role": "user" 31 - }, 32 - { 33 - "active": false, 34 - "department": "Marketing", 35 - "id": 3, 36 - "name": "Charlie", 37 - "role": "user" 38 - } 39 - ] 40 - }, 41 - "status": "success", 42 - "timestamp": "2023-11-18T21:45:30Z" 43 - } 9 + "status": "success", 10 + "code": 200, 11 + "data": { 12 + "users": [ 13 + { 14 + "id": 1, 15 + "name": "Alice", 16 + "role": "admin", 17 + "department": "Engineering", 18 + "active": true 19 + }, 20 + { 21 + "id": 2, 22 + "name": "Bob", 23 + "role": "user", 24 + "department": "Sales", 25 + "active": true 26 + }, 27 + { 28 + "id": 3, 29 + "name": "Charlie", 30 + "role": "user", 31 + "department": "Marketing", 32 + "active": false 33 + } 34 + ], 35 + "pagination": { 36 + "page": 1, 37 + "per_page": 10, 38 + "total": 3, 39 + "total_pages": 1 40 + } 41 + }, 42 + "timestamp": "2023-11-18T21:45:30Z" 43 + }
+11 -11
__snapshots__/test_snap_json_empty_structures.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "empty_array": [], 10 - "empty_object": {}, 11 - "empty_string": "", 12 - "false_value": false, 13 - "nested": { 14 - "also_empty": {}, 15 - "empty": [] 16 - }, 17 - "null_value": null, 18 - "zero": 0 19 - } 9 + "empty_array": [], 10 + "empty_object": {}, 11 + "empty_string": "", 12 + "zero": 0, 13 + "false_value": false, 14 + "null_value": null, 15 + "nested": { 16 + "empty": [], 17 + "also_empty": {} 18 + } 19 + }
+56 -96
__snapshots__/test_snap_json_large_nested_structure.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "organization": { 10 - "departments": [ 11 - { 12 - "manager": "Alice", 13 - "name": "Engineering", 14 - "teams": [ 15 - { 16 - "lead": "John", 17 - "members": [ 18 - { 19 - "id": 1, 20 - "level": "senior", 21 - "name": "John" 22 - }, 23 - { 24 - "id": 2, 25 - "level": "mid", 26 - "name": "Jane" 27 - } 28 - ], 29 - "name": "Backend", 30 - "projects": [ 31 - { 32 - "id": "proj_1", 33 - "name": "API Service", 34 - "status": "active" 35 - }, 36 - { 37 - "id": "proj_2", 38 - "name": "Database Optimization", 39 - "status": "planning" 40 - } 41 - ] 42 - }, 43 - { 44 - "lead": "Bob", 45 - "members": [ 46 - { 47 - "id": 3, 48 - "level": "senior", 49 - "name": "Bob" 50 - }, 51 - { 52 - "id": 4, 53 - "level": "junior", 54 - "name": "Carol" 55 - } 56 - ], 57 - "name": "Frontend", 58 - "projects": [ 59 - { 60 - "id": "proj_3", 61 - "name": "Web App", 62 - "status": "active" 63 - } 64 - ] 65 - } 66 - ] 67 - }, 68 - { 69 - "manager": "Charlie", 70 - "name": "Sales", 71 - "teams": [ 72 - { 73 - "lead": "Dave", 74 - "members": [ 75 - { 76 - "id": 5, 77 - "level": "senior", 78 - "name": "Dave" 79 - }, 80 - { 81 - "id": 6, 82 - "level": "mid", 83 - "name": "Eve" 84 - } 85 - ], 86 - "name": "Enterprise", 87 - "projects": [] 88 - } 89 - ] 90 - } 91 - ], 92 - "id": "org_123", 93 - "metadata": { 94 - "employees": 150, 95 - "founded": "2020", 96 - "locations": [ 97 - "USA", 98 - "EU", 99 - "APAC" 100 - ] 101 - }, 102 - "name": "TechCorp" 103 - } 104 - } 9 + "organization": { 10 + "name": "TechCorp", 11 + "id": "org_123", 12 + "departments": [ 13 + { 14 + "name": "Engineering", 15 + "manager": "Alice", 16 + "teams": [ 17 + { 18 + "name": "Backend", 19 + "lead": "John", 20 + "members": [ 21 + {"id": 1, "name": "John", "level": "senior"}, 22 + {"id": 2, "name": "Jane", "level": "mid"} 23 + ], 24 + "projects": [ 25 + {"id": "proj_1", "name": "API Service", "status": "active"}, 26 + {"id": "proj_2", "name": "Database Optimization", "status": "planning"} 27 + ] 28 + }, 29 + { 30 + "name": "Frontend", 31 + "lead": "Bob", 32 + "members": [ 33 + {"id": 3, "name": "Bob", "level": "senior"}, 34 + {"id": 4, "name": "Carol", "level": "junior"} 35 + ], 36 + "projects": [ 37 + {"id": "proj_3", "name": "Web App", "status": "active"} 38 + ] 39 + } 40 + ] 41 + }, 42 + { 43 + "name": "Sales", 44 + "manager": "Charlie", 45 + "teams": [ 46 + { 47 + "name": "Enterprise", 48 + "lead": "Dave", 49 + "members": [ 50 + {"id": 5, "name": "Dave", "level": "senior"}, 51 + {"id": 6, "name": "Eve", "level": "mid"} 52 + ], 53 + "projects": [] 54 + } 55 + ] 56 + } 57 + ], 58 + "metadata": { 59 + "founded": "2020", 60 + "employees": 150, 61 + "locations": ["USA", "EU", "APAC"] 62 + } 63 + } 64 + }
+18 -34
__snapshots__/test_snap_json_mixed_types.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "complex": [ 10 - { 11 - "id": 1, 12 - "type": "user" 13 - }, 14 - { 15 - "id": 100, 16 - "type": "post" 17 - }, 18 - [ 19 - 1, 20 - 2, 21 - 3 22 - ], 23 - "string", 24 - null 25 - ], 26 - "mixed_array": [ 27 - "string", 28 - 123, 29 - 45.67, 30 - true, 31 - false, 32 - null, 33 - { 34 - "nested": "object" 35 - }, 36 - [ 37 - 1, 38 - 2, 39 - 3 40 - ] 41 - ] 42 - } 9 + "mixed_array": [ 10 + "string", 11 + 123, 12 + 45.67, 13 + true, 14 + false, 15 + null, 16 + {"nested": "object"}, 17 + [1, 2, 3] 18 + ], 19 + "complex": [ 20 + {"type": "user", "id": 1}, 21 + {"type": "post", "id": 100}, 22 + [1, 2, 3], 23 + "string", 24 + null 25 + ] 26 + }
+73 -77
__snapshots__/test_snap_json_real_world_example.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "data": { 10 - "product": { 11 - "description": "High-quality wireless headphones with noise cancellation", 12 - "id": "prod_12345", 13 - "inventory": { 14 - "available": 425, 15 - "damaged": 25, 16 - "reserved": 50, 17 - "total": 500 18 - }, 19 - "name": "Premium Wireless Headphones", 20 - "price": { 21 - "amount": 199.99, 22 - "currency": "USD", 23 - "discount": 10, 24 - "final_price": 179.99 25 - }, 26 - "ratings": { 27 - "average": 4.5, 28 - "breakdown": { 29 - "1": 20, 30 - "2": 30, 31 - "3": 100, 32 - "4": 350, 33 - "5": 750 34 - }, 35 - "count": 1250 36 - }, 37 - "reviews": [ 38 - { 39 - "content": "Great sound quality and comfortable to wear.", 40 - "created_at": "2023-11-15T10:30:00Z", 41 - "helpful": 25, 42 - "id": "rev_001", 43 - "rating": 5, 44 - "title": "Excellent product!", 45 - "user": "john_doe" 46 - }, 47 - { 48 - "content": "Works well, could be cheaper.", 49 - "created_at": "2023-11-10T14:20:00Z", 50 - "helpful": 12, 51 - "id": "rev_002", 52 - "rating": 4, 53 - "title": "Good but pricey", 54 - "user": "jane_smith" 55 - } 56 - ], 57 - "sku": "PWH-001", 58 - "specifications": { 59 - "battery_life": "30 hours", 60 - "colors": [ 61 - "black", 62 - "white", 63 - "blue" 64 - ], 65 - "warranty_months": 24, 66 - "weight": "250g" 67 - } 68 - }, 69 - "related_products": [ 70 - { 71 - "id": "prod_12346", 72 - "name": "Headphone Case", 73 - "price": 29.99 74 - }, 75 - { 76 - "id": "prod_12347", 77 - "name": "Audio Cable", 78 - "price": 14.99 79 - } 80 - ] 81 - }, 82 - "request_id": "req_abc123def456", 83 - "success": true, 84 - "timestamp": "2023-11-18T22:00:00Z" 85 - } 9 + "success": true, 10 + "data": { 11 + "product": { 12 + "id": "prod_12345", 13 + "name": "Premium Wireless Headphones", 14 + "sku": "PWH-001", 15 + "description": "High-quality wireless headphones with noise cancellation", 16 + "price": { 17 + "amount": 199.99, 18 + "currency": "USD", 19 + "discount": 10, 20 + "final_price": 179.99 21 + }, 22 + "inventory": { 23 + "total": 500, 24 + "available": 425, 25 + "reserved": 50, 26 + "damaged": 25 27 + }, 28 + "specifications": { 29 + "battery_life": "30 hours", 30 + "weight": "250g", 31 + "colors": ["black", "white", "blue"], 32 + "warranty_months": 24 33 + }, 34 + "ratings": { 35 + "average": 4.5, 36 + "count": 1250, 37 + "breakdown": { 38 + "5": 750, 39 + "4": 350, 40 + "3": 100, 41 + "2": 30, 42 + "1": 20 43 + } 44 + }, 45 + "reviews": [ 46 + { 47 + "id": "rev_001", 48 + "user": "john_doe", 49 + "rating": 5, 50 + "title": "Excellent product!", 51 + "content": "Great sound quality and comfortable to wear.", 52 + "helpful": 25, 53 + "created_at": "2023-11-15T10:30:00Z" 54 + }, 55 + { 56 + "id": "rev_002", 57 + "user": "jane_smith", 58 + "rating": 4, 59 + "title": "Good but pricey", 60 + "content": "Works well, could be cheaper.", 61 + "helpful": 12, 62 + "created_at": "2023-11-10T14:20:00Z" 63 + } 64 + ] 65 + }, 66 + "related_products": [ 67 + { 68 + "id": "prod_12346", 69 + "name": "Headphone Case", 70 + "price": 29.99 71 + }, 72 + { 73 + "id": "prod_12347", 74 + "name": "Audio Cable", 75 + "price": 14.99 76 + } 77 + ] 78 + }, 79 + "request_id": "req_abc123def456", 80 + "timestamp": "2023-11-18T22:00:00Z" 81 + }
+5 -5
__snapshots__/test_snap_json_simple_array.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 [ 9 - "apple", 10 - "banana", 11 - "orange", 12 - "grape" 13 - ] 9 + "apple", 10 + "banana", 11 + "orange", 12 + "grape" 13 + ]
+15 -19
__snapshots__/test_snap_json_with_nested_objects.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "created_at": "2023-06-15T10:30:00Z", 10 - "user": { 11 - "id": 42, 12 - "permissions": [ 13 - "read", 14 - "write", 15 - "admin" 16 - ], 17 - "profile": { 18 - "avatar": "https://example.com/avatar.jpg", 19 - "settings": { 20 - "language": "en", 21 - "notifications": true, 22 - "theme": "dark" 23 - }, 24 - "username": "jane_smith" 25 - } 26 - } 27 - } 9 + "user": { 10 + "id": 42, 11 + "profile": { 12 + "username": "jane_smith", 13 + "avatar": "https://example.com/avatar.jpg", 14 + "settings": { 15 + "theme": "dark", 16 + "notifications": true, 17 + "language": "en" 18 + } 19 + }, 20 + "permissions": ["read", "write", "admin"] 21 + }, 22 + "created_at": "2023-06-15T10:30:00Z" 23 + }
+11 -11
__snapshots__/test_snap_json_with_nulls.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "category": null, 10 - "description": null, 11 - "id": 1, 12 - "metadata": { 13 - "created": "2023-01-01", 14 - "deleted": null, 15 - "updated": null 16 - }, 17 - "name": "Item", 18 - "tags": null 19 - } 9 + "id": 1, 10 + "name": "Item", 11 + "description": null, 12 + "category": null, 13 + "tags": null, 14 + "metadata": { 15 + "created": "2023-01-01", 16 + "updated": null, 17 + "deleted": null 18 + } 19 + }
+13 -27
__snapshots__/test_snap_json_with_numbers.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "financial": { 10 - "expenses": 750000.75, 11 - "profit_margin": 0.2499, 12 - "revenue": 1000000.5 13 - }, 14 - "floats": [ 15 - 0, 16 - 3.14, 17 - -2.5, 18 - 0.001, 19 - 0.000123, 20 - 56700000000 21 - ], 22 - "integers": [ 23 - 0, 24 - 1, 25 - -1, 26 - 42, 27 - -100, 28 - 9999999 29 - ], 30 - "measurements": { 31 - "distance": 1000.25, 32 - "temperature": -40.5, 33 - "weight": 0.5 34 - } 35 - } 9 + "integers": [0, 1, -1, 42, -100, 9999999], 10 + "floats": [0.0, 3.14, -2.5, 0.001, 1.23e-4, 5.67e10], 11 + "financial": { 12 + "revenue": 1000000.50, 13 + "expenses": 750000.75, 14 + "profit_margin": 0.2499 15 + }, 16 + "measurements": { 17 + "temperature": -40.5, 18 + "distance": 1000.25, 19 + "weight": 0.5 20 + } 21 + }
+8 -8
__snapshots__/test_snap_json_with_special_characters.snap
··· 6 6 version: 0.1.0 7 7 --- 8 8 { 9 - "escaped": "line1\nline2\ttab\rcarriage", 10 - "html": "\u003cdiv class=\"container\"\u003eContent\u003c/div\u003e", 11 - "paths": "C:\\Users\\name\\Documents\\file.txt", 12 - "quotes": "He said \"hello\" and she said 'goodbye'", 13 - "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$", 14 - "special": "!@#$%^\u0026*()_+-=[]{}|;:',.\u003c\u003e?/", 15 - "unicode": "Hello 世界 🌍 مرحبا Привет" 16 - } 9 + "special": "!@#$%^&*()_+-=[]{}|;:',.<>?/", 10 + "escaped": "line1\nline2\ttab\rcarriage", 11 + "quotes": "He said \"hello\" and she said 'goodbye'", 12 + "unicode": "Hello 世界 🌍 مرحبا Привет", 13 + "paths": "C:\\Users\\name\\Documents\\file.txt", 14 + "html": "<div class=\"container\">Content</div>", 15 + "regex": "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" 16 + }
+13
__snapshots__/test_unix_timestamp_scrubbing.snap
··· 1 + --- 2 + title: Scrubbed Unix Timestamps 3 + test_name: TestUnixTimestampScrubbing 4 + file_path: 5 + func_name: 6 + version: 0.1.0 7 + --- 8 + { 9 + "created": <UNIX_TS>, 10 + "deleted": <UNIX_TS>, 11 + "name": "Test Event", 12 + "updated": <UNIX_TS> 13 + }
+36
config.go
··· 1 + package freeze 2 + 3 + // SnapshotOption is a function that configures a SnapshotConfig. 4 + type SnapshotOption func(*SnapshotConfig) 5 + 6 + // SnapshotConfig holds configuration for snapshot scrubbing and filtering. 7 + type SnapshotConfig struct { 8 + Scrubbers []Scrubber 9 + Ignore []IgnorePattern 10 + } 11 + 12 + // newSnapshotConfig creates a new SnapshotConfig with the given options applied. 13 + func newSnapshotConfig(opts []SnapshotOption) *SnapshotConfig { 14 + config := &SnapshotConfig{ 15 + Scrubbers: []Scrubber{}, 16 + Ignore: []IgnorePattern{}, 17 + } 18 + for _, opt := range opts { 19 + opt(config) 20 + } 21 + return config 22 + } 23 + 24 + // WithScrubber adds a custom scrubber to the configuration. 25 + func WithScrubber(scrubber Scrubber) SnapshotOption { 26 + return func(c *SnapshotConfig) { 27 + c.Scrubbers = append(c.Scrubbers, scrubber) 28 + } 29 + } 30 + 31 + // WithIgnorePattern adds a custom ignore pattern to the configuration. 32 + func WithIgnorePattern(pattern IgnorePattern) SnapshotOption { 33 + return func(c *SnapshotConfig) { 34 + c.Ignore = append(c.Ignore, pattern) 35 + } 36 + }
+77 -11
freeze.go
··· 1 1 package freeze 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 5 7 6 "github.com/kortschak/utter" ··· 9 8 "github.com/ptdewey/freeze/internal/files" 10 9 "github.com/ptdewey/freeze/internal/pretty" 11 10 "github.com/ptdewey/freeze/internal/review" 11 + "github.com/ptdewey/freeze/internal/transform" 12 12 ) 13 13 14 14 const version = "0.1.0" ··· 21 21 22 22 func SnapString(t testingT, title string, content string) { 23 23 t.Helper() 24 - snap(t, title, content) 24 + SnapStringWithOptions(t, title, content, nil) 25 + } 26 + 27 + // SnapStringWithOptions takes a string and applies scrubbers before snapshotting. 28 + func SnapStringWithOptions(t testingT, title string, content string, opts []SnapshotOption) { 29 + t.Helper() 30 + config := newSnapshotConfig(opts) 31 + 32 + // Apply scrubbers to the content 33 + scrubbedContent := transform.ApplyScrubbers(content, adaptScrubbers(config.Scrubbers)) 34 + 35 + snap(t, title, scrubbedContent) 25 36 } 26 37 27 38 // SnapJSON takes a JSON string, validates it, and pretty-prints it with ··· 29 40 // format while ensuring valid JSON structure. 30 41 func SnapJSON(t testingT, title string, jsonStr string) { 31 42 t.Helper() 43 + SnapJSONWithOptions(t, title, jsonStr, nil) 44 + } 32 45 33 - var data interface{} 34 - if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 35 - t.Error("failed to unmarshal JSON:", err) 36 - return 46 + // SnapJSONWithOptions takes a JSON string and applies scrubbers and ignore patterns 47 + // before snapshotting. This allows filtering sensitive data and normalizing dynamic values. 48 + func SnapJSONWithOptions(t testingT, title string, jsonStr string, opts []SnapshotOption) { 49 + t.Helper() 50 + 51 + config := newSnapshotConfig(opts) 52 + 53 + // Transform the JSON with ignore patterns and scrubbers 54 + transformConfig := &transform.Config{ 55 + Scrubbers: adaptScrubbers(config.Scrubbers), 56 + Ignore: adaptIgnorePatterns(config.Ignore), 37 57 } 38 58 39 - // Pretty-print the JSON with consistent indentation 40 - prettyJSON, err := json.MarshalIndent(data, "", " ") 59 + transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig) 41 60 if err != nil { 42 - t.Error("failed to marshal JSON:", err) 61 + t.Error("failed to transform JSON:", err) 43 62 return 44 63 } 45 64 46 - snap(t, title, string(prettyJSON)) 65 + snap(t, title, transformedJSON) 47 66 } 48 67 49 68 func Snap(t testingT, title string, values ...any) { 50 69 t.Helper() 70 + SnapWithOptions(t, title, nil, values...) 71 + } 72 + 73 + // SnapWithOptions takes any values, formats them, and applies scrubbers before snapshotting. 74 + // For structured data (maps, slices, structs), scrubbers are applied to the formatted output. 75 + func SnapWithOptions(t testingT, title string, opts []SnapshotOption, values ...any) { 76 + t.Helper() 77 + config := newSnapshotConfig(opts) 78 + 51 79 content := formatValues(values...) 52 - snap(t, title, content) 80 + 81 + // Apply scrubbers to the formatted content 82 + scrubbedContent := transform.ApplyScrubbers(content, adaptScrubbers(config.Scrubbers)) 83 + 84 + snap(t, title, scrubbedContent) 53 85 } 54 86 55 87 func snap(t testingT, title string, content string) { ··· 142 174 Log(...any) 143 175 Cleanup(func()) 144 176 } 177 + 178 + // Adapter types to bridge freeze package types with transform package types 179 + 180 + type scrubberAdapter struct { 181 + scrubber Scrubber 182 + } 183 + 184 + func (s *scrubberAdapter) Scrub(content string) string { 185 + return s.scrubber.Scrub(content) 186 + } 187 + 188 + func adaptScrubbers(scrubbers []Scrubber) []transform.Scrubber { 189 + result := make([]transform.Scrubber, len(scrubbers)) 190 + for i, s := range scrubbers { 191 + result[i] = &scrubberAdapter{scrubber: s} 192 + } 193 + return result 194 + } 195 + 196 + type ignorePatternAdapter struct { 197 + pattern IgnorePattern 198 + } 199 + 200 + func (i *ignorePatternAdapter) ShouldIgnore(key, value string) bool { 201 + return i.pattern.ShouldIgnore(key, value) 202 + } 203 + 204 + func adaptIgnorePatterns(patterns []IgnorePattern) []transform.IgnorePattern { 205 + result := make([]transform.IgnorePattern, len(patterns)) 206 + for i, p := range patterns { 207 + result[i] = &ignorePatternAdapter{pattern: p} 208 + } 209 + return result 210 + }
+2 -40
freeze_test.go
··· 214 214 // COMPLEX GO STRUCTURES TESTS 215 215 // ============================================================================ 216 216 217 - // User represents a user in a system 218 217 type User struct { 219 218 ID int 220 219 Username string ··· 225 224 Metadata map[string]interface{} 226 225 } 227 226 228 - // Post represents a blog post 229 227 type Post struct { 230 228 ID int 231 229 Title string ··· 238 236 CreatedAt time.Time 239 237 } 240 238 241 - // Comment represents a comment on a post 242 239 type Comment struct { 243 240 ID int 244 241 Author string ··· 247 244 Replies []Comment 248 245 } 249 246 250 - // TestComplexNestedStructure tests snapshot with deeply nested Go structures 251 247 func TestComplexNestedStructure(t *testing.T) { 252 248 user := User{ 253 249 ID: 1, ··· 307 303 freeze.Snap(t, "Complex Nested Structure", post) 308 304 } 309 305 310 - // TestMultipleComplexStructures tests snapshot with multiple complex structures 311 306 func TestMultipleComplexStructures(t *testing.T) { 312 307 users := []User{ 313 308 { ··· 348 343 freeze.Snap(t, "Multiple Complex Structures", users) 349 344 } 350 345 351 - // TestStructureWithInterface tests structures containing interface{} fields 352 346 func TestStructureWithInterface(t *testing.T) { 353 347 type Response struct { 354 348 Status string ··· 402 396 freeze.Snap(t, "Structure with Interface Fields", responses) 403 397 } 404 398 405 - // TestNestedMapsAndSlices tests complex nested maps and slices 406 399 func TestNestedMapsAndSlices(t *testing.T) { 407 400 complexData := map[string]interface{}{ 408 401 "users": map[string]interface{}{ ··· 449 442 freeze.Snap(t, "Nested Maps and Slices", complexData) 450 443 } 451 444 452 - // TestStructureWithPointers tests structures with pointer fields 453 445 func TestStructureWithPointers(t *testing.T) { 454 446 type Address struct { 455 447 Street string ··· 492 484 freeze.Snap(t, "Structure with Pointers", person2) 493 485 } 494 486 495 - // TestStructureWithEmptyValues tests structures with empty slices, maps, nil values 496 487 func TestStructureWithEmptyValues(t *testing.T) { 497 488 type Container struct { 498 489 Items []string ··· 520 511 { 521 512 Items: []string{"a", "b", "c"}, 522 513 Tags: map[string]string{"type": "test", "env": "dev"}, 523 - OptionalID: intPtr(42), 514 + OptionalID: ptr(42), 524 515 Count: 3, 525 516 Active: true, 526 517 }, ··· 533 524 // JSON OBJECT TESTS 534 525 // ============================================================================ 535 526 536 - // TestJSONObject tests snapshot with JSON objects 537 527 func TestJsonObject(t *testing.T) { 538 528 jsonStr := `{ 539 529 "user": { ··· 561 551 freeze.Snap(t, "JSON Object", data) 562 552 } 563 553 564 - // TestComplexJSONStructure tests complex nested JSON structures 565 554 func TestComplexJsonStructure(t *testing.T) { 566 555 jsonStr := `{ 567 556 "api": { ··· 630 619 freeze.Snap(t, "Complex JSON Structure", data) 631 620 } 632 621 633 - // TestJSONArrayOfObjects tests JSON arrays with multiple object types 634 622 func TestJsonArrayOfObjects(t *testing.T) { 635 623 jsonStr := `[ 636 624 { ··· 669 657 freeze.Snap(t, "JSON Array of Objects", data) 670 658 } 671 659 672 - // TestJSONWithVariousTypes tests JSON with various data types 673 660 func TestJsonWithVariousTypes(t *testing.T) { 674 661 jsonStr := `{ 675 662 "string": "hello world", ··· 696 683 freeze.Snap(t, "JSON with Various Types", data) 697 684 } 698 685 699 - // TestJSONNumbers tests JSON with various number formats 700 686 func TestJsonNumbers(t *testing.T) { 701 687 jsonStr := `{ 702 688 "integers": { ··· 725 711 freeze.Snap(t, "JSON Numbers", data) 726 712 } 727 713 728 - // TestJSONWithSpecialCharacters tests JSON with special characters and unicode 729 714 func TestJsonWithSpecialCharacters(t *testing.T) { 730 715 jsonStr := `{ 731 716 "english": "Hello, World!", ··· 746 731 freeze.Snap(t, "JSON with Special Characters", data) 747 732 } 748 733 749 - // TestGoStructMarshalledToJSON tests Go struct marshalled to JSON 750 734 func TestGoStructMarshalledToJson(t *testing.T) { 751 735 type Address struct { 752 736 Street string `json:"street"` ··· 791 775 freeze.Snap(t, "Go Struct Marshalled to JSON", data) 792 776 } 793 777 794 - // TestDeeplyNestedJSON tests deeply nested JSON structure 795 778 func TestDeeplyNestedJson(t *testing.T) { 796 779 type Level4 struct { 797 780 Value string ··· 832 815 freeze.Snap(t, "Deeply Nested JSON", data) 833 816 } 834 817 835 - // TestLargeJSON tests larger JSON structure with many fields 836 818 func TestLargeJson(t *testing.T) { 837 819 type Product struct { 838 820 ID int `json:"id"` ··· 909 891 freeze.Snap(t, "Large JSON Structure", data) 910 892 } 911 893 912 - // TestJSONWithMixedArrays tests JSON with arrays containing different types 913 894 func TestJsonWithMixedArrays(t *testing.T) { 914 895 jsonStr := `{ 915 896 "heterogeneous_array": [ ··· 945 926 // SNAPJSON FUNCTION TESTS - Serialized JSON Strings 946 927 // ============================================================================ 947 928 948 - // TestSnapJSONBasic tests the SnapJSON function with basic JSON 949 929 func TestSnapJsonBasic(t *testing.T) { 950 930 jsonStr := `{ 951 931 "name": "John Doe", ··· 957 937 freeze.SnapJSON(t, "SnapJSON Basic Object", jsonStr) 958 938 } 959 939 960 - // TestSnapJSONSimpleArray tests SnapJSON with simple arrays 961 940 func TestSnapJsonSimpleArray(t *testing.T) { 962 941 jsonStr := `[ 963 942 "apple", ··· 969 948 freeze.SnapJSON(t, "SnapJSON Simple Array", jsonStr) 970 949 } 971 950 972 - // TestSnapJSONCompactFormat tests SnapJSON with compact (minified) JSON 973 951 func TestSnapJsonCompactFormat(t *testing.T) { 974 952 jsonStr := `{"id":1,"name":"Product","price":99.99,"in_stock":true,"tags":["electronics","gadgets"]}` 975 953 976 954 freeze.SnapJSON(t, "SnapJSON Compact Format", jsonStr) 977 955 } 978 956 979 - // TestSnapJSONWithNestedObjects tests SnapJSON with nested JSON structures 980 957 func TestSnapJsonWithNestedObjects(t *testing.T) { 981 958 jsonStr := `{ 982 959 "user": { ··· 998 975 freeze.SnapJSON(t, "SnapJSON Nested Objects", jsonStr) 999 976 } 1000 977 1001 - // TestSnapJSONComplexAPI tests SnapJSON with complex API response 1002 978 func TestSnapJsonComplexAPI(t *testing.T) { 1003 979 jsonStr := `{ 1004 980 "status": "success", ··· 1040 1016 freeze.SnapJSON(t, "SnapJSON Complex API Response", jsonStr) 1041 1017 } 1042 1018 1043 - // TestSnapJSONWithNulls tests SnapJSON handling of null values 1044 1019 func TestSnapJsonWithNulls(t *testing.T) { 1045 1020 jsonStr := `{ 1046 1021 "id": 1, ··· 1058 1033 freeze.SnapJSON(t, "SnapJSON With Nulls", jsonStr) 1059 1034 } 1060 1035 1061 - // TestSnapJSONArrayOfObjects tests SnapJSON with arrays of objects 1062 1036 func TestSnapJsonArrayOfObjects(t *testing.T) { 1063 1037 jsonStr := `[ 1064 1038 { ··· 1087 1061 freeze.SnapJSON(t, "SnapJSON Array of Objects", jsonStr) 1088 1062 } 1089 1063 1090 - // TestSnapJSONLargeNestedStructure tests SnapJSON with deeply nested JSON 1091 1064 func TestSnapJsonLargeNestedStructure(t *testing.T) { 1092 1065 jsonStr := `{ 1093 1066 "organization": { ··· 1150 1123 freeze.SnapJSON(t, "SnapJSON Large Nested Structure", jsonStr) 1151 1124 } 1152 1125 1153 - // TestSnapJSONWithNumbers tests SnapJSON with various number formats 1154 1126 func TestSnapJsonWithNumbers(t *testing.T) { 1155 1127 jsonStr := `{ 1156 1128 "integers": [0, 1, -1, 42, -100, 9999999], ··· 1170 1142 freeze.SnapJSON(t, "SnapJSON With Numbers", jsonStr) 1171 1143 } 1172 1144 1173 - // TestSnapJSONWithSpecialCharacters tests SnapJSON with special chars 1174 1145 func TestSnapJsonWithSpecialCharacters(t *testing.T) { 1175 1146 jsonStr := `{ 1176 1147 "special": "!@#$%^&*()_+-=[]{}|;:',.<>?/", ··· 1185 1156 freeze.SnapJSON(t, "SnapJSON With Special Characters", jsonStr) 1186 1157 } 1187 1158 1188 - // TestSnapJSONEmptyStructures tests SnapJSON with empty collections 1189 1159 func TestSnapJsonEmptyStructures(t *testing.T) { 1190 1160 jsonStr := `{ 1191 1161 "empty_array": [], ··· 1203 1173 freeze.SnapJSON(t, "SnapJSON Empty Structures", jsonStr) 1204 1174 } 1205 1175 1206 - // TestSnapJSONMixedTypes tests SnapJSON with mixed array types 1207 1176 func TestSnapJsonMixedTypes(t *testing.T) { 1208 1177 jsonStr := `{ 1209 1178 "mixed_array": [ ··· 1228 1197 freeze.SnapJSON(t, "SnapJSON Mixed Types", jsonStr) 1229 1198 } 1230 1199 1231 - // TestSnapJSONRealWorldExample tests SnapJSON with real-world API data 1232 1200 func TestSnapJsonRealWorldExample(t *testing.T) { 1233 1201 jsonStr := `{ 1234 1202 "success": true, ··· 1308 1276 freeze.SnapJSON(t, "SnapJSON Real World Example", jsonStr) 1309 1277 } 1310 1278 1311 - // ============================================================================ 1312 - // HELPER FUNCTIONS 1313 - // ============================================================================ 1314 - 1315 - func intPtr(i int) *int { 1316 - return &i 1317 - } 1279 + func ptr[T any](t T) *T { return &t }
+165
ignore.go
··· 1 + package freeze 2 + 3 + import ( 4 + "regexp" 5 + "strings" 6 + ) 7 + 8 + // IgnorePattern determines whether a key-value pair should be excluded 9 + // from the snapshot. This is primarily used for JSON and map structures. 10 + type IgnorePattern interface { 11 + ShouldIgnore(key, value string) bool 12 + } 13 + 14 + // exactKeyValueIgnore ignores exact key-value matches. 15 + type exactKeyValueIgnore struct { 16 + key string 17 + value string 18 + } 19 + 20 + func (e *exactKeyValueIgnore) ShouldIgnore(key, value string) bool { 21 + return e.key == key && (e.value == "*" || e.value == value) 22 + } 23 + 24 + // IgnoreKeyValue creates an ignore pattern that matches exact key-value pairs. 25 + // Use "*" as the value to ignore any value for the given key. 26 + func IgnoreKeyValue(key, value string) SnapshotOption { 27 + return WithIgnorePattern(&exactKeyValueIgnore{ 28 + key: key, 29 + value: value, 30 + }) 31 + } 32 + 33 + // regexKeyValueIgnore ignores key-value pairs matching regex patterns. 34 + type regexKeyValueIgnore struct { 35 + keyPattern *regexp.Regexp 36 + valuePattern *regexp.Regexp 37 + } 38 + 39 + func (r *regexKeyValueIgnore) ShouldIgnore(key, value string) bool { 40 + keyMatch := r.keyPattern == nil || r.keyPattern.MatchString(key) 41 + valueMatch := r.valuePattern == nil || r.valuePattern.MatchString(value) 42 + return keyMatch && valueMatch 43 + } 44 + 45 + // IgnoreKeyPattern creates an ignore pattern using regex patterns for keys and values. 46 + // Pass empty string for keyPattern or valuePattern to match any key or value. 47 + func IgnoreKeyPattern(keyPattern, valuePattern string) SnapshotOption { 48 + var keyRe, valueRe *regexp.Regexp 49 + if keyPattern != "" { 50 + keyRe = regexp.MustCompile(keyPattern) 51 + } 52 + if valuePattern != "" { 53 + valueRe = regexp.MustCompile(valuePattern) 54 + } 55 + return WithIgnorePattern(&regexKeyValueIgnore{ 56 + keyPattern: keyRe, 57 + valuePattern: valueRe, 58 + }) 59 + } 60 + 61 + // keyOnlyIgnore ignores any key matching the pattern, regardless of value. 62 + type keyOnlyIgnore struct { 63 + keys []string 64 + } 65 + 66 + func (k *keyOnlyIgnore) ShouldIgnore(key, value string) bool { 67 + for _, ignoreKey := range k.keys { 68 + if ignoreKey == key { 69 + return true 70 + } 71 + } 72 + return false 73 + } 74 + 75 + // IgnoreKeys creates an ignore pattern that ignores the specified keys 76 + // regardless of their values. 77 + func IgnoreKeys(keys ...string) SnapshotOption { 78 + return WithIgnorePattern(&keyOnlyIgnore{ 79 + keys: keys, 80 + }) 81 + } 82 + 83 + // regexKeyIgnore ignores keys matching a regex pattern. 84 + type regexKeyIgnore struct { 85 + pattern *regexp.Regexp 86 + } 87 + 88 + func (r *regexKeyIgnore) ShouldIgnore(key, value string) bool { 89 + return r.pattern.MatchString(key) 90 + } 91 + 92 + // IgnoreKeysMatching creates an ignore pattern that ignores keys matching 93 + // the given regex pattern. 94 + func IgnoreKeysMatching(pattern string) SnapshotOption { 95 + re := regexp.MustCompile(pattern) 96 + return WithIgnorePattern(&regexKeyIgnore{ 97 + pattern: re, 98 + }) 99 + } 100 + 101 + // Common ignore patterns for sensitive data 102 + var sensitiveKeys = []string{ 103 + "password", "secret", "token", "api_key", "apiKey", 104 + "access_token", "refresh_token", "private_key", "privateKey", 105 + "authorization", "auth", "credentials", "passwd", 106 + } 107 + 108 + // IgnoreSensitiveKeys ignores common sensitive key names like password, token, etc. 109 + func IgnoreSensitiveKeys() SnapshotOption { 110 + return WithIgnorePattern(&keyOnlyIgnore{ 111 + keys: sensitiveKeys, 112 + }) 113 + } 114 + 115 + // valueOnlyIgnore ignores any value matching the pattern, regardless of key. 116 + type valueOnlyIgnore struct { 117 + values []string 118 + } 119 + 120 + func (v *valueOnlyIgnore) ShouldIgnore(key, value string) bool { 121 + for _, ignoreValue := range v.values { 122 + if ignoreValue == value { 123 + return true 124 + } 125 + } 126 + return false 127 + } 128 + 129 + // IgnoreValues creates an ignore pattern that ignores the specified values 130 + // regardless of their keys. 131 + func IgnoreValues(values ...string) SnapshotOption { 132 + return WithIgnorePattern(&valueOnlyIgnore{ 133 + values: values, 134 + }) 135 + } 136 + 137 + // customIgnore allows users to provide a custom ignore function. 138 + type customIgnore struct { 139 + ignoreFunc func(key, value string) bool 140 + } 141 + 142 + func (c *customIgnore) ShouldIgnore(key, value string) bool { 143 + return c.ignoreFunc(key, value) 144 + } 145 + 146 + // CustomIgnore creates an ignore pattern using a custom function. 147 + func CustomIgnore(ignoreFunc func(key, value string) bool) SnapshotOption { 148 + return WithIgnorePattern(&customIgnore{ 149 + ignoreFunc: ignoreFunc, 150 + }) 151 + } 152 + 153 + // IgnoreEmptyValues ignores fields with empty string values. 154 + func IgnoreEmptyValues() SnapshotOption { 155 + return CustomIgnore(func(key, value string) bool { 156 + return strings.TrimSpace(value) == "" 157 + }) 158 + } 159 + 160 + // IgnoreNullValues ignores fields with null/nil values (represented as "null" in JSON). 161 + func IgnoreNullValues() SnapshotOption { 162 + return CustomIgnore(func(key, value string) bool { 163 + return value == "null" || value == "<nil>" 164 + }) 165 + }
+248
ignore_test.go
··· 1 + package freeze_test 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/ptdewey/freeze" 7 + ) 8 + 9 + func TestIgnoreKeyValue(t *testing.T) { 10 + jsonStr := `{ 11 + "username": "john_doe", 12 + "password": "secret123", 13 + "email": "john@example.com", 14 + "api_key": "sk_live_abc123" 15 + }` 16 + 17 + freeze.SnapJSONWithOptions(t, "Ignore Password Field", jsonStr, []freeze.SnapshotOption{ 18 + freeze.IgnoreKeyValue("password", "*"), 19 + freeze.IgnoreKeyValue("api_key", "*"), 20 + }) 21 + } 22 + 23 + func TestIgnoreKeys(t *testing.T) { 24 + jsonStr := `{ 25 + "id": 1, 26 + "name": "John Doe", 27 + "password": "secret", 28 + "secret": "confidential", 29 + "token": "abc123", 30 + "email": "john@example.com" 31 + }` 32 + 33 + freeze.SnapJSONWithOptions(t, "Ignore Multiple Keys", jsonStr, []freeze.SnapshotOption{ 34 + freeze.IgnoreKeys("password", "secret", "token"), 35 + }) 36 + } 37 + 38 + func TestIgnoreSensitiveKeys(t *testing.T) { 39 + jsonStr := `{ 40 + "username": "john_doe", 41 + "password": "secret123", 42 + "api_key": "sk_live_abc123", 43 + "access_token": "token123", 44 + "refresh_token": "refresh123", 45 + "email": "john@example.com", 46 + "name": "John Doe" 47 + }` 48 + 49 + freeze.SnapJSONWithOptions(t, "Ignore Sensitive Keys", jsonStr, []freeze.SnapshotOption{ 50 + freeze.IgnoreSensitiveKeys(), 51 + }) 52 + } 53 + 54 + func TestIgnoreKeysMatching(t *testing.T) { 55 + jsonStr := `{ 56 + "user_id": 1, 57 + "user_name": "john", 58 + "user_email": "john@example.com", 59 + "product_id": 100, 60 + "product_name": "Widget" 61 + }` 62 + 63 + freeze.SnapJSONWithOptions(t, "Ignore Keys Matching Pattern", jsonStr, []freeze.SnapshotOption{ 64 + freeze.IgnoreKeysMatching(`^user_`), 65 + }) 66 + } 67 + 68 + func TestIgnoreKeyPattern(t *testing.T) { 69 + jsonStr := `{ 70 + "username": "john_doe", 71 + "password": "secret", 72 + "admin_password": "admin_secret", 73 + "user_token": "token123", 74 + "email": "john@example.com" 75 + }` 76 + 77 + freeze.SnapJSONWithOptions(t, "Ignore Key Pattern", jsonStr, []freeze.SnapshotOption{ 78 + freeze.IgnoreKeyPattern(`.*password.*`, ""), 79 + freeze.IgnoreKeyPattern(`.*token.*`, ""), 80 + }) 81 + } 82 + 83 + func TestIgnoreValues(t *testing.T) { 84 + jsonStr := `{ 85 + "status": "pending", 86 + "result": "pending", 87 + "message": "Processing", 88 + "state": "pending" 89 + }` 90 + 91 + freeze.SnapJSONWithOptions(t, "Ignore Specific Values", jsonStr, []freeze.SnapshotOption{ 92 + freeze.IgnoreValues("pending"), 93 + }) 94 + } 95 + 96 + func TestIgnoreEmptyValues(t *testing.T) { 97 + jsonStr := `{ 98 + "name": "John Doe", 99 + "middle_name": "", 100 + "nickname": " ", 101 + "email": "john@example.com", 102 + "phone": "" 103 + }` 104 + 105 + freeze.SnapJSONWithOptions(t, "Ignore Empty Values", jsonStr, []freeze.SnapshotOption{ 106 + freeze.IgnoreEmptyValues(), 107 + }) 108 + } 109 + 110 + func TestIgnoreNullValues(t *testing.T) { 111 + jsonStr := `{ 112 + "name": "John Doe", 113 + "middle_name": null, 114 + "email": "john@example.com", 115 + "phone": null, 116 + "age": 30 117 + }` 118 + 119 + freeze.SnapJSONWithOptions(t, "Ignore Null Values", jsonStr, []freeze.SnapshotOption{ 120 + freeze.IgnoreNullValues(), 121 + }) 122 + } 123 + 124 + func TestCustomIgnore(t *testing.T) { 125 + jsonStr := `{ 126 + "id": 1, 127 + "name": "John Doe", 128 + "age": 25, 129 + "score": 95, 130 + "grade": "A" 131 + }` 132 + 133 + freeze.SnapJSONWithOptions(t, "Custom Ignore Function", jsonStr, []freeze.SnapshotOption{ 134 + freeze.CustomIgnore(func(key, value string) bool { 135 + // Ignore numeric values 136 + return value == "1" || value == "25" || value == "95" 137 + }), 138 + }) 139 + } 140 + 141 + func TestNestedIgnorePatterns(t *testing.T) { 142 + jsonStr := `{ 143 + "user": { 144 + "id": 1, 145 + "name": "John Doe", 146 + "password": "secret", 147 + "email": "john@example.com", 148 + "profile": { 149 + "bio": "Developer", 150 + "api_key": "sk_live_abc123", 151 + "website": "https://example.com" 152 + } 153 + }, 154 + "admin": { 155 + "password": "admin_secret", 156 + "token": "admin_token_123" 157 + } 158 + }` 159 + 160 + freeze.SnapJSONWithOptions(t, "Nested Ignore Patterns", jsonStr, []freeze.SnapshotOption{ 161 + freeze.IgnoreSensitiveKeys(), 162 + }) 163 + } 164 + 165 + func TestCombinedIgnoreAndScrub(t *testing.T) { 166 + jsonStr := `{ 167 + "user_id": "550e8400-e29b-41d4-a716-446655440000", 168 + "name": "John Doe", 169 + "email": "john@example.com", 170 + "password": "secret123", 171 + "created_at": "2023-01-15T10:30:00Z", 172 + "api_key": "sk_live_abc123", 173 + "ip_address": "192.168.1.1" 174 + }` 175 + 176 + freeze.SnapJSONWithOptions(t, "Combined Ignore and Scrub", jsonStr, []freeze.SnapshotOption{ 177 + // Ignore sensitive keys entirely 178 + freeze.IgnoreKeys("password", "api_key"), 179 + // Scrub dynamic/identifiable data 180 + freeze.ScrubUUIDs(), 181 + freeze.ScrubEmails(), 182 + freeze.ScrubTimestamps(), 183 + freeze.ScrubIPAddresses(), 184 + }) 185 + } 186 + 187 + func TestIgnoreInArrays(t *testing.T) { 188 + jsonStr := `{ 189 + "users": [ 190 + { 191 + "id": 1, 192 + "name": "Alice", 193 + "password": "secret1", 194 + "email": "alice@example.com" 195 + }, 196 + { 197 + "id": 2, 198 + "name": "Bob", 199 + "password": "secret2", 200 + "email": "bob@example.com" 201 + } 202 + ] 203 + }` 204 + 205 + freeze.SnapJSONWithOptions(t, "Ignore in Arrays", jsonStr, []freeze.SnapshotOption{ 206 + freeze.IgnoreKeys("password"), 207 + }) 208 + } 209 + 210 + func TestComplexRealWorldExample(t *testing.T) { 211 + jsonStr := `{ 212 + "request_id": "550e8400-e29b-41d4-a716-446655440000", 213 + "timestamp": "2023-11-20T15:30:00Z", 214 + "user": { 215 + "id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", 216 + "email": "user@example.com", 217 + "name": "John Doe", 218 + "password": "hashed_password", 219 + "api_key": "sk_live_abc123def456", 220 + "ip_address": "192.168.1.1", 221 + "created_at": "2023-01-15T10:30:00Z" 222 + }, 223 + "transaction": { 224 + "id": "txn_abc123", 225 + "amount": 99.99, 226 + "currency": "USD", 227 + "card_number": "4532-1234-5678-9010", 228 + "timestamp": "2023-11-20T15:30:00Z" 229 + }, 230 + "metadata": { 231 + "server_ip": "10.0.0.5", 232 + "session_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", 233 + "user_agent": "Mozilla/5.0" 234 + } 235 + }` 236 + 237 + freeze.SnapJSONWithOptions(t, "Real World API Response", jsonStr, []freeze.SnapshotOption{ 238 + // Ignore sensitive fields 239 + freeze.IgnoreSensitiveKeys(), 240 + freeze.IgnoreKeys("card_number"), 241 + // Scrub dynamic/identifiable data 242 + freeze.ScrubUUIDs(), 243 + freeze.ScrubEmails(), 244 + freeze.ScrubTimestamps(), 245 + freeze.ScrubIPAddresses(), 246 + freeze.ScrubJWTs(), 247 + }) 248 + }
+131
internal/transform/transform.go
··· 1 + package transform 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + // Config holds the transformation configuration. 9 + type Config struct { 10 + Scrubbers []Scrubber 11 + Ignore []IgnorePattern 12 + } 13 + 14 + // Scrubber transforms content before snapshotting. 15 + type Scrubber interface { 16 + Scrub(content string) string 17 + } 18 + 19 + // IgnorePattern determines whether a key-value pair should be excluded. 20 + type IgnorePattern interface { 21 + ShouldIgnore(key, value string) bool 22 + } 23 + 24 + // ApplyScrubbers applies all scrubbers to the content in order. 25 + func ApplyScrubbers(content string, scrubbers []Scrubber) string { 26 + result := content 27 + for _, scrubber := range scrubbers { 28 + result = scrubber.Scrub(result) 29 + } 30 + return result 31 + } 32 + 33 + // TransformJSON applies scrubbers and ignore patterns to JSON data. 34 + func TransformJSON(jsonStr string, config *Config) (string, error) { 35 + if config == nil || (len(config.Scrubbers) == 0 && len(config.Ignore) == 0) { 36 + return jsonStr, nil 37 + } 38 + 39 + var data interface{} 40 + if err := json.Unmarshal([]byte(jsonStr), &data); err != nil { 41 + return "", fmt.Errorf("failed to unmarshal JSON: %w", err) 42 + } 43 + 44 + // Apply ignore patterns first (removes fields) 45 + if len(config.Ignore) > 0 { 46 + data = walkAndFilter(data, config.Ignore) 47 + } 48 + 49 + // Marshal back to JSON 50 + prettyJSON, err := json.MarshalIndent(data, "", " ") 51 + if err != nil { 52 + return "", fmt.Errorf("failed to marshal JSON: %w", err) 53 + } 54 + 55 + result := string(prettyJSON) 56 + 57 + // Apply scrubbers to the final string 58 + result = ApplyScrubbers(result, config.Scrubbers) 59 + 60 + return result, nil 61 + } 62 + 63 + // walkAndFilter recursively walks the data structure and filters out ignored fields. 64 + func walkAndFilter(data interface{}, ignorePatterns []IgnorePattern) interface{} { 65 + switch v := data.(type) { 66 + case map[string]interface{}: 67 + return filterMap(v, ignorePatterns) 68 + case []interface{}: 69 + return filterSlice(v, ignorePatterns) 70 + default: 71 + return data 72 + } 73 + } 74 + 75 + // filterMap filters a map, removing entries that match ignore patterns. 76 + func filterMap(m map[string]interface{}, ignorePatterns []IgnorePattern) map[string]interface{} { 77 + result := make(map[string]interface{}) 78 + for key, value := range m { 79 + // Convert value to string for comparison 80 + valueStr := valueToString(value) 81 + 82 + // Check if this key-value pair should be ignored 83 + shouldIgnore := false 84 + for _, pattern := range ignorePatterns { 85 + if pattern.ShouldIgnore(key, valueStr) { 86 + shouldIgnore = true 87 + break 88 + } 89 + } 90 + 91 + if !shouldIgnore { 92 + // Recursively filter nested structures 93 + result[key] = walkAndFilter(value, ignorePatterns) 94 + } 95 + } 96 + return result 97 + } 98 + 99 + // filterSlice filters a slice, recursively processing each element. 100 + func filterSlice(s []interface{}, ignorePatterns []IgnorePattern) []interface{} { 101 + result := make([]interface{}, len(s)) 102 + for i, item := range s { 103 + result[i] = walkAndFilter(item, ignorePatterns) 104 + } 105 + return result 106 + } 107 + 108 + // valueToString converts various value types to string for comparison. 109 + func valueToString(value interface{}) string { 110 + switch v := value.(type) { 111 + case string: 112 + return v 113 + case nil: 114 + return "null" 115 + case bool: 116 + if v { 117 + return "true" 118 + } 119 + return "false" 120 + case float64: 121 + return fmt.Sprintf("%v", v) 122 + case int, int64: 123 + return fmt.Sprintf("%d", v) 124 + default: 125 + // For complex types, marshal to JSON 126 + if bytes, err := json.Marshal(v); err == nil { 127 + return string(bytes) 128 + } 129 + return fmt.Sprintf("%v", v) 130 + } 131 + }
+153
scrubbers.go
··· 1 + package freeze 2 + 3 + import ( 4 + "regexp" 5 + "strings" 6 + ) 7 + 8 + // Scrubber transforms content before snapshotting, typically to remove 9 + // or replace dynamic or sensitive data. 10 + type Scrubber interface { 11 + Scrub(content string) string 12 + } 13 + 14 + // regexScrubber replaces all matches of a regex pattern with a replacement string. 15 + type regexScrubber struct { 16 + pattern *regexp.Regexp 17 + replacement string 18 + } 19 + 20 + func (r *regexScrubber) Scrub(content string) string { 21 + return r.pattern.ReplaceAllString(content, r.replacement) 22 + } 23 + 24 + // RegexScrubber creates a scrubber that replaces all matches of the given 25 + // regex pattern with the replacement string. 26 + func RegexScrubber(pattern string, replacement string) SnapshotOption { 27 + re := regexp.MustCompile(pattern) 28 + return WithScrubber(&regexScrubber{ 29 + pattern: re, 30 + replacement: replacement, 31 + }) 32 + } 33 + 34 + // exactMatchScrubber replaces exact string matches with a replacement. 35 + type exactMatchScrubber struct { 36 + match string 37 + replacement string 38 + } 39 + 40 + func (e *exactMatchScrubber) Scrub(content string) string { 41 + return strings.ReplaceAll(content, e.match, e.replacement) 42 + } 43 + 44 + // ExactMatchScrubber creates a scrubber that replaces exact string matches. 45 + func ExactMatchScrubber(match string, replacement string) SnapshotOption { 46 + return WithScrubber(&exactMatchScrubber{ 47 + match: match, 48 + replacement: replacement, 49 + }) 50 + } 51 + 52 + // Common regex patterns for scrubbing 53 + var ( 54 + uuidPattern = regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`) 55 + iso8601Pattern = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?`) 56 + emailPattern = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`) 57 + unixTsPattern = regexp.MustCompile(`\b\d{10,13}\b`) 58 + ipv4Pattern = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) 59 + creditCardPattern = regexp.MustCompile(`\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b`) 60 + jwtPattern = regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`) 61 + ) 62 + 63 + // ScrubUUIDs replaces all UUIDs with "<UUID>". 64 + func ScrubUUIDs() SnapshotOption { 65 + return WithScrubber(&regexScrubber{ 66 + pattern: uuidPattern, 67 + replacement: "<UUID>", 68 + }) 69 + } 70 + 71 + // ScrubTimestamps replaces ISO8601 timestamps with "<TIMESTAMP>". 72 + func ScrubTimestamps() SnapshotOption { 73 + return WithScrubber(&regexScrubber{ 74 + pattern: iso8601Pattern, 75 + replacement: "<TIMESTAMP>", 76 + }) 77 + } 78 + 79 + // ScrubEmails replaces email addresses with "<EMAIL>". 80 + func ScrubEmails() SnapshotOption { 81 + return WithScrubber(&regexScrubber{ 82 + pattern: emailPattern, 83 + replacement: "<EMAIL>", 84 + }) 85 + } 86 + 87 + // ScrubUnixTimestamps replaces Unix timestamps (10-13 digits) with "<UNIX_TS>". 88 + func ScrubUnixTimestamps() SnapshotOption { 89 + return WithScrubber(&regexScrubber{ 90 + pattern: unixTsPattern, 91 + replacement: "<UNIX_TS>", 92 + }) 93 + } 94 + 95 + // ScrubIPAddresses replaces IPv4 addresses with "<IP>". 96 + func ScrubIPAddresses() SnapshotOption { 97 + return WithScrubber(&regexScrubber{ 98 + pattern: ipv4Pattern, 99 + replacement: "<IP>", 100 + }) 101 + } 102 + 103 + // ScrubCreditCards replaces credit card numbers with "<CREDIT_CARD>". 104 + func ScrubCreditCards() SnapshotOption { 105 + return WithScrubber(&regexScrubber{ 106 + pattern: creditCardPattern, 107 + replacement: "<CREDIT_CARD>", 108 + }) 109 + } 110 + 111 + // ScrubJWTs replaces JWT tokens with "<JWT>". 112 + func ScrubJWTs() SnapshotOption { 113 + return WithScrubber(&regexScrubber{ 114 + pattern: jwtPattern, 115 + replacement: "<JWT>", 116 + }) 117 + } 118 + 119 + // ScrubDates replaces dates in various formats with "<DATE>". 120 + // Matches formats like: 2023-01-15, 01/15/2023, 15-01-2023 121 + func ScrubDates() SnapshotOption { 122 + datePattern := regexp.MustCompile(`\b\d{4}[-/]\d{2}[-/]\d{2}\b|\b\d{2}[-/]\d{2}[-/]\d{4}\b`) 123 + return WithScrubber(&regexScrubber{ 124 + pattern: datePattern, 125 + replacement: "<DATE>", 126 + }) 127 + } 128 + 129 + // ScrubAPIKeys replaces common API key patterns with "<API_KEY>". 130 + // Matches patterns like: sk_live_..., pk_test_..., api_key_... 131 + func ScrubAPIKeys() SnapshotOption { 132 + apiKeyPattern := regexp.MustCompile(`\b(sk|pk|api[_-]?key)[_-](live|test|prod|dev)[_-][a-zA-Z0-9]+\b`) 133 + return WithScrubber(&regexScrubber{ 134 + pattern: apiKeyPattern, 135 + replacement: "<API_KEY>", 136 + }) 137 + } 138 + 139 + // customScrubber allows users to provide a custom scrubbing function. 140 + type customScrubber struct { 141 + scrubFunc func(string) string 142 + } 143 + 144 + func (c *customScrubber) Scrub(content string) string { 145 + return c.scrubFunc(content) 146 + } 147 + 148 + // CustomScrubber creates a scrubber using a custom function. 149 + func CustomScrubber(scrubFunc func(string) string) SnapshotOption { 150 + return WithScrubber(&customScrubber{ 151 + scrubFunc: scrubFunc, 152 + }) 153 + }
+182
scrubbers_test.go
··· 1 + package freeze_test 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/ptdewey/freeze" 8 + ) 9 + 10 + func TestScrubUUIDs(t *testing.T) { 11 + jsonStr := `{ 12 + "user_id": "550e8400-e29b-41d4-a716-446655440000", 13 + "session_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", 14 + "name": "John Doe" 15 + }` 16 + 17 + freeze.SnapJSONWithOptions(t, "Scrubbed UUIDs", jsonStr, []freeze.SnapshotOption{ 18 + freeze.ScrubUUIDs(), 19 + }) 20 + } 21 + 22 + func TestScrubTimestamps(t *testing.T) { 23 + jsonStr := `{ 24 + "created_at": "2023-01-15T10:30:00Z", 25 + "updated_at": "2023-11-20T15:45:30.123Z", 26 + "deleted_at": "2023-12-01T08:00:00+05:00", 27 + "name": "Test Event" 28 + }` 29 + 30 + freeze.SnapJSONWithOptions(t, "Scrubbed Timestamps", jsonStr, []freeze.SnapshotOption{ 31 + freeze.ScrubTimestamps(), 32 + }) 33 + } 34 + 35 + func TestScrubEmails(t *testing.T) { 36 + jsonStr := `{ 37 + "email": "user@example.com", 38 + "backup_email": "backup.user+tag@subdomain.example.co.uk", 39 + "name": "John Doe" 40 + }` 41 + 42 + freeze.SnapJSONWithOptions(t, "Scrubbed Emails", jsonStr, []freeze.SnapshotOption{ 43 + freeze.ScrubEmails(), 44 + }) 45 + } 46 + 47 + func TestScrubIPAddresses(t *testing.T) { 48 + jsonStr := `{ 49 + "client_ip": "192.168.1.1", 50 + "server_ip": "10.0.0.5", 51 + "message": "Connection from 172.16.0.100" 52 + }` 53 + 54 + freeze.SnapJSONWithOptions(t, "Scrubbed IPs", jsonStr, []freeze.SnapshotOption{ 55 + freeze.ScrubIPAddresses(), 56 + }) 57 + } 58 + 59 + func TestScrubJWTs(t *testing.T) { 60 + jsonStr := `{ 61 + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", 62 + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" 63 + }` 64 + 65 + freeze.SnapJSONWithOptions(t, "Scrubbed JWTs", jsonStr, []freeze.SnapshotOption{ 66 + freeze.ScrubJWTs(), 67 + }) 68 + } 69 + 70 + func TestMultipleScrubbers(t *testing.T) { 71 + jsonStr := `{ 72 + "user_id": "550e8400-e29b-41d4-a716-446655440000", 73 + "email": "user@example.com", 74 + "created_at": "2023-01-15T10:30:00Z", 75 + "ip_address": "192.168.1.1", 76 + "name": "John Doe" 77 + }` 78 + 79 + freeze.SnapJSONWithOptions(t, "Multiple Scrubbers", jsonStr, []freeze.SnapshotOption{ 80 + freeze.ScrubUUIDs(), 81 + freeze.ScrubEmails(), 82 + freeze.ScrubTimestamps(), 83 + freeze.ScrubIPAddresses(), 84 + }) 85 + } 86 + 87 + func TestRegexScrubber(t *testing.T) { 88 + jsonStr := `{ 89 + "api_key": "sk_live_abc123def456", 90 + "secret_key": "sk_test_xyz789uvw012", 91 + "name": "Test User" 92 + }` 93 + 94 + freeze.SnapJSONWithOptions(t, "Custom Regex Scrubber", jsonStr, []freeze.SnapshotOption{ 95 + freeze.RegexScrubber(`sk_(live|test)_[a-zA-Z0-9]+`, "<API_KEY>"), 96 + }) 97 + } 98 + 99 + func TestExactMatchScrubber(t *testing.T) { 100 + content := "The secret password is 'p@ssw0rd123' and should be hidden." 101 + 102 + freeze.SnapStringWithOptions(t, "Exact Match Scrubber", content, []freeze.SnapshotOption{ 103 + freeze.ExactMatchScrubber("p@ssw0rd123", "<PASSWORD>"), 104 + }) 105 + } 106 + 107 + func TestCustomScrubber(t *testing.T) { 108 + content := "Hello World! This is a TEST." 109 + 110 + freeze.SnapStringWithOptions(t, "Custom Scrubber", content, []freeze.SnapshotOption{ 111 + freeze.CustomScrubber(func(s string) string { 112 + return strings.ToLower(s) 113 + }), 114 + }) 115 + } 116 + 117 + func TestScrubDates(t *testing.T) { 118 + jsonStr := `{ 119 + "birth_date": "1990-05-15", 120 + "hire_date": "2020-01-01", 121 + "us_format": "12/25/2023", 122 + "name": "John Doe" 123 + }` 124 + 125 + freeze.SnapJSONWithOptions(t, "Scrubbed Dates", jsonStr, []freeze.SnapshotOption{ 126 + freeze.ScrubDates(), 127 + }) 128 + } 129 + 130 + func TestScrubAPIKeys(t *testing.T) { 131 + jsonStr := `{ 132 + "stripe_key": "sk_live_51HqZ2bKl4FGBMFpLxO0123", 133 + "test_key": "pk_test_51HqZ2bKl4FGBMFpLxO0456", 134 + "api_key_prod": "api_key_prod_abc123def456", 135 + "name": "Test Config" 136 + }` 137 + 138 + freeze.SnapJSONWithOptions(t, "Scrubbed API Keys", jsonStr, []freeze.SnapshotOption{ 139 + freeze.ScrubAPIKeys(), 140 + }) 141 + } 142 + 143 + func TestScrubWithSnapFunction(t *testing.T) { 144 + data := map[string]interface{}{ 145 + "user_id": "550e8400-e29b-41d4-a716-446655440000", 146 + "email": "user@example.com", 147 + "created_at": "2023-01-15T10:30:00Z", 148 + "name": "John Doe", 149 + } 150 + 151 + freeze.SnapWithOptions(t, "Scrub With Snap", []freeze.SnapshotOption{ 152 + freeze.ScrubUUIDs(), 153 + freeze.ScrubEmails(), 154 + freeze.ScrubTimestamps(), 155 + }, data) 156 + } 157 + 158 + func TestCreditCardScrubbing(t *testing.T) { 159 + jsonStr := `{ 160 + "card_number": "4532-1234-5678-9010", 161 + "backup_card": "4532 1234 5678 9010", 162 + "another_card": "4532123456789010", 163 + "name": "John Doe" 164 + }` 165 + 166 + freeze.SnapJSONWithOptions(t, "Scrubbed Credit Cards", jsonStr, []freeze.SnapshotOption{ 167 + freeze.ScrubCreditCards(), 168 + }) 169 + } 170 + 171 + func TestUnixTimestampScrubbing(t *testing.T) { 172 + jsonStr := `{ 173 + "created": 1699999999, 174 + "updated": 1700000000000, 175 + "deleted": 1700000000, 176 + "name": "Test Event" 177 + }` 178 + 179 + freeze.SnapJSONWithOptions(t, "Scrubbed Unix Timestamps", jsonStr, []freeze.SnapshotOption{ 180 + freeze.ScrubUnixTimestamps(), 181 + }) 182 + }