Skip to content

Commit 3a37e5a

Browse files
committed
Enhance FeatureFlagService tests with ConfigCat parsing and targeting rules
(#5092, gitkraken/vscode-gitlens-private#78)
1 parent 1bb346c commit 3a37e5a

1 file changed

Lines changed: 276 additions & 18 deletions

File tree

Lines changed: 276 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,57 @@
11
import * as assert from 'assert';
2+
import * as crypto from 'crypto';
3+
import * as http from 'http';
24
import * as sinon from 'sinon';
5+
import { env as vscodeEnv } from 'vscode';
36
import type { FeatureFlagMap } from '../featureFlagService.js';
47
import { 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+
643
function 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,49 +61,267 @@ 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());
35-
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitle, true), true);
36-
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitle, false), false);
37-
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitle, 'fallback'), 'fallback');
38-
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitle, 99), 99);
77+
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitleVariant, true), true);
78+
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitleVariant, false), false);
79+
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitleVariant, 'fallback'), 'fallback');
80+
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitleVariant, 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(
46-
createMockContainer({ [FeatureFlagKey.WelcomeTitle]: 'variant-a' }),
86+
createMockContainer({ [FeatureFlagKey.WelcomeTitleVariant]: 'variant-a' }),
4787
);
48-
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitle, 'control'), 'variant-a');
88+
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitleVariant, 'control'), 'variant-a');
4989
s.dispose();
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', () => {
63-
const flags: FeatureFlagMap = { [FeatureFlagKey.WelcomeTitle]: true };
101+
const flags: FeatureFlagMap = { [FeatureFlagKey.WelcomeTitleVariant]: 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.WelcomeTitleVariant]: { 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.WelcomeTitleVariant], expected);
126+
}
127+
128+
s.dispose();
129+
});
130+
131+
test('ignores flags with keys not in FeatureFlagKey', async () => {
132+
const configJson = makeConfigJson({
133+
[FeatureFlagKey.WelcomeTitleVariant]: { 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.WelcomeTitleVariant], 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.WelcomeTitleVariant);
151+
const configJson = makeConfigJson({
152+
[FeatureFlagKey.WelcomeTitleVariant]: {
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.WelcomeTitleVariant], 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.WelcomeTitleVariant);
175+
const configJson = makeConfigJson({
176+
[FeatureFlagKey.WelcomeTitleVariant]: {
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.WelcomeTitleVariant], 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.WelcomeTitleVariant);
201+
const configJson = makeConfigJson({
202+
[FeatureFlagKey.WelcomeTitleVariant]: {
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.WelcomeTitleVariant], 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.WelcomeTitleVariant);
225+
const configJson = makeConfigJson({
226+
[FeatureFlagKey.WelcomeTitleVariant]: {
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.WelcomeTitleVariant], 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.WelcomeTitleVariant]: 'cached-value' };
251+
const s = new ConfigCatFeatureFlagService(createMockContainer(storedFlags));
252+
253+
// These are available synchronously — no await needed
254+
assert.strictEqual(s.getFlag(FeatureFlagKey.WelcomeTitleVariant, '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.WelcomeTitleVariant]: { 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.WelcomeTitleVariant]: '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.WelcomeTitleVariant],
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.WelcomeTitleVariant, 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.WelcomeTitleVariant, 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.WelcomeTitleVariant]: '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.WelcomeTitleVariant]: 'new-value' });
316+
317+
// Service still returns the flags it read at construction
318+
assert.strictEqual(
319+
s.getFlag(FeatureFlagKey.WelcomeTitleVariant, '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

Comments
 (0)