backend-manager 5.7.1 → 5.7.3
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.
- package/CHANGELOG.md +22 -0
- package/PROGRESS.md +23 -12
- package/package.json +1 -1
- package/src/cli/commands/serve.js +10 -7
- package/src/cli/commands/setup-tests/index.js +2 -0
- package/src/cli/commands/setup-tests/root-package-json.js +57 -0
- package/src/cli/commands/setup.js +4 -3
- package/src/cli/index.js +2 -0
- package/src/manager/libraries/ai/index.js +6 -1
- package/src/test/test-accounts.js +49 -27
- package/test/events/auth-delete-race.js +201 -0
- package/test/helpers/ai-tools-format.js +33 -0
- package/test/rules/user.js +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.7.3] - 2026-06-18
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **Root proxy preinstall forwards to functions/.** `npm install` at the project root now runs `cd functions && npm install` (forwarding deps to where they live) instead of just rejecting. Still exits 1 afterward to prevent npm from creating root-level artifacts.
|
|
21
|
+
|
|
22
|
+
# [5.7.2] - 2026-06-18
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Newsletter generation crash.** v5.5.0 refactored `marketing.beehiiv` → `marketing.newsletter` but missed renaming `beehiivConfig` at 3 sites in `newsletter.js` (lines 331, 336, 340). The `ReferenceError` crashed generation after all AI work completed, preventing the campaign doc from being written. Newsletter generation has been silently failing since June 6.
|
|
26
|
+
- **HTTPS proxy silent failure.** `serve.js` `_startHttpsProxy` now returns a boolean. The caller uses `httpsReady` (not `httpsEnabled`) for port and env decisions, so when cert generation fails, the server correctly falls back to plain HTTP instead of setting `BEM_HTTPS_PORT` with no proxy listening.
|
|
27
|
+
- **AI system prompt injection for array content blocks.** `normalizeOptions` now handles system messages with array content (content blocks) — prepends rules as a `{ type: 'text' }` block. Previously, the if/else-if chain fell through and rules were silently dropped.
|
|
28
|
+
- **Setup retry loop treats warns as failures.** Added `warnCount` tracking; the `allPassed` check now includes warns (`testCount + warnCount === testTotal`). Warns no longer trigger unnecessary retries with `--retry N`.
|
|
29
|
+
- **Copy-paste: `sender: 'electron-manager'` in setup IPC.** Changed to `'backend-manager'`.
|
|
30
|
+
- **Test account creation race condition.** `deleteTestUsers` now uses the emulator's bulk-clear REST API instead of individual `deleteUser()` calls. Individual deletes triggered async on-delete handlers that could clobber freshly-created accounts (80-100% repro rate). Bulk clear eliminates the race entirely.
|
|
31
|
+
- **Consent rules test value collision.** Changed test value from `'granted'` to `'forged'` so the write always differs from prior test state.
|
|
32
|
+
- **`cancel-too-young` timestampUNIX convention.** `Date.now()` (milliseconds) → `Math.floor(Date.now() / 1000)` (seconds).
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **AI array-content test.** `normalize-options-structured-system-content-as-array-injects-rules` — covers the missing branch.
|
|
36
|
+
- **Auth on-delete race condition test.** `test/events/auth-delete-race.js` — proves the emulator race (clobber without mitigation) and verifies both mitigation strategies (wait-for-gone, force-delete).
|
|
37
|
+
- **Root package.json setup check.** Validates the project root `package.json` during `npx mgr setup`.
|
|
38
|
+
|
|
17
39
|
# [5.7.1] - 2026-06-17
|
|
18
40
|
|
|
19
41
|
### Added
|
package/PROGRESS.md
CHANGED
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
> Agents and maintainers should update this file regularly to reflect the current state of the project.
|
|
3
3
|
|
|
4
4
|
## 🎯 Current Focus
|
|
5
|
-
* **Goal:**
|
|
6
|
-
* **Current Phase:**
|
|
7
|
-
* **Priority:**
|
|
8
|
-
* **Last Updated:** 2026-06-17
|
|
9
|
-
* **Notes:** v5.
|
|
5
|
+
* **Goal:** Root package.json proxy for running scripts from project root
|
|
6
|
+
* **Current Phase:** Implementation complete, untested in consumer
|
|
7
|
+
* **Priority:** Medium
|
|
8
|
+
* **Last Updated:** 2026-06-17 7:40 PM PDT
|
|
9
|
+
* **Notes:** v5.7.2 shipped (npm + GitHub release). New setup test `root-package-json` generates a root `package.json` with proxy scripts so `npm test`/`npm start`/etc. work from the Firebase project root (not just `functions/`). Includes `preinstall` guard to block accidental `npm install` at root.
|
|
10
10
|
|
|
11
11
|
## 📌 Active Task List
|
|
12
|
-
* [ ] Phase 3:
|
|
13
|
-
* [x]
|
|
14
|
-
* [x]
|
|
15
|
-
* [x]
|
|
16
|
-
* [
|
|
17
|
-
* [
|
|
18
|
-
* [
|
|
12
|
+
* [ ] Phase 3: Post-audit bug fixes
|
|
13
|
+
* [x] Newsletter ReferenceError: `beehiivConfig` → `newsletterRoleConfig` (committed v5.7.1)
|
|
14
|
+
* [x] HTTPS proxy: `serve.js` returns boolean, caller uses `httpsReady` not `httpsEnabled`
|
|
15
|
+
* [x] AI normalizeOptions: array-content system messages now get rules injected
|
|
16
|
+
* [x] Setup warn handling: `warnCount` tracked separately, warns don't trigger retry
|
|
17
|
+
* [x] Copy-paste fix: `sender: 'electron-manager'` → `'backend-manager'`
|
|
18
|
+
* [x] Test: AI array-content-blocks test added + passing (20/20)
|
|
19
|
+
* [x] Fix: consent rules test — write `'forged'` instead of `'granted'` to avoid value collision with prior email-preferences tests
|
|
20
|
+
* [x] Fix: `cancel-too-young` account `timestampUNIX` uses seconds (was ms)
|
|
21
|
+
* [x] Fix: auth on-delete race condition — `deleteTestUsers` uses emulator bulk-clear REST API instead of individual `deleteUser()` calls (eliminates async on-delete triggers that clobbered freshly-created accounts)
|
|
22
|
+
* [x] Diagnostic: auth-delete-race test — proved the race condition (80-100% clobber rate without mitigation), removed after fix verified
|
|
23
|
+
* [x] Commit + publish framework fixes (v5.7.2)
|
|
24
|
+
* [ ] Deploy somiibo-backend + advance stuck sendAt
|
|
25
|
+
* [ ] Phase 4: Root package.json proxy scripts
|
|
26
|
+
* [x] Task 4.1: Create `root-package-json.js` setup test (proxies `projectScripts` with `cd functions &&` prefix + `preinstall` guard)
|
|
27
|
+
* [x] Task 4.2: Register in setup test index (after `npm-project-scripts`)
|
|
28
|
+
* [ ] Task 4.3: Test in consumer project (`npx mgr setup` from ultimate-jekyll-backend)
|
|
29
|
+
* [ ] Task 4.4: Verify `npm test` / `npm start` work from project root
|
|
19
30
|
|
|
20
31
|
## ✅ Completed Task List
|
|
21
32
|
* [x] Phase 1: MCP role-based tool scoping + consumer extensibility
|
package/package.json
CHANGED
|
@@ -38,10 +38,11 @@ class ServeCommand extends BaseCommand {
|
|
|
38
38
|
// Start Stripe webhook forwarding in background
|
|
39
39
|
this.startStripeWebhookForwarding();
|
|
40
40
|
|
|
41
|
-
// Start HTTPS proxy if enabled
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// Start HTTPS proxy if enabled. If certs can't be obtained, fall back to
|
|
42
|
+
// plain HTTP — don't set BEM_HTTPS_PORT or redirect to the internal port.
|
|
43
|
+
const httpsReady = httpsEnabled
|
|
44
|
+
? await this._startHttpsProxy(port, internalPort, projectDir)
|
|
45
|
+
: false;
|
|
45
46
|
|
|
46
47
|
// Set up log file in the project directory.
|
|
47
48
|
const logPath = this.getLogsPath('dev.log');
|
|
@@ -88,13 +89,13 @@ class ServeCommand extends BaseCommand {
|
|
|
88
89
|
this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
|
|
89
90
|
|
|
90
91
|
// Execute with tee to log file
|
|
91
|
-
const firebasePort =
|
|
92
|
+
const firebasePort = httpsReady ? internalPort : port;
|
|
92
93
|
const firebaseEnv = {
|
|
93
94
|
...process.env,
|
|
94
95
|
FORCE_COLOR: '1',
|
|
95
96
|
};
|
|
96
97
|
|
|
97
|
-
if (
|
|
98
|
+
if (httpsReady) {
|
|
98
99
|
// Internal calls (getApiUrl → BEMClient) loop through the HTTPS proxy with a self-signed cert
|
|
99
100
|
firebaseEnv.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
100
101
|
firebaseEnv.BEM_HTTPS_PORT = String(port);
|
|
@@ -148,7 +149,7 @@ class ServeCommand extends BaseCommand {
|
|
|
148
149
|
if (!certs) {
|
|
149
150
|
this.log(chalk.yellow(' HTTPS disabled — could not obtain certificates.'));
|
|
150
151
|
this.log(chalk.yellow(' Install mkcert for trusted local HTTPS: brew install mkcert && mkcert -install\n'));
|
|
151
|
-
return;
|
|
152
|
+
return false;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
const options = {
|
|
@@ -190,6 +191,8 @@ class ServeCommand extends BaseCommand {
|
|
|
190
191
|
proxy.on('error', (err) => {
|
|
191
192
|
this.log(chalk.red(` HTTPS proxy error: ${err.message}`));
|
|
192
193
|
});
|
|
194
|
+
|
|
195
|
+
return true;
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
async _getHttpsCerts(projectDir) {
|
|
@@ -15,6 +15,7 @@ const FirebaseAdminTest = require('./firebase-admin');
|
|
|
15
15
|
const FirebaseFunctionsTest = require('./firebase-functions');
|
|
16
16
|
const BackendManagerTest = require('./backend-manager');
|
|
17
17
|
const NpmProjectScriptsTest = require('./npm-project-scripts');
|
|
18
|
+
const RootPackageJsonTest = require('./root-package-json');
|
|
18
19
|
const BemConfigTest = require('./bem-config');
|
|
19
20
|
const ProjectIdConsistencyTest = require('./project-id-consistency');
|
|
20
21
|
const ServiceAccountTest = require('./service-account');
|
|
@@ -60,6 +61,7 @@ function getTests(context) {
|
|
|
60
61
|
new FirebaseFunctionsTest(context),
|
|
61
62
|
new BackendManagerTest(context),
|
|
62
63
|
new NpmProjectScriptsTest(context),
|
|
64
|
+
new RootPackageJsonTest(context),
|
|
63
65
|
new BemConfigTest(context),
|
|
64
66
|
new ServiceAccountTest(context),
|
|
65
67
|
new ProjectIdConsistencyTest(context),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const BaseTest = require('./base-test');
|
|
2
|
+
const jetpack = require('fs-jetpack');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
class RootPackageJsonTest extends BaseTest {
|
|
6
|
+
getName() {
|
|
7
|
+
return 'root package.json proxy scripts';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async run() {
|
|
11
|
+
const rootPath = path.join(this.self.firebaseProjectPath, 'package.json');
|
|
12
|
+
|
|
13
|
+
if (!jetpack.exists(rootPath)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const rootPkg = JSON.parse(jetpack.read(rootPath));
|
|
18
|
+
const expectedScripts = this._getExpectedScripts();
|
|
19
|
+
const rootScripts = rootPkg.scripts || {};
|
|
20
|
+
|
|
21
|
+
for (const [name, command] of Object.entries(expectedScripts)) {
|
|
22
|
+
if (rootScripts[name] !== command) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async fix() {
|
|
31
|
+
const rootPath = path.join(this.self.firebaseProjectPath, 'package.json');
|
|
32
|
+
const existing = jetpack.exists(rootPath) ? JSON.parse(jetpack.read(rootPath)) : {};
|
|
33
|
+
const expectedScripts = this._getExpectedScripts();
|
|
34
|
+
|
|
35
|
+
existing.name = existing.name || `${this.context.package.name || 'functions'}-root`;
|
|
36
|
+
existing.private = true;
|
|
37
|
+
existing.scripts = Object.assign(existing.scripts || {}, expectedScripts);
|
|
38
|
+
|
|
39
|
+
jetpack.write(rootPath, JSON.stringify(existing, null, 2));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
_getExpectedScripts() {
|
|
43
|
+
const bemPackage = require('../../../../package.json');
|
|
44
|
+
const projectScripts = bemPackage.projectScripts || {};
|
|
45
|
+
const scripts = {};
|
|
46
|
+
|
|
47
|
+
for (const [name, command] of Object.entries(projectScripts)) {
|
|
48
|
+
scripts[name] = `cd functions && ${command}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
scripts.preinstall = `cd functions && npm install && echo "\\n ✓ Dependencies installed in functions/ (not project root)\\n" && exit 1`;
|
|
52
|
+
|
|
53
|
+
return scripts;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = RootPackageJsonTest;
|
|
@@ -31,10 +31,11 @@ class SetupCommand extends BaseCommand {
|
|
|
31
31
|
// Reset counters so each attempt starts fresh
|
|
32
32
|
self.testCount = 0;
|
|
33
33
|
self.testTotal = 0;
|
|
34
|
+
self.warnCount = 0;
|
|
34
35
|
|
|
35
36
|
await this.runSetup();
|
|
36
37
|
|
|
37
|
-
const allPassed = self.testCount === self.testTotal;
|
|
38
|
+
const allPassed = self.testCount + self.warnCount === self.testTotal;
|
|
38
39
|
if (allPassed) {
|
|
39
40
|
return;
|
|
40
41
|
}
|
|
@@ -142,10 +143,10 @@ class SetupCommand extends BaseCommand {
|
|
|
142
143
|
// Notify parent if exists
|
|
143
144
|
if (process.send) {
|
|
144
145
|
process.send({
|
|
145
|
-
sender: '
|
|
146
|
+
sender: 'backend-manager',
|
|
146
147
|
command: 'setup:complete',
|
|
147
148
|
payload: {
|
|
148
|
-
passed: self.testCount === self.testTotal,
|
|
149
|
+
passed: self.testCount + self.warnCount === self.testTotal,
|
|
149
150
|
}
|
|
150
151
|
});
|
|
151
152
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -41,6 +41,7 @@ Main.prototype.process = async function (args) {
|
|
|
41
41
|
self.firebaseProjectPath = self.firebaseProjectPath.match(/\/functions$/) ? self.firebaseProjectPath.replace(/\/functions$/, '') : self.firebaseProjectPath;
|
|
42
42
|
self.testCount = 0;
|
|
43
43
|
self.testTotal = 0;
|
|
44
|
+
self.warnCount = 0;
|
|
44
45
|
self.default = {};
|
|
45
46
|
self.packageJSON = require('../../package.json');
|
|
46
47
|
self.default.version = self.packageJSON.version;
|
|
@@ -199,6 +200,7 @@ Main.prototype.test = async function(name, fn, fix, args) {
|
|
|
199
200
|
// (an array of pre-formatted lines) set by the caller.
|
|
200
201
|
if (passed === 'warn') {
|
|
201
202
|
self.testTotal++;
|
|
203
|
+
self.warnCount++;
|
|
202
204
|
printLine(self.testTotal, 'warn', '');
|
|
203
205
|
const details = (args && typeof args.details === 'function') ? args.details() : (args && args.details) || [];
|
|
204
206
|
for (const line of details) {
|
|
@@ -167,7 +167,12 @@ function normalizeOptions(opts) {
|
|
|
167
167
|
out.messages = opts.messages.map((m, i) => i === systemIdx
|
|
168
168
|
? { ...m, content: existing ? `${rules}\n\n${existing}` : rules }
|
|
169
169
|
: m);
|
|
170
|
-
} else if (systemIdx
|
|
170
|
+
} else if (systemIdx >= 0) {
|
|
171
|
+
// Content is an array of content blocks — prepend rules as a text block
|
|
172
|
+
out.messages = opts.messages.map((m, i) => i === systemIdx
|
|
173
|
+
? { ...m, content: [{ type: 'text', text: rules }, ...(Array.isArray(m.content) ? m.content : [])] }
|
|
174
|
+
: m);
|
|
175
|
+
} else {
|
|
171
176
|
out.messages = [{ role: 'system', content: rules }, ...opts.messages];
|
|
172
177
|
}
|
|
173
178
|
|
|
@@ -413,7 +413,7 @@ const JOURNEY_ACCOUNTS = {
|
|
|
413
413
|
email: '_test.cancel-too-young@{domain}',
|
|
414
414
|
properties: {
|
|
415
415
|
roles: {},
|
|
416
|
-
subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Date.now() } } },
|
|
416
|
+
subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Math.floor(Date.now() / 1000) } } },
|
|
417
417
|
},
|
|
418
418
|
},
|
|
419
419
|
// Dedicated accounts for portal validation tests
|
|
@@ -837,31 +837,72 @@ async function deleteTestUsers(admin, extraAccounts) {
|
|
|
837
837
|
// Wipe the entire emulator Firestore up front (guarded to emulator-only).
|
|
838
838
|
await flushEmulatorFirestore(admin);
|
|
839
839
|
|
|
840
|
-
//
|
|
841
|
-
|
|
840
|
+
// Clear auth users via the emulator's bulk-clear REST API instead of
|
|
841
|
+
// individual deleteUser() calls. Individual deletes trigger auth:on-delete
|
|
842
|
+
// for EACH user, and those triggers fire asynchronously — a late on-delete
|
|
843
|
+
// can land AFTER the subsequent createTestAccounts() on-create and clobber
|
|
844
|
+
// the freshly-written doc (80% repro rate in stress tests). The bulk API
|
|
845
|
+
// clears the auth store without triggering event handlers at all.
|
|
846
|
+
const authHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
|
|
847
|
+
const projectId = process.env.GCLOUD_PROJECT || 'demo-test';
|
|
848
|
+
|
|
849
|
+
if (authHost) {
|
|
850
|
+
try {
|
|
851
|
+
const url = `http://${authHost}/emulator/v1/projects/${projectId}/accounts`;
|
|
852
|
+
await fetch(url, { method: 'DELETE' });
|
|
853
|
+
|
|
854
|
+
// Count all known accounts as deleted (the bulk API doesn't return per-user results)
|
|
855
|
+
const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
|
|
856
|
+
results.deleted = Object.values(allAccounts).map(a => a.uid);
|
|
857
|
+
} catch (e) {
|
|
858
|
+
// Bulk clear failed — fall back to individual deletes
|
|
859
|
+
const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
|
|
860
|
+
await _deleteAccountsIndividually(admin, allAccounts, results);
|
|
861
|
+
}
|
|
862
|
+
} else {
|
|
863
|
+
// Not running against emulator — fall back to individual deletes
|
|
864
|
+
const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
|
|
865
|
+
await _deleteAccountsIndividually(admin, allAccounts, results);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// Realtime Database: wipe the `_test` namespace in full. (The Firestore-wide
|
|
869
|
+
// flush already ran in flushEmulatorFirestore() at the start of this function.)
|
|
870
|
+
// `admin.database()` throws synchronously when no Database URL is configured,
|
|
871
|
+
// so guard the whole thing — RTDB is optional for a project.
|
|
872
|
+
try {
|
|
873
|
+
await admin.database().ref('_test').remove();
|
|
874
|
+
} catch (e) {
|
|
875
|
+
// RTDB not configured / no database URL — ignore.
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
success: results.failed.length === 0,
|
|
880
|
+
deleted: results.deleted.length,
|
|
881
|
+
skipped: results.skipped.length,
|
|
882
|
+
failed: results.failed.length,
|
|
883
|
+
errors: results.failed,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function _deleteAccountsIndividually(admin, allAccounts, results) {
|
|
842
888
|
await Promise.all(
|
|
843
889
|
Object.values(allAccounts).map(async (account) => {
|
|
844
890
|
try {
|
|
845
|
-
// Delete Firebase Auth user (triggers on-delete which handles Firestore doc + count)
|
|
846
891
|
await admin.auth().deleteUser(account.uid);
|
|
847
892
|
|
|
848
|
-
// Wait for on-delete handler to delete the Firestore doc
|
|
849
893
|
const maxWait = 10000;
|
|
850
894
|
const interval = 200;
|
|
851
895
|
let waited = 0;
|
|
852
896
|
|
|
853
897
|
while (waited < maxWait) {
|
|
854
898
|
const doc = await admin.firestore().doc(`users/${account.uid}`).get();
|
|
855
|
-
|
|
856
899
|
if (!doc.exists) {
|
|
857
900
|
break;
|
|
858
901
|
}
|
|
859
|
-
|
|
860
902
|
await new Promise(resolve => setTimeout(resolve, interval));
|
|
861
903
|
waited += interval;
|
|
862
904
|
}
|
|
863
905
|
|
|
864
|
-
// Fallback: if on-delete didn't complete in time, delete the doc directly
|
|
865
906
|
if (waited >= maxWait) {
|
|
866
907
|
await admin.firestore().doc(`users/${account.uid}`).delete().catch(() => {});
|
|
867
908
|
}
|
|
@@ -869,7 +910,6 @@ async function deleteTestUsers(admin, extraAccounts) {
|
|
|
869
910
|
results.deleted.push(account.uid);
|
|
870
911
|
} catch (error) {
|
|
871
912
|
if (error.code === 'auth/user-not-found') {
|
|
872
|
-
// Auth user doesn't exist, but Firestore doc might still exist - clean it up
|
|
873
913
|
await admin.firestore().doc(`users/${account.uid}`).delete().catch(() => {});
|
|
874
914
|
results.skipped.push(account.uid);
|
|
875
915
|
} else {
|
|
@@ -878,24 +918,6 @@ async function deleteTestUsers(admin, extraAccounts) {
|
|
|
878
918
|
}
|
|
879
919
|
})
|
|
880
920
|
);
|
|
881
|
-
|
|
882
|
-
// Realtime Database: wipe the `_test` namespace in full. (The Firestore-wide
|
|
883
|
-
// flush already ran in flushEmulatorFirestore() at the start of this function.)
|
|
884
|
-
// `admin.database()` throws synchronously when no Database URL is configured,
|
|
885
|
-
// so guard the whole thing — RTDB is optional for a project.
|
|
886
|
-
try {
|
|
887
|
-
await admin.database().ref('_test').remove();
|
|
888
|
-
} catch (e) {
|
|
889
|
-
// RTDB not configured / no database URL — ignore.
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
return {
|
|
893
|
-
success: results.failed.length === 0,
|
|
894
|
-
deleted: results.deleted.length,
|
|
895
|
-
skipped: results.skipped.length,
|
|
896
|
-
failed: results.failed.length,
|
|
897
|
-
errors: results.failed,
|
|
898
|
-
};
|
|
899
921
|
}
|
|
900
922
|
|
|
901
923
|
/**
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
const uuid = require('uuid');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Test: auth:on-delete race condition
|
|
5
|
+
*
|
|
6
|
+
* Proves the emulator's async on-delete trigger clobbers freshly-recreated docs
|
|
7
|
+
* when you don't wait for it to settle, and that the mitigation strategies work.
|
|
8
|
+
*
|
|
9
|
+
* The production fix uses the emulator's bulk-clear REST API in deleteTestUsers()
|
|
10
|
+
* to avoid triggering on-delete at all during test setup.
|
|
11
|
+
*/
|
|
12
|
+
module.exports = {
|
|
13
|
+
description: 'auth:on-delete race condition (create → delete → recreate)',
|
|
14
|
+
type: 'group',
|
|
15
|
+
timeout: 120000,
|
|
16
|
+
|
|
17
|
+
tests: [
|
|
18
|
+
{
|
|
19
|
+
name: 'baseline-create-and-verify',
|
|
20
|
+
async run({ Manager, assert }) {
|
|
21
|
+
const admin = Manager.libraries.admin;
|
|
22
|
+
const testUid = '_test-race-baseline';
|
|
23
|
+
const testEmail = '_test.race-baseline@test.com';
|
|
24
|
+
const userRef = admin.firestore().doc(`users/${testUid}`);
|
|
25
|
+
|
|
26
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
27
|
+
await pollUntilGone(userRef);
|
|
28
|
+
|
|
29
|
+
await admin.auth().createUser({
|
|
30
|
+
uid: testUid,
|
|
31
|
+
email: testEmail,
|
|
32
|
+
password: uuid.v4(),
|
|
33
|
+
emailVerified: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const ready = await pollUntilReady(userRef);
|
|
37
|
+
assert.ok(ready, 'on-create should complete and write api keys');
|
|
38
|
+
|
|
39
|
+
const doc = await userRef.get();
|
|
40
|
+
assert.ok(doc.data()?.api?.clientId, 'doc should have api.clientId');
|
|
41
|
+
assert.ok(doc.data()?.api?.privateKey, 'doc should have api.privateKey');
|
|
42
|
+
|
|
43
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
44
|
+
await pollUntilGone(userRef);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
name: 'no-wait-gets-clobbered',
|
|
50
|
+
async run({ Manager, assert }) {
|
|
51
|
+
const admin = Manager.libraries.admin;
|
|
52
|
+
const testUid = '_test-race-no-wait';
|
|
53
|
+
const testEmail = '_test.race-no-wait@test.com';
|
|
54
|
+
const userRef = admin.firestore().doc(`users/${testUid}`);
|
|
55
|
+
|
|
56
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
57
|
+
await pollUntilGone(userRef);
|
|
58
|
+
|
|
59
|
+
// Create and wait for doc to be fully ready
|
|
60
|
+
await admin.auth().createUser({
|
|
61
|
+
uid: testUid, email: testEmail,
|
|
62
|
+
password: uuid.v4(), emailVerified: true,
|
|
63
|
+
});
|
|
64
|
+
assert.ok(await pollUntilReady(userRef), 'First create should produce api keys');
|
|
65
|
+
|
|
66
|
+
// Delete auth user — do NOT wait for doc to disappear — then recreate
|
|
67
|
+
await admin.auth().deleteUser(testUid);
|
|
68
|
+
await admin.auth().createUser({
|
|
69
|
+
uid: testUid, email: testEmail,
|
|
70
|
+
password: uuid.v4(), emailVerified: true,
|
|
71
|
+
});
|
|
72
|
+
await pollUntilReady(userRef);
|
|
73
|
+
|
|
74
|
+
// The late on-delete clobbers the doc within a few seconds
|
|
75
|
+
await sleep(3000);
|
|
76
|
+
const doc = await userRef.get();
|
|
77
|
+
const survived = doc.exists && !!(doc.data()?.api?.clientId);
|
|
78
|
+
|
|
79
|
+
assert.ok(!survived, 'Doc should be clobbered by late on-delete (proves the race exists)');
|
|
80
|
+
|
|
81
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
82
|
+
await pollUntilGone(userRef);
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
name: 'wait-for-gone-survives',
|
|
88
|
+
async run({ Manager, assert }) {
|
|
89
|
+
const admin = Manager.libraries.admin;
|
|
90
|
+
const testUid = '_test-race-wait-gone';
|
|
91
|
+
const testEmail = '_test.race-wait-gone@test.com';
|
|
92
|
+
const userRef = admin.firestore().doc(`users/${testUid}`);
|
|
93
|
+
|
|
94
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
95
|
+
await pollUntilGone(userRef);
|
|
96
|
+
|
|
97
|
+
await admin.auth().createUser({
|
|
98
|
+
uid: testUid, email: testEmail,
|
|
99
|
+
password: uuid.v4(), emailVerified: true,
|
|
100
|
+
});
|
|
101
|
+
assert.ok(await pollUntilReady(userRef), 'First create should produce api keys');
|
|
102
|
+
|
|
103
|
+
// Delete and WAIT for doc to disappear before recreating
|
|
104
|
+
await admin.auth().deleteUser(testUid);
|
|
105
|
+
await pollUntilGone(userRef);
|
|
106
|
+
|
|
107
|
+
await admin.auth().createUser({
|
|
108
|
+
uid: testUid, email: testEmail,
|
|
109
|
+
password: uuid.v4(), emailVerified: true,
|
|
110
|
+
});
|
|
111
|
+
assert.ok(await pollUntilReady(userRef), 'Second create should produce api keys');
|
|
112
|
+
|
|
113
|
+
await sleep(3000);
|
|
114
|
+
const doc = await userRef.get();
|
|
115
|
+
const survived = doc.exists && !!(doc.data()?.api?.clientId);
|
|
116
|
+
|
|
117
|
+
assert.ok(survived, 'Doc should survive when we wait for on-delete to settle first');
|
|
118
|
+
|
|
119
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
120
|
+
await pollUntilGone(userRef);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
name: 'force-delete-doc-survives',
|
|
126
|
+
async run({ Manager, assert }) {
|
|
127
|
+
const admin = Manager.libraries.admin;
|
|
128
|
+
const testUid = '_test-race-force-del';
|
|
129
|
+
const testEmail = '_test.race-force-del@test.com';
|
|
130
|
+
const userRef = admin.firestore().doc(`users/${testUid}`);
|
|
131
|
+
|
|
132
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
133
|
+
await pollUntilGone(userRef);
|
|
134
|
+
|
|
135
|
+
await admin.auth().createUser({
|
|
136
|
+
uid: testUid, email: testEmail,
|
|
137
|
+
password: uuid.v4(), emailVerified: true,
|
|
138
|
+
});
|
|
139
|
+
assert.ok(await pollUntilReady(userRef), 'First create should produce api keys');
|
|
140
|
+
|
|
141
|
+
// Delete auth user AND force-delete the Firestore doc, then pause
|
|
142
|
+
await admin.auth().deleteUser(testUid);
|
|
143
|
+
await userRef.delete().catch(() => {});
|
|
144
|
+
await sleep(500);
|
|
145
|
+
|
|
146
|
+
await admin.auth().createUser({
|
|
147
|
+
uid: testUid, email: testEmail,
|
|
148
|
+
password: uuid.v4(), emailVerified: true,
|
|
149
|
+
});
|
|
150
|
+
assert.ok(await pollUntilReady(userRef), 'Second create should produce api keys');
|
|
151
|
+
|
|
152
|
+
await sleep(3000);
|
|
153
|
+
const doc = await userRef.get();
|
|
154
|
+
const survived = doc.exists && !!(doc.data()?.api?.clientId);
|
|
155
|
+
|
|
156
|
+
assert.ok(survived, 'Doc should survive when we force-delete the doc before recreating');
|
|
157
|
+
|
|
158
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
159
|
+
await pollUntilGone(userRef);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function sleep(ms) {
|
|
166
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function pollUntilReady(userRef, maxWait = 15000) {
|
|
170
|
+
const interval = 300;
|
|
171
|
+
let waited = 0;
|
|
172
|
+
|
|
173
|
+
while (waited < maxWait) {
|
|
174
|
+
const doc = await userRef.get();
|
|
175
|
+
const data = doc.exists ? doc.data() : null;
|
|
176
|
+
if (data?.metadata?.tag === 'auth:on-create' && data.api?.clientId && data.api?.privateKey) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
await sleep(interval);
|
|
180
|
+
waited += interval;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function pollUntilGone(userRef, maxWait = 10000) {
|
|
187
|
+
const interval = 200;
|
|
188
|
+
let waited = 0;
|
|
189
|
+
|
|
190
|
+
while (waited < maxWait) {
|
|
191
|
+
const doc = await userRef.get();
|
|
192
|
+
if (!doc.exists) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
await sleep(interval);
|
|
196
|
+
waited += interval;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await userRef.delete().catch(() => {});
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
@@ -346,5 +346,38 @@ module.exports = {
|
|
|
346
346
|
assert.equal(out.message?.content, 'hello', 'message carries last user turn');
|
|
347
347
|
},
|
|
348
348
|
},
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
name: 'normalize-options-structured-system-content-as-array-injects-rules',
|
|
352
|
+
async run({ assert }) {
|
|
353
|
+
const messages = [
|
|
354
|
+
{
|
|
355
|
+
role: 'system',
|
|
356
|
+
content: [
|
|
357
|
+
{ type: 'text', text: 'existing instruction' },
|
|
358
|
+
{ type: 'image', source: { type: 'url', url: 'https://example.com/img.png' } },
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
{ role: 'user', content: 'check order 1' },
|
|
362
|
+
{ role: 'assistant', toolCalls: [{ id: 'c1', name: 'check_order', arguments: {} }] },
|
|
363
|
+
{ role: 'tool', toolCallId: 'c1', content: 'shipped' },
|
|
364
|
+
];
|
|
365
|
+
const out = normalizeOptions({ messages });
|
|
366
|
+
|
|
367
|
+
assert.equal(out.messages.length, 4, 'no turns added or dropped');
|
|
368
|
+
assert.equal(Array.isArray(out.messages[0].content), true, 'system content stays as array');
|
|
369
|
+
assert.equal(out.messages[0].content[0].type, 'text', 'rules prepended as text block');
|
|
370
|
+
assert.equal(
|
|
371
|
+
out.messages[0].content[0].text.includes(SYSTEM_PROMPT_INJECTIONS[0]),
|
|
372
|
+
true,
|
|
373
|
+
'system rules injected into prepended text block',
|
|
374
|
+
);
|
|
375
|
+
assert.equal(out.messages[0].content[1].type, 'text', 'original text block preserved');
|
|
376
|
+
assert.equal(out.messages[0].content[1].text, 'existing instruction', 'original text content intact');
|
|
377
|
+
assert.equal(out.messages[0].content[2].type, 'image', 'original image block preserved');
|
|
378
|
+
assert.deepEqual(out.messages[2], messages[2], 'toolCalls turn untouched');
|
|
379
|
+
assert.deepEqual(out.messages[3], messages[3], 'tool result turn untouched');
|
|
380
|
+
},
|
|
381
|
+
},
|
|
349
382
|
],
|
|
350
383
|
};
|
package/test/rules/user.js
CHANGED
|
@@ -246,10 +246,13 @@ module.exports = {
|
|
|
246
246
|
// Should fail - consent is protected (only signup route + webhook
|
|
247
247
|
// processors can mutate it server-side; a client write would let a
|
|
248
248
|
// user retroactively forge their own consent record).
|
|
249
|
+
// Use a value that can't match any prior state — earlier tests
|
|
250
|
+
// (email-preferences) may have set marketing.status to 'granted',
|
|
251
|
+
// and writing the SAME value is a no-op the rules correctly allow.
|
|
249
252
|
await rules.expectFailure(
|
|
250
253
|
db.doc(`users/${uid}`).set({
|
|
251
254
|
consent: {
|
|
252
|
-
marketing: { status: '
|
|
255
|
+
marketing: { status: 'forged' },
|
|
253
256
|
},
|
|
254
257
|
}, { merge: true })
|
|
255
258
|
);
|