11import * as assert from 'assert' ;
2+ import * as crypto from 'crypto' ;
3+ import * as http from 'http' ;
24import * as sinon from 'sinon' ;
5+ import { env as vscodeEnv } from 'vscode' ;
36import type { FeatureFlagMap } from '../featureFlagService.js' ;
47import { ConfigCatFeatureFlagService , FeatureFlagKey } from '../featureFlagService.js' ;
58
9+ const testSalt = 'test-salt' ;
10+ const testMachineId = 'test-machine-id' ;
11+
12+ /**
13+ * Computes the same SHA-256 hash that ConfigCat SDK uses for sensitive comparisons.
14+ * Hash input: utf8(value) + utf8(configJsonSalt) + utf8(settingKey)
15+ */
16+ function configCatHash ( value : string , settingKey : string ) : string {
17+ const input = Buffer . concat ( [
18+ Buffer . from ( value , 'utf8' ) ,
19+ Buffer . from ( testSalt , 'utf8' ) ,
20+ Buffer . from ( settingKey , 'utf8' ) ,
21+ ] ) ;
22+ return crypto . createHash ( 'sha256' ) . update ( input ) . digest ( 'hex' ) ;
23+ }
24+
25+ /**
26+ * Computes the hashed-prefix format used by ConfigCat's "starts with" comparator (22).
27+ * Format: `{prefixByteLength}_{SHA256(value[0:prefixByteLength] + salt + settingKey)}`
28+ */
29+ function configCatHashPrefix ( value : string , prefixByteLen : number , settingKey : string ) : string {
30+ const slice = Buffer . from ( value , 'utf8' ) . subarray ( 0 , prefixByteLen ) ;
31+ const input = Buffer . concat ( [ slice , Buffer . from ( testSalt , 'utf8' ) , Buffer . from ( settingKey , 'utf8' ) ] ) ;
32+ return `${ prefixByteLen } _${ crypto . createHash ( 'sha256' ) . update ( input ) . digest ( 'hex' ) } ` ;
33+ }
34+
35+ /** Builds a ConfigCat v6 config JSON string with the real structure from our dev server. */
36+ function makeConfigJson ( flags : Record < string , Record < string , unknown > > ) : string {
37+ return JSON . stringify ( {
38+ p : { u : 'https://cdn-global.configcat.com' , r : 0 , s : testSalt } ,
39+ f : flags ,
40+ } ) ;
41+ }
42+
643function createMockContainer ( flags ?: FeatureFlagMap ) : any {
44+ let f = flags ;
745 return {
846 urls : { getGkApiUrl : ( ...segments : string [ ] ) => `https://api.test.com/${ segments . join ( '/' ) } ` } ,
947 env : 'production' ,
1048 debugging : false ,
1149 prereleaseOrDebugging : false ,
1250 storage : {
13- get : sinon . stub ( ) . returns ( flags ) ,
14- store : sinon . stub ( ) . resolves ( ) ,
51+ get : sinon . stub ( ) . callsFake ( ( ) => f ) ,
52+ store : sinon . stub ( ) . callsFake ( ( _key : string , v : FeatureFlagMap ) => {
53+ f = v ;
54+ } ) ,
1555 } ,
1656 } ;
1757}
@@ -21,26 +61,26 @@ suite('FeatureFlagService Test Suite', () => {
2161
2262 setup ( ( ) => {
2363 sandbox = sinon . createSandbox ( ) ;
24- // Prevent background fetch from running during tests
64+ // Prevent background fetch from running during tests by default
2565 sandbox . stub ( ConfigCatFeatureFlagService . prototype as any , 'fetchAndCacheFlags' ) . resolves ( ) ;
66+ // Use a deterministic machine ID so we can pre-compute hashes
67+ sandbox . stub ( vscodeEnv , 'machineId' ) . value ( testMachineId ) ;
2668 } ) ;
2769
2870 teardown ( ( ) => {
2971 sandbox . restore ( ) ;
3072 } ) ;
3173
32- suite ( 'getFlag — no cached flags ' , ( ) => {
33- test ( 'returns default value for each type ' , ( ) => {
74+ suite ( 'getFlag' , ( ) => {
75+ test ( 'returns default values when no flags are cached ' , ( ) => {
3476 const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
3577 assert . strictEqual ( s . getFlag ( FeatureFlagKey . WelcomeTitle , true ) , true ) ;
3678 assert . strictEqual ( s . getFlag ( FeatureFlagKey . WelcomeTitle , false ) , false ) ;
3779 assert . strictEqual ( s . getFlag ( FeatureFlagKey . WelcomeTitle , 'fallback' ) , 'fallback' ) ;
3880 assert . strictEqual ( s . getFlag ( FeatureFlagKey . WelcomeTitle , 99 ) , 99 ) ;
3981 s . dispose ( ) ;
4082 } ) ;
41- } ) ;
4283
43- suite ( 'getFlag — cached flags available' , ( ) => {
4484 test ( 'returns cached value over default' , ( ) => {
4585 const s = new ConfigCatFeatureFlagService (
4686 createMockContainer ( { [ FeatureFlagKey . WelcomeTitle ] : 'variant-a' } ) ,
@@ -50,20 +90,238 @@ suite('FeatureFlagService Test Suite', () => {
5090 } ) ;
5191 } ) ;
5292
53- suite ( 'getAllFlags — no cached flags ' , ( ) => {
54- test ( 'returns empty object' , ( ) => {
93+ suite ( 'getAllFlags' , ( ) => {
94+ test ( 'returns empty object when no flags are cached ' , ( ) => {
5595 const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
5696 assert . deepStrictEqual ( s . getAllFlags ( ) , { } ) ;
5797 s . dispose ( ) ;
5898 } ) ;
59- } ) ;
6099
61- suite ( 'getAllFlags — cached flags available' , ( ) => {
62100 test ( 'returns cached flag map' , ( ) => {
63101 const flags : FeatureFlagMap = { [ FeatureFlagKey . WelcomeTitle ] : true } ;
64102 const s = new ConfigCatFeatureFlagService ( createMockContainer ( flags ) ) ;
65103 assert . deepStrictEqual ( s . getAllFlags ( ) , flags ) ;
66104 s . dispose ( ) ;
67105 } ) ;
68106 } ) ;
107+
108+ suite ( 'evaluateFlags — ConfigCat parsing' , ( ) => {
109+ test ( 'parses boolean, string, and integer flag types' , async ( ) => {
110+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
111+
112+ const cases : { type : number ; value : Record < string , unknown > ; expected : unknown } [ ] = [
113+ { type : 0 , value : { b : true } , expected : true } ,
114+ { type : 1 , value : { s : 'variant-b' } , expected : 'variant-b' } ,
115+ { type : 2 , value : { i : 42 } , expected : 42 } ,
116+ ] ;
117+
118+ for ( const { type, value, expected } of cases ) {
119+ const configJson = makeConfigJson ( {
120+ [ FeatureFlagKey . WelcomeTitle ] : { t : type , v : value , i : 'var-1' } ,
121+ } ) ;
122+ const result : FeatureFlagMap | undefined = await ( s as any ) . evaluateFlags ( configJson ) ;
123+
124+ assert . ok ( result != null , `evaluateFlags should return a flag map for type ${ type } ` ) ;
125+ assert . strictEqual ( result [ FeatureFlagKey . WelcomeTitle ] , expected ) ;
126+ }
127+
128+ s . dispose ( ) ;
129+ } ) ;
130+
131+ test ( 'ignores flags with keys not in FeatureFlagKey' , async ( ) => {
132+ const configJson = makeConfigJson ( {
133+ [ FeatureFlagKey . WelcomeTitle ] : { t : 0 , v : { b : true } , i : 'var-1' } ,
134+ unknownFlag : { t : 1 , v : { s : 'should-be-ignored' } , i : 'var-x' } ,
135+ } ) ;
136+
137+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
138+ const result : FeatureFlagMap | undefined = await ( s as any ) . evaluateFlags ( configJson ) ;
139+
140+ assert . ok ( result != null ) ;
141+ assert . strictEqual ( result [ FeatureFlagKey . WelcomeTitle ] , true ) ;
142+ assert . strictEqual ( Object . keys ( result ) . length , 1 , 'should only contain known flag keys' ) ;
143+ s . dispose ( ) ;
144+ } ) ;
145+ } ) ;
146+
147+ suite ( 'evaluateFlags — targeting rules with hashed comparisons' , ( ) => {
148+ // Comparator 16: Identifier IS ONE OF (hashed)
149+ test ( 'identifier equals (hashed) — positive match' , async ( ) => {
150+ const hash = configCatHash ( testMachineId , FeatureFlagKey . WelcomeTitle ) ;
151+ const configJson = makeConfigJson ( {
152+ [ FeatureFlagKey . WelcomeTitle ] : {
153+ t : 0 ,
154+ r : [
155+ {
156+ c : [ { u : { a : 'Identifier' , c : 16 , l : [ hash ] } } ] ,
157+ s : { v : { b : true } , i : 'rule-match' } ,
158+ } ,
159+ ] ,
160+ v : { b : false } ,
161+ i : 'default' ,
162+ } ,
163+ } ) ;
164+
165+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
166+ const result = await ( s as any ) . evaluateFlags ( configJson ) ;
167+
168+ assert . ok ( result != null ) ;
169+ assert . strictEqual ( result [ FeatureFlagKey . WelcomeTitle ] , true , 'should match the targeting rule' ) ;
170+ s . dispose ( ) ;
171+ } ) ;
172+
173+ test ( 'identifier equals (hashed) — negative, no match' , async ( ) => {
174+ const wrongHash = configCatHash ( 'some-other-machine-id' , FeatureFlagKey . WelcomeTitle ) ;
175+ const configJson = makeConfigJson ( {
176+ [ FeatureFlagKey . WelcomeTitle ] : {
177+ t : 0 ,
178+ r : [
179+ {
180+ c : [ { u : { a : 'Identifier' , c : 16 , l : [ wrongHash ] } } ] ,
181+ s : { v : { b : true } , i : 'rule-match' } ,
182+ } ,
183+ ] ,
184+ v : { b : false } ,
185+ i : 'default' ,
186+ } ,
187+ } ) ;
188+
189+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
190+ const result = await ( s as any ) . evaluateFlags ( configJson ) ;
191+
192+ assert . ok ( result != null ) ;
193+ assert . strictEqual ( result [ FeatureFlagKey . WelcomeTitle ] , false , 'should fall through to default' ) ;
194+ s . dispose ( ) ;
195+ } ) ;
196+
197+ // Comparator 22: Identifier STARTS WITH ANY OF (hashed)
198+ // testMachineId = 'test-machine-id', prefix 'test-' = 5 bytes
199+ test ( 'identifier starts with (hashed) — positive match' , async ( ) => {
200+ const prefixHash = configCatHashPrefix ( testMachineId , 5 , FeatureFlagKey . WelcomeTitle ) ;
201+ const configJson = makeConfigJson ( {
202+ [ FeatureFlagKey . WelcomeTitle ] : {
203+ t : 0 ,
204+ r : [
205+ {
206+ c : [ { u : { a : 'Identifier' , c : 22 , l : [ prefixHash ] } } ] ,
207+ s : { v : { b : true } , i : 'rule-match' } ,
208+ } ,
209+ ] ,
210+ v : { b : false } ,
211+ i : 'default' ,
212+ } ,
213+ } ) ;
214+
215+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
216+ const result = await ( s as any ) . evaluateFlags ( configJson ) ;
217+
218+ assert . ok ( result != null ) ;
219+ assert . strictEqual ( result [ FeatureFlagKey . WelcomeTitle ] , true , 'should match the starts-with rule' ) ;
220+ s . dispose ( ) ;
221+ } ) ;
222+
223+ test ( 'identifier starts with (hashed) — negative, no match' , async ( ) => {
224+ const wrongPrefixHash = configCatHashPrefix ( 'other-prefix-id' , 6 , FeatureFlagKey . WelcomeTitle ) ;
225+ const configJson = makeConfigJson ( {
226+ [ FeatureFlagKey . WelcomeTitle ] : {
227+ t : 0 ,
228+ r : [
229+ {
230+ c : [ { u : { a : 'Identifier' , c : 22 , l : [ wrongPrefixHash ] } } ] ,
231+ s : { v : { b : true } , i : 'rule-match' } ,
232+ } ,
233+ ] ,
234+ v : { b : false } ,
235+ i : 'default' ,
236+ } ,
237+ } ) ;
238+
239+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( ) ) ;
240+ const result = await ( s as any ) . evaluateFlags ( configJson ) ;
241+
242+ assert . ok ( result != null ) ;
243+ assert . strictEqual ( result [ FeatureFlagKey . WelcomeTitle ] , false , 'should fall through to default' ) ;
244+ s . dispose ( ) ;
245+ } ) ;
246+ } ) ;
247+
248+ suite ( 'flags lifecycle' , ( ) => {
249+ test ( 'serves flags from storage immediately, before fetch completes' , ( ) => {
250+ const storedFlags : FeatureFlagMap = { [ FeatureFlagKey . WelcomeTitle ] : 'cached-value' } ;
251+ const s = new ConfigCatFeatureFlagService ( createMockContainer ( storedFlags ) ) ;
252+
253+ // These are available synchronously — no await needed
254+ assert . strictEqual ( s . getFlag ( FeatureFlagKey . WelcomeTitle , 'default' ) , 'cached-value' ) ;
255+ assert . deepStrictEqual ( s . getAllFlags ( ) , storedFlags ) ;
256+ s . dispose ( ) ;
257+ } ) ;
258+
259+ test ( 'fetchAndCacheFlags stores evaluated flags to storage' , async ( ) => {
260+ const configJson = makeConfigJson ( {
261+ [ FeatureFlagKey . WelcomeTitle ] : { t : 0 , v : { b : true } , i : 'var-1' } ,
262+ } ) ;
263+
264+ // Spin up a local HTTP server that serves the config JSON
265+ const server = http . createServer ( ( _req , res ) => {
266+ res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
267+ res . end ( configJson ) ;
268+ } ) ;
269+ await new Promise < void > ( resolve => server . listen ( 0 , '127.0.0.1' , resolve ) ) ;
270+ const port = ( server . address ( ) as import ( 'net' ) . AddressInfo ) . port ;
271+
272+ try {
273+ const container = createMockContainer ( { [ FeatureFlagKey . WelcomeTitle ] : 'old-value' } ) ;
274+ // Point the URL at our local server so the real fetch hits it
275+ container . urls . getGkApiUrl = ( ) => `http://127.0.0.1:${ port } /feature-flags/config` ;
276+
277+ // Let the real fetchAndCacheFlags run
278+ ( ConfigCatFeatureFlagService . prototype as any ) . fetchAndCacheFlags . restore ( ) ;
279+
280+ const s1 = new ConfigCatFeatureFlagService ( container ) ;
281+
282+ // Wait for the background fire-and-forget fetch to complete
283+ await new Promise ( resolve => setTimeout ( resolve , 500 ) ) ;
284+
285+ // Verify storage received the evaluated flags
286+ assert . ok ( container . storage . store . calledOnce , 'storage.store should have been called' ) ;
287+ const storedFlags = container . storage . store . firstCall . args [ 1 ] as FeatureFlagMap ;
288+ assert . strictEqual (
289+ storedFlags [ FeatureFlagKey . WelcomeTitle ] ,
290+ true ,
291+ 'should store the evaluated flag value' ,
292+ ) ;
293+
294+ // s1 still serves the old flags — new ones are for the next activation
295+ assert . strictEqual ( s1 . getFlag ( FeatureFlagKey . WelcomeTitle , false ) , 'old-value' ) ;
296+
297+ // A new service instance reads the updated storage
298+ sandbox . stub ( ConfigCatFeatureFlagService . prototype as any , 'fetchAndCacheFlags' ) . resolves ( ) ;
299+ const s2 = new ConfigCatFeatureFlagService ( container ) ;
300+ assert . strictEqual ( s2 . getFlag ( FeatureFlagKey . WelcomeTitle , false ) , true ) ;
301+
302+ s1 . dispose ( ) ;
303+ s2 . dispose ( ) ;
304+ } finally {
305+ server . close ( ) ;
306+ }
307+ } ) ;
308+
309+ test ( 'flags are frozen at construction and unaffected by later storage changes' , ( ) => {
310+ const oldFlags : FeatureFlagMap = { [ FeatureFlagKey . WelcomeTitle ] : 'old-value' } ;
311+ const container = createMockContainer ( oldFlags ) ;
312+ const s = new ConfigCatFeatureFlagService ( container ) ;
313+
314+ // Simulate storage being updated (as fetchAndCacheFlags would do)
315+ container . storage . store ( 'featureFlags:flags' , { [ FeatureFlagKey . WelcomeTitle ] : 'new-value' } ) ;
316+
317+ // Service still returns the flags it read at construction
318+ assert . strictEqual (
319+ s . getFlag ( FeatureFlagKey . WelcomeTitle , 'default' ) ,
320+ 'old-value' ,
321+ 'should still serve flags from initial storage read' ,
322+ ) ;
323+ assert . deepStrictEqual ( s . getAllFlags ( ) , oldFlags ) ;
324+ s . dispose ( ) ;
325+ } ) ;
326+ } ) ;
69327} ) ;
0 commit comments