@firebase-function-kits/delete-user-data 0.0.1 → 0.0.2-rc.0
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 +1 -0
- package/README.md +151 -0
- package/lib/config.d.ts +22 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +86 -0
- package/lib/config.js.map +1 -0
- package/lib/events.d.ts +18 -0
- package/lib/events.d.ts.map +1 -0
- package/lib/events.js +71 -0
- package/lib/events.js.map +1 -0
- package/lib/export-config.d.ts +55 -0
- package/lib/export-config.d.ts.map +1 -0
- package/lib/export-config.js +56 -0
- package/lib/export-config.js.map +1 -0
- package/lib/handlers.d.ts +35 -0
- package/lib/handlers.d.ts.map +1 -0
- package/lib/handlers.js +243 -0
- package/lib/handlers.js.map +1 -0
- package/lib/helpers.d.ts +19 -0
- package/lib/helpers.d.ts.map +1 -0
- package/lib/helpers.js +39 -0
- package/lib/helpers.js.map +1 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +112 -0
- package/lib/index.js.map +1 -0
- package/lib/lib.d.ts +23 -0
- package/lib/lib.d.ts.map +1 -0
- package/lib/lib.js +38 -0
- package/lib/lib.js.map +1 -0
- package/lib/logs.d.ts +26 -0
- package/lib/logs.d.ts.map +1 -0
- package/lib/logs.js +116 -0
- package/lib/logs.js.map +1 -0
- package/lib/recursiveDelete.d.ts +18 -0
- package/lib/recursiveDelete.d.ts.map +1 -0
- package/lib/recursiveDelete.js +37 -0
- package/lib/recursiveDelete.js.map +1 -0
- package/lib/runBatchPubSubDeletions.d.ts +27 -0
- package/lib/runBatchPubSubDeletions.d.ts.map +1 -0
- package/lib/runBatchPubSubDeletions.js +47 -0
- package/lib/runBatchPubSubDeletions.js.map +1 -0
- package/lib/runCustomSearchFunction.d.ts +18 -0
- package/lib/runCustomSearchFunction.d.ts.map +1 -0
- package/lib/runCustomSearchFunction.js +78 -0
- package/lib/runCustomSearchFunction.js.map +1 -0
- package/lib/search.d.ts +19 -0
- package/lib/search.d.ts.map +1 -0
- package/lib/search.js +29 -0
- package/lib/search.js.map +1 -0
- package/package.json +37 -4
- package/src/config.ts +98 -0
- package/src/events.ts +38 -0
- package/src/export-config.ts +112 -0
- package/src/handlers.ts +271 -0
- package/src/helpers.ts +42 -0
- package/src/index.ts +100 -0
- package/src/lib.ts +41 -0
- package/src/logs.ts +127 -0
- package/src/recursiveDelete.ts +42 -0
- package/src/runBatchPubSubDeletions.ts +66 -0
- package/src/runCustomSearchFunction.ts +50 -0
- package/src/search.ts +37 -0
- package/tests/config.test.ts +180 -0
- package/tests/export-config.test.ts +117 -0
- package/tests/fakes.ts +325 -0
- package/tests/handlers.test.ts +517 -0
- package/tests/helpers.test.ts +126 -0
- package/tests/lib.test.ts +40 -0
- package/tests/recursiveDelete.test.ts +102 -0
- package/tests/runBatchPubSubDeletions.test.ts +164 -0
- package/tests/runCustomSearchFunction.test.ts +125 -0
- package/tests/search.test.ts +127 -0
- package/tsconfig.json +18 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/index.js +0 -0
package/tests/fakes.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
/**
|
|
18
|
+
* In-memory doubles for Firestore and Pub/Sub.
|
|
19
|
+
*
|
|
20
|
+
* The extension suite drives these behaviours through the Firebase emulators
|
|
21
|
+
* (`firebase emulators:exec jest`). Kits have no emulator harness, so the same
|
|
22
|
+
* behaviours are exercised against fakes: an in-memory document store that
|
|
23
|
+
* implements the surface `src/` actually touches, plus a Pub/Sub double that
|
|
24
|
+
* queues published messages so a test can pump discovery/deletion rounds
|
|
25
|
+
* synchronously instead of polling `onSnapshot`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type {
|
|
29
|
+
DeleteMessageData,
|
|
30
|
+
HandlerContext,
|
|
31
|
+
SearchMessageData,
|
|
32
|
+
} from "../src/handlers";
|
|
33
|
+
import { handleDeletion, handleSearch } from "../src/handlers";
|
|
34
|
+
import type { ResolvedDeleteUserDataConfig } from "../src/export-config";
|
|
35
|
+
import { resolveDeleteUserDataConfig } from "../src/export-config";
|
|
36
|
+
|
|
37
|
+
type DocData = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
export interface RecursiveDeleteCall {
|
|
40
|
+
path: string;
|
|
41
|
+
type: "document" | "collection";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FakeFirestore {
|
|
45
|
+
/** Raw store, keyed by full document path. */
|
|
46
|
+
store: Map<string, DocData>;
|
|
47
|
+
/** Every `db.recursiveDelete()` invocation, in order. */
|
|
48
|
+
recursiveDeleteCalls: RecursiveDeleteCall[];
|
|
49
|
+
/** Number of committed write batches. */
|
|
50
|
+
batchCommits: number;
|
|
51
|
+
doc(path: string): any;
|
|
52
|
+
collection(path: string): any;
|
|
53
|
+
listCollections(): Promise<any[]>;
|
|
54
|
+
getAll(...refs: any[]): Promise<any[]>;
|
|
55
|
+
batch(): any;
|
|
56
|
+
bulkWriter(): any;
|
|
57
|
+
recursiveDelete(ref: any, bulkWriter?: any): Promise<void>;
|
|
58
|
+
runTransaction(fn: (transaction: any) => Promise<void>): Promise<void>;
|
|
59
|
+
/** Convenience: seed a document. */
|
|
60
|
+
seed(path: string, data?: DocData): void;
|
|
61
|
+
/** Convenience: does a document exist? */
|
|
62
|
+
exists(path: string): boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const lastSegment = (path: string): string => path.split("/").at(-1) as string;
|
|
66
|
+
|
|
67
|
+
const isDocumentPath = (path: string): boolean =>
|
|
68
|
+
path.split("/").length % 2 === 0;
|
|
69
|
+
|
|
70
|
+
function readField(data: DocData | undefined, fieldPath: unknown): unknown {
|
|
71
|
+
if (!data) return undefined;
|
|
72
|
+
const segments = String(fieldPath).split(".");
|
|
73
|
+
let current: unknown = data;
|
|
74
|
+
for (const segment of segments) {
|
|
75
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
76
|
+
current = (current as DocData)[segment];
|
|
77
|
+
}
|
|
78
|
+
return current;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createFakeFirestore(
|
|
82
|
+
seed: Record<string, DocData> = {}
|
|
83
|
+
): FakeFirestore {
|
|
84
|
+
const store = new Map<string, DocData>(
|
|
85
|
+
Object.entries(seed).map(([path, data]) => [path, { ...data }])
|
|
86
|
+
);
|
|
87
|
+
const recursiveDeleteCalls: RecursiveDeleteCall[] = [];
|
|
88
|
+
let autoId = 0;
|
|
89
|
+
let batchCommits = 0;
|
|
90
|
+
|
|
91
|
+
const childIds = (prefix: string): string[] => {
|
|
92
|
+
const scoped = prefix.length > 0 ? `${prefix}/` : "";
|
|
93
|
+
const ids = new Set<string>();
|
|
94
|
+
for (const path of store.keys()) {
|
|
95
|
+
if (!path.startsWith(scoped)) continue;
|
|
96
|
+
ids.add(path.slice(scoped.length).split("/")[0]);
|
|
97
|
+
}
|
|
98
|
+
return [...ids];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const snapshot = (path: string) => {
|
|
102
|
+
const data = store.get(path);
|
|
103
|
+
return {
|
|
104
|
+
id: lastSegment(path),
|
|
105
|
+
ref: docRef(path),
|
|
106
|
+
exists: data !== undefined,
|
|
107
|
+
data: () => (data ? { ...data } : undefined),
|
|
108
|
+
get: (fieldPath: unknown) => readField(data, fieldPath),
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function docRef(path: string): any {
|
|
113
|
+
return {
|
|
114
|
+
id: lastSegment(path),
|
|
115
|
+
path,
|
|
116
|
+
get: async () => snapshot(path),
|
|
117
|
+
set: async (data: DocData) => {
|
|
118
|
+
store.set(path, { ...data });
|
|
119
|
+
},
|
|
120
|
+
create: async (data: DocData) => {
|
|
121
|
+
store.set(path, { ...data });
|
|
122
|
+
},
|
|
123
|
+
delete: async () => {
|
|
124
|
+
store.delete(path);
|
|
125
|
+
},
|
|
126
|
+
collection: (id: string) => collectionRef(`${path}/${id}`),
|
|
127
|
+
listCollections: async () =>
|
|
128
|
+
childIds(path).map((id) => collectionRef(`${path}/${id}`)),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function collectionRef(path: string): any {
|
|
133
|
+
return {
|
|
134
|
+
id: lastSegment(path),
|
|
135
|
+
path,
|
|
136
|
+
doc: (id?: string) => docRef(`${path}/${id ?? `auto-${++autoId}`}`),
|
|
137
|
+
add: async (data: DocData) => {
|
|
138
|
+
const ref = docRef(`${path}/auto-${++autoId}`);
|
|
139
|
+
await ref.set(data);
|
|
140
|
+
return ref;
|
|
141
|
+
},
|
|
142
|
+
get: async () => {
|
|
143
|
+
const docs = childIds(path)
|
|
144
|
+
.map((id) => snapshot(`${path}/${id}`))
|
|
145
|
+
.filter((doc) => doc.exists);
|
|
146
|
+
return { docs, empty: docs.length === 0, size: docs.length };
|
|
147
|
+
},
|
|
148
|
+
// Firestore returns refs for "missing" documents that only exist as
|
|
149
|
+
// parents of subcollections; `childIds` covers that by walking paths.
|
|
150
|
+
listDocuments: async () =>
|
|
151
|
+
childIds(path).map((id) => docRef(`${path}/${id}`)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const db: FakeFirestore = {
|
|
156
|
+
store,
|
|
157
|
+
recursiveDeleteCalls,
|
|
158
|
+
get batchCommits() {
|
|
159
|
+
return batchCommits;
|
|
160
|
+
},
|
|
161
|
+
doc: (path: string) => docRef(path),
|
|
162
|
+
collection: (path: string) => collectionRef(path),
|
|
163
|
+
listCollections: async () => childIds("").map((id) => collectionRef(id)),
|
|
164
|
+
getAll: async (...refs: any[]) => refs.map((ref) => snapshot(ref.path)),
|
|
165
|
+
batch: () => {
|
|
166
|
+
const deletes: string[] = [];
|
|
167
|
+
return {
|
|
168
|
+
delete: (ref: any) => deletes.push(ref.path),
|
|
169
|
+
commit: async () => {
|
|
170
|
+
batchCommits++;
|
|
171
|
+
for (const path of deletes) store.delete(path);
|
|
172
|
+
return deletes.map(() => ({}));
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
bulkWriter: () => ({
|
|
177
|
+
onWriteError: (_handler: unknown) => undefined,
|
|
178
|
+
close: async () => undefined,
|
|
179
|
+
}),
|
|
180
|
+
recursiveDelete: async (ref: any) => {
|
|
181
|
+
recursiveDeleteCalls.push({
|
|
182
|
+
path: ref.path,
|
|
183
|
+
type: isDocumentPath(ref.path) ? "document" : "collection",
|
|
184
|
+
});
|
|
185
|
+
for (const path of [...store.keys()]) {
|
|
186
|
+
if (path === ref.path || path.startsWith(`${ref.path}/`)) {
|
|
187
|
+
store.delete(path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
runTransaction: async (fn: (transaction: any) => Promise<void>) => {
|
|
192
|
+
const deletes: string[] = [];
|
|
193
|
+
await fn({ delete: (ref: any) => deletes.push(ref.path) });
|
|
194
|
+
for (const path of deletes) store.delete(path);
|
|
195
|
+
},
|
|
196
|
+
seed: (path: string, data: DocData = { seeded: true }) => {
|
|
197
|
+
store.set(path, { ...data });
|
|
198
|
+
},
|
|
199
|
+
exists: (path: string) => store.has(path),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return db;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface PublishedMessage {
|
|
206
|
+
topic: string;
|
|
207
|
+
json: unknown;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface FakePubSub {
|
|
211
|
+
published: PublishedMessage[];
|
|
212
|
+
topic(name: string): {
|
|
213
|
+
publishMessage(message: { json: unknown }): Promise<string>;
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function createFakePubSub(): FakePubSub {
|
|
218
|
+
const published: PublishedMessage[] = [];
|
|
219
|
+
return {
|
|
220
|
+
published,
|
|
221
|
+
topic: (name: string) => ({
|
|
222
|
+
publishMessage: async ({ json }: { json: unknown }) => {
|
|
223
|
+
published.push({ topic: name, json });
|
|
224
|
+
return `message-${published.length}`;
|
|
225
|
+
},
|
|
226
|
+
}),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface TestContext extends HandlerContext {
|
|
231
|
+
firestore: FakeFirestore;
|
|
232
|
+
pubsub: FakePubSub;
|
|
233
|
+
rtdbRemovals: string[];
|
|
234
|
+
storageDeletions: Array<{ bucket: string; prefix: string }>;
|
|
235
|
+
/**
|
|
236
|
+
* Dispatch every queued discovery/deletion message back into its handler,
|
|
237
|
+
* repeating until the queue drains. Stands in for the emulator's Pub/Sub
|
|
238
|
+
* delivery loop.
|
|
239
|
+
*/
|
|
240
|
+
drain(maxRounds?: number): Promise<void>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface TestContextOptions {
|
|
244
|
+
config?: Partial<ResolvedDeleteUserDataConfig>;
|
|
245
|
+
firestore?: FakeFirestore;
|
|
246
|
+
/** Reject RTDB removes / storage deletes with this error. */
|
|
247
|
+
rtdbError?: unknown;
|
|
248
|
+
storageError?: unknown;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function makeContext(options: TestContextOptions = {}): TestContext {
|
|
252
|
+
const firestore = options.firestore ?? createFakeFirestore();
|
|
253
|
+
const pubsub = createFakePubSub();
|
|
254
|
+
const rtdbRemovals: string[] = [];
|
|
255
|
+
const storageDeletions: Array<{ bucket: string; prefix: string }> = [];
|
|
256
|
+
|
|
257
|
+
const config = resolveDeleteUserDataConfig({
|
|
258
|
+
instanceId: "test-instance",
|
|
259
|
+
projectId: "demo-test",
|
|
260
|
+
firestoreDeleteMode: "shallow",
|
|
261
|
+
searchFields: "uid",
|
|
262
|
+
searchDepth: 3,
|
|
263
|
+
...options.config,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const database = {
|
|
267
|
+
ref: (path: string) => ({
|
|
268
|
+
remove: async () => {
|
|
269
|
+
if (options.rtdbError) throw options.rtdbError;
|
|
270
|
+
rtdbRemovals.push(path);
|
|
271
|
+
},
|
|
272
|
+
}),
|
|
273
|
+
} as any;
|
|
274
|
+
|
|
275
|
+
const storage = {
|
|
276
|
+
bucket: (name: string) => ({
|
|
277
|
+
name,
|
|
278
|
+
deleteFiles: async ({ prefix }: { prefix: string }) => {
|
|
279
|
+
if (options.storageError) throw options.storageError;
|
|
280
|
+
storageDeletions.push({ bucket: name, prefix });
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
} as any;
|
|
284
|
+
|
|
285
|
+
const ctx = {
|
|
286
|
+
firestore,
|
|
287
|
+
storage,
|
|
288
|
+
database,
|
|
289
|
+
pubsub,
|
|
290
|
+
config,
|
|
291
|
+
rtdbRemovals,
|
|
292
|
+
storageDeletions,
|
|
293
|
+
} as unknown as TestContext;
|
|
294
|
+
|
|
295
|
+
ctx.drain = async (maxRounds = 50) => {
|
|
296
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
297
|
+
if (pubsub.published.length === 0) return;
|
|
298
|
+
const pending = pubsub.published.splice(0, pubsub.published.length);
|
|
299
|
+
for (const message of pending) {
|
|
300
|
+
if (message.topic.endsWith(config.discoveryTopicName)) {
|
|
301
|
+
await handleSearch(message.json as SearchMessageData, ctx);
|
|
302
|
+
} else if (message.topic.endsWith(config.deletionTopicName)) {
|
|
303
|
+
await handleDeletion(message.json as DeleteMessageData, ctx);
|
|
304
|
+
} else {
|
|
305
|
+
throw new Error(`Unexpected topic: ${message.topic}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
throw new Error("drain() did not settle: message loop exceeded maxRounds");
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
return ctx;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Messages queued for the discovery topic, oldest first. */
|
|
316
|
+
export const discoveryMessages = (ctx: TestContext): SearchMessageData[] =>
|
|
317
|
+
ctx.pubsub.published
|
|
318
|
+
.filter((message) => message.topic.endsWith(ctx.config.discoveryTopicName))
|
|
319
|
+
.map((message) => message.json as SearchMessageData);
|
|
320
|
+
|
|
321
|
+
/** Messages queued for the deletion topic, oldest first. */
|
|
322
|
+
export const deletionMessages = (ctx: TestContext): DeleteMessageData[] =>
|
|
323
|
+
ctx.pubsub.published
|
|
324
|
+
.filter((message) => message.topic.endsWith(ctx.config.deletionTopicName))
|
|
325
|
+
.map((message) => message.json as DeleteMessageData);
|