Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor firebase root id computation #2403

Merged
merged 4 commits into from
Sep 19, 2024
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
8 changes: 4 additions & 4 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,10 @@ service cloud.firestore {
allow read, write: if isAuthed();
}

match /dev/{userId}/{restOfPath=**} {
allow read: if isAuthed();
// users can only write to their own folders
allow write: if matchFirebaseUserId(userId);
// In the future, developers might use a random unique key instead of
// the firebase user id. So this rule is relaxed in order to allow that.
match /dev/{devId}/{restOfPath=**} {
allow read, write: if isAuthed();
}

match /qa/{userId}/{restOfPath=**} {
Expand Down
16 changes: 12 additions & 4 deletions src/clue/components/clue-app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,16 @@ export const ClueAppHeaderComponent: React.FC<IProps> = observer(function ClueAp
const { showGroup } = props;
const { appConfig, appMode, appVersion, db, user, problem, groups, investigation, ui, unit } = useStores();
const myGroup = showGroup ? groups.getGroupById(user.currentGroupId) : undefined;
const userTitle = appMode !== "authed" && appMode !== "demo"
? `Firebase UID: ${db.firebase.userId}` : undefined;
const getUserTitle = () => {
switch(appMode){
case "dev":
case "qa":
case "test":
return `Firebase UID: ${db.firebase.userId}`;
default:
return undefined;
}
};

const renderPanelButtons = () => {
const { panels, onPanelChange, current} = props;
Expand Down Expand Up @@ -150,7 +158,7 @@ export const ClueAppHeaderComponent: React.FC<IProps> = observer(function ClueAp
</div>
<div className="right">
<div className="version">Version {appVersion}</div>
<div className="user teacher" title={userTitle}>
<div className="user teacher" title={getUserTitle()}>
<div className="class" data-test="user-class">
<ClassMenuContainer />
</div>
Expand Down Expand Up @@ -191,7 +199,7 @@ export const ClueAppHeaderComponent: React.FC<IProps> = observer(function ClueAp
<NetworkStatus user={user}/>
<div className="version">Version {appVersion}</div>
{myGroup ? renderGroup(myGroup) : null}
<div className="user" title={userTitle}>
<div className="user" title={getUserTitle()}>
<div className="user-contents">
<div className="name" data-test="user-name">{user.name}</div>
<div className="class" data-test="user-class">{user.className}</div>
Expand Down
3 changes: 2 additions & 1 deletion src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { urlParams } from "../utilities/url-params";
import { firebaseConfig } from "./firebase-config";
import { UserModelType } from "../models/stores/user";
import { logExemplarDocumentEvent } from "../models/document/log-exemplar-document-event";
import { AppMode } from "../models/stores/store-types";
import { DEBUG_FIRESTORE } from "./debug";

export type IDBConnectOptions = IDBAuthConnectOptions | IDBNonAuthConnectOptions;
Expand All @@ -55,7 +56,7 @@ export interface IDBAuthConnectOptions extends IDBBaseConnectOptions {
rawFirebaseJWT: string;
}
export interface IDBNonAuthConnectOptions extends IDBBaseConnectOptions {
appMode: "dev" | "test" | "demo" | "qa";
appMode: Exclude<AppMode, "authed">;
}
export interface UserGroupMap {
[key: string]: {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/firebase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DB } from "./db";
import { Firebase } from "./firebase";

const mockStores = {
appMode: "authed",
demo: { name: "demo" },
user: { portal: "test-portal" }
};
const mockDB = {
stores: mockStores
} as DB;

describe("Firebase class", () => {
describe("initialization", () => {
it("should create a valid Firebase object", () => {
const firebase = new Firebase(mockDB);
expect(firebase).toBeDefined();
});
});
describe("getRootFolder", () => {
it("should handle authed mode", () => {
const firebase = new Firebase(mockDB);
expect(firebase.getRootFolder()).toBe("/authed/portals/test-portal/");
});
describe("should handle the demo appMode", () => {
it("handles basic demo name", () => {
const stores = {...mockStores,
appMode: "demo", demo: { name: "test-demo" }};
const firebase = new Firebase({stores} as DB);
expect(firebase.getRootFolder()).toBe("/demo/test-demo/portals/test-portal/");
});
it("handles empty demo name", () => {
const stores = {...mockStores,
appMode: "demo", demo: { name: "" }};
const firebase = new Firebase({stores} as DB);
expect(firebase.getRootFolder()).toBe("/demo/test-portal/portals/test-portal/");
});
it("handles empty demo name and empty portal", () => {
const stores = {...mockStores,
appMode: "demo", demo: { name: "" }, user: { portal: ""}};
const firebase = new Firebase({stores} as DB);
expect(firebase.getRootFolder()).toBe("/demo/demo/portals//");
});
});
});
});
13 changes: 4 additions & 9 deletions src/lib/firebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { DB } from "./db";
import { escapeKey } from "./fire-utils";
import { urlParams } from "../utilities/url-params";
import { DocumentModelType } from "src/models/document/document";
import { getRootId } from "./root-id";

// Set this during database testing in combination with the urlParam testMigration=true to
// override the top-level Firebase key regardless of mode. For example, setting this to "authed-copy"
Expand Down Expand Up @@ -65,21 +66,15 @@ export class Firebase {
// dev: /dev/<firebaseUserId>/portals/localhost <as portalDomain>
// qa: /qa/<firebaseUserId>/portals/qa <as portalDomain>
// test: /test/<firebaseUserId>/portals/<arbitraryString as portalDomain>
const { appMode, demo: { name: demoName }, user } = this.db.stores;
const { appMode, user } = this.db.stores;

const parts = [];
if (urlParams.testMigration === "true" && FIREBASE_ROOT_OVERRIDE) {
parts.push(FIREBASE_ROOT_OVERRIDE);
} else {
parts.push(`${appMode}`);
if ((appMode === "dev") || (appMode === "test") || (appMode === "qa")) {
parts.push(this.userId);
}
else if (appMode === "demo") {
const slug = demoName && demoName.length > 0 ? escapeKey(demoName) : "";
if (slug.length > 0) {
parts.push(slug);
}
if (appMode !== "authed") {
parts.push(getRootId(this.db.stores, this.userId));
}
}
parts.push("portals");
Expand Down
27 changes: 27 additions & 0 deletions src/lib/firestore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,35 @@ describe("Firestore class", () => {
it("should create a valid Firestore object", () => {
const firestore = new Firestore(mockDB);
expect(firestore).toBeDefined();
});
});

describe("getRootFolder", () => {
beforeEach(() => resetMocks());
it("should handle the authed appMode", () => {
const firestore = new Firestore(mockDB);
expect(firestore.getRootFolder()).toBe("/authed/test-portal/");
});
describe("should handle the demo appMode", () => {
it("handles basic demo name", () => {
const stores = {...mockStores,
appMode: "demo", demo: { name: "test-demo" }};
const firestore = new Firestore({stores} as DB);
expect(firestore.getRootFolder()).toBe("/demo/test-demo/");
});
it("handles empty demo name", () => {
const stores = {...mockStores,
appMode: "demo", demo: { name: "" }};
const firestore = new Firestore({stores} as DB);
expect(firestore.getRootFolder()).toBe("/demo/test-portal/");
});
it("handles empty demo name and empty portal", () => {
const stores = {...mockStores,
appMode: "demo", demo: { name: "" }, user: { portal: ""}};
const firestore = new Firestore({stores} as DB);
expect(firestore.getRootFolder()).toBe("/demo/demo/");
});
});
});

describe("setFirebaseUser", () => {
Expand Down
22 changes: 3 additions & 19 deletions src/lib/firestore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import firebase from "firebase/app";
import "firebase/firestore";
import { DB } from "./db";
import { getRootId } from "./root-id";
import { escapeKey } from "./fire-utils";
import { UserDocument } from "./firestore-schema";

Expand Down Expand Up @@ -40,25 +41,8 @@ export class Firestore {
//

public getRootFolder() {
const { appMode, demo: { name: demoName }, user: { portal } } = this.db.stores;
let rootDocId: string;
const escapedPortal = portal ? escapeKey(portal) : portal;

// `authed/${escapedPortalDomain}`
if (appMode === "authed") {
rootDocId = escapedPortal;
}
// `demo/${escapedDemoName}`
else if ((appMode === "demo") && (demoName?.length > 0)) {
const escapedDemoName = demoName ? escapeKey(demoName) : demoName;
rootDocId = escapedDemoName || escapedPortal || "demo";
}
// `${appMode}/${userId}`
else { // (appMode === "dev") || (appMode === "test") || (appMode === "qa")
rootDocId = this.userId;
}

return `/${appMode}/${rootDocId}/`;
const { appMode } = this.db.stores;
return `/${appMode}/${getRootId(this.db.stores, this.userId)}/`;
}

public getFullPath(path = "") {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/root-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IStores } from "../models/stores/stores";
import { escapeKey } from "./fire-utils";

type IRootDocIdStores = Pick<IStores, "appMode" | "demo" | "user">;

export function getRootId(stores: IRootDocIdStores, firebaseUserId: string) {
const { appMode, demo: { name: demoName }, user: { portal } } = stores;
const escapedPortal = portal ? escapeKey(portal) : portal;

switch (appMode) {
case "authed": {
return escapedPortal;
}
case "demo": {
// Legacy Note: Previously if the demoName was "", the root id in the realtime database
// and Firestore would be different. The paths would end up being:
// database: /demo/portals/demo/ (root id is skipped)
// firestore: /demo/{firebaseUserId}/
// Now the root id will be the default "demo" in this case so the paths will be:
// database: /demo/demo/portals/demo/
// firestore: /demo/demo/
const escapedDemoName = demoName ? escapeKey(demoName) : demoName;
return escapedDemoName || escapedPortal || "demo";
}
// "dev", "qa", and "test"
default: {
return firebaseUserId;
}
}
}
4 changes: 4 additions & 0 deletions src/models/stores/user-context-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export class UserContextProvider {
this.stores = stores;
}

/**
* This user context is sent to the Firebase functions so they know the context of the
* request.
*/
get userContext() {
const appMode = this.stores.appMode;
const { name: demoName } = this.stores.demo;
Expand Down
Loading