pyric-admin 0.1.0-alpha.11 → 0.1.0-alpha.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md.orig +2 -0
- package/package.json +7 -2
- package/src/app/index.ts +235 -0
- package/src/app/lifecycle.ts +17 -0
- package/src/auth/auth.test.ts +322 -0
- package/src/auth/index.ts +905 -0
- package/src/database/database.test.ts +196 -0
- package/src/database/index.ts +1187 -0
- package/src/firestore/index.ts +100 -0
- package/src/messaging/index.ts +319 -0
- package/src/storage/index.ts +615 -0
- package/src/storage/storage.test.ts +236 -0
package/README.md.orig
CHANGED
|
@@ -21,6 +21,7 @@ loads `firebase-admin` directly.
|
|
|
21
21
|
| `pyric-admin/auth` | Admin Auth shape |
|
|
22
22
|
| `pyric-admin/database` | Admin Realtime Database shape |
|
|
23
23
|
| `pyric-admin/storage` | Admin Storage shape |
|
|
24
|
+
| `pyric-admin/messaging` | Admin Cloud Messaging send plane |
|
|
24
25
|
|
|
25
26
|
## Explicit sandbox setup
|
|
26
27
|
|
|
@@ -46,3 +47,4 @@ without sandbox activation and therefore resolves Firebase Admin directly.
|
|
|
46
47
|
- [Auth API](https://pyric.dev/docs/pyric-admin-auth-reference-api/)
|
|
47
48
|
- [Realtime Database API](https://pyric.dev/docs/pyric-admin-database-reference-api/)
|
|
48
49
|
- [Storage API](https://pyric.dev/docs/pyric-admin-storage-reference-api/)
|
|
50
|
+
- [Messaging API](https://pyric.dev/docs/pyric-admin-messaging-reference-api/)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pyric-admin",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.12",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://pyric.dev",
|
|
6
6
|
"repository": {
|
|
@@ -33,10 +33,15 @@
|
|
|
33
33
|
"./storage": {
|
|
34
34
|
"types": "./dist/storage/index.d.ts",
|
|
35
35
|
"import": "./dist/storage/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./messaging": {
|
|
38
|
+
"types": "./dist/messaging/index.d.ts",
|
|
39
|
+
"import": "./dist/messaging/index.js"
|
|
36
40
|
}
|
|
37
41
|
},
|
|
38
42
|
"files": [
|
|
39
43
|
"dist",
|
|
44
|
+
"src",
|
|
40
45
|
"README.md",
|
|
41
46
|
"LICENSE"
|
|
42
47
|
],
|
|
@@ -46,7 +51,7 @@
|
|
|
46
51
|
"typecheck": "bun x tsc -p tsconfig.json --noEmit"
|
|
47
52
|
},
|
|
48
53
|
"dependencies": {
|
|
49
|
-
"pyric": "^0.1.0-alpha.
|
|
54
|
+
"pyric": "^0.1.0-alpha.12"
|
|
50
55
|
},
|
|
51
56
|
"devDependencies": {
|
|
52
57
|
"@types/bun": "latest",
|
package/src/app/index.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pyric-admin/app` — sandbox-only admin app registry.
|
|
3
|
+
*
|
|
4
|
+
* Production selection happens before this module loads: activated Node
|
|
5
|
+
* processes resolve `firebase-admin/app` here, while inactive applications
|
|
6
|
+
* continue resolving their own `firebase-admin/app` package unchanged.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
REMOTE_SANDBOX_FACTORY,
|
|
10
|
+
type RemoteSandboxFactory,
|
|
11
|
+
type RemoteSandboxFactoryOptions,
|
|
12
|
+
type Sandbox,
|
|
13
|
+
} from 'pyric/sandbox';
|
|
14
|
+
import { assertAdminAppActive, markAdminAppDeleted } from './lifecycle.js';
|
|
15
|
+
|
|
16
|
+
/** Brand carried by every sandbox admin app. */
|
|
17
|
+
export const ADMIN_APP_TARGET = Symbol.for('pyric.admin.app.target');
|
|
18
|
+
export type PyricAdminAppTarget = 'sandbox';
|
|
19
|
+
|
|
20
|
+
/** firebase-admin's default app name. */
|
|
21
|
+
export const DEFAULT_APP_NAME = '[DEFAULT]';
|
|
22
|
+
|
|
23
|
+
export interface SandboxAdminApp {
|
|
24
|
+
readonly [ADMIN_APP_TARGET]: 'sandbox';
|
|
25
|
+
readonly sandbox: Sandbox;
|
|
26
|
+
readonly name: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type PyricAdminApp = SandboxAdminApp;
|
|
30
|
+
export type InitializeAdminAppConfig = { sandbox: Sandbox };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Firebase Functions' ESM runtime statically imports this credential factory
|
|
34
|
+
* while linking its database provider. Pyric initializes the sandbox app
|
|
35
|
+
* before that provider executes, so the factory is not used by supported
|
|
36
|
+
* Functions flows. Keep the named export link-compatible, but fail clearly if
|
|
37
|
+
* application code asks the development sandbox for production credentials.
|
|
38
|
+
*/
|
|
39
|
+
export function applicationDefault(): never {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'pyric-admin/app: applicationDefault() is unavailable in the sandbox. ' +
|
|
42
|
+
'Pyric development does not use production credentials.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Local error with the observable firebase-admin app-error shape. */
|
|
47
|
+
class FirebaseAppError extends Error {
|
|
48
|
+
readonly code: string;
|
|
49
|
+
|
|
50
|
+
constructor(code: string, message: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'FirebaseAppError';
|
|
53
|
+
this.code = `app/${code}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Node may evaluate this ESM-only mirror through distinct require(esm) and
|
|
58
|
+
// import module records when the register hook rewrites both CJS and ESM
|
|
59
|
+
// Firebase consumers. Firebase Admin's app registry is process-wide, so keep
|
|
60
|
+
// the mirror registry behind Symbol.for as well: both module records must see
|
|
61
|
+
// the same default app and sandbox handle.
|
|
62
|
+
const APP_REGISTRY = Symbol.for('pyric.admin.app.registry');
|
|
63
|
+
const AMBIENT_APPS = Symbol.for('pyric.admin.app.ambientApps');
|
|
64
|
+
interface GlobalAppRegistry {
|
|
65
|
+
[APP_REGISTRY]?: Map<string, PyricAdminApp>;
|
|
66
|
+
[AMBIENT_APPS]?: WeakSet<PyricAdminApp>;
|
|
67
|
+
}
|
|
68
|
+
const globalRegistry = globalThis as GlobalAppRegistry;
|
|
69
|
+
const appRegistry = globalRegistry[APP_REGISTRY] ??= new Map<string, PyricAdminApp>();
|
|
70
|
+
const ambientApps = globalRegistry[AMBIENT_APPS] ??= new WeakSet<PyricAdminApp>();
|
|
71
|
+
|
|
72
|
+
function validateAppName(name: unknown): asserts name is string {
|
|
73
|
+
if (typeof name !== 'string' || name === '') {
|
|
74
|
+
throw new FirebaseAppError(
|
|
75
|
+
'invalid-app-name',
|
|
76
|
+
`Invalid Firebase app name "${String(name)}" provided. App name must be a non-empty string.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function alreadyExists(name: string, code: 'duplicate-app' | 'invalid-app-options'): FirebaseAppError {
|
|
82
|
+
return new FirebaseAppError(
|
|
83
|
+
code,
|
|
84
|
+
`A Firebase app named "${name}" already exists with a different configuration.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Initialize a sandbox admin app.
|
|
90
|
+
*
|
|
91
|
+
* An explicit `{ sandbox }` config binds an in-process or remote sandbox.
|
|
92
|
+
* A bare call resolves the remote sandbox factory installed by
|
|
93
|
+
* `@pyric/cli/register`. Production callers must import `firebase-admin/app`
|
|
94
|
+
* without Pyric activation instead of passing production options here.
|
|
95
|
+
*/
|
|
96
|
+
export function initializeApp(
|
|
97
|
+
config?: InitializeAdminAppConfig,
|
|
98
|
+
name: string = DEFAULT_APP_NAME,
|
|
99
|
+
): PyricAdminApp {
|
|
100
|
+
validateAppName(name);
|
|
101
|
+
const existing = appRegistry.get(name);
|
|
102
|
+
|
|
103
|
+
if (config === undefined) {
|
|
104
|
+
if (existing !== undefined) {
|
|
105
|
+
if (ambientApps.has(existing)) return existing;
|
|
106
|
+
throw alreadyExists(name, 'invalid-app-options');
|
|
107
|
+
}
|
|
108
|
+
const app = initializeAmbientApp(name);
|
|
109
|
+
appRegistry.set(name, app);
|
|
110
|
+
ambientApps.add(app);
|
|
111
|
+
return app;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!isSandboxConfig(config)) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
'pyric-admin/app is a sandbox-only mirror. Production applications must ' +
|
|
117
|
+
'load firebase-admin/app without Pyric activation.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (existing !== undefined) {
|
|
122
|
+
if (!ambientApps.has(existing) && existing.sandbox === config.sandbox) return existing;
|
|
123
|
+
throw alreadyExists(
|
|
124
|
+
name,
|
|
125
|
+
ambientApps.has(existing) ? 'invalid-app-options' : 'duplicate-app',
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const app: SandboxAdminApp = {
|
|
130
|
+
[ADMIN_APP_TARGET]: 'sandbox',
|
|
131
|
+
sandbox: config.sandbox,
|
|
132
|
+
name,
|
|
133
|
+
};
|
|
134
|
+
appRegistry.set(name, app);
|
|
135
|
+
return app;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Return the registered app for `name`. */
|
|
139
|
+
export function getApp(name: string = DEFAULT_APP_NAME): PyricAdminApp {
|
|
140
|
+
validateAppName(name);
|
|
141
|
+
const app = appRegistry.get(name);
|
|
142
|
+
if (app === undefined) {
|
|
143
|
+
const lead = name === DEFAULT_APP_NAME
|
|
144
|
+
? 'The default Firebase app does not exist. '
|
|
145
|
+
: `Firebase app named "${name}" does not exist. `;
|
|
146
|
+
throw new FirebaseAppError(
|
|
147
|
+
'no-app',
|
|
148
|
+
lead + 'Make sure you call initializeApp() before using any of the Firebase services.',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return app;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Return a copy of the app registry. */
|
|
155
|
+
export function getApps(): PyricAdminApp[] {
|
|
156
|
+
return Array.from(appRegistry.values());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Remove a sandbox app from the registry. */
|
|
160
|
+
export function deleteApp(app: PyricAdminApp): Promise<void> {
|
|
161
|
+
if (typeof app !== 'object' || app === null || !(ADMIN_APP_TARGET in app)) {
|
|
162
|
+
throw new FirebaseAppError('invalid-argument', 'Invalid app argument.');
|
|
163
|
+
}
|
|
164
|
+
assertAdminAppActive(app);
|
|
165
|
+
const existing = getApp(app.name);
|
|
166
|
+
appRegistry.delete(existing.name);
|
|
167
|
+
markAdminAppDeleted(existing);
|
|
168
|
+
return Promise.resolve();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function initializeAmbientApp(name: string): PyricAdminApp {
|
|
172
|
+
const env = process.env.PYRIC_SANDBOX;
|
|
173
|
+
if (env === undefined || env.trim() === '') {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'pyric-admin/app is a sandbox-only mirror and no sandbox is active. ' +
|
|
176
|
+
'Run under `pyric dev`, set PYRIC_SANDBOX with @pyric/cli/register, ' +
|
|
177
|
+
'or load firebase-admin/app without Pyric activation for production.',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const opts = parsePyricSandboxEnv(env);
|
|
182
|
+
if (process.env.NODE_ENV === 'production' && process.env.PYRIC_SANDBOX_FORCE !== '1') {
|
|
183
|
+
throw new Error(
|
|
184
|
+
'pyric-admin: PYRIC_SANDBOX is set but NODE_ENV is "production" — ' +
|
|
185
|
+
'refusing to route firebase-admin to a development sandbox. ' +
|
|
186
|
+
'Unset PYRIC_SANDBOX in production, or set PYRIC_SANDBOX_FORCE=1 ' +
|
|
187
|
+
'if this routing is intentional.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const factory = (globalThis as { [REMOTE_SANDBOX_FACTORY]?: RemoteSandboxFactory })[
|
|
192
|
+
REMOTE_SANDBOX_FACTORY
|
|
193
|
+
];
|
|
194
|
+
if (typeof factory !== 'function') {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`pyric-admin: PYRIC_SANDBOX=${env} is set but no remote sandbox ` +
|
|
197
|
+
"factory is installed (globalThis[Symbol.for('pyric.remote.sandboxFactory')] is absent). " +
|
|
198
|
+
'Run your server under `pyric dev`, or add `--import @pyric/cli/register` to NODE_OPTIONS.',
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const sandbox = factory(opts);
|
|
203
|
+
process.stderr.write(
|
|
204
|
+
`pyric: firebase-admin routed to sandbox${opts.url !== undefined ? ` at ${opts.url}` : ''}\n`,
|
|
205
|
+
);
|
|
206
|
+
return { [ADMIN_APP_TARGET]: 'sandbox', sandbox, name };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parsePyricSandboxEnv(env: string): RemoteSandboxFactoryOptions {
|
|
210
|
+
const value = env.trim();
|
|
211
|
+
if (value === 'remote') return {};
|
|
212
|
+
if (value.startsWith('remote:')) {
|
|
213
|
+
const url = value.slice('remote:'.length).trim();
|
|
214
|
+
if (url === '') {
|
|
215
|
+
throw new Error(
|
|
216
|
+
'pyric-admin: PYRIC_SANDBOX=remote: has an empty url. Use ' +
|
|
217
|
+
'PYRIC_SANDBOX=remote to auto-discover the running `pyric dev`, ' +
|
|
218
|
+
'or PYRIC_SANDBOX=remote:<url> with the host url.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return { url };
|
|
222
|
+
}
|
|
223
|
+
throw new Error(
|
|
224
|
+
`pyric-admin: unrecognized PYRIC_SANDBOX value "${env}". Supported ` +
|
|
225
|
+
'values: "remote" (auto-discover the running `pyric dev`) or "remote:<url>".',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isSandboxConfig(config: InitializeAdminAppConfig): config is { sandbox: Sandbox } {
|
|
230
|
+
return typeof config === 'object' && config !== null && 'sandbox' in config;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function isSandboxAdminApp(app: PyricAdminApp): app is SandboxAdminApp {
|
|
234
|
+
return app[ADMIN_APP_TARGET] === 'sandbox';
|
|
235
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const deletedApps = new WeakSet<object>();
|
|
2
|
+
|
|
3
|
+
/** Internal lifecycle guard shared by every pyric-admin service factory. */
|
|
4
|
+
export function assertAdminAppActive(app: object & { readonly name: string }): void {
|
|
5
|
+
if (!deletedApps.has(app)) return;
|
|
6
|
+
const error = new Error(
|
|
7
|
+
`Firebase app named "${app.name}" has already been deleted.`,
|
|
8
|
+
) as Error & { code: string };
|
|
9
|
+
error.name = 'FirebaseAppError';
|
|
10
|
+
error.code = 'app/app-deleted';
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Tombstone a wrapper while leaving the caller-owned Sandbox alive. */
|
|
15
|
+
export function markAdminAppDeleted(app: object): void {
|
|
16
|
+
deletedApps.add(app);
|
|
17
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `pyric-admin/auth`.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - Phase 3 dispatch: branded `PyricAdminApp` from `pyric-admin/app`
|
|
6
|
+
* routes to the right backend (`prod` → firebase-admin/auth;
|
|
7
|
+
* `sandbox` → in-memory backend). Inputs that aren't a
|
|
8
|
+
* `PyricAdminApp` throw a clear `TypeError`.
|
|
9
|
+
* - Phase 4b sandbox backend: roundtrips for createCustomToken /
|
|
10
|
+
* verifyIdToken, createUser / getUser / getUserByEmail / deleteUser,
|
|
11
|
+
* setCustomUserClaims persistence, and that `sandbox.reset()` clears
|
|
12
|
+
* the in-memory user store.
|
|
13
|
+
* - Documented "not implemented" surface throws the canonical
|
|
14
|
+
* `not implemented in pyric-admin/auth sandbox backend` message.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, describe, it, expect } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { initializeSandbox } from 'pyric/sandbox';
|
|
20
|
+
|
|
21
|
+
import { initializeApp, deleteApp, getApps, ADMIN_APP_TARGET } from '../app/index.js';
|
|
22
|
+
import { getAuth, SANDBOX_TOKEN_PREFIX } from './index.js';
|
|
23
|
+
|
|
24
|
+
// The app registry is module-global (mirror of firebase-admin's
|
|
25
|
+
// defaultAppStore) — deregister every app after each test so unnamed
|
|
26
|
+
// `initializeApp({ sandbox })` calls don't collide across tests.
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await Promise.all(getApps().map((app) => deleteApp(app)));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('getAuth — Phase 3 dispatch', () => {
|
|
32
|
+
it('rejects values that are not PyricAdminApp with a clear TypeError', () => {
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
expect(() => getAuth(null as any)).toThrow(TypeError);
|
|
35
|
+
// `undefined` is the no-arg mirror: it resolves the default app from
|
|
36
|
+
// the registry and (with none initialized) throws firebase-admin's
|
|
37
|
+
// app/no-app error, not the entry-guard TypeError.
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
39
|
+
expect(() => getAuth(undefined as any)).toThrow(
|
|
40
|
+
/The default Firebase app does not exist/,
|
|
41
|
+
);
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
43
|
+
expect(() => getAuth({} as any)).toThrow(/ADMIN_APP_TARGET brand/);
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
45
|
+
expect(() => getAuth('not an app' as any)).toThrow(TypeError);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects PyricAdminApp-shaped values with an unknown target string', () => {
|
|
49
|
+
// Manually-constructed handle with an unrecognized target value —
|
|
50
|
+
// simulates a future `pyric-admin/app` adding a new arm that this
|
|
51
|
+
// adapter hasn't been updated for. The error names the offending
|
|
52
|
+
// target so the remediation is obvious.
|
|
53
|
+
const futureApp = {
|
|
54
|
+
[ADMIN_APP_TARGET]: 'replay' as const,
|
|
55
|
+
// Carry a stub `sandbox` so it doesn't fail the prod-arm cast.
|
|
56
|
+
sandbox: {},
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
} as any;
|
|
59
|
+
expect(() => getAuth(futureApp)).toThrow(/expected a sandbox admin app/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns a sandbox Auth handle for a sandbox-target PyricAdminApp', () => {
|
|
63
|
+
const sandbox = initializeSandbox();
|
|
64
|
+
const app = initializeApp({ sandbox });
|
|
65
|
+
const auth = getAuth(app);
|
|
66
|
+
// The sandbox handle exposes the documented method subset as
|
|
67
|
+
// callable functions. Cast through unknown — the Auth type is the
|
|
68
|
+
// upstream class; the sandbox returns a structurally-compatible
|
|
69
|
+
// duck.
|
|
70
|
+
const a = auth as unknown as Record<string, unknown>;
|
|
71
|
+
expect(typeof a.createCustomToken).toBe('function');
|
|
72
|
+
expect(typeof a.verifyIdToken).toBe('function');
|
|
73
|
+
expect(typeof a.createUser).toBe('function');
|
|
74
|
+
expect(typeof a.getUser).toBe('function');
|
|
75
|
+
expect(typeof a.getUserByEmail).toBe('function');
|
|
76
|
+
expect(typeof a.deleteUser).toBe('function');
|
|
77
|
+
expect(typeof a.setCustomUserClaims).toBe('function');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('sandbox backend — createCustomToken / verifyIdToken roundtrip', () => {
|
|
82
|
+
it('mints a deterministic token with the documented prefix', async () => {
|
|
83
|
+
const sandbox = initializeSandbox();
|
|
84
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
85
|
+
const token = await auth.createCustomToken('alice');
|
|
86
|
+
expect(token.startsWith(`${SANDBOX_TOKEN_PREFIX}:`)).toBe(true);
|
|
87
|
+
expect(token).toBe(`${SANDBOX_TOKEN_PREFIX}:alice:{}`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('round-trips uid + claims through verifyIdToken', async () => {
|
|
91
|
+
const sandbox = initializeSandbox();
|
|
92
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
93
|
+
const token = await auth.createCustomToken('alice', { role: 'admin', tier: 2 });
|
|
94
|
+
const decoded = await auth.verifyIdToken(token);
|
|
95
|
+
expect(decoded.uid).toBe('alice');
|
|
96
|
+
expect(decoded.sub).toBe('alice');
|
|
97
|
+
expect(decoded.role).toBe('admin');
|
|
98
|
+
expect(decoded.tier).toBe(2);
|
|
99
|
+
// Required DecodedIdToken fields are populated with sandbox placeholders.
|
|
100
|
+
expect(decoded.iss).toBe('pyric-sandbox');
|
|
101
|
+
expect(decoded.aud).toBe('pyric-sandbox');
|
|
102
|
+
expect(decoded.firebase.sign_in_provider).toBe('custom');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects tokens that do not carry the sandbox prefix', async () => {
|
|
106
|
+
const sandbox = initializeSandbox();
|
|
107
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
108
|
+
// A real-looking JWT — three base64 segments joined by '.'. The
|
|
109
|
+
// sandbox backend doesn't try to parse JWTs; it only accepts its
|
|
110
|
+
// own minted tokens.
|
|
111
|
+
const realJwt = 'eyJhbGciOi.eyJzdWIiOi.signature';
|
|
112
|
+
await expect(auth.verifyIdToken(realJwt)).rejects.toThrow(
|
|
113
|
+
/sandbox.*createCustomToken/,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects tokens with malformed claim JSON', async () => {
|
|
118
|
+
const sandbox = initializeSandbox();
|
|
119
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
120
|
+
const badToken = `${SANDBOX_TOKEN_PREFIX}:alice:not-json`;
|
|
121
|
+
await expect(auth.verifyIdToken(badToken)).rejects.toThrow(/JSON/);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('sandbox backend — user CRUD', () => {
|
|
126
|
+
it('createUser stores by uid; getUser retrieves it', async () => {
|
|
127
|
+
const sandbox = initializeSandbox();
|
|
128
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
129
|
+
const created = await auth.createUser({
|
|
130
|
+
uid: 'alice',
|
|
131
|
+
email: 'alice@example.com',
|
|
132
|
+
displayName: 'Alice',
|
|
133
|
+
});
|
|
134
|
+
expect(created.uid).toBe('alice');
|
|
135
|
+
expect(created.email).toBe('alice@example.com');
|
|
136
|
+
expect(created.displayName).toBe('Alice');
|
|
137
|
+
expect(created.disabled).toBe(false);
|
|
138
|
+
expect(created.emailVerified).toBe(false);
|
|
139
|
+
|
|
140
|
+
const fetched = await auth.getUser('alice');
|
|
141
|
+
expect(fetched.uid).toBe('alice');
|
|
142
|
+
expect(fetched.email).toBe('alice@example.com');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('createUser auto-generates a uid when omitted', async () => {
|
|
146
|
+
const sandbox = initializeSandbox();
|
|
147
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
148
|
+
const created = await auth.createUser({ email: 'noid@example.com' });
|
|
149
|
+
expect(created.uid).toMatch(/^pyric-sandbox-/);
|
|
150
|
+
// Round-trips: the auto-uid is retrievable.
|
|
151
|
+
const fetched = await auth.getUser(created.uid);
|
|
152
|
+
expect(fetched.email).toBe('noid@example.com');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('createUser rejects on uid collision', async () => {
|
|
156
|
+
const sandbox = initializeSandbox();
|
|
157
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
158
|
+
await auth.createUser({ uid: 'alice' });
|
|
159
|
+
await expect(auth.createUser({ uid: 'alice' })).rejects.toThrow(/already exists/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('getUserByEmail finds users by email via linear scan', async () => {
|
|
163
|
+
const sandbox = initializeSandbox();
|
|
164
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
165
|
+
await auth.createUser({ uid: 'alice', email: 'a@e.com' });
|
|
166
|
+
await auth.createUser({ uid: 'bob', email: 'b@e.com' });
|
|
167
|
+
const fetched = await auth.getUserByEmail('b@e.com');
|
|
168
|
+
expect(fetched.uid).toBe('bob');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('getUserByEmail rejects on miss', async () => {
|
|
172
|
+
const sandbox = initializeSandbox();
|
|
173
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
174
|
+
await expect(auth.getUserByEmail('nobody@e.com')).rejects.toThrow(
|
|
175
|
+
/no user with email/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('getUser rejects on miss', async () => {
|
|
180
|
+
const sandbox = initializeSandbox();
|
|
181
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
182
|
+
await expect(auth.getUser('ghost')).rejects.toThrow(/no user with uid/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('deleteUser removes the record', async () => {
|
|
186
|
+
const sandbox = initializeSandbox();
|
|
187
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
188
|
+
await auth.createUser({ uid: 'alice' });
|
|
189
|
+
await auth.deleteUser('alice');
|
|
190
|
+
await expect(auth.getUser('alice')).rejects.toThrow(/no user/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('deleteUser rejects on miss', async () => {
|
|
194
|
+
const sandbox = initializeSandbox();
|
|
195
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
196
|
+
await expect(auth.deleteUser('ghost')).rejects.toThrow(/no user with uid/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('sandbox backend — setCustomUserClaims persistence', () => {
|
|
201
|
+
it('persists claims and getUser reads them back', async () => {
|
|
202
|
+
const sandbox = initializeSandbox();
|
|
203
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
204
|
+
await auth.createUser({ uid: 'alice' });
|
|
205
|
+
await auth.setCustomUserClaims('alice', { role: 'admin', org: 'acme' });
|
|
206
|
+
const fetched = await auth.getUser('alice');
|
|
207
|
+
expect(fetched.customClaims).toEqual({ role: 'admin', org: 'acme' });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('passing null clears claims', async () => {
|
|
211
|
+
const sandbox = initializeSandbox();
|
|
212
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
213
|
+
await auth.createUser({ uid: 'alice' });
|
|
214
|
+
await auth.setCustomUserClaims('alice', { role: 'admin' });
|
|
215
|
+
await auth.setCustomUserClaims('alice', null);
|
|
216
|
+
const fetched = await auth.getUser('alice');
|
|
217
|
+
expect(fetched.customClaims).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('claims minted via setCustomUserClaims surface through createCustomToken + verifyIdToken', async () => {
|
|
221
|
+
// The token format is stateless (claims are baked in at mint time
|
|
222
|
+
// by the caller, not pulled from the store), so this test is
|
|
223
|
+
// really asserting the caller's pattern: read claims off the
|
|
224
|
+
// UserRecord, pass them through to createCustomToken.
|
|
225
|
+
const sandbox = initializeSandbox();
|
|
226
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
227
|
+
await auth.createUser({ uid: 'alice' });
|
|
228
|
+
await auth.setCustomUserClaims('alice', { role: 'admin' });
|
|
229
|
+
const stored = await auth.getUser('alice');
|
|
230
|
+
const token = await auth.createCustomToken(
|
|
231
|
+
stored.uid,
|
|
232
|
+
stored.customClaims ?? {},
|
|
233
|
+
);
|
|
234
|
+
const decoded = await auth.verifyIdToken(token);
|
|
235
|
+
expect(decoded.role).toBe('admin');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('setCustomUserClaims rejects on missing user', async () => {
|
|
239
|
+
const sandbox = initializeSandbox();
|
|
240
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
241
|
+
await expect(auth.setCustomUserClaims('ghost', { x: 1 })).rejects.toThrow(
|
|
242
|
+
/no user with uid/,
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('sandbox backend — reset clears state', () => {
|
|
248
|
+
it('sandbox.reset() wipes the auth user store', async () => {
|
|
249
|
+
const sandbox = initializeSandbox();
|
|
250
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
251
|
+
await auth.createUser({ uid: 'alice', email: 'a@e.com' });
|
|
252
|
+
// Confirm pre-reset state.
|
|
253
|
+
const before = await auth.getUser('alice');
|
|
254
|
+
expect(before.uid).toBe('alice');
|
|
255
|
+
|
|
256
|
+
sandbox.reset();
|
|
257
|
+
|
|
258
|
+
// After reset, the same auth handle should see no users.
|
|
259
|
+
await expect(auth.getUser('alice')).rejects.toThrow(/no user with uid/);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('repeat getAuth(app) calls share the same in-memory store', async () => {
|
|
263
|
+
// Mirrors firebase-admin's `getAuth(app)` idempotency — writes
|
|
264
|
+
// through one handle are visible through another for the same
|
|
265
|
+
// sandbox.
|
|
266
|
+
const sandbox = initializeSandbox();
|
|
267
|
+
const app = initializeApp({ sandbox });
|
|
268
|
+
const auth1 = getAuth(app);
|
|
269
|
+
const auth2 = getAuth(app);
|
|
270
|
+
await auth1.createUser({ uid: 'alice' });
|
|
271
|
+
const fetched = await auth2.getUser('alice');
|
|
272
|
+
expect(fetched.uid).toBe('alice');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('sandbox backend — explicitly-not-implemented surface', () => {
|
|
277
|
+
it('updateUser throws the canonical not-implemented message', async () => {
|
|
278
|
+
const sandbox = initializeSandbox();
|
|
279
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
280
|
+
await expect(
|
|
281
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
282
|
+
(auth as any).updateUser('alice', { displayName: 'A' }),
|
|
283
|
+
).rejects.toThrow(/not implemented in pyric-admin\/auth sandbox backend/);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it.each([
|
|
287
|
+
'getUserByPhoneNumber',
|
|
288
|
+
'getUserByProviderUid',
|
|
289
|
+
'getUsers',
|
|
290
|
+
'deleteUsers',
|
|
291
|
+
'listUsers',
|
|
292
|
+
'importUsers',
|
|
293
|
+
'revokeRefreshTokens',
|
|
294
|
+
'createSessionCookie',
|
|
295
|
+
'verifySessionCookie',
|
|
296
|
+
'generatePasswordResetLink',
|
|
297
|
+
'generateEmailVerificationLink',
|
|
298
|
+
'generateSignInWithEmailLink',
|
|
299
|
+
'generateVerifyAndChangeEmailLink',
|
|
300
|
+
'createProviderConfig',
|
|
301
|
+
'getProviderConfig',
|
|
302
|
+
'listProviderConfigs',
|
|
303
|
+
'updateProviderConfig',
|
|
304
|
+
'deleteProviderConfig',
|
|
305
|
+
])('%s rejects with the not-implemented message', async (method) => {
|
|
306
|
+
const sandbox = initializeSandbox();
|
|
307
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
308
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
309
|
+
const fn = (auth as any)[method] as (...args: unknown[]) => Promise<unknown>;
|
|
310
|
+
await expect(fn.call(auth)).rejects.toThrow(
|
|
311
|
+
/not implemented in pyric-admin\/auth sandbox backend/,
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('tenantManager getter throws the not-implemented message', () => {
|
|
316
|
+
const sandbox = initializeSandbox();
|
|
317
|
+
const auth = getAuth(initializeApp({ sandbox }));
|
|
318
|
+
expect(() => auth.tenantManager()).toThrow(
|
|
319
|
+
/not implemented in pyric-admin\/auth sandbox backend/,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
});
|