|
1 | 1 | import { Messenger } from '@metamask/base-controller'; |
2 | 2 | import { strict as assert } from 'assert'; |
3 | | -import nock from 'nock'; |
| 3 | +import nock, { cleanAll, isDone, pendingMocks } from 'nock'; |
4 | 4 | import sinon from 'sinon'; |
5 | 5 |
|
6 | 6 | import { |
@@ -56,6 +56,7 @@ function getPhishingController(options?: Partial<PhishingControllerOptions>) { |
56 | 56 | describe('PhishingController', () => { |
57 | 57 | afterEach(() => { |
58 | 58 | sinon.restore(); |
| 59 | + cleanAll(); |
59 | 60 | }); |
60 | 61 |
|
61 | 62 | it('should have no default phishing lists', () => { |
@@ -2156,7 +2157,7 @@ describe('PhishingController', () => { |
2156 | 2157 |
|
2157 | 2158 | describe('PhishingController - isBlockedRequest', () => { |
2158 | 2159 | afterEach(() => { |
2159 | | - nock.cleanAll(); |
| 2160 | + cleanAll(); |
2160 | 2161 | }); |
2161 | 2162 |
|
2162 | 2163 | it('should return false if c2DomainBlocklist is not defined or empty', async () => { |
@@ -2871,3 +2872,323 @@ describe('PhishingController', () => { |
2871 | 2872 | }); |
2872 | 2873 | }); |
2873 | 2874 | }); |
| 2875 | + |
| 2876 | +describe('URL Scan Cache', () => { |
| 2877 | + let clock: sinon.SinonFakeTimers; |
| 2878 | + |
| 2879 | + beforeEach(() => { |
| 2880 | + clock = sinon.useFakeTimers(); |
| 2881 | + }); |
| 2882 | + afterEach(() => { |
| 2883 | + sinon.restore(); |
| 2884 | + cleanAll(); |
| 2885 | + }); |
| 2886 | + |
| 2887 | + it('should cache scan results and return them on subsequent calls', async () => { |
| 2888 | + const testDomain = 'example.com'; |
| 2889 | + |
| 2890 | + // Spy on the fetch function to track calls |
| 2891 | + const fetchSpy = jest.spyOn(global, 'fetch'); |
| 2892 | + |
| 2893 | + nock(PHISHING_DETECTION_BASE_URL) |
| 2894 | + .get( |
| 2895 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 2896 | + ) |
| 2897 | + .reply(200, { |
| 2898 | + recommendedAction: RecommendedAction.None, |
| 2899 | + }); |
| 2900 | + |
| 2901 | + const controller = getPhishingController(); |
| 2902 | + |
| 2903 | + const result1 = await controller.scanUrl(`https://${testDomain}`); |
| 2904 | + expect(result1).toStrictEqual({ |
| 2905 | + domainName: testDomain, |
| 2906 | + recommendedAction: RecommendedAction.None, |
| 2907 | + }); |
| 2908 | + |
| 2909 | + const result2 = await controller.scanUrl(`https://${testDomain}`); |
| 2910 | + expect(result2).toStrictEqual({ |
| 2911 | + domainName: testDomain, |
| 2912 | + recommendedAction: RecommendedAction.None, |
| 2913 | + }); |
| 2914 | + |
| 2915 | + // Verify that fetch was called exactly once |
| 2916 | + expect(fetchSpy).toHaveBeenCalledTimes(1); |
| 2917 | + |
| 2918 | + fetchSpy.mockRestore(); |
| 2919 | + }); |
| 2920 | + |
| 2921 | + it('should expire cache entries after TTL', async () => { |
| 2922 | + const testDomain = 'example.com'; |
| 2923 | + const cacheTTL = 300; // 5 minutes |
| 2924 | + |
| 2925 | + nock(PHISHING_DETECTION_BASE_URL) |
| 2926 | + .get( |
| 2927 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 2928 | + ) |
| 2929 | + .reply(200, { |
| 2930 | + recommendedAction: RecommendedAction.None, |
| 2931 | + }) |
| 2932 | + .get( |
| 2933 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 2934 | + ) |
| 2935 | + .reply(200, { |
| 2936 | + recommendedAction: RecommendedAction.None, |
| 2937 | + }); |
| 2938 | + |
| 2939 | + const controller = getPhishingController({ |
| 2940 | + urlScanCacheTTL: cacheTTL, |
| 2941 | + }); |
| 2942 | + |
| 2943 | + await controller.scanUrl(`https://${testDomain}`); |
| 2944 | + |
| 2945 | + // Before TTL expires, should use cache |
| 2946 | + clock.tick((cacheTTL - 10) * 1000); |
| 2947 | + await controller.scanUrl(`https://${testDomain}`); |
| 2948 | + expect(pendingMocks()).toHaveLength(1); // One mock remaining |
| 2949 | + |
| 2950 | + // After TTL expires, should fetch again |
| 2951 | + clock.tick(11 * 1000); |
| 2952 | + await controller.scanUrl(`https://${testDomain}`); |
| 2953 | + expect(pendingMocks()).toHaveLength(0); // All mocks used |
| 2954 | + }); |
| 2955 | + |
| 2956 | + it('should evict oldest entries when cache exceeds max size', async () => { |
| 2957 | + const maxCacheSize = 2; |
| 2958 | + const domains = ['domain1.com', 'domain2.com', 'domain3.com']; |
| 2959 | + |
| 2960 | + // Setup nock to respond to all three domains |
| 2961 | + domains.forEach((domain) => { |
| 2962 | + nock(PHISHING_DETECTION_BASE_URL) |
| 2963 | + .get( |
| 2964 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domain)}`, |
| 2965 | + ) |
| 2966 | + .reply(200, { |
| 2967 | + recommendedAction: RecommendedAction.None, |
| 2968 | + }); |
| 2969 | + }); |
| 2970 | + |
| 2971 | + // Setup a second request for the first domain |
| 2972 | + nock(PHISHING_DETECTION_BASE_URL) |
| 2973 | + .get( |
| 2974 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domains[0])}`, |
| 2975 | + ) |
| 2976 | + .reply(200, { |
| 2977 | + recommendedAction: RecommendedAction.Warn, |
| 2978 | + }); |
| 2979 | + |
| 2980 | + const controller = getPhishingController({ |
| 2981 | + urlScanCacheMaxSize: maxCacheSize, |
| 2982 | + }); |
| 2983 | + |
| 2984 | + // Fill the cache |
| 2985 | + await controller.scanUrl(`https://${domains[0]}`); |
| 2986 | + clock.tick(1000); // Ensure different timestamps |
| 2987 | + await controller.scanUrl(`https://${domains[1]}`); |
| 2988 | + |
| 2989 | + // This should evict the oldest entry (domain1) |
| 2990 | + clock.tick(1000); |
| 2991 | + await controller.scanUrl(`https://${domains[2]}`); |
| 2992 | + |
| 2993 | + // Now domain1 should not be in cache and require a new fetch |
| 2994 | + await controller.scanUrl(`https://${domains[0]}`); |
| 2995 | + |
| 2996 | + // All mocks should be used |
| 2997 | + expect(isDone()).toBe(true); |
| 2998 | + }); |
| 2999 | + |
| 3000 | + it('should clear the cache when clearUrlScanCache is called', async () => { |
| 3001 | + const testDomain = 'example.com'; |
| 3002 | + |
| 3003 | + nock(PHISHING_DETECTION_BASE_URL) |
| 3004 | + .get( |
| 3005 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3006 | + ) |
| 3007 | + .reply(200, { |
| 3008 | + recommendedAction: RecommendedAction.None, |
| 3009 | + }) |
| 3010 | + .get( |
| 3011 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3012 | + ) |
| 3013 | + .reply(200, { |
| 3014 | + recommendedAction: RecommendedAction.None, |
| 3015 | + }); |
| 3016 | + |
| 3017 | + const controller = getPhishingController(); |
| 3018 | + |
| 3019 | + // First call should fetch from API |
| 3020 | + await controller.scanUrl(`https://${testDomain}`); |
| 3021 | + |
| 3022 | + // Clear the cache |
| 3023 | + controller.clearUrlScanCache(); |
| 3024 | + |
| 3025 | + // Should fetch again |
| 3026 | + await controller.scanUrl(`https://${testDomain}`); |
| 3027 | + |
| 3028 | + // All mocks should be used |
| 3029 | + expect(isDone()).toBe(true); |
| 3030 | + }); |
| 3031 | + |
| 3032 | + it('should allow changing the TTL', async () => { |
| 3033 | + const testDomain = 'example.com'; |
| 3034 | + const initialTTL = 300; // 5 minutes |
| 3035 | + const newTTL = 60; // 1 minute |
| 3036 | + |
| 3037 | + nock(PHISHING_DETECTION_BASE_URL) |
| 3038 | + .get( |
| 3039 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3040 | + ) |
| 3041 | + .reply(200, { |
| 3042 | + recommendedAction: RecommendedAction.None, |
| 3043 | + }) |
| 3044 | + .get( |
| 3045 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3046 | + ) |
| 3047 | + .reply(200, { |
| 3048 | + recommendedAction: RecommendedAction.None, |
| 3049 | + }); |
| 3050 | + |
| 3051 | + const controller = getPhishingController({ |
| 3052 | + urlScanCacheTTL: initialTTL, |
| 3053 | + }); |
| 3054 | + |
| 3055 | + // First call should fetch from API |
| 3056 | + await controller.scanUrl(`https://${testDomain}`); |
| 3057 | + |
| 3058 | + // Change TTL |
| 3059 | + controller.setUrlScanCacheTTL(newTTL); |
| 3060 | + |
| 3061 | + // Before new TTL expires, should use cache |
| 3062 | + clock.tick((newTTL - 10) * 1000); |
| 3063 | + await controller.scanUrl(`https://${testDomain}`); |
| 3064 | + expect(pendingMocks()).toHaveLength(1); // One mock remaining |
| 3065 | + |
| 3066 | + // After new TTL expires, should fetch again |
| 3067 | + clock.tick(11 * 1000); |
| 3068 | + await controller.scanUrl(`https://${testDomain}`); |
| 3069 | + expect(pendingMocks()).toHaveLength(0); // All mocks used |
| 3070 | + }); |
| 3071 | + |
| 3072 | + it('should allow changing the max cache size', async () => { |
| 3073 | + const initialMaxSize = 3; |
| 3074 | + const newMaxSize = 2; |
| 3075 | + const domains = [ |
| 3076 | + 'domain1.com', |
| 3077 | + 'domain2.com', |
| 3078 | + 'domain3.com', |
| 3079 | + 'domain4.com', |
| 3080 | + ]; |
| 3081 | + |
| 3082 | + // Setup nock to respond to all domains |
| 3083 | + domains.forEach((domain) => { |
| 3084 | + nock(PHISHING_DETECTION_BASE_URL) |
| 3085 | + .get( |
| 3086 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(domain)}`, |
| 3087 | + ) |
| 3088 | + .reply(200, { |
| 3089 | + recommendedAction: RecommendedAction.None, |
| 3090 | + }); |
| 3091 | + }); |
| 3092 | + |
| 3093 | + const controller = getPhishingController({ |
| 3094 | + urlScanCacheMaxSize: initialMaxSize, |
| 3095 | + }); |
| 3096 | + |
| 3097 | + // Fill the cache to initial size |
| 3098 | + await controller.scanUrl(`https://${domains[0]}`); |
| 3099 | + clock.tick(1000); // Ensure different timestamps |
| 3100 | + await controller.scanUrl(`https://${domains[1]}`); |
| 3101 | + clock.tick(1000); |
| 3102 | + await controller.scanUrl(`https://${domains[2]}`); |
| 3103 | + |
| 3104 | + // Verify initial cache size |
| 3105 | + expect(Object.keys(controller.state.urlScanCache)).toHaveLength( |
| 3106 | + initialMaxSize, |
| 3107 | + ); |
| 3108 | + // Reduce the max size |
| 3109 | + controller.setUrlScanCacheMaxSize(newMaxSize); |
| 3110 | + |
| 3111 | + // Add another entry which should trigger eviction |
| 3112 | + await controller.scanUrl(`https://${domains[3]}`); |
| 3113 | + |
| 3114 | + // Verify the cache size doesn't exceed new max size |
| 3115 | + expect( |
| 3116 | + Object.keys(controller.state.urlScanCache).length, |
| 3117 | + ).toBeLessThanOrEqual(newMaxSize); |
| 3118 | + }); |
| 3119 | + |
| 3120 | + it('should handle fetch errors and not cache them', async () => { |
| 3121 | + const testDomain = 'example.com'; |
| 3122 | + |
| 3123 | + nock(PHISHING_DETECTION_BASE_URL) |
| 3124 | + .get( |
| 3125 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3126 | + ) |
| 3127 | + .reply(500, { error: 'Internal Server Error' }) |
| 3128 | + .get( |
| 3129 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3130 | + ) |
| 3131 | + .reply(200, { |
| 3132 | + recommendedAction: RecommendedAction.None, |
| 3133 | + }); |
| 3134 | + |
| 3135 | + const controller = getPhishingController(); |
| 3136 | + |
| 3137 | + // First call should result in an error response |
| 3138 | + const result1 = await controller.scanUrl(`https://${testDomain}`); |
| 3139 | + expect(result1.fetchError).toBeDefined(); |
| 3140 | + |
| 3141 | + // Second call should try again (not use cache since errors aren't cached) |
| 3142 | + const result2 = await controller.scanUrl(`https://${testDomain}`); |
| 3143 | + expect(result2.fetchError).toBeUndefined(); |
| 3144 | + expect(result2.recommendedAction).toBe(RecommendedAction.None); |
| 3145 | + |
| 3146 | + // All mocks should be used |
| 3147 | + expect(isDone()).toBe(true); |
| 3148 | + }); |
| 3149 | + |
| 3150 | + it('should handle timeout errors and not cache them', async () => { |
| 3151 | + const testDomain = 'example.com'; |
| 3152 | + |
| 3153 | + // First mock a timeout/error response |
| 3154 | + nock(PHISHING_DETECTION_BASE_URL) |
| 3155 | + .get( |
| 3156 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3157 | + ) |
| 3158 | + .replyWithError('connection timeout') |
| 3159 | + .get( |
| 3160 | + `/${PHISHING_DETECTION_SCAN_ENDPOINT}?url=${encodeURIComponent(testDomain)}`, |
| 3161 | + ) |
| 3162 | + .reply(200, { |
| 3163 | + recommendedAction: RecommendedAction.None, |
| 3164 | + }); |
| 3165 | + |
| 3166 | + const controller = getPhishingController(); |
| 3167 | + |
| 3168 | + // First call should result in an error |
| 3169 | + const result1 = await controller.scanUrl(`https://${testDomain}`); |
| 3170 | + expect(result1.fetchError).toBeDefined(); |
| 3171 | + |
| 3172 | + // Second call should succeed (not use cache since errors aren't cached) |
| 3173 | + const result2 = await controller.scanUrl(`https://${testDomain}`); |
| 3174 | + expect(result2.fetchError).toBeUndefined(); |
| 3175 | + expect(result2.recommendedAction).toBe(RecommendedAction.None); |
| 3176 | + |
| 3177 | + // All mocks should be used |
| 3178 | + expect(isDone()).toBe(true); |
| 3179 | + }); |
| 3180 | + |
| 3181 | + it('should handle invalid URLs and not cache them', async () => { |
| 3182 | + const invalidUrl = 'not-a-valid-url'; |
| 3183 | + |
| 3184 | + const controller = getPhishingController(); |
| 3185 | + |
| 3186 | + // First call should return an error for invalid URL |
| 3187 | + const result1 = await controller.scanUrl(invalidUrl); |
| 3188 | + expect(result1.fetchError).toBeDefined(); |
| 3189 | + |
| 3190 | + // Second call should also return an error (not from cache) |
| 3191 | + const result2 = await controller.scanUrl(invalidUrl); |
| 3192 | + expect(result2.fetchError).toBeDefined(); |
| 3193 | + }); |
| 3194 | +}); |
0 commit comments