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
6 changes: 6 additions & 0 deletions src/schematics/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,15 @@ export interface DataConnectConfig {
source?: string;
}

export interface FirebaseFirestoreConfig {
rules?: string;
indexes?: string;
}

export interface FirebaseJSON {
hosting?: FirebaseHostingConfig[] | FirebaseHostingConfig;
functions?: FirebaseFunctionsConfig;
firestore?: FirebaseFirestoreConfig;
dataconnect?: DataConnectConfig;
}

Expand Down
176 changes: 176 additions & 0 deletions src/schematics/setup/firebaseConfigs.jasmine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { mkdtempSync, readFileSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { logging } from '@angular-devkit/core';
import { HostTree, SchematicContext } from '@angular-devkit/schematics';
import fsExtra from 'fs-extra';
import { FEATURES } from '../interfaces.js';
import {
addFirestoreToFirebaseJson,
createFirestoreStarterFiles,
setDefaultProjectInFirebaseRc,
} from './firebaseConfigs.js';
import 'jasmine';

const context = { logger: new logging.Logger('test') } as unknown as SchematicContext;

describe('setDefaultProjectInFirebaseRc', () => {
let projectRoot: string;

beforeEach(() => {
projectRoot = mkdtempSync(join(tmpdir(), 'angularfire-test-'));
});

afterEach(() => {
fsExtra.removeSync(projectRoot);
});

const firebaseRcOnDisk = () =>
JSON.parse(readFileSync(join(projectRoot, '.firebaserc')).toString());

it('creates .firebaserc recording the selected project as default', () => {
setDefaultProjectInFirebaseRc(projectRoot, 'my-project');
expect(firebaseRcOnDisk()).toEqual({
projects: { default: 'my-project' },
});
});

it('merges into an existing .firebaserc, preserving targets and other aliases', () => {
writeFileSync(join(projectRoot, '.firebaserc'), JSON.stringify({
projects: { default: 'old-project', staging: 'staging-project' },
targets: { 'old-project': { hosting: { app: ['app-site'] } } },
}));
setDefaultProjectInFirebaseRc(projectRoot, 'new-project');
expect(firebaseRcOnDisk()).toEqual({
projects: { default: 'new-project', staging: 'staging-project' },
targets: { 'old-project': { hosting: { app: ['app-site'] } } },
});
});

it('names the file when an existing .firebaserc cannot be parsed', () => {
writeFileSync(join(projectRoot, '.firebaserc'), '{ not json');
expect(() => setDefaultProjectInFirebaseRc(projectRoot, 'my-project'))
.toThrowError(/\.firebaserc/);
});

});

describe('createFirestoreStarterFiles', () => {

it('creates test-mode rules and an empty index manifest when Firestore is selected', () => {
const tree = new HostTree();
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
const rules = tree.readText('/firestore.rules');
expect(rules).toContain("rules_version='2'");
expect(rules).toContain('allow read, write: if request.time < timestamp.date(');
expect(JSON.parse(tree.readText('/firestore.indexes.json'))).toEqual({
indexes: [],
fieldOverrides: [],
});
});

it('sets the rules to expire roughly 30 days out', () => {
const tree = new HostTree();
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
const match = /timestamp\.date\((\d+), (\d+), (\d+)\)/.exec(tree.readText('/firestore.rules'));
expect(match).not.toBeNull();
const [, year, month, day] = match.map(Number);
const expiry = new Date(year, month - 1, day);
const daysOut = (expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
expect(daysOut).toBeGreaterThan(28);
expect(daysOut).toBeLessThan(31);
});

it('never overwrites an existing rules file', () => {
const tree = new HostTree();
tree.create('/firestore.rules', 'my existing rules');
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
expect(tree.readText('/firestore.rules')).toBe('my existing rules');
});

it('still creates the index manifest when only the rules file exists', () => {
const tree = new HostTree();
tree.create('/firestore.rules', 'my existing rules');
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore]);
expect(tree.exists('/firestore.indexes.json')).toBeTrue();
});

it('does nothing when Firestore is not selected', () => {
const tree = new HostTree();
createFirestoreStarterFiles(tree, context, [FEATURES.Authentication]);
expect(tree.exists('/firestore.rules')).toBeFalse();
expect(tree.exists('/firestore.indexes.json')).toBeFalse();
});

it('creates nothing when firebase.json already has a firestore section', () => {
const tree = new HostTree();
createFirestoreStarterFiles(tree, context, [FEATURES.Firestore], {
firestore: { rules: 'custom.rules' },
});
expect(tree.exists('/firestore.rules')).toBeFalse();
expect(tree.exists('/firestore.indexes.json')).toBeFalse();
});

});

describe('addFirestoreToFirebaseJson', () => {
let projectRoot: string;

beforeEach(() => {
projectRoot = mkdtempSync(join(tmpdir(), 'angularfire-test-'));
});

afterEach(() => {
fsExtra.removeSync(projectRoot);
});

const firebaseJsonOnDisk = () =>
JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString());

it('adds the firestore section pointing at the starter files', () => {
writeFileSync(join(projectRoot, 'firebase.json'), '{}');
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
expect(firebaseJsonOnDisk().firestore).toEqual({
rules: 'firestore.rules',
indexes: 'firestore.indexes.json',
});
});

it('preserves sections other tools wrote to firebase.json', () => {
writeFileSync(join(projectRoot, 'firebase.json'), JSON.stringify({
dataconnect: { source: 'dataconnect' },
}));
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
expect(firebaseJsonOnDisk().dataconnect).toEqual({ source: 'dataconnect' });
expect(firebaseJsonOnDisk().firestore).toBeDefined();
});

it('leaves an existing firestore section untouched', () => {
writeFileSync(join(projectRoot, 'firebase.json'), JSON.stringify({
firestore: { rules: 'custom.rules' },
}));
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
expect(firebaseJsonOnDisk().firestore).toEqual({ rules: 'custom.rules' });
});

it('does nothing when Firestore is not selected', () => {
writeFileSync(join(projectRoot, 'firebase.json'), '{}');
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Authentication]);
expect(firebaseJsonOnDisk().firestore).toBeUndefined();
});

it('warns and skips when firebase.json cannot be read', () => {
const warnSpy = spyOn(context.logger, 'warn');
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
expect(warnSpy).toHaveBeenCalled();
});

it('warns and skips when firebase.json parses to a non-object', () => {
writeFileSync(join(projectRoot, 'firebase.json'), 'null');
const warnSpy = spyOn(context.logger, 'warn');
addFirestoreToFirebaseJson(projectRoot, context, [FEATURES.Firestore]);
expect(warnSpy).toHaveBeenCalled();
expect(readFileSync(join(projectRoot, 'firebase.json')).toString()).toBe('null');
});

});
128 changes: 128 additions & 0 deletions src/schematics/setup/firebaseConfigs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics';
import { stringifyFormatted } from '../common.js';
import { FEATURES, FirebaseJSON, FirebaseRc } from '../interfaces.js';

/**
* Builds the same test-mode starter rules `firebase init firestore` generates: anyone can read
* and write until a date 30 days from generation, after which all client requests are denied.
*/
const testModeFirestoreRules = () => {
const expiry = new Date();
expiry.setDate(expiry.getDate() + 30);
return `rules_version='2'

service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
// This rule allows anyone with your database reference to view, edit,
// and delete all data in your database. It is useful for getting
// started, but it is configured to expire after 30 days because it
// leaves your app open to attackers. At that time, all client
// requests to your database will be denied.
//
// Make sure to write security rules for your app before that time, or
// else all client requests to your database will be denied until you
// update your rules.
allow read, write: if request.time < timestamp.date(${expiry.getFullYear()}, ${expiry.getMonth() + 1}, ${expiry.getDate()});
}
}
}
`;
};

/**
* Records the selected Firebase project as the workspace's default in `.firebaserc`, so
* firebase-tools commands the user runs after setup completes don't prompt for (or fail without)
* a project. Merges into an existing file — only `projects.default` is set; targets and other
* aliases are preserved. Written to the real filesystem (not the schematic Tree) because
* `firebaseTools.init` writes `.firebaserc` on disk during the same `ng add` run — a Tree-staged
* copy would collide with it when the schematic commits; call this after the last
* `firebaseTools.init` call for that reason.
*/
export const setDefaultProjectInFirebaseRc = (projectRoot: string, projectId: string) => {
const path = join(projectRoot, '.firebaserc');
let rc: FirebaseRc = {};
if (existsSync(path)) {
try {
rc = JSON.parse(readFileSync(path).toString());
} catch (e) {
throw new SchematicsException(`Error when parsing ${path}: ${e.message}`);
}
}
rc.projects = { ...rc.projects, default: projectId };
writeFileSync(path, stringifyFormatted(rc));
};

/**
* When Firestore is selected, generates `firestore.rules` (test mode, with a logged warning
* about the 30-day expiry) and `firestore.indexes.json`. Existing files are never overwritten —
* a later `firebase deploy` would replace rules already deployed from elsewhere. A workspace
* whose firebase.json already has a `firestore` section is left entirely untouched: its section
* may point at differently-named files, and creating the default-named ones would only add
* orphans nothing references.
*
* `firebaseJsonConfig`, when passed, should be the post-init snapshot of firebase.json (read
* after the last `firebaseTools.init` call and before `addFirestoreToFirebaseJson` runs) — see
* the call site in `ngAddSetupProject` for why that order matters. Passing a stale pre-init
* snapshot risks staging the default-named starter files on top of files firebase-tools already
* wrote to disk, colliding when the Tree commits.
*/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth adding one sentence here noting that this must be called with the pre-init firebaseJson snapshot (not a post-init re-read). If a future firebaseTools.init call adds a firestore section to firebase.json on disk, addFirestoreToFirebaseJson will correctly skip, but the Tree-staged starter files will have already been created and will be orphaned. The ordering constraint is non-obvious without it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch - traced it further and it's a bit worse than an orphan: if a future init call adds a firestore section while this is still reading the pre-init snapshot, it'd stage the default-named starter files right on top of the ones that init call would have written to disk - the same collision .firebaserc hit earlier in this PR, which aborts the whole ng add. So:

  • I moved the call to run after the init calls with a fresh re-read, rather than just documenting the risk.
  • Also worth noting: it now has to run before addFirestoreToFirebaseJson, since that's what actually adds the firestore section on a normal run — added a comment on that ordering too.

export const createFirestoreStarterFiles = (
host: Tree,
context: SchematicContext,
features: FEATURES[],
firebaseJsonConfig?: FirebaseJSON,
) => {
if (!features.includes(FEATURES.Firestore)) { return; }
if (firebaseJsonConfig?.firestore) {
context.logger.info('firebase.json already has a firestore section — leaving its rules and indexes untouched.');
return;
}
if (host.exists('/firestore.rules')) {
context.logger.info('firestore.rules already exists — leaving it untouched.');
} else {
host.create('/firestore.rules', testModeFirestoreRules());
context.logger.warn(
'Generated firestore.rules in test mode: anyone can read and write your database until the rules expire in 30 days. ' +
'Write real security rules before then — https://firebase.google.com/docs/rules'
);
}
if (!host.exists('/firestore.indexes.json')) {
// Must exist once firebase.json references it — `firebase deploy` errors on a missing indexes file.
host.create('/firestore.indexes.json', stringifyFormatted({ indexes: [], fieldOverrides: [] }));
}
};

/**
* When Firestore is selected, points the `firestore` section of `firebase.json` at the starter
* files so `firebase deploy` includes rules and indexes. Written to the real filesystem (not the
* schematic Tree) because firebase-tools reads and rewrites `firebase.json` during the same
* `ng add` run; call this after the last `firebaseTools.init` call for that reason.
*/
export const addFirestoreToFirebaseJson = (
projectRoot: string,
context: SchematicContext,
features: FEATURES[],
) => {
if (!features.includes(FEATURES.Firestore)) { return; }
// firebaseTools.init calls may have rewritten firebase.json on disk, so re-read it, add the
// firestore section if absent, and write it back. The whole read-check-write is guarded: a
// failed read (missing or unparseable file), a parse that isn't a usable object (reading
// `.firestore` off `null`/`undefined` throws; assigning it on a non-object primitive throws
// too), or a failed write (disk full, permissions) all warn and leave the file for the user to
// finish, rather than crashing the schematic with a raw Node error.
try {
const firebaseJson: FirebaseJSON = JSON.parse(readFileSync(join(projectRoot, 'firebase.json')).toString());
if (!firebaseJson.firestore) {
firebaseJson.firestore = { rules: 'firestore.rules', indexes: 'firestore.indexes.json' };
writeFileSync(join(projectRoot, 'firebase.json'), stringifyFormatted(firebaseJson));
}
} catch (e) {
context.logger.warn(
`Could not update firebase.json with the firestore section (${e.message}). Check firebase.json ` +
'and add { "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" } } manually.'
);
}
};
29 changes: 27 additions & 2 deletions src/schematics/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
parseDataConnectConfig,
setupTanstackDependencies,
} from '../utils';
import {
addFirestoreToFirebaseJson,
createFirestoreStarterFiles,
setDefaultProjectInFirebaseRc,
} from './firebaseConfigs';
import { appPrompt, featuresPrompt, projectPrompt, userPrompt } from './prompts';

// FirebaseOptions keys — apps.sdkconfig responses include management-API extras that initializeApp() rejects.
Expand Down Expand Up @@ -84,7 +89,7 @@ export const ngAddSetupProject = (
const [ defaultProjectName ] = getFirebaseProjectNameFromHost(host, ngProjectName);

const firebaseProject = await projectPrompt(defaultProjectName, { projectRoot, account: user.email });

let firebaseApp: FirebaseApp|undefined;
let sdkConfig: Record<string, string>|undefined;

Expand Down Expand Up @@ -139,8 +144,28 @@ export const ngAddSetupProject = (
setupTanstackDependencies(host, context);
setupConfig.dataConnectConfig = dataConnectConfig;
}


}

// Read after the init calls, never before — firebase-tools rewrites firebase.json on disk
// mid-run. A failed read isn't fatal: createFirestoreStarterFiles falls back to checking the
// disk for each file it would create.
let firebaseJsonAfterInit: FirebaseJSON | undefined;
try {
firebaseJsonAfterInit = JSON.parse(
readFileSync(join(projectRoot, "firebase.json")).toString()
);
} catch (e) {
context.logger.warn(`Could not re-read firebase.json after setup (${e.message}).`);
}
// Must run before addFirestoreToFirebaseJson: that call adds the firestore section, and if it
// ran first this snapshot would see it and skip creating the files it points at.
createFirestoreStarterFiles(host, context, features, firebaseJsonAfterInit);

// Both write the real filesystem, after the last firebaseTools.init call — init writes
// .firebaserc and rewrites firebase.json on disk during the run.
setDefaultProjectInFirebaseRc(projectRoot, firebaseProject.projectId);
addFirestoreToFirebaseJson(projectRoot, context, features);

return setupProject(host, context, features, setupConfig);
}
Expand Down
Loading