From 619e555d61958c6878707da58b45d0f23a2a33f1 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 18 Aug 2026 17:55:51 +0100 Subject: [PATCH 1/2] test(delete-user-data): add kit test suite Ports the extension's coverage to the kit and wires up `pnpm test` with vitest. The extension suite runs against the Firestore, Auth and Pub/Sub emulators; the kit takes its clients from an injected context, so the same behaviours are covered with test doubles instead. --- kits/delete-user-data/package-lock.json | 1202 ++++++++++++++++- kits/delete-user-data/package.json | 5 +- .../tests/export-config.test.ts | 95 ++ kits/delete-user-data/tests/handlers.test.ts | 412 ++++++ kits/delete-user-data/tests/helpers.test.ts | 98 ++ .../tests/recursiveDelete.test.ts | 93 ++ .../tests/runBatchPubSubDeletions.test.ts | 126 ++ .../tests/runCustomSearchFunction.test.ts | 107 ++ kits/delete-user-data/tests/search.test.ts | 87 ++ 9 files changed, 2216 insertions(+), 9 deletions(-) create mode 100644 kits/delete-user-data/tests/export-config.test.ts create mode 100644 kits/delete-user-data/tests/handlers.test.ts create mode 100644 kits/delete-user-data/tests/helpers.test.ts create mode 100644 kits/delete-user-data/tests/recursiveDelete.test.ts create mode 100644 kits/delete-user-data/tests/runBatchPubSubDeletions.test.ts create mode 100644 kits/delete-user-data/tests/runCustomSearchFunction.test.ts create mode 100644 kits/delete-user-data/tests/search.test.ts diff --git a/kits/delete-user-data/package-lock.json b/kits/delete-user-data/package-lock.json index b97c0c1c2..c3f68251b 100644 --- a/kits/delete-user-data/package-lock.json +++ b/kits/delete-user-data/package-lock.json @@ -1,12 +1,12 @@ { - "name": "@firebase/delete-user-data", - "version": "0.1.0", + "name": "@firebase-function-kits/delete-user-data", + "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@firebase/delete-user-data", - "version": "0.1.0", + "name": "@firebase-function-kits/delete-user-data", + "version": "0.0.0", "license": "Apache-2.0", "dependencies": { "@google-cloud/pubsub": "^4.3.3", @@ -17,7 +17,8 @@ }, "devDependencies": { "@types/lodash.chunk": "^4.2.7", - "@types/node-fetch": "^2.6.2" + "@types/node-fetch": "^2.6.2", + "vitest": "^4.1.10" }, "engines": { "node": ">=22" @@ -290,6 +291,13 @@ "node": ">=6" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@js-sdsl/ordered-map": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", @@ -331,6 +339,16 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -388,6 +406,276 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -413,6 +701,17 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -431,6 +730,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -606,6 +919,119 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -686,6 +1112,16 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -812,6 +1248,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -878,6 +1324,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -957,6 +1410,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1040,6 +1503,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1082,6 +1552,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1100,6 +1580,16 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -1217,6 +1707,24 @@ "node": ">=0.8.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fetch-blob": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", @@ -1462,8 +1970,23 @@ "node": ">= 0.8" } }, - "node_modules/function-bind": { - "version": "1.1.2", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", @@ -1912,6 +2435,279 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/limiter": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", @@ -2011,6 +2807,16 @@ "lru-cache": "6.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2089,6 +2895,25 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2168,6 +2993,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2249,6 +3088,62 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proto3-json-serializer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", @@ -2388,6 +3283,39 @@ "node": ">=14" } }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2565,6 +3493,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2574,6 +3526,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-events": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", @@ -2687,6 +3646,50 @@ "node": ">= 6" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2783,6 +3786,174 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -2831,6 +4002,23 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/kits/delete-user-data/package.json b/kits/delete-user-data/package.json index 995732a4c..2392a4823 100644 --- a/kits/delete-user-data/package.json +++ b/kits/delete-user-data/package.json @@ -21,7 +21,7 @@ "scripts": { "build": "tsc -b", "clean": "tsc -b --clean", - "test": "echo \"Warning: tests are not implemented for this package\"", + "test": "vitest run", "deploy": "pnpm build && firebase deploy --only functions", "serve": "firebase emulators:start --only functions" }, @@ -34,6 +34,7 @@ }, "devDependencies": { "@types/lodash.chunk": "^4.2.7", - "@types/node-fetch": "^2.6.2" + "@types/node-fetch": "^2.6.2", + "vitest": "^4.1.10" } } \ No newline at end of file diff --git a/kits/delete-user-data/tests/export-config.test.ts b/kits/delete-user-data/tests/export-config.test.ts new file mode 100644 index 000000000..8894f609d --- /dev/null +++ b/kits/delete-user-data/tests/export-config.test.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, test } from "vitest"; + +import { + getDatabaseUrl, + resolveDeleteUserDataConfig, +} from "../src/export-config"; + +describe("getDatabaseUrl", () => { + test("returns the legacy domain for us-central1", () => { + expect(getDatabaseUrl("server-name", "us-central1")).toBe( + "https://server-name.firebaseio.com" + ); + }); + + test("returns the regional domain for europe-west1", () => { + expect(getDatabaseUrl("server-name", "europe-west1")).toBe( + "https://server-name.europe-west1.firebasedatabase.app" + ); + }); + + test("returns the regional domain for asia-southeast1", () => { + expect(getDatabaseUrl("server-name", "asia-southeast1")).toBe( + "https://server-name.asia-southeast1.firebasedatabase.app" + ); + }); + + test("returns null if the instance is undefined", () => { + expect(getDatabaseUrl(undefined, "asia-southeast1")).toBeNull(); + }); + + test("returns null if the location is undefined", () => { + expect(getDatabaseUrl("server-name", undefined)).toBeNull(); + }); +}); + +describe("resolveDeleteUserDataConfig", () => { + test("applies the documented defaults", () => { + const resolved = resolveDeleteUserDataConfig({ instanceId: "inst" }); + + expect(resolved.firestoreDatabaseId).toBe("(default)"); + expect(resolved.firestoreDeleteMode).toBe("shallow"); + expect(resolved.enableAutoDiscovery).toBe(false); + expect(resolved.searchDepth).toBe(3); + expect(resolved.searchFields).toBe("id,uid,userId"); + }); + + test("derives topic names from the instance id", () => { + const resolved = resolveDeleteUserDataConfig({ instanceId: "inst" }); + + expect(resolved.discoveryTopicName).toBe("kit-inst-discovery"); + expect(resolved.deletionTopicName).toBe("kit-inst-deletion"); + }); + + test("keeps explicitly configured topic names", () => { + const resolved = resolveDeleteUserDataConfig({ + instanceId: "inst", + discoveryTopicName: "my-discovery", + deletionTopicName: "my-deletion", + }); + + expect(resolved.discoveryTopicName).toBe("my-discovery"); + expect(resolved.deletionTopicName).toBe("my-deletion"); + }); + + test("passes through the caller's search settings", () => { + const resolved = resolveDeleteUserDataConfig({ + instanceId: "inst", + enableAutoDiscovery: true, + searchDepth: 5, + searchFields: "owner", + firestoreDeleteMode: "recursive", + }); + + expect(resolved.enableAutoDiscovery).toBe(true); + expect(resolved.searchDepth).toBe(5); + expect(resolved.searchFields).toBe("owner"); + expect(resolved.firestoreDeleteMode).toBe("recursive"); + }); +}); diff --git a/kits/delete-user-data/tests/handlers.test.ts b/kits/delete-user-data/tests/handlers.test.ts new file mode 100644 index 000000000..6f558656d --- /dev/null +++ b/kits/delete-user-data/tests/handlers.test.ts @@ -0,0 +1,412 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PubSub } from "@google-cloud/pubsub"; +import type * as admin from "firebase-admin"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("../src/events"); +vi.mock("../src/logs"); +vi.mock("../src/recursiveDelete"); +vi.mock("../src/runCustomSearchFunction"); + +import * as events from "../src/events"; +import { + type DeleteUserDataConfig, + resolveDeleteUserDataConfig, +} from "../src/export-config"; +import { + type HandlerContext, + handleClear, + handleDeletion, + handleSearch, +} from "../src/handlers"; +import * as logs from "../src/logs"; +import { recursiveDelete } from "../src/recursiveDelete"; +import { runCustomSearchFunction } from "../src/runCustomSearchFunction"; + +interface FakeDoc { + id: string; + path: string; + data?: Record; + collections?: string[]; +} + +/** + * Firestore double backed by a flat map of documents. Only the surface the + * handlers touch is implemented: batches, references, listDocuments and getAll. + */ +function fakeFirestore(docs: FakeDoc[] = []) { + const byPath = new Map(docs.map((doc) => [doc.path, doc])); + const batches: { deleted: string[]; committed: boolean }[] = []; + const transactionDeletes: string[] = []; + + const snapshotOf = (doc: FakeDoc | undefined, path: string) => ({ + exists: doc?.data !== undefined, + ref: { path }, + get: (fieldPath: unknown) => doc?.data?.[String(fieldPath)], + }); + + const docRef = (path: string) => ({ + id: path.split("/").pop() as string, + path, + get: async () => snapshotOf(byPath.get(path), path), + listCollections: async () => + (byPath.get(path)?.collections ?? []).map((id) => ({ + id, + path: `${path}/${id}`, + })), + }); + + const firestore = { + batch: () => { + const batch = { deleted: [] as string[], committed: false }; + batches.push(batch); + return { + delete: (ref: { path: string }) => batch.deleted.push(ref.path), + commit: async () => { + batch.committed = true; + }, + }; + }, + doc: (path: string) => docRef(path), + collection: (path: string) => ({ + id: path.split("/").pop() as string, + path, + listDocuments: async () => + docs + .filter((doc) => doc.path.startsWith(`${path}/`)) + .filter( + (doc) => doc.path.split("/").length === path.split("/").length + 1 + ) + .map((doc) => docRef(doc.path)), + }), + getAll: async (...refs: { path: string }[]) => + refs.map((ref) => snapshotOf(byPath.get(ref.path), ref.path)), + runTransaction: async ( + updateFunction: (transaction: { + delete: (ref: { path: string }) => void; + }) => Promise + ) => + updateFunction({ + delete: (ref: { path: string }) => transactionDeletes.push(ref.path), + }), + }; + + return { + firestore: firestore as unknown as admin.firestore.Firestore, + batches, + transactionDeletes, + }; +} + +function makeCtx( + configOverrides: Partial = {}, + docs: FakeDoc[] = [] +) { + const { firestore, batches, transactionDeletes } = fakeFirestore(docs); + const published: unknown[] = []; + const removed: string[] = []; + const deleteFiles = vi.fn().mockResolvedValue(undefined); + const bucket = vi.fn(() => ({ deleteFiles })); + + const ctx: HandlerContext = { + firestore, + storage: { bucket } as unknown as admin.storage.Storage, + database: { + ref: (path: string) => ({ + remove: async () => { + removed.push(path); + }, + }), + } as unknown as admin.database.Database, + pubsub: { + topic: () => ({ + publishMessage: (message: { json: unknown }) => { + published.push(message.json); + return Promise.resolve("id"); + }, + }), + } as unknown as PubSub, + config: resolveDeleteUserDataConfig({ + instanceId: "inst", + projectId: "test-project", + ...configOverrides, + }), + }; + + return { + ctx, + published, + batches, + transactionDeletes, + removed, + bucket, + deleteFiles, + }; +} + +describe("handleDeletion", () => { + beforeEach(() => vi.clearAllMocks()); + + test("recursively deletes paths that belong to the uid", async () => { + const { ctx } = makeCtx({ firestoreDeleteMode: "recursive" }, [ + { id: "path1", path: "valid/path1", data: { uid: "testUid" } }, + { id: "path2", path: "valid/path2", data: { uid: "testUid" } }, + ]); + + await handleDeletion( + { uid: "testUid", paths: ["valid/path1", "valid/path2"] }, + ctx + ); + + expect(recursiveDelete).toHaveBeenCalledTimes(2); + expect(recursiveDelete).toHaveBeenCalledWith("valid/path1", ctx.firestore); + expect(recursiveDelete).toHaveBeenCalledWith("valid/path2", ctx.firestore); + }); + + test("deletes through a batch in shallow mode", async () => { + const { ctx, batches } = makeCtx({ firestoreDeleteMode: "shallow" }, [ + { id: "path1", path: "valid/path1", data: { uid: "testUid" } }, + ]); + + await handleDeletion({ uid: "testUid", paths: ["valid/path1"] }, ctx); + + expect(recursiveDelete).not.toHaveBeenCalled(); + expect(batches).toEqual([{ deleted: ["valid/path1"], committed: true }]); + }); + + test("cannot delete paths that do not belong to the uid", async () => { + const { ctx, batches } = makeCtx({ firestoreDeleteMode: "recursive" }, [ + { id: "doc1", path: "collection/doc1", data: { uid: "someoneElse" } }, + ]); + + await handleDeletion( + { uid: "invalidUserId", paths: ["collection/doc1"] }, + ctx + ); + + expect(recursiveDelete).not.toHaveBeenCalled(); + expect(batches[0].deleted).toEqual([]); + expect(logs.warnInvalidPaths).toHaveBeenCalledWith(1, "invalidUserId"); + }); + + test("publishes the deletion event with the invalid paths", async () => { + const { ctx } = makeCtx({ firestoreDeleteMode: "recursive" }, [ + { id: "path1", path: "valid/path1", data: { uid: "testUid" } }, + ]); + + await handleDeletion( + { uid: "testUid", paths: ["valid/path1", "other/path2"] }, + ctx + ); + + expect(events.publishDeletionEvent).toHaveBeenCalledWith("firestore", { + uid: "testUid", + documentPaths: ["valid/path1", "other/path2"], + invalidPaths: ["other/path2"], + }); + }); + + test("commits one batch per chunk of 450 paths", async () => { + const paths = Array.from( + { length: 901 }, + (_, i) => `users/testUid/doc${i}` + ); + const { ctx, batches } = makeCtx({ firestoreDeleteMode: "shallow" }); + + await handleDeletion({ uid: "testUid", paths }, ctx); + + expect(batches).toHaveLength(3); + expect(batches.map((batch) => batch.deleted.length)).toEqual([450, 450, 1]); + expect(batches.every((batch) => batch.committed)).toBe(true); + }); +}); + +describe("handleSearch", () => { + beforeEach(() => vi.clearAllMocks()); + + test("deletes a collection named after the uid", async () => { + const { ctx } = makeCtx(); + + await handleSearch({ path: "parent/doc/uid1", depth: 1, uid: "uid1" }, ctx); + + expect(recursiveDelete).toHaveBeenCalledWith( + "parent/doc/uid1", + ctx.firestore + ); + expect(events.publishDeletionEvent).toHaveBeenCalledWith("firestore", { + uid: "uid1", + collectionPath: "parent/doc/uid1", + }); + }); + + test("queues a document named after the uid for deletion", async () => { + const { ctx, published } = makeCtx({}, [ + { id: "uid1", path: "posts/uid1", data: {} }, + ]); + + await handleSearch({ path: "posts", depth: 1, uid: "uid1" }, ctx); + + expect(published).toContainEqual({ paths: ["posts/uid1"], uid: "uid1" }); + }); + + test("queues a document whose search field holds the uid", async () => { + const { ctx, published } = makeCtx({ searchFields: "uid" }, [ + { id: "doc1", path: "posts/doc1", data: { uid: "uid1" } }, + ]); + + await handleSearch({ path: "posts", depth: 1, uid: "uid1" }, ctx); + + expect(published).toContainEqual({ paths: ["posts/doc1"], uid: "uid1" }); + }); + + test("does not queue documents that match nothing", async () => { + const { ctx, published } = makeCtx({ searchFields: "uid" }, [ + { id: "doc1", path: "posts/doc1", data: { uid: "someoneElse" } }, + ]); + + await handleSearch({ path: "posts", depth: 1, uid: "uid1" }, ctx); + + expect(published).not.toContainEqual( + expect.objectContaining({ paths: ["posts/doc1"] }) + ); + }); + + test("does not exceed the configured search depth", async () => { + const { ctx, published } = makeCtx({ searchDepth: 3 }, [ + { id: "uid1", path: "posts/uid1", data: {} }, + ]); + + await handleSearch({ path: "posts", depth: 4, uid: "uid1" }, ctx); + + expect(recursiveDelete).not.toHaveBeenCalled(); + expect(published).toHaveLength(0); + }); + + test("recurses into child documents while within the search depth", async () => { + const { ctx, published } = makeCtx({ searchDepth: 3 }, [ + { id: "doc1", path: "posts/doc1", data: {}, collections: ["comments"] }, + ]); + + await handleSearch({ path: "posts", depth: 1, uid: "uid1" }, ctx); + + expect(published).toContainEqual({ + path: "posts/doc1/comments", + uid: "uid1", + depth: 2, + }); + }); + + test("skips field matching when no search fields are configured", async () => { + const { ctx, published } = makeCtx({ searchFields: "" }, [ + { id: "doc1", path: "posts/doc1", data: { uid: "uid1" } }, + ]); + + await handleSearch({ path: "posts", depth: 1, uid: "uid1" }, ctx); + + expect(published).toHaveLength(0); + }); +}); + +describe("handleClear", () => { + beforeEach(() => vi.clearAllMocks()); + + test("deletes the configured Firestore paths shallowly", async () => { + const { ctx, transactionDeletes } = makeCtx({ + firestorePaths: "users/{UID},logs/{UID}", + firestoreDeleteMode: "shallow", + }); + + await handleClear("uid1", ctx); + + expect(transactionDeletes.sort()).toEqual(["logs/uid1", "users/uid1"]); + expect(recursiveDelete).not.toHaveBeenCalled(); + }); + + test("deletes the configured Firestore paths recursively", async () => { + const { ctx } = makeCtx({ + firestorePaths: "users/{UID}", + firestoreDeleteMode: "recursive", + }); + + await handleClear("uid1", ctx); + + expect(recursiveDelete).toHaveBeenCalledWith("users/uid1", ctx.firestore); + }); + + test("removes the configured RTDB paths", async () => { + const { ctx, removed } = makeCtx({ rtdbPaths: "users/{UID}" }); + + await handleClear("uid1", ctx); + + expect(removed).toEqual(["users/uid1"]); + }); + + test("resolves {DEFAULT} to the configured storage bucket", async () => { + const { ctx, bucket, deleteFiles } = makeCtx({ + storagePaths: "{DEFAULT}/users/{UID}/avatar", + storageBucket: "my-bucket", + }); + + await handleClear("uid1", ctx); + + expect(bucket).toHaveBeenCalledWith("my-bucket"); + expect(deleteFiles).toHaveBeenCalledWith({ prefix: "users/uid1/avatar" }); + }); + + test("treats a missing storage prefix as nothing to delete", async () => { + const { ctx, deleteFiles } = makeCtx({ + storagePaths: "{DEFAULT}/users/{UID}", + storageBucket: "my-bucket", + }); + deleteFiles.mockRejectedValueOnce({ code: 404 }); + + await handleClear("uid1", ctx); + + expect(logs.storagePath404).toHaveBeenCalledWith("users/uid1"); + expect(logs.storagePathError).not.toHaveBeenCalled(); + }); + + test("logs which targets are not configured", async () => { + const { ctx } = makeCtx(); + + await handleClear("uid1", ctx); + + expect(logs.firestoreNotConfigured).toHaveBeenCalled(); + expect(logs.rtdbNotConfigured).toHaveBeenCalled(); + expect(logs.storageNotConfigured).toHaveBeenCalled(); + }); + + test("starts auto discovery only when it is enabled", async () => { + const { ctx, published } = makeCtx({ enableAutoDiscovery: true }); + ( + ctx.firestore as unknown as { listCollections: () => Promise } + ).listCollections = async () => [{ path: "users" }]; + + await handleClear("uid1", ctx); + + expect(published).toEqual([{ path: "users", uid: "uid1", depth: 1 }]); + }); + + test("runs the custom search function when one is configured", async () => { + const { ctx } = makeCtx({ searchFunction: "https://example.com/search" }); + + await handleClear("uid1", ctx); + + expect(runCustomSearchFunction).toHaveBeenCalledWith("uid1", ctx); + }); +}); diff --git a/kits/delete-user-data/tests/helpers.test.ts b/kits/delete-user-data/tests/helpers.test.ts new file mode 100644 index 000000000..cf4fb777e --- /dev/null +++ b/kits/delete-user-data/tests/helpers.test.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { DocumentReference } from "firebase-admin/firestore"; +import { describe, expect, test } from "vitest"; + +import { extractUserPaths, hasValidUserPath } from "../src/helpers"; + +const SEARCH_FIELDS = "id,uid,userId"; + +/** Fake document reference exposing only the fields hasValidUserPath reads. */ +function docRef(data: Record | undefined): DocumentReference { + return { + get: async () => ({ + exists: data !== undefined, + get: (fieldPath: unknown) => data?.[String(fieldPath)], + }), + } as unknown as DocumentReference; +} + +describe("hasValidUserPath", () => { + test("returns true if the path itself contains the uid", async () => { + const ref = docRef(undefined); + + expect(await hasValidUserPath(ref, "users/uid1", "uid1", "")).toBe(true); + }); + + test("returns true if a search field holds the uid", async () => { + const ref = docRef({ uid: "uid1" }); + + expect(await hasValidUserPath(ref, "", "uid1", SEARCH_FIELDS)).toBe(true); + }); + + test("returns true if a search field holds a path containing the uid", async () => { + const ref = docRef({ uid: "testing/uid1" }); + + expect(await hasValidUserPath(ref, "", "uid1", SEARCH_FIELDS)).toBe(true); + }); + + test("returns false for a non-string field value", async () => { + // The uid is a substring of the stringified number, so this only passes + // while the field value is required to be a string. + const ref = docRef({ uid: 12345 }); + + expect(await hasValidUserPath(ref, "", "234", SEARCH_FIELDS)).toBe(false); + }); + + test("returns false if no search field matches", async () => { + const ref = docRef({ uid: "someone-else" }); + + expect(await hasValidUserPath(ref, "", "uid1", SEARCH_FIELDS)).toBe(false); + }); + + test("returns false if the document does not exist", async () => { + const ref = docRef(undefined); + + expect(await hasValidUserPath(ref, "", "uid1", SEARCH_FIELDS)).toBe(false); + }); + + test("does not read the document when no search fields are configured", async () => { + let read = false; + const ref = { + get: async () => { + read = true; + return { exists: true, get: () => "uid1" }; + }, + } as unknown as DocumentReference; + + expect(await hasValidUserPath(ref, "users/other", "uid1", "")).toBe(false); + expect(read).toBe(false); + }); +}); + +describe("extractUserPaths", () => { + test("substitutes every {UID} placeholder", () => { + expect(extractUserPaths("users/{UID},logs/{UID}/entries", "uid1")).toEqual([ + "users/uid1", + "logs/uid1/entries", + ]); + }); + + test("leaves paths without a placeholder untouched", () => { + expect(extractUserPaths("public/data", "uid1")).toEqual(["public/data"]); + }); +}); diff --git a/kits/delete-user-data/tests/recursiveDelete.test.ts b/kits/delete-user-data/tests/recursiveDelete.test.ts new file mode 100644 index 000000000..335544fb3 --- /dev/null +++ b/kits/delete-user-data/tests/recursiveDelete.test.ts @@ -0,0 +1,93 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as admin from "firebase-admin"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { recursiveDelete } from "../src/recursiveDelete"; + +/** Fake Firestore exposing only what recursiveDelete touches. */ +function fakeFirestore() { + const bulkWriter = { onWriteError: vi.fn(), close: vi.fn() }; + const db = { + bulkWriter: vi.fn(() => bulkWriter), + doc: vi.fn((path: string) => ({ kind: "document", path })), + collection: vi.fn((path: string) => ({ kind: "collection", path })), + recursiveDelete: vi.fn().mockResolvedValue(undefined), + }; + return { + bulkWriter, + db: db as unknown as admin.firestore.Firestore, + calls: db, + }; +} + +describe("recursiveDelete", () => { + beforeEach(() => vi.clearAllMocks()); + + test("deletes a document reference for an even number of segments", async () => { + const { db, calls, bulkWriter } = fakeFirestore(); + + await recursiveDelete("documents/doc1", db); + + expect(calls.doc).toHaveBeenCalledWith("documents/doc1"); + expect(calls.collection).not.toHaveBeenCalled(); + expect(calls.recursiveDelete).toHaveBeenCalledWith( + { kind: "document", path: "documents/doc1" }, + bulkWriter + ); + }); + + test("deletes a collection reference for an odd number of segments", async () => { + const { db, calls, bulkWriter } = fakeFirestore(); + + await recursiveDelete("documents/doc1/collection1", db); + + expect(calls.collection).toHaveBeenCalledWith("documents/doc1/collection1"); + expect(calls.doc).not.toHaveBeenCalled(); + expect(calls.recursiveDelete).toHaveBeenCalledWith( + { kind: "collection", path: "documents/doc1/collection1" }, + bulkWriter + ); + }); + + test("deletes a document by reference so subcollections go with it", async () => { + const { db, calls } = fakeFirestore(); + + await recursiveDelete("documents/doc1", db); + + // Firestore's recursiveDelete removes descendants of the reference it is + // given, so a nested collection needs no separate call. + expect(calls.recursiveDelete).toHaveBeenCalledTimes(1); + }); + + test("retries a failed write up to three attempts", async () => { + const { db, bulkWriter } = fakeFirestore(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await recursiveDelete("documents/doc1", db); + + const [onError] = bulkWriter.onWriteError.mock.calls[0]; + const error = (failedAttempts: number) => ({ + failedAttempts, + documentRef: { path: "documents/doc1" }, + }); + + expect(onError(error(1))).toBe(true); + expect(onError(error(2))).toBe(true); + expect(onError(error(3))).toBe(false); + }); +}); diff --git a/kits/delete-user-data/tests/runBatchPubSubDeletions.test.ts b/kits/delete-user-data/tests/runBatchPubSubDeletions.test.ts new file mode 100644 index 000000000..b5312cbb1 --- /dev/null +++ b/kits/delete-user-data/tests/runBatchPubSubDeletions.test.ts @@ -0,0 +1,126 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PubSub } from "@google-cloud/pubsub"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { resolveDeleteUserDataConfig } from "../src/export-config"; +import { + type PublisherContext, + publishSearch, + runBatchPubSubDeletions, +} from "../src/runBatchPubSubDeletions"; + +/** Fake PubSub recording the topic each message was published to. */ +function fakePubSub() { + const published: { topic: string; json: unknown }[] = []; + const publishMessage = vi.fn(); + const topic = vi.fn((topicName: string) => ({ + publishMessage: (message: { json: unknown }) => { + published.push({ topic: topicName, json: message.json }); + return publishMessage(message); + }, + })); + return { published, topic, pubsub: { topic } as unknown as PubSub }; +} + +function makeCtx(overrides: Record = {}) { + const { pubsub, published, topic } = fakePubSub(); + const ctx: PublisherContext = { + pubsub, + config: resolveDeleteUserDataConfig({ + instanceId: "inst", + projectId: "test-project", + ...overrides, + }), + }; + return { ctx, published, topic }; +} + +describe("runBatchPubSubDeletions", () => { + beforeEach(() => vi.clearAllMocks()); + + test("publishes the paths to the deletion topic", async () => { + const { ctx, published } = makeCtx(); + + await runBatchPubSubDeletions( + { firestorePaths: ["users/uid1", "logs/uid1"] }, + "uid1", + ctx + ); + + expect(published).toEqual([ + { + topic: "projects/test-project/topics/kit-inst-deletion", + json: { paths: ["users/uid1", "logs/uid1"], uid: "uid1" }, + }, + ]); + }); + + test("chunks paths so a single message stays under the batch limit", async () => { + const { ctx, published } = makeCtx(); + const paths = Array.from({ length: 901 }, (_, i) => `users/uid1/doc${i}`); + + await runBatchPubSubDeletions({ firestorePaths: paths }, "uid1", ctx); + + expect(published).toHaveLength(3); + expect( + published.map((message) => (message.json as { paths: string[] }).paths) + ).toEqual([paths.slice(0, 450), paths.slice(450, 900), paths.slice(900)]); + }); + + test("publishes nothing for an empty or missing path list", async () => { + const { ctx, published } = makeCtx(); + + await runBatchPubSubDeletions({ firestorePaths: [] }, "uid1", ctx); + await runBatchPubSubDeletions( + {} as { firestorePaths: string[] }, + "uid1", + ctx + ); + + expect(published).toHaveLength(0); + }); + + test("falls back to the bare topic name without a project id", async () => { + const { ctx, published } = makeCtx({ projectId: undefined }); + delete process.env.GOOGLE_CLOUD_PROJECT; + delete process.env.PROJECT_ID; + + await runBatchPubSubDeletions( + { firestorePaths: ["users/uid1"] }, + "uid1", + ctx + ); + + expect(published[0].topic).toBe("kit-inst-deletion"); + }); +}); + +describe("publishSearch", () => { + test("publishes the path and depth to the discovery topic", async () => { + const { ctx, published } = makeCtx(); + + await publishSearch("uid1", 2, "users", ctx); + + expect(published).toEqual([ + { + topic: "projects/test-project/topics/kit-inst-discovery", + json: { path: "users", uid: "uid1", depth: 2 }, + }, + ]); + }); +}); diff --git a/kits/delete-user-data/tests/runCustomSearchFunction.test.ts b/kits/delete-user-data/tests/runCustomSearchFunction.test.ts new file mode 100644 index 000000000..6184c94ae --- /dev/null +++ b/kits/delete-user-data/tests/runCustomSearchFunction.test.ts @@ -0,0 +1,107 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { PubSub } from "@google-cloud/pubsub"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("node-fetch", () => ({ default: vi.fn() })); +vi.mock("../src/logs"); + +import fetch from "node-fetch"; +import { resolveDeleteUserDataConfig } from "../src/export-config"; +import * as logs from "../src/logs"; +import type { PublisherContext } from "../src/runBatchPubSubDeletions"; +import { runCustomSearchFunction } from "../src/runCustomSearchFunction"; + +const mockFetch = vi.mocked(fetch); + +function makeCtx(searchFunction?: string) { + const published: unknown[] = []; + const ctx: PublisherContext = { + pubsub: { + topic: () => ({ + publishMessage: (message: { json: unknown }) => { + published.push(message.json); + return Promise.resolve("id"); + }, + }), + } as unknown as PubSub, + config: resolveDeleteUserDataConfig({ + instanceId: "inst", + projectId: "test-project", + searchFunction, + }), + }; + return { ctx, published }; +} + +function response(body: unknown, ok = true) { + return { + ok, + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Awaited>; +} + +describe("runCustomSearchFunction", () => { + beforeEach(() => vi.clearAllMocks()); + + test("does nothing when no search function is configured", async () => { + const { ctx, published } = makeCtx(); + + await runCustomSearchFunction("uid1", ctx); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(published).toHaveLength(0); + }); + + test("posts the uid and queues the returned paths", async () => { + const { ctx, published } = makeCtx("https://example.com/search"); + mockFetch.mockResolvedValue( + response({ firestorePaths: ["users/uid1", "logs/uid1"] }) + ); + + await runCustomSearchFunction("uid1", ctx); + + expect(mockFetch).toHaveBeenCalledWith("https://example.com/search", { + method: "POST", + body: JSON.stringify({ uid: "uid1" }), + headers: { "Content-Type": "application/json" }, + }); + expect(published).toEqual([ + { paths: ["users/uid1", "logs/uid1"], uid: "uid1" }, + ]); + }); + + test("accepts a bare array of paths", async () => { + const { ctx, published } = makeCtx("https://example.com/search"); + mockFetch.mockResolvedValue(response(["users/uid1"])); + + await runCustomSearchFunction("uid1", ctx); + + expect(published).toEqual([{ paths: ["users/uid1"], uid: "uid1" }]); + }); + + test("logs and queues nothing when the function fails", async () => { + const { ctx, published } = makeCtx("https://example.com/search"); + mockFetch.mockResolvedValue(response({ error: "boom" }, false)); + + await runCustomSearchFunction("uid1", ctx); + + expect(logs.customFunctionError).toHaveBeenCalled(); + expect(published).toHaveLength(0); + }); +}); diff --git a/kits/delete-user-data/tests/search.test.ts b/kits/delete-user-data/tests/search.test.ts new file mode 100644 index 000000000..3d09b50f6 --- /dev/null +++ b/kits/delete-user-data/tests/search.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as admin from "firebase-admin"; +import type { PubSub } from "@google-cloud/pubsub"; +import { describe, expect, test } from "vitest"; + +import { resolveDeleteUserDataConfig } from "../src/export-config"; +import type { PublisherContext } from "../src/runBatchPubSubDeletions"; +import { search } from "../src/search"; + +function makeCtx() { + const published: unknown[] = []; + const ctx: PublisherContext = { + pubsub: { + topic: () => ({ + publishMessage: (message: { json: unknown }) => { + published.push(message.json); + return Promise.resolve("id"); + }, + }), + } as unknown as PubSub, + config: resolveDeleteUserDataConfig({ + instanceId: "inst", + projectId: "test-project", + }), + }; + return { ctx, published }; +} + +describe("search", () => { + test("queues every top-level collection for discovery", async () => { + const { ctx, published } = makeCtx(); + const db = { + listCollections: async () => [{ path: "users" }, { path: "logs" }], + } as unknown as admin.firestore.Firestore; + + await search("uid1", 1, db, ctx); + + expect(published).toEqual([ + { path: "users", uid: "uid1", depth: 1 }, + { path: "logs", uid: "uid1", depth: 1 }, + ]); + }); + + test("queues the subcollections of a document when one is given", async () => { + const { ctx, published } = makeCtx(); + const document = { + listCollections: async () => [{ path: "users/uid1/orders" }], + } as unknown as admin.firestore.DocumentReference; + const db = { + listCollections: async () => { + throw new Error("should not list root collections"); + }, + } as unknown as admin.firestore.Firestore; + + await search("uid1", 2, db, ctx, document); + + expect(published).toEqual([ + { path: "users/uid1/orders", uid: "uid1", depth: 2 }, + ]); + }); + + test("publishes nothing when there are no collections", async () => { + const { ctx, published } = makeCtx(); + const db = { + listCollections: async () => [], + } as unknown as admin.firestore.Firestore; + + await search("uid1", 1, db, ctx); + + expect(published).toHaveLength(0); + }); +}); From 9391e3ffdf7fa9bcab6d1cf5c43310568587c128 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 18 Aug 2026 23:31:01 +0100 Subject: [PATCH 2/2] test(delete-user-data): add emulator tests Ports the extension's emulator suite. A small app under tests/emulator/app loads the built kit as a functions codebase, so the auth and Pub/Sub triggers run for real and the discovery and deletion loop is exercised end to end. The app carries its own package.json because the Functions emulator rejects the kit's `engines: ">=22"` and wants an exact major. Its .env sets every param so the emulator never prompts and caches a stale .env.local. `pnpm test` still runs only the unit tests; `pnpm test:emulator` runs these. --- kits/.gitignore | 2 + kits/delete-user-data/firebase.json | 35 ++++ kits/delete-user-data/package.json | 1 + kits/delete-user-data/tests/emulator/app/.env | 15 ++ .../tests/emulator/app/index.js | 17 ++ .../tests/emulator/app/package.json | 6 + .../tests/emulator/cascade.test.ts | 194 ++++++++++++++++++ .../tests/emulator/database.rules.json | 1 + .../tests/emulator/firestore.test.ts | 84 ++++++++ .../tests/emulator/helpers.ts | 76 +++++++ kits/delete-user-data/vitest.config.ts | 23 +++ .../vitest.emulator.config.ts | 28 +++ 12 files changed, 482 insertions(+) create mode 100644 kits/delete-user-data/firebase.json create mode 100644 kits/delete-user-data/tests/emulator/app/.env create mode 100644 kits/delete-user-data/tests/emulator/app/index.js create mode 100644 kits/delete-user-data/tests/emulator/app/package.json create mode 100644 kits/delete-user-data/tests/emulator/cascade.test.ts create mode 100644 kits/delete-user-data/tests/emulator/database.rules.json create mode 100644 kits/delete-user-data/tests/emulator/firestore.test.ts create mode 100644 kits/delete-user-data/tests/emulator/helpers.ts create mode 100644 kits/delete-user-data/vitest.config.ts create mode 100644 kits/delete-user-data/vitest.emulator.config.ts diff --git a/kits/.gitignore b/kits/.gitignore index a0f26bba1..4d9946868 100644 --- a/kits/.gitignore +++ b/kits/.gitignore @@ -9,6 +9,8 @@ dist/ # Env / secrets .env .env.* +# Emulator test apps commit their params: demo values, never secrets. +!*/tests/emulator/app/.env # Test / coverage coverage/ diff --git a/kits/delete-user-data/firebase.json b/kits/delete-user-data/firebase.json new file mode 100644 index 000000000..6b8f0be4b --- /dev/null +++ b/kits/delete-user-data/firebase.json @@ -0,0 +1,35 @@ +{ + "functions": [ + { + "source": "tests/emulator/app", + "codebase": "delete-user-data" + } + ], + "emulators": { + "firestore": { + "host": "127.0.0.1", + "port": 8080 + }, + "auth": { + "port": 9099 + }, + "pubsub": { + "port": 8085 + }, + "functions": { + "port": 5001 + }, + "eventarc": { + "port": 9299 + }, + "ui": { + "enabled": false + }, + "database": { + "port": 9000 + } + }, + "database": { + "rules": "tests/emulator/database.rules.json" + } +} diff --git a/kits/delete-user-data/package.json b/kits/delete-user-data/package.json index 2392a4823..111b76dc3 100644 --- a/kits/delete-user-data/package.json +++ b/kits/delete-user-data/package.json @@ -22,6 +22,7 @@ "build": "tsc -b", "clean": "tsc -b --clean", "test": "vitest run", + "test:emulator": "npm run build && firebase emulators:exec --only functions,firestore,auth,pubsub,database --project demo-test \"vitest run --config vitest.emulator.config.ts\"", "deploy": "pnpm build && firebase deploy --only functions", "serve": "firebase emulators:start --only functions" }, diff --git a/kits/delete-user-data/tests/emulator/app/.env b/kits/delete-user-data/tests/emulator/app/.env new file mode 100644 index 000000000..0492579d7 --- /dev/null +++ b/kits/delete-user-data/tests/emulator/app/.env @@ -0,0 +1,15 @@ +INSTANCE_ID=demo +FIRESTORE_PATHS=users/{UID} +FIRESTORE_DATABASE_ID=(default) +FIRESTORE_DELETE_MODE=recursive +SELECTED_DATABASE_INSTANCE=demo-test +SELECTED_DATABASE_LOCATION=us-central1 +RTDB_PATHS=user-data/{UID} +CLOUD_STORAGE_BUCKET=demo-test.appspot.com +STORAGE_PATHS= +ENABLE_AUTO_DISCOVERY=true +AUTO_DISCOVERY_SEARCH_DEPTH=3 +AUTO_DISCOVERY_SEARCH_FIELDS=id,uid,userId +SEARCH_FUNCTION= +DISCOVERY_TOPIC_NAME=kit-demo-discovery +DELETION_TOPIC_NAME=kit-demo-deletion diff --git a/kits/delete-user-data/tests/emulator/app/index.js b/kits/delete-user-data/tests/emulator/app/index.js new file mode 100644 index 000000000..66310fcfb --- /dev/null +++ b/kits/delete-user-data/tests/emulator/app/index.js @@ -0,0 +1,17 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = require("../../../lib/index.js"); diff --git a/kits/delete-user-data/tests/emulator/app/package.json b/kits/delete-user-data/tests/emulator/app/package.json new file mode 100644 index 000000000..7f0853335 --- /dev/null +++ b/kits/delete-user-data/tests/emulator/app/package.json @@ -0,0 +1,6 @@ +{ + "name": "delete-user-data-emulator-app", + "private": true, + "main": "index.js", + "engines": { "node": "24" } +} diff --git a/kits/delete-user-data/tests/emulator/cascade.test.ts b/kits/delete-user-data/tests/emulator/cascade.test.ts new file mode 100644 index 000000000..63080b390 --- /dev/null +++ b/kits/delete-user-data/tests/emulator/cascade.test.ts @@ -0,0 +1,194 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Firestore } from "firebase-admin/firestore"; +import type * as admin from "firebase-admin"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { resolveDeleteUserDataConfig } from "../../src/export-config"; +import { + publishSearch, + runBatchPubSubDeletions, +} from "../../src/runBatchPubSubDeletions"; +import { search } from "../../src/search"; +import { + collectionEmpty, + createUser, + documentGone, + initialize, + publisherContext, + randomId, + waitFor, +} from "./helpers"; + +// Matches tests/emulator/app/.env, which is what the deployed functions read. +const config = resolveDeleteUserDataConfig({ + instanceId: "demo", + projectId: "demo-test", + discoveryTopicName: "kit-demo-discovery", + deletionTopicName: "kit-demo-deletion", + searchDepth: 3, + searchFields: "id,uid,userId", +}); + +let db: Firestore; +let auth: admin.auth.Auth; +let ctx: ReturnType; + +beforeAll(() => { + ({ db, auth } = initialize()); + ctx = publisherContext(config); +}); + +/** + * Scopes discovery to one root collection. `search` publishes the same + * message for every root collection, which in a shared emulator means every + * collection left behind by another test. + */ +const discover = (uid: string, path: string) => + publishSearch(uid, 1, path, ctx); + +describe("auto discovery", () => { + test("deletes a top level collection named after the uid", async () => { + const uid = randomId(); + await db.collection(uid).add({ foo: "bar" }); + + await search(uid, 1, db, ctx); + + expect(await waitFor(collectionEmpty(db, uid))).toBe(true); + }); + + test("deletes a document named after the uid", async () => { + const uid = randomId(); + const root = randomId(); + const doc = db.collection(root).doc(uid); + await doc.set({ foo: "bar" }); + + await discover(uid, root); + + expect(await waitFor(documentGone(doc))).toBe(true); + }); + + test("deletes a document whose search field holds the uid", async () => { + const uid = randomId(); + const root = randomId(); + const doc = await db.collection(root).add({ uid }); + + await discover(uid, root); + + expect(await waitFor(documentGone(doc))).toBe(true); + }); + + test("deletes a subcollection named after the uid", async () => { + const uid = randomId(); + const root = randomId(); + const subcollection = db.collection(root).doc("parent").collection(uid); + await subcollection.add({ foo: "bar" }); + + await discover(uid, root); + + expect(await waitFor(collectionEmpty(db, subcollection.path))).toBe(true); + }); + + test("handles a document with no fields", async () => { + const uid = randomId(); + const root = randomId(); + const doc = await db.collection(root).add({}); + + await discover(uid, root); + + // Nothing matches, so the document survives and nothing throws. + await new Promise((resolve) => setTimeout(resolve, 5000)); + expect((await doc.get()).exists).toBe(true); + }); + + test("does not delete documents that match nothing", async () => { + const uid = randomId(); + const root = randomId(); + const doc = await db.collection(root).add({ uid: "someone-else" }); + + await discover(uid, root); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + expect((await doc.get()).exists).toBe(true); + }); + + test("does not search past the configured depth", async () => { + const uid = randomId(); + const root = randomId(); + const deep = db + .collection(root) + .doc("1") + .collection("2") + .doc("2") + .collection("3") + .doc("3") + .collection("4") + .doc("4") + .collection(uid); + await deep.add({ foo: "bar" }); + + expect(deep.path.split("/").length / 2).toBeGreaterThan(config.searchDepth); + + await discover(uid, root); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + expect((await deep.get()).empty).toBe(false); + }); +}); + +describe("deletion topic", () => { + test("deletes the paths it is given", async () => { + const uid = randomId(); + const root = randomId(); + const doc = db.collection(root).doc("path1"); + await doc.set({ uid }); + await db.doc(`${doc.path}/subcollection/child`).set({ foo: "bar" }); + + await runBatchPubSubDeletions({ firestorePaths: [doc.path] }, uid, ctx); + + expect(await waitFor(documentGone(doc))).toBe(true); + expect( + await waitFor(collectionEmpty(db, `${doc.path}/subcollection`)) + ).toBe(true); + }); + + test("cannot delete paths that belong to another user", async () => { + const root = randomId(); + const doc = await db.collection(root).add({ uid: "the-real-owner" }); + + await runBatchPubSubDeletions( + { firestorePaths: [doc.path] }, + "invalidUserId", + ctx + ); + + await new Promise((resolve) => setTimeout(resolve, 10000)); + expect((await doc.get()).exists).toBe(true); + }); +}); + +describe("account deletion", () => { + test("clears the configured Firestore path when a user is deleted", async () => { + const user = await createUser(auth); + const doc = db.collection("users").doc(user.uid); + await doc.set({ email: user.email }); + + await auth.deleteUser(user.uid); + + expect(await waitFor(documentGone(doc))).toBe(true); + }); +}); diff --git a/kits/delete-user-data/tests/emulator/database.rules.json b/kits/delete-user-data/tests/emulator/database.rules.json new file mode 100644 index 000000000..1c8004711 --- /dev/null +++ b/kits/delete-user-data/tests/emulator/database.rules.json @@ -0,0 +1 @@ +{ "rules": { ".read": true, ".write": true } } diff --git a/kits/delete-user-data/tests/emulator/firestore.test.ts b/kits/delete-user-data/tests/emulator/firestore.test.ts new file mode 100644 index 000000000..9e73ddf6f --- /dev/null +++ b/kits/delete-user-data/tests/emulator/firestore.test.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Firestore } from "firebase-admin/firestore"; +import { beforeAll, describe, expect, test } from "vitest"; + +import { hasValidUserPath } from "../../src/helpers"; +import { recursiveDelete } from "../../src/recursiveDelete"; +import { initialize, randomId } from "./helpers"; + +const SEARCH_FIELDS = "id,uid,userId"; + +let db: Firestore; + +beforeAll(() => { + ({ db } = initialize()); +}); + +describe("recursiveDelete", () => { + test("deletes a document reference", async () => { + const ref = `${randomId()}/doc1`; + await db.doc(ref).set({ foo: "bar" }); + + await recursiveDelete(ref, db); + + expect((await db.doc(ref).get()).exists).toBe(false); + }); + + test("deletes a collection reference", async () => { + const ref = `${randomId()}/doc1/collection1`; + await db.collection(ref).add({ foo: "bar" }); + + await recursiveDelete(ref, db); + + expect((await db.collection(ref).get()).docs).toHaveLength(0); + }); + + test("deletes a document together with its subcollections", async () => { + const root = randomId(); + const parentRef = `${root}/doc1`; + const nested = `${root}/doc1/collection1/doc2/collection2`; + await db.doc(parentRef).set({ foo: "bar" }); + await db.collection(nested).add({ foo: "bar" }); + + await recursiveDelete(parentRef, db); + + expect((await db.collection(nested).get()).docs).toHaveLength(0); + }); +}); + +describe("hasValidUserPath", () => { + test("is true when a field holds the uid", async () => { + const uid = randomId(); + const doc = await db.collection(randomId()).add({ uid }); + + expect(await hasValidUserPath(doc, "", uid, SEARCH_FIELDS)).toBe(true); + }); + + test("is true when a field holds a path containing the uid", async () => { + const uid = randomId(); + const doc = await db.collection(randomId()).add({ uid: `testing/${uid}` }); + + expect(await hasValidUserPath(doc, "", uid, SEARCH_FIELDS)).toBe(true); + }); + + test("is false for a non-string field value", async () => { + const doc = await db.collection(randomId()).add({ uid: 12345 }); + + expect(await hasValidUserPath(doc, "", "234", SEARCH_FIELDS)).toBe(false); + }); +}); diff --git a/kits/delete-user-data/tests/emulator/helpers.ts b/kits/delete-user-data/tests/emulator/helpers.ts new file mode 100644 index 000000000..ee386d05f --- /dev/null +++ b/kits/delete-user-data/tests/emulator/helpers.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PubSub } from "@google-cloud/pubsub"; +import * as admin from "firebase-admin"; +import type { DocumentReference, Firestore } from "firebase-admin/firestore"; + +process.env.FIRESTORE_EMULATOR_HOST = "127.0.0.1:8080"; +process.env.FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"; +process.env.PUBSUB_EMULATOR_HOST = "127.0.0.1:8085"; +process.env.GOOGLE_CLOUD_PROJECT = "demo-test"; + +export const PROJECT_ID = "demo-test"; + +export function initialize(): { + db: Firestore; + auth: admin.auth.Auth; +} { + if (!admin.apps.length) { + admin.initializeApp({ projectId: PROJECT_ID }); + } + return { db: admin.firestore(), auth: admin.auth() }; +} + +/** Publisher context matching the params in tests/emulator/app/.env. */ +export function publisherContext(config: { + instanceId: string; + discoveryTopicName: string; + deletionTopicName: string; + searchDepth: number; + searchFields: string; +}) { + return { + pubsub: new PubSub({ projectId: PROJECT_ID }), + config, + }; +} + +export const randomId = (): string => + `t${Math.random().toString(36).slice(2, 12)}`; + +export const createUser = (auth: admin.auth.Auth) => + auth.createUser({ email: `${randomId()}@example.com` }); + +/** Polls until the predicate holds, so a slow trigger does not flake. */ +export async function waitFor( + predicate: () => Promise, + timeout = 45_000 +): Promise { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + if (await predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + return false; +} + +export const documentGone = (ref: DocumentReference) => async () => + !(await ref.get()).exists; + +export const collectionEmpty = + (db: Firestore, path: string) => async (): Promise => + (await db.collection(path).get()).empty; diff --git a/kits/delete-user-data/vitest.config.ts b/kits/delete-user-data/vitest.config.ts new file mode 100644 index 000000000..dbf395464 --- /dev/null +++ b/kits/delete-user-data/vitest.config.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + exclude: ["**/node_modules/**", "tests/emulator/**"], + }, +}); diff --git a/kits/delete-user-data/vitest.emulator.config.ts b/kits/delete-user-data/vitest.emulator.config.ts new file mode 100644 index 000000000..62cb29f5f --- /dev/null +++ b/kits/delete-user-data/vitest.emulator.config.ts @@ -0,0 +1,28 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/emulator/**/*.test.ts"], + testTimeout: 90_000, + hookTimeout: 90_000, + // The discovery and deletion loop runs through one Pub/Sub emulator, so + // the files cannot race each other. + fileParallelism: false, + }, +});