Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions electron/controllers/authController.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

const { ipcMain } = require('electron');
const fetch = require('node-fetch');
const { getAuth } = require('firebase-admin/auth');

let adminRef = null;
let authEmulatorHost = null;
Expand All @@ -19,8 +20,8 @@ function setAuthEmulatorHost(host) {
}

function getProjectId() {
if (adminRef?.apps?.length > 0) {
return adminRef.app().options.projectId;
if (adminRef?.getApps().length > 0) {
return adminRef.getApps()[0].options.projectId;
}
return null;
}
Expand Down Expand Up @@ -77,8 +78,8 @@ function registerHandlers() {
return { success: true, users };
}

if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase');
const listUsersResult = await adminRef.auth().listUsers(maxResults);
if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase');
const listUsersResult = await getAuth().listUsers(maxResults);
const users = listUsersResult.users.map((user) => ({
uid: user.uid,
email: user.email,
Expand Down Expand Up @@ -134,15 +135,15 @@ function registerHandlers() {
};
}

if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase');
if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase');
const userData = { email, password };
if (displayName) userData.displayName = displayName;
if (phoneNumber) userData.phoneNumber = phoneNumber;
if (uid) userData.uid = uid;
if (photoURL) userData.photoURL = photoURL;
if (disabled) userData.disabled = disabled;
if (emailVerified) userData.emailVerified = emailVerified;
const userRecord = await adminRef.auth().createUser(userData);
const userRecord = await getAuth().createUser(userData);
return {
success: true,
user: { uid: userRecord.uid, email: userRecord.email, displayName: userRecord.displayName },
Expand Down Expand Up @@ -185,7 +186,7 @@ function registerHandlers() {
};
}

if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase');
if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase');
const updateData = {};
if (email !== undefined) updateData.email = email;
if (password !== undefined) updateData.password = password;
Expand All @@ -194,7 +195,7 @@ function registerHandlers() {
if (disabled !== undefined) updateData.disabled = disabled;
if (photoURL !== undefined) updateData.photoURL = photoURL;
if (emailVerified !== undefined) updateData.emailVerified = emailVerified;
const userRecord = await adminRef.auth().updateUser(uid, updateData);
const userRecord = await getAuth().updateUser(uid, updateData);
return {
success: true,
user: {
Expand Down Expand Up @@ -230,8 +231,8 @@ function registerHandlers() {
return { success: true };
}

if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase');
await adminRef.auth().deleteUser(uid);
if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase');
await getAuth().deleteUser(uid);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
Expand Down Expand Up @@ -261,8 +262,8 @@ function registerHandlers() {
return { success: true, user: normalizeUserRecord(data.users[0]) };
}

if (!adminRef?.apps?.length) throw new Error('Not connected to Firebase');
const userRecord = await adminRef.auth().getUser(uid);
if (!adminRef?.getApps().length) throw new Error('Not connected to Firebase');
const userRecord = await getAuth().getUser(uid);
return {
success: true,
user: {
Expand Down
18 changes: 10 additions & 8 deletions electron/controllers/firebaseController.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

const { ipcMain, dialog } = require('electron');
const fs = require('fs');
const { getFirestore } = require('firebase-admin/firestore');

let admin = null;
let db = null;
Expand Down Expand Up @@ -65,10 +66,11 @@ function registerHandlers() {
// Never call app().delete() unless an app exists — after a failed connect, `admin` may
// still reference the SDK module while no default app was initialized, which throws:
// "The default Firebase app does not exist".
const existingApps = [...adminSdk.apps];
// ponytail: firebase-admin v14 removed the `apps` getter and `app.delete()`; use getApps() + deleteApp()
const existingApps = adminSdk.getApps();
for (const appInstance of existingApps) {
try {
await appInstance.delete();
await adminSdk.deleteApp(appInstance);
} catch (e) {
void e;
}
Expand All @@ -80,7 +82,7 @@ function registerHandlers() {
const serviceAccount = JSON.parse(fs.readFileSync(serviceAccountPath, 'utf8'));
projectId = serviceAccount.project_id;
adminSdk.initializeApp({
credential: adminSdk.credential.cert(serviceAccount),
credential: adminSdk.cert(serviceAccount),
projectId,
});
} else if (emulatorHost && explicitProjectId) {
Expand All @@ -91,7 +93,7 @@ function registerHandlers() {
}

admin = adminSdk;
db = adminSdk.firestore();
db = getFirestore();

if (databaseId) {
db.settings({ databaseId });
Expand All @@ -110,10 +112,10 @@ function registerHandlers() {
currentStorageEmulatorHost = null;
try {
const adminSdk = require('firebase-admin');
const leftover = [...adminSdk.apps];
const leftover = adminSdk.getApps();
for (const appInstance of leftover) {
try {
await appInstance.delete();
await adminSdk.deleteApp(appInstance);
} catch (e) {
void e;
}
Expand All @@ -132,10 +134,10 @@ function registerHandlers() {
ipcMain.handle('firebase:disconnect', async () => {
try {
const adminSdk = admin || require('firebase-admin');
const existingApps = [...adminSdk.apps];
const existingApps = adminSdk.getApps();
for (const appInstance of existingApps) {
try {
await appInstance.delete();
await adminSdk.deleteApp(appInstance);
} catch (e) {
void e;
}
Expand Down
26 changes: 17 additions & 9 deletions electron/controllers/firebaseController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const readFileSyncMock = vi.fn();
const handleMock = vi.fn();
const mockAppDelete = vi.fn().mockResolvedValue(undefined);
const mockSettings = vi.fn();
/** Simulates firebase-admin `apps` after initializeApp */
/** Simulates firebase-admin apps after initializeApp (v14: getApps() returns an array) */
const mockAppsList = [];

// Inject electron mock into require cache
Expand All @@ -32,24 +32,32 @@ require_.cache[require_.resolve('fs')] = {
},
};

// Inject firebase-admin mock
// Inject firebase-admin mock (v14 modular API)
const firebaseAdminPath = require_.resolve('firebase-admin');
require_.cache[firebaseAdminPath] = {
id: 'firebase-admin',
filename: firebaseAdminPath,
loaded: true,
exports: {
initializeApp: vi.fn(() => {
const app = { delete: mockAppDelete };
const app = { options: {}, delete: mockAppDelete };
mockAppsList.push(app);
return app;
}),
credential: { cert: vi.fn().mockReturnValue('mock-credential') },
firestore: vi.fn().mockReturnValue({ settings: mockSettings }),
app: vi.fn(() => mockAppsList[0]),
get apps() {
return mockAppsList;
},
cert: vi.fn().mockReturnValue('mock-credential'),
getApps: () => mockAppsList,
deleteApp: mockAppDelete,
},
};

// Inject firebase-admin/firestore mock (v14 modular API)
const firestoreModulePath = require_.resolve('firebase-admin/firestore');
require_.cache[firestoreModulePath] = {
id: 'firebase-admin/firestore',
filename: firestoreModulePath,
loaded: true,
exports: {
getFirestore: vi.fn().mockReturnValue({ settings: mockSettings }),
},
};

Expand Down
12 changes: 5 additions & 7 deletions electron/controllers/firestoreController.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
const { ipcMain, dialog } = require('electron');
const fs = require('fs');
const vm = require('vm');
const { FieldValue, Filter, Timestamp, GeoPoint } = require('firebase-admin/firestore');
const { fetchDocumentsPage } = require('./firestore/documentList');
const { firestoreDocumentToData } = require('../utils/firestoreHelpers');

let adminRef = null;
let dbRef = null;

function normalizeFirestoreError(error) {
Expand All @@ -24,10 +24,11 @@ function normalizeFirestoreError(error) {
}

/**
* Sets references to admin SDK and database
* Sets reference to the Firestore database. The `admin` arg is accepted for
* backwards compatibility with callers but is unused under firebase-admin v14.
*/
function setRefs(admin, db) {
adminRef = admin;
void admin;
dbRef = db;
}

Expand Down Expand Up @@ -235,10 +236,7 @@ function registerHandlers() {
if (!dbRef) throw new Error('Not connected to Firebase');

const wrappedCode = `(async () => { ${jsQuery} return await run(); })()`;
const FieldValue = adminRef ? adminRef.firestore.FieldValue : null;
const Filter = adminRef ? adminRef.firestore.Filter : null;
const Timestamp = adminRef ? adminRef.firestore.Timestamp : null;
const GeoPoint = adminRef ? adminRef.firestore.GeoPoint : null;
// ponytail: firebase-admin v14 removed the `.firestore` namespace; these are top-level exports
const context = vm.createContext({
db: dbRef,
FieldValue,
Expand Down
26 changes: 16 additions & 10 deletions electron/controllers/firestoreController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ require_.cache[require_.resolve('fs')] = {
},
};

// firestoreController (v14) imports FieldValue/Filter/Timestamp/GeoPoint from firebase-admin/firestore
require_.cache[require_.resolve('firebase-admin/firestore')] = {
id: 'firebase-admin/firestore',
filename: require_.resolve('firebase-admin/firestore'),
loaded: true,
exports: {
FieldValue: { serverTimestamp: vi.fn() },
Filter: { where: vi.fn() },
Timestamp: { now: vi.fn() },
GeoPoint: vi.fn(),
},
};

// Load controller with mocked deps
const controllerPath = require_.resolve('./firestoreController');
delete require_.cache[controllerPath];
Expand Down Expand Up @@ -253,16 +266,9 @@ describe('firestoreController', () => {
});
const mockDb = { collection: mockCollection };

const mockAdmin = {
firestore: {
FieldValue: { serverTimestamp: vi.fn() },
Filter: { where: vi.fn() },
Timestamp: { now: vi.fn() },
GeoPoint: vi.fn(),
},
};

setRefs(mockAdmin, mockDb);
// v14: FieldValue/Filter/Timestamp/GeoPoint come from firebase-admin/firestore (mocked above),
// not from adminRef — so adminRef is no longer needed here.
setRefs(null, mockDb);

const jsQuery = `
function run() {
Expand Down
3 changes: 2 additions & 1 deletion electron/controllers/storage/bucketResolver.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const fetch = require('node-fetch');
const { getStorage } = require('firebase-admin/storage');

const cache = new Map();

Expand All @@ -20,7 +21,7 @@ async function resolveWithProbe(cacheKey, candidates, probe) {
function resolveAdminBucket(adminRef, projectId) {
return resolveWithProbe(`admin:${projectId}`, bucketCandidates(projectId), async (bucketName) => {
try {
const [exists] = await adminRef.storage().bucket(bucketName).exists();
const [exists] = await getStorage().bucket(bucketName).exists();
return exists;
} catch {
return false;
Expand Down
22 changes: 15 additions & 7 deletions electron/controllers/storage/bucketResolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ require_.cache[require_.resolve('node-fetch')] = {
exports: fetchMock,
};

// bucketResolver (v14) uses getStorage() from firebase-admin/storage
const getStorageMock = { bucket: vi.fn() };
require_.cache[require_.resolve('firebase-admin/storage')] = {
id: 'firebase-admin/storage',
filename: require_.resolve('firebase-admin/storage'),
loaded: true,
exports: { getStorage: () => getStorageMock },
};

const resolverPath = require_.resolve('./bucketResolver');
delete require_.cache[resolverPath];
const { resolveAdminBucket, resolveOauthBucket, resolveEmulatorBucket, clearBucketCache } = require_(resolverPath);
Expand All @@ -24,7 +33,8 @@ function adminRefWithBuckets(existingBuckets) {
return Promise.resolve([existingBuckets.includes(name)]);
},
}));
return { adminRef: { storage: () => ({ bucket: bucketMock }) }, existsMock };
getStorageMock.bucket = bucketMock;
return { existsMock };
}

describe('bucketResolver', () => {
Expand Down Expand Up @@ -64,13 +74,11 @@ describe('bucketResolver', () => {
});

it('resolveAdminBucket treats probe errors as missing buckets', async () => {
const adminRef = {
storage: () => ({
bucket: () => ({ exists: () => Promise.reject(new Error('network')) }),
}),
};
getStorageMock.bucket = vi.fn(() => ({
exists: () => Promise.reject(new Error('network')),
}));

expect(await resolveAdminBucket(adminRef, 'p1')).toBe('p1.firebasestorage.app');
expect(await resolveAdminBucket(null, 'p1')).toBe('p1.firebasestorage.app');
});

it('clearBucketCache forces a re-probe', async () => {
Expand Down
Loading
Loading