@firebase-function-kits/delete-user-data 0.0.1 → 0.0.2-rc.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/README.md +220 -0
  3. package/lib/config.d.ts +22 -0
  4. package/lib/config.d.ts.map +1 -0
  5. package/lib/config.js +153 -0
  6. package/lib/config.js.map +1 -0
  7. package/lib/events.d.ts +18 -0
  8. package/lib/events.d.ts.map +1 -0
  9. package/lib/events.js +71 -0
  10. package/lib/events.js.map +1 -0
  11. package/lib/export-config.d.ts +55 -0
  12. package/lib/export-config.d.ts.map +1 -0
  13. package/lib/export-config.js +56 -0
  14. package/lib/export-config.js.map +1 -0
  15. package/lib/handlers.d.ts +35 -0
  16. package/lib/handlers.d.ts.map +1 -0
  17. package/lib/handlers.js +243 -0
  18. package/lib/handlers.js.map +1 -0
  19. package/lib/helpers.d.ts +19 -0
  20. package/lib/helpers.d.ts.map +1 -0
  21. package/lib/helpers.js +39 -0
  22. package/lib/helpers.js.map +1 -0
  23. package/lib/index.d.ts +6 -0
  24. package/lib/index.d.ts.map +1 -0
  25. package/lib/index.js +112 -0
  26. package/lib/index.js.map +1 -0
  27. package/lib/lib.d.ts +23 -0
  28. package/lib/lib.d.ts.map +1 -0
  29. package/lib/lib.js +38 -0
  30. package/lib/lib.js.map +1 -0
  31. package/lib/logs.d.ts +26 -0
  32. package/lib/logs.d.ts.map +1 -0
  33. package/lib/logs.js +116 -0
  34. package/lib/logs.js.map +1 -0
  35. package/lib/recursiveDelete.d.ts +18 -0
  36. package/lib/recursiveDelete.d.ts.map +1 -0
  37. package/lib/recursiveDelete.js +37 -0
  38. package/lib/recursiveDelete.js.map +1 -0
  39. package/lib/runBatchPubSubDeletions.d.ts +27 -0
  40. package/lib/runBatchPubSubDeletions.d.ts.map +1 -0
  41. package/lib/runBatchPubSubDeletions.js +47 -0
  42. package/lib/runBatchPubSubDeletions.js.map +1 -0
  43. package/lib/runCustomSearchFunction.d.ts +18 -0
  44. package/lib/runCustomSearchFunction.d.ts.map +1 -0
  45. package/lib/runCustomSearchFunction.js +78 -0
  46. package/lib/runCustomSearchFunction.js.map +1 -0
  47. package/lib/search.d.ts +19 -0
  48. package/lib/search.d.ts.map +1 -0
  49. package/lib/search.js +29 -0
  50. package/lib/search.js.map +1 -0
  51. package/npm-shrinkwrap.json +4167 -0
  52. package/package.json +37 -4
  53. package/src/config.ts +188 -0
  54. package/src/events.ts +38 -0
  55. package/src/export-config.ts +112 -0
  56. package/src/handlers.ts +271 -0
  57. package/src/helpers.ts +42 -0
  58. package/src/index.ts +100 -0
  59. package/src/lib.ts +41 -0
  60. package/src/logs.ts +127 -0
  61. package/src/recursiveDelete.ts +42 -0
  62. package/src/runBatchPubSubDeletions.ts +66 -0
  63. package/src/runCustomSearchFunction.ts +50 -0
  64. package/src/search.ts +37 -0
  65. package/tests/config.test.ts +180 -0
  66. package/tests/export-config.test.ts +117 -0
  67. package/tests/fakes.ts +325 -0
  68. package/tests/handlers.test.ts +517 -0
  69. package/tests/helpers.test.ts +126 -0
  70. package/tests/lib.test.ts +40 -0
  71. package/tests/recursiveDelete.test.ts +102 -0
  72. package/tests/runBatchPubSubDeletions.test.ts +164 -0
  73. package/tests/runCustomSearchFunction.test.ts +125 -0
  74. package/tests/search.test.ts +127 -0
  75. package/tsconfig.json +18 -0
  76. package/tsconfig.tsbuildinfo +1 -0
  77. package/index.js +0 -0
@@ -0,0 +1,517 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { beforeEach, describe, expect, test, vi } from "vitest";
18
+
19
+ const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
20
+
21
+ vi.mock("../src/logs");
22
+ vi.mock("../src/events");
23
+ vi.mock("node-fetch", () => ({ default: mocks.fetch }));
24
+
25
+ import * as events from "../src/events";
26
+ import { handleClear, handleDeletion, handleSearch } from "../src/handlers";
27
+ import * as logs from "../src/logs";
28
+ import {
29
+ createFakeFirestore,
30
+ deletionMessages,
31
+ discoveryMessages,
32
+ makeContext,
33
+ } from "./fakes";
34
+
35
+ const UID = "testUid";
36
+ const log = vi.mocked(logs);
37
+ const publishDeletionEvent = vi.mocked(events.publishDeletionEvent);
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ // Parity: delete-user-data/functions/__tests__/handleDelete.test.ts
44
+ describe("handleDeletion", () => {
45
+ test("deletes valid paths correctly", async () => {
46
+ const paths = ["valid/path1", "valid/path2"];
47
+ const firestore = createFakeFirestore({
48
+ "valid/path1": { uid: UID },
49
+ "valid/path2": { uid: UID },
50
+ });
51
+ const ctx = makeContext({ firestore });
52
+
53
+ await handleDeletion({ uid: UID, paths }, ctx);
54
+
55
+ for (const path of paths) {
56
+ expect(firestore.exists(path)).toBe(false);
57
+ }
58
+ expect(log.warnInvalidPaths).not.toHaveBeenCalled();
59
+ });
60
+
61
+ test("deletes subcollections of matching docs in recursive mode", async () => {
62
+ const paths = ["valid/path1", "valid/path2"];
63
+ const firestore = createFakeFirestore({
64
+ "valid/path1": { uid: UID },
65
+ "valid/path1/subcollection/doc": { foo: "bar" },
66
+ "valid/path2": { uid: UID },
67
+ "valid/path2/subcollection/doc": { foo: "bar" },
68
+ });
69
+ const ctx = makeContext({
70
+ firestore,
71
+ config: { firestoreDeleteMode: "recursive" },
72
+ });
73
+
74
+ await handleDeletion({ uid: UID, paths }, ctx);
75
+
76
+ for (const path of paths) {
77
+ expect(firestore.exists(path)).toBe(false);
78
+ expect(firestore.exists(`${path}/subcollection/doc`)).toBe(false);
79
+ }
80
+ });
81
+
82
+ test("keeps subcollections of matching docs in shallow mode", async () => {
83
+ const firestore = createFakeFirestore({
84
+ "valid/path1": { uid: UID },
85
+ "valid/path1/subcollection/doc": { foo: "bar" },
86
+ });
87
+ const ctx = makeContext({ firestore });
88
+
89
+ await handleDeletion({ uid: UID, paths: ["valid/path1"] }, ctx);
90
+
91
+ expect(firestore.exists("valid/path1")).toBe(false);
92
+ expect(firestore.exists("valid/path1/subcollection/doc")).toBe(true);
93
+ expect(firestore.recursiveDeleteCalls).toHaveLength(0);
94
+ });
95
+
96
+ test("does not delete paths that do not belong to the uid", async () => {
97
+ const firestore = createFakeFirestore({
98
+ "valid/path1": { uid: "someoneElse" },
99
+ });
100
+ const ctx = makeContext({ firestore });
101
+
102
+ await handleDeletion({ uid: UID, paths: ["valid/path1"] }, ctx);
103
+
104
+ expect(firestore.exists("valid/path1")).toBe(true);
105
+ expect(log.warnInvalidPaths).toHaveBeenCalledWith(1, UID);
106
+ });
107
+
108
+ test("deletes a path that contains the uid without a matching field", async () => {
109
+ const firestore = createFakeFirestore({
110
+ [`users/${UID}`]: { foo: "bar" },
111
+ });
112
+ const ctx = makeContext({ firestore });
113
+
114
+ await handleDeletion({ uid: UID, paths: [`users/${UID}`] }, ctx);
115
+
116
+ expect(firestore.exists(`users/${UID}`)).toBe(false);
117
+ });
118
+
119
+ test("chunks deletions into batches of 450", async () => {
120
+ const seed: Record<string, Record<string, unknown>> = {};
121
+ const paths: string[] = [];
122
+ for (let index = 0; index < 500; index++) {
123
+ const path = `valid/path${index}`;
124
+ paths.push(path);
125
+ seed[path] = { uid: UID };
126
+ }
127
+ const firestore = createFakeFirestore(seed);
128
+ const ctx = makeContext({ firestore });
129
+
130
+ await handleDeletion({ uid: UID, paths }, ctx);
131
+
132
+ expect(firestore.batchCommits).toBe(2);
133
+ expect(firestore.store.size).toBe(0);
134
+ });
135
+
136
+ test("publishes a firestore deletion event with the invalid paths", async () => {
137
+ const firestore = createFakeFirestore({
138
+ "valid/path1": { uid: UID },
139
+ "valid/path2": { uid: "someoneElse" },
140
+ });
141
+ const ctx = makeContext({ firestore });
142
+
143
+ await handleDeletion(
144
+ { uid: UID, paths: ["valid/path1", "valid/path2"] },
145
+ ctx
146
+ );
147
+
148
+ expect(publishDeletionEvent).toHaveBeenCalledWith("firestore", {
149
+ uid: UID,
150
+ documentPaths: ["valid/path1", "valid/path2"],
151
+ invalidPaths: ["valid/path2"],
152
+ });
153
+ });
154
+ });
155
+
156
+ // Parity: delete-user-data/functions/__tests__/search.test.ts, which drives the
157
+ // same discovery rounds through the Pub/Sub emulator.
158
+ describe("handleSearch", () => {
159
+ test("recursively deletes a collection named {uid}", async () => {
160
+ const firestore = createFakeFirestore({
161
+ [`${UID}/doc1`]: { foo: "bar" },
162
+ [`${UID}/doc1/nested/doc2`]: { foo: "bar" },
163
+ });
164
+ const ctx = makeContext({ firestore });
165
+
166
+ await handleSearch({ path: UID, depth: 1, uid: UID }, ctx);
167
+
168
+ expect(firestore.store.size).toBe(0);
169
+ expect(publishDeletionEvent).toHaveBeenCalledWith("firestore", {
170
+ uid: UID,
171
+ collectionPath: UID,
172
+ });
173
+ });
174
+
175
+ test("queues a document named {uid} for deletion", async () => {
176
+ const firestore = createFakeFirestore({
177
+ [`users/${UID}`]: { foo: "bar" },
178
+ });
179
+ const ctx = makeContext({ firestore });
180
+
181
+ await handleSearch({ path: "users", depth: 1, uid: UID }, ctx);
182
+
183
+ expect(deletionMessages(ctx)).toEqual([
184
+ { paths: [`users/${UID}`], uid: UID },
185
+ ]);
186
+ });
187
+
188
+ test("queues a document whose search field matches the uid", async () => {
189
+ const firestore = createFakeFirestore({
190
+ "users/doc1": { uid: UID },
191
+ });
192
+ const ctx = makeContext({ firestore });
193
+
194
+ await handleSearch({ path: "users", depth: 1, uid: UID }, ctx);
195
+
196
+ expect(deletionMessages(ctx)).toEqual([
197
+ { paths: ["users/doc1"], uid: UID },
198
+ ]);
199
+ });
200
+
201
+ test("handles a document without any field values", async () => {
202
+ const firestore = createFakeFirestore({ "users/doc1": {} });
203
+ const ctx = makeContext({ firestore });
204
+
205
+ await expect(
206
+ handleSearch({ path: "users", depth: 1, uid: UID }, ctx)
207
+ ).resolves.toBeUndefined();
208
+ expect(deletionMessages(ctx)).toEqual([]);
209
+ });
210
+
211
+ test("does not queue a document without a matching field value", async () => {
212
+ const firestore = createFakeFirestore({
213
+ "users/doc1": { field1: "unknown" },
214
+ });
215
+ const ctx = makeContext({ firestore });
216
+
217
+ await handleSearch({ path: "users", depth: 1, uid: UID }, ctx);
218
+
219
+ expect(deletionMessages(ctx)).toEqual([]);
220
+ expect(firestore.exists("users/doc1")).toBe(true);
221
+ });
222
+
223
+ test("skips field matching entirely when no search fields are configured", async () => {
224
+ const firestore = createFakeFirestore({ "users/doc1": { uid: UID } });
225
+ const ctx = makeContext({ firestore, config: { searchFields: "" } });
226
+
227
+ await handleSearch({ path: "users", depth: 1, uid: UID }, ctx);
228
+
229
+ expect(deletionMessages(ctx)).toEqual([]);
230
+ });
231
+
232
+ test("queues subcollection searches while within the search depth", async () => {
233
+ const firestore = createFakeFirestore({
234
+ "users/doc1/posts/post1": { foo: "bar" },
235
+ });
236
+ const ctx = makeContext({ firestore });
237
+
238
+ await handleSearch({ path: "users", depth: 1, uid: UID }, ctx);
239
+
240
+ expect(discoveryMessages(ctx)).toEqual([
241
+ { path: "users/doc1/posts", uid: UID, depth: 2 },
242
+ ]);
243
+ });
244
+
245
+ test("does not queue subcollection searches beyond the search depth", async () => {
246
+ const firestore = createFakeFirestore({
247
+ "1/1/2/2/3/3/4/doc": { foo: "bar" },
248
+ });
249
+ const ctx = makeContext({ firestore });
250
+
251
+ await handleSearch({ path: "1/1/2/2/3", depth: 3, uid: UID }, ctx);
252
+
253
+ expect(discoveryMessages(ctx)).toEqual([]);
254
+ });
255
+
256
+ test("returns before deleting when the depth exceeds the search depth", async () => {
257
+ const firestore = createFakeFirestore({
258
+ [`1/1/2/2/3/3/4/4/${UID}/doc`]: { foo: "bar" },
259
+ });
260
+ const ctx = makeContext({ firestore });
261
+
262
+ await handleSearch(
263
+ { path: `1/1/2/2/3/3/4/4/${UID}`, depth: 4, uid: UID },
264
+ ctx
265
+ );
266
+
267
+ // The collection is named {uid} but sits past the search depth.
268
+ expect(firestore.exists(`1/1/2/2/3/3/4/4/${UID}/doc`)).toBe(true);
269
+ expect(firestore.recursiveDeleteCalls).toHaveLength(0);
270
+ expect(deletionMessages(ctx)).toEqual([]);
271
+ expect(discoveryMessages(ctx)).toEqual([]);
272
+ });
273
+ });
274
+
275
+ // End-to-end discovery, standing in for the extension's emulator round trip:
276
+ // handleClear seeds the discovery topic, then every queued message is
277
+ // dispatched back into its handler until the queue drains.
278
+ describe("auto discovery", () => {
279
+ const clearWithDiscovery = async (
280
+ firestore: ReturnType<typeof createFakeFirestore>,
281
+ config = {}
282
+ ) => {
283
+ const ctx = makeContext({
284
+ firestore,
285
+ config: { enableAutoDiscovery: true, ...config },
286
+ });
287
+ await handleClear(UID, ctx);
288
+ await ctx.drain();
289
+ return ctx;
290
+ };
291
+
292
+ test("deletes a top level collection named {uid}", async () => {
293
+ const firestore = createFakeFirestore({ [`${UID}/doc1`]: { foo: "bar" } });
294
+
295
+ await clearWithDiscovery(firestore);
296
+
297
+ expect(firestore.exists(`${UID}/doc1`)).toBe(false);
298
+ });
299
+
300
+ test("deletes a top level document named {uid}", async () => {
301
+ const firestore = createFakeFirestore({ [`users/${UID}`]: { foo: "bar" } });
302
+
303
+ await clearWithDiscovery(firestore);
304
+
305
+ expect(firestore.exists(`users/${UID}`)).toBe(false);
306
+ });
307
+
308
+ test("deletes a document with a field value matching the uid", async () => {
309
+ const firestore = createFakeFirestore({ "users/doc1": { uid: UID } });
310
+
311
+ await clearWithDiscovery(firestore);
312
+
313
+ expect(firestore.exists("users/doc1")).toBe(false);
314
+ });
315
+
316
+ test("deletes a subcollection named {uid}", async () => {
317
+ const firestore = createFakeFirestore({
318
+ [`rooms/room1/${UID}/doc1`]: { foo: "bar" },
319
+ });
320
+
321
+ await clearWithDiscovery(firestore);
322
+
323
+ expect(firestore.exists(`rooms/room1/${UID}/doc1`)).toBe(false);
324
+ });
325
+
326
+ test("does not exceed the search depth for a collection named {uid}", async () => {
327
+ const path = `1/1/2/2/3/3/4/4/${UID}/doc`;
328
+ const firestore = createFakeFirestore({ [path]: { foo: "bar" } });
329
+
330
+ await clearWithDiscovery(firestore);
331
+
332
+ expect(firestore.exists(path)).toBe(true);
333
+ });
334
+
335
+ test("does not exceed the search depth for a document field match", async () => {
336
+ const path = "1/1/2/2/3/3/4/4/5/doc";
337
+ const firestore = createFakeFirestore({ [path]: { uid: UID } });
338
+
339
+ await clearWithDiscovery(firestore);
340
+
341
+ expect(firestore.exists(path)).toBe(true);
342
+ });
343
+
344
+ test("does not delete documents that do not match the search criteria", async () => {
345
+ const firestore = createFakeFirestore({
346
+ "users/doc1": { testing: "should-not-delete" },
347
+ "users/doc1/posts/post1": { field1: "unknown" },
348
+ });
349
+
350
+ await clearWithDiscovery(firestore);
351
+
352
+ expect(firestore.exists("users/doc1")).toBe(true);
353
+ expect(firestore.exists("users/doc1/posts/post1")).toBe(true);
354
+ });
355
+
356
+ test("is not run when auto discovery is disabled", async () => {
357
+ const firestore = createFakeFirestore({ [`${UID}/doc1`]: { foo: "bar" } });
358
+ const ctx = makeContext({ firestore });
359
+
360
+ await handleClear(UID, ctx);
361
+
362
+ expect(discoveryMessages(ctx)).toEqual([]);
363
+ expect(firestore.exists(`${UID}/doc1`)).toBe(true);
364
+ });
365
+ });
366
+
367
+ describe("handleClear", () => {
368
+ test("deletes the configured firestore paths in shallow mode", async () => {
369
+ const firestore = createFakeFirestore({
370
+ [`users/${UID}`]: { foo: "bar" },
371
+ [`users/${UID}/posts/post1`]: { foo: "bar" },
372
+ });
373
+ const ctx = makeContext({
374
+ firestore,
375
+ config: { firestorePaths: "users/{UID}" },
376
+ });
377
+
378
+ await handleClear(UID, ctx);
379
+
380
+ expect(firestore.exists(`users/${UID}`)).toBe(false);
381
+ expect(firestore.exists(`users/${UID}/posts/post1`)).toBe(true);
382
+ expect(log.firestorePathDeleted).toHaveBeenCalledWith(
383
+ `users/${UID}`,
384
+ false
385
+ );
386
+ });
387
+
388
+ test("deletes the configured firestore paths in recursive mode", async () => {
389
+ const firestore = createFakeFirestore({
390
+ [`users/${UID}`]: { foo: "bar" },
391
+ [`users/${UID}/posts/post1`]: { foo: "bar" },
392
+ });
393
+ const ctx = makeContext({
394
+ firestore,
395
+ config: {
396
+ firestorePaths: "users/{UID}",
397
+ firestoreDeleteMode: "recursive",
398
+ },
399
+ });
400
+
401
+ await handleClear(UID, ctx);
402
+
403
+ expect(firestore.exists(`users/${UID}`)).toBe(false);
404
+ expect(firestore.exists(`users/${UID}/posts/post1`)).toBe(false);
405
+ });
406
+
407
+ test("deletes the configured rtdb paths", async () => {
408
+ const ctx = makeContext({
409
+ config: { rtdbPaths: "users/{UID},admins/{UID}" },
410
+ });
411
+
412
+ await handleClear(UID, ctx);
413
+
414
+ expect(ctx.rtdbRemovals).toEqual([`users/${UID}`, `admins/${UID}`]);
415
+ expect(publishDeletionEvent).toHaveBeenCalledWith("database", {
416
+ uid: UID,
417
+ paths: [`users/${UID}`, `admins/${UID}`],
418
+ });
419
+ });
420
+
421
+ test("deletes the configured storage paths", async () => {
422
+ const ctx = makeContext({
423
+ config: {
424
+ storagePaths: `{DEFAULT}/{UID}/avatar.png,other-bucket/{UID}`,
425
+ storageBucket: "default-bucket",
426
+ },
427
+ });
428
+
429
+ await handleClear(UID, ctx);
430
+
431
+ expect(ctx.storageDeletions).toEqual(
432
+ expect.arrayContaining([
433
+ { bucket: "default-bucket", prefix: `${UID}/avatar.png` },
434
+ { bucket: "other-bucket", prefix: UID },
435
+ ])
436
+ );
437
+ });
438
+
439
+ test("tolerates a 404 from storage", async () => {
440
+ const ctx = makeContext({
441
+ config: { storagePaths: "{DEFAULT}/{UID}", storageBucket: "bucket" },
442
+ storageError: { code: 404 },
443
+ });
444
+
445
+ await handleClear(UID, ctx);
446
+
447
+ expect(log.storagePath404).toHaveBeenCalledWith(UID);
448
+ expect(log.storagePathError).not.toHaveBeenCalled();
449
+ });
450
+
451
+ test("logs other storage errors", async () => {
452
+ const error = Object.assign(new Error("boom"), { code: 500 });
453
+ const ctx = makeContext({
454
+ config: { storagePaths: "{DEFAULT}/{UID}", storageBucket: "bucket" },
455
+ storageError: error,
456
+ });
457
+
458
+ await handleClear(UID, ctx);
459
+
460
+ expect(log.storagePathError).toHaveBeenCalledWith(UID, error);
461
+ });
462
+
463
+ test("logs rtdb errors without failing", async () => {
464
+ const error = new Error("boom");
465
+ const ctx = makeContext({
466
+ config: { rtdbPaths: "users/{UID}" },
467
+ rtdbError: error,
468
+ });
469
+
470
+ await expect(handleClear(UID, ctx)).resolves.toBeUndefined();
471
+ expect(log.rtdbPathError).toHaveBeenCalledWith(`users/${UID}`, error);
472
+ });
473
+
474
+ test("skips each target that is not configured", async () => {
475
+ const ctx = makeContext();
476
+
477
+ await handleClear(UID, ctx);
478
+
479
+ expect(log.firestoreNotConfigured).toHaveBeenCalled();
480
+ expect(log.rtdbNotConfigured).toHaveBeenCalled();
481
+ expect(log.storageNotConfigured).toHaveBeenCalled();
482
+ expect(log.complete).toHaveBeenCalledWith(UID);
483
+ });
484
+
485
+ // Parity: delete-user-data/functions/__tests__/searchFunction.test.ts
486
+ test("deletes the paths returned by a custom search function", async () => {
487
+ const firestore = createFakeFirestore({
488
+ "searchFunction/testing": { uid: UID },
489
+ });
490
+ mocks.fetch.mockResolvedValue({
491
+ ok: true,
492
+ json: async () => [`searchFunction/testing`],
493
+ });
494
+ const ctx = makeContext({
495
+ firestore,
496
+ config: { searchFunction: "https://example.com/search" },
497
+ });
498
+
499
+ await handleClear(UID, ctx);
500
+ await ctx.drain();
501
+
502
+ expect(mocks.fetch).toHaveBeenCalledWith("https://example.com/search", {
503
+ method: "POST",
504
+ body: JSON.stringify({ uid: UID }),
505
+ headers: { "Content-Type": "application/json" },
506
+ });
507
+ expect(firestore.exists("searchFunction/testing")).toBe(false);
508
+ });
509
+
510
+ test("does not call a custom search function when none is configured", async () => {
511
+ const ctx = makeContext();
512
+
513
+ await handleClear(UID, ctx);
514
+
515
+ expect(mocks.fetch).not.toHaveBeenCalled();
516
+ });
517
+ });
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { describe, expect, test, vi } from "vitest";
18
+ import { extractUserPaths, hasValidUserPath } from "../src/helpers";
19
+ import { createFakeFirestore } from "./fakes";
20
+
21
+ const UID = "test-uid";
22
+
23
+ // Parity: delete-user-data/functions/__tests__/helpers.test.ts
24
+ // ("hasValidUserPath"). The kit takes `searchFields` as an argument instead of
25
+ // reading module config, so each case passes it explicitly.
26
+ describe("hasValidUserPath", () => {
27
+ test("returns true if the field matches the uid exactly", async () => {
28
+ const db = createFakeFirestore({
29
+ "hasValidUserPath/doc1": { field1: UID },
30
+ });
31
+
32
+ await expect(
33
+ hasValidUserPath(db.doc("hasValidUserPath/doc1"), "", UID, "field1")
34
+ ).resolves.toBe(true);
35
+ });
36
+
37
+ test("returns true if the field contains the uid as a path", async () => {
38
+ const db = createFakeFirestore({
39
+ "hasValidUserPath/doc1": { field1: `testing/${UID}` },
40
+ });
41
+
42
+ await expect(
43
+ hasValidUserPath(db.doc("hasValidUserPath/doc1"), "", UID, "field1")
44
+ ).resolves.toBe(true);
45
+ });
46
+
47
+ test("returns false for a non-string field value", async () => {
48
+ const db = createFakeFirestore({
49
+ "hasValidUserPath/doc1": { field1: 1234 },
50
+ });
51
+
52
+ await expect(
53
+ hasValidUserPath(db.doc("hasValidUserPath/doc1"), "", UID, "field1")
54
+ ).resolves.toBe(false);
55
+ });
56
+
57
+ test("returns false when the document does not exist", async () => {
58
+ const db = createFakeFirestore();
59
+
60
+ await expect(
61
+ hasValidUserPath(db.doc("hasValidUserPath/missing"), "", UID, "field1")
62
+ ).resolves.toBe(false);
63
+ });
64
+
65
+ test("returns true from the path without reading the document", async () => {
66
+ const db = createFakeFirestore();
67
+ const ref = db.doc(`users/${UID}`);
68
+ const get = vi.spyOn(ref, "get");
69
+
70
+ await expect(
71
+ hasValidUserPath(ref, `users/${UID}`, UID, "uid")
72
+ ).resolves.toBe(true);
73
+ expect(get).not.toHaveBeenCalled();
74
+ });
75
+
76
+ test("checks every configured search field", async () => {
77
+ const db = createFakeFirestore({
78
+ "hasValidUserPath/doc1": { userId: UID },
79
+ });
80
+
81
+ await expect(
82
+ hasValidUserPath(
83
+ db.doc("hasValidUserPath/doc1"),
84
+ "",
85
+ UID,
86
+ "id,uid,userId"
87
+ )
88
+ ).resolves.toBe(true);
89
+ });
90
+
91
+ test("tolerates a trailing comma in the search fields", async () => {
92
+ const db = createFakeFirestore({
93
+ "hasValidUserPath/doc1": { uid: UID },
94
+ });
95
+
96
+ await expect(
97
+ hasValidUserPath(db.doc("hasValidUserPath/doc1"), "", UID, "uid,")
98
+ ).resolves.toBe(true);
99
+ });
100
+
101
+ // Kit-specific: the extension builds `new FieldPath("")` here and throws.
102
+ test("short-circuits to false when no search fields are configured", async () => {
103
+ const db = createFakeFirestore({
104
+ "hasValidUserPath/doc1": { uid: UID },
105
+ });
106
+ const ref = db.doc("hasValidUserPath/doc1");
107
+ const get = vi.spyOn(ref, "get");
108
+
109
+ await expect(hasValidUserPath(ref, "", UID, "")).resolves.toBe(false);
110
+ expect(get).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+
114
+ describe("extractUserPaths", () => {
115
+ test("substitutes every {UID} placeholder", () => {
116
+ expect(extractUserPaths("users/{UID}/posts/{UID}", UID)).toEqual([
117
+ `users/${UID}/posts/${UID}`,
118
+ ]);
119
+ });
120
+
121
+ test("splits comma separated paths", () => {
122
+ expect(
123
+ extractUserPaths("users/{UID},admins/{UID},static/path", UID)
124
+ ).toEqual([`users/${UID}`, `admins/${UID}`, "static/path"]);
125
+ });
126
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * https://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { describe, expect, test, vi } from "vitest";
18
+
19
+ vi.mock("firebase-functions/params", () => {
20
+ throw new Error("./lib must not import firebase-functions/params");
21
+ });
22
+
23
+ describe("./lib", () => {
24
+ test("imports without declaring Firebase params", async () => {
25
+ const lib = await import("../src/lib");
26
+
27
+ expect(lib.handleClear).toBeTypeOf("function");
28
+ expect(lib.handleDeletion).toBeTypeOf("function");
29
+ expect(lib.handleSearch).toBeTypeOf("function");
30
+ expect(lib.search).toBeTypeOf("function");
31
+ expect(lib.recursiveDelete).toBeTypeOf("function");
32
+ expect(lib.publishSearch).toBeTypeOf("function");
33
+ expect(lib.runBatchPubSubDeletions).toBeTypeOf("function");
34
+ expect(lib.runCustomSearchFunction).toBeTypeOf("function");
35
+ expect(lib.hasValidUserPath).toBeTypeOf("function");
36
+ expect(lib.extractUserPaths).toBeTypeOf("function");
37
+ expect(lib.resolveDeleteUserDataConfig).toBeTypeOf("function");
38
+ expect(lib.getDatabaseUrl).toBeTypeOf("function");
39
+ }, 15000);
40
+ });