backend-manager 5.2.17 → 5.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Test: mergeLineBasedFiles()
3
+ * Unit tests for the line-based .env / .gitignore / CLAUDE.md merge.
4
+ *
5
+ * Tests the pure function directly — no emulator, no Firestore, no HTTP.
6
+ *
7
+ * Regression coverage for the bug that scrambled consumers' `.env` files: an
8
+ * older positional implementation zipped comment lines and value lines by index,
9
+ * so any drift in key order shifted every value under the wrong header. The
10
+ * canonical impl merges BY KEY, so headers always re-anchor to the template and
11
+ * values follow their key across sections.
12
+ */
13
+ const {
14
+ mergeLineBasedFiles,
15
+ DEFAULT_MARKER,
16
+ CUSTOM_MARKER,
17
+ } = require('../../src/utils/merge-line-files.js');
18
+
19
+ // Setup-test helper shim — must re-export the SAME canonical impl (SSOT).
20
+ const helperShim = require('../../src/cli/commands/setup-tests/helpers/merge-line-files.js');
21
+
22
+ // A representative framework .env template (keys grouped under headers).
23
+ const TEMPLATE = [
24
+ DEFAULT_MARKER,
25
+ '# GitHub',
26
+ 'GH_TOKEN=""',
27
+ '',
28
+ '# AI',
29
+ 'OPENAI_API_KEY=""',
30
+ 'ANTHROPIC_API_KEY=""',
31
+ '',
32
+ '# Payment Processors',
33
+ 'PAYPAL_CLIENT_SECRET=""',
34
+ 'STRIPE_SECRET_KEY=""',
35
+ '',
36
+ CUSTOM_MARKER,
37
+ '# Add your custom environment variables below this line',
38
+ ].join('\n');
39
+
40
+ // Pull the value assigned to a key in a given section of a merged result.
41
+ function keyValueInSection(merged, key, section /* 'default' | 'custom' */) {
42
+ const lines = merged.split('\n');
43
+ let mode = null;
44
+ for (const line of lines) {
45
+ const trimmed = line.trim();
46
+ if (trimmed === DEFAULT_MARKER) { mode = 'default'; continue; }
47
+ if (trimmed === CUSTOM_MARKER) { mode = 'custom'; continue; }
48
+ if (mode !== section || !trimmed || trimmed.startsWith('#')) continue;
49
+ const eq = trimmed.indexOf('=');
50
+ if (eq < 0) continue;
51
+ if (trimmed.slice(0, eq).trim() === key) return trimmed.slice(eq + 1);
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ // Count how many times a key appears anywhere (catches duplication bugs).
57
+ function keyOccurrences(merged, key) {
58
+ return merged.split('\n').filter((l) => l.trim().startsWith(`${key}=`)).length;
59
+ }
60
+
61
+ // Assert a key sits directly under the expected comment header in the merged output.
62
+ function headerAboveKey(merged, key) {
63
+ const lines = merged.split('\n');
64
+ const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
65
+ if (idx < 0) return null;
66
+ for (let i = idx - 1; i >= 0; i--) {
67
+ const t = lines[i].trim();
68
+ if (t.startsWith('#') && t !== DEFAULT_MARKER && t !== CUSTOM_MARKER) return t;
69
+ if (t && !t.startsWith('#')) continue; // another key — keep scanning up
70
+ }
71
+ return null;
72
+ }
73
+
74
+ module.exports = {
75
+ description: 'mergeLineBasedFiles() line-based .env / .gitignore merge',
76
+ type: 'group',
77
+
78
+ tests: [
79
+ {
80
+ name: 'ssot-helper-shim-is-same-function',
81
+ async run({ assert }) {
82
+ assert.equal(
83
+ helperShim.mergeLineBasedFiles,
84
+ mergeLineBasedFiles,
85
+ 'setup-tests helper must re-export the canonical mergeLineBasedFiles (SSOT)'
86
+ );
87
+ assert.equal(helperShim.DEFAULT_SECTION_MARKER, DEFAULT_MARKER, 'marker alias matches');
88
+ assert.equal(helperShim.CUSTOM_SECTION_MARKER, CUSTOM_MARKER, 'marker alias matches');
89
+ assert.ok(helperShim.hasSectionMarkers(TEMPLATE), 'hasSectionMarkers detects markers');
90
+ },
91
+ },
92
+
93
+ {
94
+ name: 'preserves-existing-values-by-key',
95
+ async run({ assert }) {
96
+ const existing = [
97
+ DEFAULT_MARKER,
98
+ '# GitHub',
99
+ 'GH_TOKEN="ghp_real"',
100
+ '# AI',
101
+ 'OPENAI_API_KEY="sk-real"',
102
+ 'ANTHROPIC_API_KEY=""',
103
+ '# Payment Processors',
104
+ 'PAYPAL_CLIENT_SECRET="pp_real"',
105
+ 'STRIPE_SECRET_KEY=""',
106
+ CUSTOM_MARKER,
107
+ ].join('\n');
108
+
109
+ const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
110
+ assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"ghp_real"', 'GH_TOKEN value kept');
111
+ assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"sk-real"', 'OPENAI value kept');
112
+ assert.equal(keyValueInSection(merged, 'PAYPAL_CLIENT_SECRET', 'default'), '"pp_real"', 'PAYPAL value kept');
113
+ },
114
+ },
115
+
116
+ {
117
+ name: 'keys-stay-under-correct-header-when-order-drifts',
118
+ async run({ assert }) {
119
+ // Existing file has keys in a DIFFERENT order than the template. The old
120
+ // positional merge would shift values under the wrong header here.
121
+ const existing = [
122
+ DEFAULT_MARKER,
123
+ '# Misc (old grouping)',
124
+ 'STRIPE_SECRET_KEY="stripe_real"',
125
+ 'OPENAI_API_KEY="openai_real"',
126
+ 'GH_TOKEN="gh_real"',
127
+ 'PAYPAL_CLIENT_SECRET="pp_real"',
128
+ 'ANTHROPIC_API_KEY="anthropic_real"',
129
+ CUSTOM_MARKER,
130
+ ].join('\n');
131
+
132
+ const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
133
+
134
+ assert.equal(headerAboveKey(merged, 'OPENAI_API_KEY'), '# AI', 'OPENAI under # AI');
135
+ assert.equal(headerAboveKey(merged, 'ANTHROPIC_API_KEY'), '# AI', 'ANTHROPIC under # AI');
136
+ assert.equal(headerAboveKey(merged, 'PAYPAL_CLIENT_SECRET'), '# Payment Processors', 'PAYPAL under # Payment Processors');
137
+ assert.equal(headerAboveKey(merged, 'STRIPE_SECRET_KEY'), '# Payment Processors', 'STRIPE under # Payment Processors');
138
+ assert.equal(headerAboveKey(merged, 'GH_TOKEN'), '# GitHub', 'GH_TOKEN under # GitHub');
139
+
140
+ // Values still attached to the right keys.
141
+ assert.equal(keyValueInSection(merged, 'STRIPE_SECRET_KEY', 'default'), '"stripe_real"', 'STRIPE value intact');
142
+ assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"openai_real"', 'OPENAI value intact');
143
+ },
144
+ },
145
+
146
+ {
147
+ name: 'promotes-custom-key-to-default-when-template-adopts-it',
148
+ async run({ assert }) {
149
+ // User had APOLLO_API_KEY in Custom; framework adds it to the template.
150
+ const tplWithApollo = TEMPLATE.replace('STRIPE_SECRET_KEY=""', 'STRIPE_SECRET_KEY=""\nAPOLLO_API_KEY=""');
151
+ const existing = [
152
+ DEFAULT_MARKER,
153
+ '# GitHub',
154
+ 'GH_TOKEN="gh_real"',
155
+ '# AI',
156
+ 'OPENAI_API_KEY=""',
157
+ 'ANTHROPIC_API_KEY=""',
158
+ '# Payment Processors',
159
+ 'PAYPAL_CLIENT_SECRET=""',
160
+ 'STRIPE_SECRET_KEY=""',
161
+ CUSTOM_MARKER,
162
+ 'APOLLO_API_KEY="apollo_real"',
163
+ 'GITHUB_TOKEN="legacy"',
164
+ ].join('\n');
165
+
166
+ const merged = mergeLineBasedFiles(existing, tplWithApollo, '.env');
167
+
168
+ assert.equal(keyValueInSection(merged, 'APOLLO_API_KEY', 'default'), '"apollo_real"', 'APOLLO promoted to Default with value');
169
+ assert.equal(keyValueInSection(merged, 'APOLLO_API_KEY', 'custom'), undefined, 'APOLLO removed from Custom');
170
+ assert.equal(keyOccurrences(merged, 'APOLLO_API_KEY'), 1, 'APOLLO not duplicated');
171
+ },
172
+ },
173
+
174
+ {
175
+ name: 'migrates-unknown-default-key-to-custom',
176
+ async run({ assert }) {
177
+ // User has a legacy key in Default that the template no longer defines.
178
+ const existing = [
179
+ DEFAULT_MARKER,
180
+ '# GitHub',
181
+ 'GITHUB_TOKEN="legacy_real"', // template now uses GH_TOKEN
182
+ 'GH_TOKEN="gh_real"',
183
+ '# AI',
184
+ 'OPENAI_API_KEY=""',
185
+ 'ANTHROPIC_API_KEY=""',
186
+ '# Payment Processors',
187
+ 'PAYPAL_CLIENT_SECRET=""',
188
+ 'STRIPE_SECRET_KEY=""',
189
+ CUSTOM_MARKER,
190
+ ].join('\n');
191
+
192
+ const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
193
+
194
+ assert.equal(keyValueInSection(merged, 'GITHUB_TOKEN', 'custom'), '"legacy_real"', 'legacy key migrated to Custom with value');
195
+ assert.equal(keyValueInSection(merged, 'GITHUB_TOKEN', 'default'), undefined, 'legacy key gone from Default');
196
+ assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"gh_real"', 'GH_TOKEN kept in Default');
197
+ },
198
+ },
199
+
200
+ {
201
+ name: 'preserves-custom-section-verbatim',
202
+ async run({ assert }) {
203
+ const existing = [
204
+ DEFAULT_MARKER,
205
+ '# GitHub',
206
+ 'GH_TOKEN=""',
207
+ '# AI',
208
+ 'OPENAI_API_KEY=""',
209
+ 'ANTHROPIC_API_KEY=""',
210
+ '# Payment Processors',
211
+ 'PAYPAL_CLIENT_SECRET=""',
212
+ 'STRIPE_SECRET_KEY=""',
213
+ CUSTOM_MARKER,
214
+ 'MY_CUSTOM_KEY="custom_real"',
215
+ '# OAuth2',
216
+ 'OAUTH_ID="abc"',
217
+ ].join('\n');
218
+
219
+ const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
220
+ assert.equal(keyValueInSection(merged, 'MY_CUSTOM_KEY', 'custom'), '"custom_real"', 'custom key preserved');
221
+ assert.equal(keyValueInSection(merged, 'OAUTH_ID', 'custom'), '"abc"', 'custom OAuth key preserved');
222
+ assert.ok(merged.includes('# OAuth2'), 'custom comment preserved');
223
+ },
224
+ },
225
+
226
+ {
227
+ name: 'normalizes-raw-values-to-double-quoted',
228
+ async run({ assert }) {
229
+ const existing = [
230
+ DEFAULT_MARKER,
231
+ '# GitHub',
232
+ 'GH_TOKEN=raw-no-quotes',
233
+ '# AI',
234
+ "OPENAI_API_KEY='single'",
235
+ 'ANTHROPIC_API_KEY=""',
236
+ '# Payment Processors',
237
+ 'PAYPAL_CLIENT_SECRET=""',
238
+ 'STRIPE_SECRET_KEY=""',
239
+ CUSTOM_MARKER,
240
+ ].join('\n');
241
+
242
+ const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
243
+ assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"raw-no-quotes"', 'raw value double-quoted');
244
+ assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"single"', 'single-quoted canonicalized to double');
245
+ },
246
+ },
247
+
248
+ {
249
+ name: 'is-idempotent',
250
+ async run({ assert }) {
251
+ const existing = [
252
+ DEFAULT_MARKER,
253
+ '# GitHub',
254
+ 'GH_TOKEN="gh_real"',
255
+ '# AI',
256
+ 'OPENAI_API_KEY="sk"',
257
+ 'ANTHROPIC_API_KEY=""',
258
+ '# Payment Processors',
259
+ 'PAYPAL_CLIENT_SECRET=""',
260
+ 'STRIPE_SECRET_KEY=""',
261
+ CUSTOM_MARKER,
262
+ 'CUSTOM="x"',
263
+ ].join('\n');
264
+
265
+ const once = mergeLineBasedFiles(existing, TEMPLATE, '.env');
266
+ const twice = mergeLineBasedFiles(once, TEMPLATE, '.env');
267
+ assert.equal(once, twice, 're-running the merge produces identical output');
268
+ },
269
+ },
270
+ ],
271
+ };
@@ -446,11 +446,20 @@ module.exports = {
446
446
  async run({ http, firestore, assert, accounts }) {
447
447
  const uid = accounts['signup-merge'].uid;
448
448
 
449
- // Seed real/custom state that the signup write must NOT clobber, plus a deliberately
450
- // PARTIAL attribution (only affiliate.code) to prove leaves get filled, not replaced-away.
449
+ // Capture the REAL api keys onCreate generated — the signup write must preserve these
450
+ // exactly (regenerating them would break the user's API access AND this test's auth,
451
+ // since http.as() authenticates with api.privateKey). We assert against the real values,
452
+ // NOT a seeded fake (seeding a fake privateKey would 401 the request).
453
+ const before = await firestore.get(`users/${uid}`);
454
+ const realClientId = before?.api?.clientId;
455
+ const realPrivateKey = before?.api?.privateKey;
456
+ const realAffiliateCode = before?.affiliate?.code;
457
+
458
+ // Seed adversarial NON-auth state the signup write must NOT clobber: a paid subscription,
459
+ // admin role, a custom non-schema field, and a deliberately PARTIAL attribution (only
460
+ // affiliate.code) to prove leaves get filled, not replaced-away. We do NOT touch api.* —
461
+ // that's the auth credential and is asserted-preserved via the captured real values.
451
462
  await firestore.set(`users/${uid}`, {
452
- api: { clientId: 'REAL-CLIENT-ID', privateKey: 'REAL-PRIVATE-KEY' },
453
- affiliate: { code: 'REALAFFILIATECODE', referrals: [{ uid: 'someone' }] },
454
463
  subscription: { product: { id: 'pro', name: 'Pro' }, status: 'active' },
455
464
  roles: { admin: true, betaTester: false, developer: false },
456
465
  attribution: { affiliate: { code: 'PARTIALONLY' } },
@@ -466,9 +475,9 @@ module.exports = {
466
475
  const doc = await firestore.get(`users/${uid}`);
467
476
 
468
477
  // (b) Existing real values must survive untouched — NOT regenerated/reset by the schema layer.
469
- assert.equal(doc?.api?.clientId, 'REAL-CLIENT-ID', 'api.clientId must be preserved (not regenerated)');
470
- assert.equal(doc?.api?.privateKey, 'REAL-PRIVATE-KEY', 'api.privateKey must be preserved (not regenerated)');
471
- assert.equal(doc?.affiliate?.code, 'REALAFFILIATECODE', 'affiliate.code must be preserved (not regenerated)');
478
+ assert.equal(doc?.api?.clientId, realClientId, 'api.clientId must be preserved (not regenerated)');
479
+ assert.equal(doc?.api?.privateKey, realPrivateKey, 'api.privateKey must be preserved (not regenerated)');
480
+ assert.equal(doc?.affiliate?.code, realAffiliateCode, 'affiliate.code must be preserved (not regenerated)');
472
481
  assert.equal(doc?.subscription?.product?.id, 'pro', 'subscription must be preserved (not reset to basic)');
473
482
  assert.equal(doc?.roles?.admin, true, 'roles.admin must be preserved (not reset to false)');
474
483