diff --git a/firestore.rules b/firestore.rules index 7f01a54011..da47f6fbc7 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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=**} { diff --git a/src/clue/components/clue-app-header.tsx b/src/clue/components/clue-app-header.tsx index 33dc5dc88e..ef39da545c 100644 --- a/src/clue/components/clue-app-header.tsx +++ b/src/clue/components/clue-app-header.tsx @@ -26,8 +26,16 @@ export const ClueAppHeaderComponent: React.FC = 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; @@ -150,7 +158,7 @@ export const ClueAppHeaderComponent: React.FC = observer(function ClueAp
Version {appVersion}
-
+
@@ -191,7 +199,7 @@ export const ClueAppHeaderComponent: React.FC = observer(function ClueAp
Version {appVersion}
{myGroup ? renderGroup(myGroup) : null} -
+
{user.name}
{user.className}
diff --git a/src/lib/db.ts b/src/lib/db.ts index 826f9f5960..f24ff93fb9 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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; @@ -55,7 +56,7 @@ export interface IDBAuthConnectOptions extends IDBBaseConnectOptions { rawFirebaseJWT: string; } export interface IDBNonAuthConnectOptions extends IDBBaseConnectOptions { - appMode: "dev" | "test" | "demo" | "qa"; + appMode: Exclude; } export interface UserGroupMap { [key: string]: { diff --git a/src/lib/firebase.test.ts b/src/lib/firebase.test.ts new file mode 100644 index 0000000000..b8732e95e2 --- /dev/null +++ b/src/lib/firebase.test.ts @@ -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//"); + }); + }); + }); +}); diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts index ef2f77393c..378c81d4ae 100644 --- a/src/lib/firebase.ts +++ b/src/lib/firebase.ts @@ -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" @@ -65,21 +66,15 @@ export class Firebase { // dev: /dev//portals/localhost // qa: /qa//portals/qa // test: /test//portals/ - 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"); diff --git a/src/lib/firestore.test.ts b/src/lib/firestore.test.ts index 2233c49331..956c689caa 100644 --- a/src/lib/firestore.test.ts +++ b/src/lib/firestore.test.ts @@ -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", () => { diff --git a/src/lib/firestore.ts b/src/lib/firestore.ts index cb867bbd8c..4575cd9611 100644 --- a/src/lib/firestore.ts +++ b/src/lib/firestore.ts @@ -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"; @@ -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 = "") { diff --git a/src/lib/root-id.ts b/src/lib/root-id.ts new file mode 100644 index 0000000000..f2d14f1a1f --- /dev/null +++ b/src/lib/root-id.ts @@ -0,0 +1,30 @@ +import { IStores } from "../models/stores/stores"; +import { escapeKey } from "./fire-utils"; + +type IRootDocIdStores = Pick; + +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; + } + } +} diff --git a/src/models/stores/user-context-provider.ts b/src/models/stores/user-context-provider.ts index 72f5a157b5..e81d9e0299 100644 --- a/src/models/stores/user-context-provider.ts +++ b/src/models/stores/user-context-provider.ts @@ -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;