From c32cc33c41f4fed5e2d1c2fdd11a051f096a94ba Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Tue, 19 Dec 2023 16:26:32 +0700 Subject: [PATCH 1/9] Add firestore rules for games --- firestore/firestore.rules | 23 ++++++++++ firestore/test/club.test.ts | 2 +- firestore/test/game.test.ts | 76 +++++++++++++++++++++++++++++++ firestore/test/run.sh | 2 +- functions/src/createToken.test.ts | 3 +- functions/src/createToken.ts | 5 +- 6 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 firestore/test/game.test.ts diff --git a/firestore/firestore.rules b/firestore/firestore.rules index 3d391b8..4de9f60 100644 --- a/firestore/firestore.rules +++ b/firestore/firestore.rules @@ -24,5 +24,28 @@ service cloud.firestore { allow read, create, delete: if isClubAdmin(clubId); } } + + match /games/{gameId} { + function isTableAdmin(clubId, tableId) { + return isClubMember(clubId) && request.auth.token.tableId == tableId; + } + function canReadGame() { + let clubId = get(resource.data.club).id; + let tableId = get(resource.data.table).id; + return isClubAdmin(clubId) || isTableAdmin(clubId, tableId); + } + function canWriteGame() { + let clubId = get(resource.data.club).id; + let tableId = get(resource.data.table).id; + return isTableAdmin(clubId, tableId); + } + function isValidAction() { + //TODO: validate actions incrementally + return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['state', 'type']); + } + + allow read: if canReadGame(); + allow update: if canWriteGame() && isValidAction(); + } } } \ No newline at end of file diff --git a/firestore/test/club.test.ts b/firestore/test/club.test.ts index 0e4ab16..cda07e1 100644 --- a/firestore/test/club.test.ts +++ b/firestore/test/club.test.ts @@ -46,7 +46,7 @@ it('should forbid access to club for non club members', async () => { await assertFails(getDoc(doc(aliceDb, clubPath))); const unauthenticatedDb = testEnv.unauthenticatedContext().firestore(); - await assertFails(getDoc(doc(unauthenticatedDb, 'clubs/bc73'))); + await assertFails(getDoc(doc(unauthenticatedDb, clubPath))); }); it('should allow read club', async () => { diff --git a/firestore/test/game.test.ts b/firestore/test/game.test.ts new file mode 100644 index 0000000..7751f44 --- /dev/null +++ b/firestore/test/game.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'fs'; +import { + assertFails, assertSucceeds, initializeTestEnvironment, RulesTestEnvironment, +} from '@firebase/rules-unit-testing'; +import { + setLogLevel, setDoc, doc, getDoc, deleteDoc, updateDoc, +} from 'firebase/firestore'; + +let testEnv: RulesTestEnvironment; +const clubId = 'bc73'; +const clubName = 'bc73'; +const clubPath = `clubs/${clubId}`; +const tableId = 'Table 1'; +const tablePath = `clubs/${clubId}/tables/${tableId}`; +const gameId = '123'; +const gamePath = `games/${gameId}`; + +beforeAll(async () => { + // Silence expected rules rejections from Firestore SDK. Unexpected rejections + // will still bubble up and will be thrown as an error (failing the tests). + setLogLevel('error'); + + testEnv = await initializeTestEnvironment({ + firestore: { + rules: readFileSync('firestore.rules', 'utf8'), + }, + }); +}); + +beforeEach(async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + const clubRef = doc(db, clubPath); + await setDoc(doc(db, clubPath), { name: clubName }); + const tableRef = doc(db, tablePath); + await setDoc(tableRef, { foo: 'bar' }); + await setDoc(doc(db, gamePath), { club: clubRef, table: tableRef }); + }); +}); + +afterEach(async () => { + await testEnv.clearFirestore(); +}); + +afterAll(async () => { + await testEnv.cleanup(); +}); + +it('should allow read access to game for club and table admins', async () => { + const clubAdminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); + await assertSucceeds(getDoc(doc(clubAdminDb, gamePath))); + + const tableAdminDb = testEnv.authenticatedContext('alice', { clubId, tableId }).firestore(); + await assertSucceeds(getDoc(doc(tableAdminDb, gamePath))); + + const aliceDb = testEnv.authenticatedContext('alice').firestore(); + await assertFails(getDoc(doc(aliceDb, gamePath))); + + const unauthenticatedDb = testEnv.unauthenticatedContext().firestore(); + await assertFails(getDoc(doc(unauthenticatedDb, gamePath))); +}); + +it('should allow write access to state and type for table admins', async () => { + const clubAdminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); + await assertFails(updateDoc(doc(clubAdminDb, gamePath), { type: '8' })); + + const tableAdminDb = testEnv.authenticatedContext('alice', { clubId, tableId }).firestore(); + await assertSucceeds(updateDoc(doc(tableAdminDb, gamePath), { type: '8', state: {} })); + await assertFails(updateDoc(doc(tableAdminDb, gamePath), { table: 'foo' })); + + const aliceDb = testEnv.authenticatedContext('alice').firestore(); + await assertFails(updateDoc(doc(aliceDb, gamePath), { type: '8' })); + + const unauthenticatedDb = testEnv.unauthenticatedContext().firestore(); + await assertFails(updateDoc(doc(unauthenticatedDb, gamePath), { type: '8' })); +}); diff --git a/firestore/test/run.sh b/firestore/test/run.sh index 298388e..a8c12b0 100755 --- a/firestore/test/run.sh +++ b/firestore/test/run.sh @@ -18,7 +18,7 @@ RULES_FILE=firestore.rules # Execute Tests -npx jest || error "Tests failed" +npx jest --runInBand || error "Tests failed" # Fetch Coverage Report curl $COVERAGE_URL > $JSON_REPORT diff --git a/functions/src/createToken.test.ts b/functions/src/createToken.test.ts index badf0ac..a23bbbb 100644 --- a/functions/src/createToken.test.ts +++ b/functions/src/createToken.test.ts @@ -23,8 +23,6 @@ const firebaseConfig = { apiKey: "test-key", }; -console.log(firebaseConfig); -console.log(process.env); const app = initializeApp(firebaseConfig); const auth = getAuth(app); connectAuthEmulator(auth, "http://127.0.0.1:9099", {disableWarnings: true}); @@ -213,6 +211,7 @@ it("should issue jwt with clubId", async function() { const result = await auth.currentUser?.getIdTokenResult(); expect(result?.claims.clubId).toBeDefined(); + expect(result?.claims.tableId).toBeDefined(); }); it("should issue jwt usable for multiple logins", async function() { diff --git a/functions/src/createToken.ts b/functions/src/createToken.ts index 36b5b6b..f210dae 100644 --- a/functions/src/createToken.ts +++ b/functions/src/createToken.ts @@ -84,7 +84,10 @@ export const createJWT = onCall({cors: true}, async (request) => { } const clubId = query.docs.at(0)?.ref.parent?.parent?.id; - const jwt = await auth.createCustomToken(nanoid(), {clubId}); + const jwt = await auth.createCustomToken(nanoid(), { + clubId, + tableId: query.docs[0].id, + }); return {jwt}; }); From bd578beafcf9cf4d76021beb69184f25c49434ee Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Wed, 20 Dec 2023 09:31:25 +0700 Subject: [PATCH 2/9] Create Gamestate for each table --- functions/package.json | 4 +-- functions/src/createGame.test.ts | 59 ++++++++++++++++++++++++++++++++ functions/src/createGame.ts | 45 ++++++++++++++++++++++++ functions/src/index.ts | 3 ++ 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 functions/src/createGame.test.ts create mode 100644 functions/src/createGame.ts diff --git a/functions/package.json b/functions/package.json index 47e8d6c..218dce6 100644 --- a/functions/package.json +++ b/functions/package.json @@ -12,8 +12,8 @@ "predeploy": "npm run build", "deploy": "npx firebase-tools deploy --force --only functions", "pretest": "npm run build", - "test": "npx firebase-tools --debug emulators:exec --only 'functions,firestore,auth' 'npx jest'", - "test:watch": "concurrently --kill-others \"npm:build:watch\" \"npx firebase-tools emulators:exec 'npx jest --watch'\"" + "test": "npx firebase-tools emulators:exec --only 'functions,firestore,auth' 'npx jest --runInBand'", + "test:watch": "concurrently --kill-others \"npm:build:watch\" \"npx firebase-tools emulators:exec --only 'functions,firestore,auth' 'npx jest --watch --runInBand'\"" }, "engines": { "node": "16" diff --git a/functions/src/createGame.test.ts b/functions/src/createGame.test.ts new file mode 100644 index 0000000..4eb763b --- /dev/null +++ b/functions/src/createGame.test.ts @@ -0,0 +1,59 @@ +import {DocumentReference} from "firebase-admin/firestore"; +import {db as adminDb} from "./firebase"; + +const sleep = (ms: number) => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const clubId = "bc73"; +const clubPath = `clubs/${clubId}`; +const tableId = "Tisch 1"; +const tablePath = `${clubPath}/tables/${tableId}`; + +const createTable = async () => { + const tableRef = adminDb.doc(tablePath); + await tableRef.create({}); + return tableRef; +}; + +const getGameRef = async (tableRef: DocumentReference) => { + const tableData = (await tableRef.get()).data(); + return tableData?.game as DocumentReference; +}; + +beforeEach(async () => { + await adminDb.doc(clubPath).create({}); +}); + +afterEach(async () => { + await adminDb.recursiveDelete(adminDb.collection("games")); + await adminDb.recursiveDelete(adminDb.collection(`clubs/${clubId}/tables`)); + await adminDb.recursiveDelete(adminDb.collection("clubs")); +}); + +it("should create a game if a table is added", async function() { + const tableRef = await createTable(); + await sleep(1000); // wait for cloud trigger + const gameRef = await getGameRef(tableRef); + expect(gameRef.path).toBeDefined(); + + const gameData = (await gameRef.get()).data(); + expect(gameData?.club.path).toBe(clubPath); + expect(gameData?.table.path).toBe(tablePath); + expect(gameData?.type).toBe("8_BALL"); + expect(gameData?.state).toStrictEqual({}); +}); + +it("should delete game if corresponding table is removed", async function() { + const tableRef = await createTable(); + await sleep(1000); // wait for cloud trigger + const gameRef = await getGameRef(tableRef); + expect(gameRef.path).toBeDefined(); + + await tableRef.delete(); + await sleep(1000); // wait for cloud trigger + const gameSnapshot = await gameRef.get(); + expect(gameSnapshot.exists).toBeFalsy(); +}); diff --git a/functions/src/createGame.ts b/functions/src/createGame.ts new file mode 100644 index 0000000..f9842c7 --- /dev/null +++ b/functions/src/createGame.ts @@ -0,0 +1,45 @@ +import { + onDocumentCreated, + onDocumentDeleted, +} from "firebase-functions/v2/firestore"; +import {db} from "./firebase"; +import {DocumentReference} from "firebase-admin/firestore"; + +export const createGame = onDocumentCreated( + "clubs/{clubId}/tables/{tableId}", + async (event) => { + const snapshot = event.data; + const data = snapshot?.data(); + if (!snapshot || !data || data.game) { + return; + } + + const tableRef = snapshot.ref; + const clubRef = db.doc(`clubs/${event.params.clubId}`); + const gameRef = db.collection("games").doc(); + + const batch = db.batch(); + batch.set(gameRef, { + table: tableRef, + club: clubRef, + type: "8_BALL", + state: {}, + }); + batch.set(snapshot.ref, {game: gameRef}, {merge: true}); + await batch.commit(); + }, +); + +export const deleteGame = onDocumentDeleted( + "clubs/{clubId}/tables/{tableId}", + async (event) => { + const snapshot = event.data; + const data = snapshot?.data(); + if (!snapshot || !data || !data.game) { + return; + } + + const gameRef = data.game as DocumentReference; + await gameRef.delete(); + }, +); diff --git a/functions/src/index.ts b/functions/src/index.ts index 2582c5e..86f4a36 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -2,6 +2,7 @@ import {beforeUserCreated} from "firebase-functions/v2/identity"; import createClub from "./createUser"; import {createJWT, createToken} from "./createToken"; +import {createGame, deleteGame} from "./createGame"; export const user = { /* @@ -18,4 +19,6 @@ export const user = { export const table = { createtoken: createToken, createjwt: createJWT, + creategame: createGame, + deletegame: deleteGame, }; From b03035bc57daf63a8915a5692afa08c5a34640e3 Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Wed, 20 Dec 2023 15:18:38 +0700 Subject: [PATCH 3/9] Update Emulator Data --- .../emulator_data/auth_export/accounts.json | 2 +- .../emulator_data/firebase-export-metadata.json | 4 ++-- .../all_namespaces_all_kinds.export_metadata | Bin 52 -> 52 bytes .../all_namespaces/all_kinds/output-0 | Bin 244 -> 625 bytes .../firestore_export.overall_export_metadata | Bin 95 -> 95 bytes 5 files changed, 3 insertions(+), 3 deletions(-) diff --git a/firestore/emulator_data/auth_export/accounts.json b/firestore/emulator_data/auth_export/accounts.json index c38c814..642df03 100644 --- a/firestore/emulator_data/auth_export/accounts.json +++ b/firestore/emulator_data/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"MZTpmPJIADM8EBNDTrlALtiNtzCt","createdAt":"1679951184357","lastLoginAt":"1700997858927","passwordHash":"fakeHash:salt=fakeSaltlRxnLx63Dc9zghOAJnrK:password=123456","salt":"fakeSaltlRxnLx63Dc9zghOAJnrK","passwordUpdatedAt":1700985871012,"providerUserInfo":[{"providerId":"password","email":"test@bc73.de","federatedId":"test@bc73.de","rawId":"test@bc73.de","displayName":"","photoUrl":""}],"validSince":"1700985871","email":"test@bc73.de","emailVerified":false,"disabled":false,"displayName":"","photoUrl":"","customAttributes":"{\"clubId\": \"y3zJIOZ3hRGB2DSHMMBS\", \"admin\": true}","lastRefreshAt":"2023-11-26T11:24:18.928Z"}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"F5ohKMSIdirz6H4uYu15H","customAuth":true,"lastLoginAt":"1703042814100","createdAt":"1703042814100","lastRefreshAt":"2023-12-20T04:24:02.926Z"},{"localId":"MZTpmPJIADM8EBNDTrlALtiNtzCt","createdAt":"1679951184357","lastLoginAt":"1703042412435","displayName":"","photoUrl":"","passwordHash":"fakeHash:salt=fakeSaltlRxnLx63Dc9zghOAJnrK:password=123456","salt":"fakeSaltlRxnLx63Dc9zghOAJnrK","passwordUpdatedAt":1703041879881,"customAttributes":"{\"clubId\": \"y3zJIOZ3hRGB2DSHMMBS\", \"admin\": true}","providerUserInfo":[{"providerId":"password","email":"test@bc73.de","federatedId":"test@bc73.de","rawId":"test@bc73.de","displayName":"","photoUrl":""}],"validSince":"1703041879","email":"test@bc73.de","emailVerified":false,"disabled":false,"lastRefreshAt":"2023-12-20T03:20:12.435Z"}]} \ No newline at end of file diff --git a/firestore/emulator_data/firebase-export-metadata.json b/firestore/emulator_data/firebase-export-metadata.json index b8801cd..dc475fd 100644 --- a/firestore/emulator_data/firebase-export-metadata.json +++ b/firestore/emulator_data/firebase-export-metadata.json @@ -1,12 +1,12 @@ { - "version": "12.8.1", + "version": "13.0.2", "firestore": { "version": "1.18.2", "path": "firestore_export", "metadata_file": "firestore_export/firestore_export.overall_export_metadata" }, "auth": { - "version": "12.8.1", + "version": "13.0.2", "path": "auth_export" } } \ No newline at end of file diff --git a/firestore/emulator_data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/firestore/emulator_data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 71c582b563307c1dcacac813bb89dbcdd8dced47..89705714da30a2088dc43efa03324dee996953fc 100644 GIT binary patch delta 36 ocmXppnII}ZYx?9j3+6U6OMIOPVF>YXF$i(wmzETimgpJ)02VF{*Z=?k delta 36 ncmXppnIJ0PaN*LzGY^}XCGK2;Fobxx7=$?TOG^q$OLPqYErt%= diff --git a/firestore/emulator_data/firestore_export/all_namespaces/all_kinds/output-0 b/firestore/emulator_data/firestore_export/all_namespaces/all_kinds/output-0 index e8707027fa00b79869e4ecf1b9a5fe9ee06cbd73..a8d5d671b4621bca205ab6cb195ec510d3e1e61b 100644 GIT binary patch literal 625 zcmZSGd_b&`As{U^EiE-KBeAqNHLr=0kP(*2 z$t)*;4)DP003!kp;1*&lNleN~EmmR=$t+IJP%z}Fa*$$4hq%cs8JnTSRjeTS;x0y! z?1+${NDG%Ri+p3Jobq7vfHXIYl9Vb54q++QlKkw{JfO$8r8u)#vKWdOnizqIkay~Q z?g&aT;tNl(-AY)k2D=>;-au<{x*Z%JK(}Ly5SZJGOA<>!k%H{@s!*VxKw$+G_reu0 hcnvSEf(CXMBP_Im7V=230#;>XCqXspnC^Xo(&e->tik1POIss}m% delta 23 ecma!#=h>0#;>XCqXza1&O#MV2e- Date: Wed, 20 Dec 2023 15:19:06 +0700 Subject: [PATCH 4/9] Fix create game function --- functions/src/createGame.test.ts | 2 +- functions/src/createGame.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/functions/src/createGame.test.ts b/functions/src/createGame.test.ts index 4eb763b..92d7306 100644 --- a/functions/src/createGame.test.ts +++ b/functions/src/createGame.test.ts @@ -42,7 +42,7 @@ it("should create a game if a table is added", async function() { const gameData = (await gameRef.get()).data(); expect(gameData?.club.path).toBe(clubPath); expect(gameData?.table.path).toBe(tablePath); - expect(gameData?.type).toBe("8_BALL"); + expect(gameData?.mode).toBeUndefined(); expect(gameData?.state).toStrictEqual({}); }); diff --git a/functions/src/createGame.ts b/functions/src/createGame.ts index f9842c7..a15f1e7 100644 --- a/functions/src/createGame.ts +++ b/functions/src/createGame.ts @@ -22,7 +22,7 @@ export const createGame = onDocumentCreated( batch.set(gameRef, { table: tableRef, club: clubRef, - type: "8_BALL", + mode: undefined, state: {}, }); batch.set(snapshot.ref, {game: gameRef}, {merge: true}); From dd7fd233bd5f850dd6473f7376aaf8dd3b80900a Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Wed, 20 Dec 2023 15:19:38 +0700 Subject: [PATCH 5/9] Provide table id from claims --- client/src/store/AuthProvider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/store/AuthProvider.tsx b/client/src/store/AuthProvider.tsx index a680c8e..21ff899 100644 --- a/client/src/store/AuthProvider.tsx +++ b/client/src/store/AuthProvider.tsx @@ -14,6 +14,7 @@ interface AuthState { userId?: string; userIdLoading: boolean; clubId?: string; + tableId?: string; admin?: boolean; signUp: (email: string, password: string) => void; signUpError?: AuthError; @@ -66,6 +67,7 @@ export default function AuthProvider({ children }: AuthProviderProps) { userIdLoading: userLoading, clubId: tokenResult?.claims.clubId, admin: tokenResult?.claims.admin, + tableId: tokenResult?.claims.tableId, signUp, signUpError, signIn, From 80eac8645cdc298192a4be5eadd1c74277a8cb26 Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Thu, 21 Dec 2023 09:36:19 +0700 Subject: [PATCH 6/9] Sync Game State --- client/src/App.tsx | 9 +- client/src/Router.tsx | 2 - .../{GameBar.tsx => GameFooter.tsx} | 5 +- client/src/components/GameLayout.tsx | 2 +- client/src/components/GameToolbar.tsx | 42 ++++ client/src/store/Firebase.ts | 4 +- client/src/store/GameModes.ts | 31 +++ client/src/store/GameProvider.tsx | 87 +++++++++ client/src/store/GameState.ts | 5 +- client/src/store/GameState14.ts | 4 +- client/src/store/Store.tsx | 4 + client/src/views/Game.tsx | 180 ++++++------------ client/src/views/Game14.tsx | 82 ++------ client/src/views/Game8.tsx | 80 ++++++++ firestore/firestore.rules | 14 +- 15 files changed, 336 insertions(+), 215 deletions(-) rename client/src/components/{GameBar.tsx => GameFooter.tsx} (86%) create mode 100644 client/src/components/GameToolbar.tsx create mode 100644 client/src/store/GameModes.ts create mode 100644 client/src/store/GameProvider.tsx create mode 100644 client/src/views/Game8.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index e453552..a5555d2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import Router from './Router'; import AuthProvider from './store/AuthProvider'; import ClubProvider from './store/ClubProvider'; import TableProvider from './store/TableProvider'; +import GameProvider from './store/GameProvider'; import { Store } from './store/Store'; const darkTheme = createTheme({ @@ -21,9 +22,11 @@ export default function App() { - - - + + + + + diff --git a/client/src/Router.tsx b/client/src/Router.tsx index 573515e..eb0f38c 100644 --- a/client/src/Router.tsx +++ b/client/src/Router.tsx @@ -4,7 +4,6 @@ import { import ErrorPage from './views/ErrorPage'; import Game from './views/Game'; -import Game14 from './views/Game14'; import Tables from './views/Tables'; import Matchdays from './views/Matchdays'; import Matchday from './views/Matchday'; @@ -21,7 +20,6 @@ export default function Router() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/client/src/components/GameBar.tsx b/client/src/components/GameFooter.tsx similarity index 86% rename from client/src/components/GameBar.tsx rename to client/src/components/GameFooter.tsx index 3ced1cf..82de2b7 100644 --- a/client/src/components/GameBar.tsx +++ b/client/src/components/GameFooter.tsx @@ -2,14 +2,13 @@ import { Home } from '@mui/icons-material'; import { AppBar, Toolbar, IconButton, Stack, } from '@mui/material'; -import { memo } from 'react'; import { Link } from 'react-router-dom'; interface HeaderProps { children: React.ReactNode; } -function Header({ children }: HeaderProps) { +export default function GameFooter({ children }: HeaderProps) { return ( @@ -28,5 +27,3 @@ function Header({ children }: HeaderProps) { ); } - -export default memo(Header); diff --git a/client/src/components/GameLayout.tsx b/client/src/components/GameLayout.tsx index 09cd846..1f7ab0d 100644 --- a/client/src/components/GameLayout.tsx +++ b/client/src/components/GameLayout.tsx @@ -1,7 +1,7 @@ import { Stack, Container } from '@mui/material'; import RequireDesktop from './RequireDesktop'; -import GameBar from './GameBar'; +import GameBar from './GameFooter'; interface LayoutProps { children: React.ReactNode; diff --git a/client/src/components/GameToolbar.tsx b/client/src/components/GameToolbar.tsx new file mode 100644 index 0000000..b7dd71f --- /dev/null +++ b/client/src/components/GameToolbar.tsx @@ -0,0 +1,42 @@ +import { + SportsEsports, PlayCircleFilled, Undo, +} from '@mui/icons-material'; +import { Button } from '@mui/material'; +import { Mode } from '../store/GameModes'; + +interface GameToolbarProps { + mode: Mode, + onRestart: () => void; + onChangeMode: () => void; + onRollback: () => void; +} + +export default function GameToolbar({ + mode, onRestart, onChangeMode, onRollback, +}:GameToolbarProps) { + return ( + <> + + + + + ); +} diff --git a/client/src/store/Firebase.ts b/client/src/store/Firebase.ts index 8a21bcb..8bd8222 100644 --- a/client/src/store/Firebase.ts +++ b/client/src/store/Firebase.ts @@ -1,6 +1,6 @@ import { initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth } from 'firebase/auth'; -import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore'; +import { initializeFirestore, connectFirestoreEmulator } from 'firebase/firestore'; import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; import isDevelopment from '../util/environment'; @@ -18,7 +18,7 @@ const app = initializeApp(firebaseConfig); const auth = getAuth(app); if (isDevelopment()) { connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); } -const db = getFirestore(app); +const db = initializeFirestore(app, { ignoreUndefinedProperties: true }); if (isDevelopment()) { connectFirestoreEmulator(db, 'localhost', 8080); } const functions = getFunctions(app); diff --git a/client/src/store/GameModes.ts b/client/src/store/GameModes.ts new file mode 100644 index 0000000..1832a06 --- /dev/null +++ b/client/src/store/GameModes.ts @@ -0,0 +1,31 @@ +import { + State as State8, Action as Action8, initialState as initial8State, reducer as reducer8, +} from './GameState'; +import { + State as State14, Action as Action14, initialState as initial14state, reducer as reducer14, +} from './GameState14'; + +export enum Mode { + Ball8 = 'BALL8', + Straight = 'STRAIGHT', +} + +export type State = State8 | State14; + +export type Action = Action8 | Action14; + +export const getInitialState = (mode: Mode) => { + switch (mode) { + case Mode.Ball8: return initial8State; + case Mode.Straight: return initial14state; + default: throw Error(`Unkown Mode '${mode}'`); + } +}; + +export const reducer = (mode: Mode, state: State, action: Action) => { + switch (mode) { + case Mode.Ball8: return reducer8(state as State8, action as Action8); + case Mode.Straight: return reducer14(state as State14, action as Action14); + default: throw Error(`Unkown Mode '${mode}'`); + } +}; diff --git a/client/src/store/GameProvider.tsx b/client/src/store/GameProvider.tsx new file mode 100644 index 0000000..8cf6654 --- /dev/null +++ b/client/src/store/GameProvider.tsx @@ -0,0 +1,87 @@ +import { + createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState, +} from 'react'; +import { useCollectionData } from 'react-firebase-hooks/firestore'; +import { + collection, doc, query, where, updateDoc, limit, +} from 'firebase/firestore'; +import { useAuth } from './AuthProvider'; +import { db } from './Firebase'; +import { + Mode, State, Action, getInitialState, reducer, +} from './GameModes'; + +interface GameState { + mode?: Mode; + state: State; + startNewGame: (mode: Mode) => void; + updateState: (action: Action) => void; +} + +const GameContext = createContext(undefined); + +export function useGame() { + const value = useContext(GameContext); + if (value === undefined) throw new Error('Expected games context value to be set.'); + return value; +} + +interface GameProviderProps { + children: ReactNode; +} +export default function GameProvider({ children }: GameProviderProps) { + const { clubId, tableId } = useAuth(); + const [mode, setMode] = useState(Mode.Ball8); + const [state, setState] = useState(getInitialState(mode)); + + const clubRef = clubId ? doc(db, `clubs/${clubId}`) : null; + const tableRef = clubId && tableId ? doc(db, `clubs/${clubId}/tables/${tableId}`) : null; + const q = clubRef && tableRef + ? query(collection(db, 'games'), where('club', '==', clubRef), where('table', '==', tableRef), limit(1)) + : null; + const [gameData, gameDataLoading, gameDataError, snapshot] = useCollectionData(q); + const gameRef = snapshot ? snapshot.docs[0].ref : null; + useEffect(() => { if (gameDataError) throw gameDataError; }, [gameDataError]); + + // Sync behaviour: + // - no online state available + // - just use local state + // - start new game + // - online state available + // - local changes -> push to firestore + // - changes in firestore (e.g. from other client) -> apply to local + const onlineSync = gameRef && !gameDataError && !gameDataLoading + && gameData?.at(0) && gameData.at(0)?.mode; + useEffect(() => { + if (onlineSync) { + setMode(gameData?.at(0)?.mode); + setState(gameData?.at(0)?.state); + } + }, [gameData, onlineSync]); + + const startNewGame = useCallback(async (newMode: Mode) => { + setMode(newMode); + setState(getInitialState(newMode)); + if (onlineSync) { + await updateDoc(gameRef, { mode: newMode, state: getInitialState(newMode) }); + } + }, [gameRef, onlineSync]); + + const updateState = useCallback(async (action: Action) => { + const newState = reducer(mode, state, action); + setState(newState); + if (onlineSync) { + await updateDoc(gameRef, { state: newState }); + } + }, [mode, state, setState, gameRef, onlineSync]); + + const value = useMemo(() => ({ + mode, state, startNewGame, updateState, + }), [mode, state, startNewGame, updateState]); + + return ( + + {children} + + ); +} diff --git a/client/src/store/GameState.ts b/client/src/store/GameState.ts index 5a3416b..4852675 100644 --- a/client/src/store/GameState.ts +++ b/client/src/store/GameState.ts @@ -1,4 +1,4 @@ -interface State { +export interface State { actions: Action[]; scoreHome: number; scoreGuest: number; @@ -9,7 +9,7 @@ const initialState = { scoreGuest: 0, }; -interface Action { +export interface Action { type: 'HOME_PLUS_ONE' | 'HOME_MINUS_ONE' | 'GUEST_PLUS_ONE' @@ -18,7 +18,6 @@ interface Action { | 'RESET'; } -// eslint-disable-next-line consistent-return function reducer(state: State, action: Action) : State { const actions = [...state.actions, action]; diff --git a/client/src/store/GameState14.ts b/client/src/store/GameState14.ts index 6a106c8..b6ced59 100644 --- a/client/src/store/GameState14.ts +++ b/client/src/store/GameState14.ts @@ -25,7 +25,7 @@ const initialPlayerState = { fouls: 0, }; -interface State { +export interface State { actions: Action[]; activePlayer: undefined | 'home' | 'guest'; remainingBalls: number; @@ -66,7 +66,7 @@ interface ResetAction { type: 'RESET'; } -type Action = SetActivePlayerAction +export type Action = SetActivePlayerAction | SetRemainingBallsAction | BreakFoulAction | FoulAction diff --git a/client/src/store/Store.tsx b/client/src/store/Store.tsx index 7bebc58..c4c42bb 100644 --- a/client/src/store/Store.tsx +++ b/client/src/store/Store.tsx @@ -1,3 +1,7 @@ +/** + * @module @deprecated This component should be removed + */ + import React, { createContext, Dispatch, useReducer, useMemo, useCallback, } from 'react'; diff --git a/client/src/views/Game.tsx b/client/src/views/Game.tsx index f9d8c2b..d7c7873 100644 --- a/client/src/views/Game.tsx +++ b/client/src/views/Game.tsx @@ -1,137 +1,71 @@ -import { useReducer, useState } from 'react'; -import { - Button, Grid, Stack, Typography, -} from '@mui/material'; -import { - Add, PlayCircleFilled, Remove, SportsEsports, Undo, -} from '@mui/icons-material'; - -import { Link } from 'react-router-dom'; -import { reducer, initialState } from '../store/GameState'; +import { useEffect, useState } from 'react'; +import { useGame } from '../store/GameProvider'; +import { Mode } from '../store/GameModes'; +import Game14 from './Game14'; +import Game8 from './Game8'; +import GameToolbar from '../components/GameToolbar'; import Layout from '../components/GameLayout'; -import useCallbackPrompt from '../util/useCallbackPrompt'; +import { State as State8 } from '../store/GameState'; +import { State as State14 } from '../store/GameState14'; import AlertDialog from '../components/AlertDialog'; -const scoreSx = { - fontSize: '60vh', - fontWeight: 400, - lineHeight: 0.6, - userSelect: 'none', -}; +export default function Game() { + const { + mode, state, startNewGame, updateState, + } = useGame(); -interface ButtonProps { - onClick: () => void; -} -function AddButton({ onClick }: ButtonProps) { - return ( - - ); -} -function RemoveButton({ onClick }: ButtonProps) { - return ( - - ); -} + const [showPrompt, setShowPrompt] = useState(false); + const [nextMode, setNextMode] = useState(Mode.Ball8); -function Game() { - const [state, dispatch] = useReducer(reducer, initialState); - const [showReset, setShowReset] = useState(false); - const isBlocked = state.actions.length !== 0; - const [showPrompt, confirmNavigation, cancelNavigation] = useCallbackPrompt(isBlocked); + useEffect(() => { + if (mode == null) { + startNewGame(Mode.Ball8); + } + }); - const homePlusOne = () => { dispatch?.({ type: 'HOME_PLUS_ONE' }); }; - const homeMinusOne = () => { dispatch?.({ type: 'HOME_MINUS_ONE' }); }; - const guestPlusOne = () => { dispatch?.({ type: 'GUEST_PLUS_ONE' }); }; - const guestMinusOne = () => { dispatch?.({ type: 'GUEST_MINUS_ONE' }); }; + const toolbar = mode != null ? ( + { + setNextMode(mode); + setShowPrompt(true); + }} + onChangeMode={() => { + setNextMode(mode === Mode.Ball8 ? Mode.Straight : Mode.Ball8); + setShowPrompt(true); + }} + onRollback={() => { + updateState({ type: 'ROLLBACK' }); + }} + /> + ) : null; - const toolbar = ( - <> - - - - + const dialog = ( + { + setShowPrompt(false); + }} + acceptText="Ok" + onAccept={() => { + startNewGame(nextMode); + setShowPrompt(false); + }} + /> ); + const game = mode === Mode.Ball8 + ? ( + + ) : ; + return ( - - - - Heim - - - - Gast - - - - - - {state.scoreHome} - - - - : - - - - {state.scoreGuest} - - - - - - - - - - - - - - - - { - if (showPrompt) (cancelNavigation as (() => void))(); - else setShowReset(false); - }} - acceptText="Ok" - onAccept={() => { - dispatch?.({ type: 'RESET' }); - if (showPrompt) (confirmNavigation as (() => void))(); - else setShowReset(false); - }} - /> + {game} + {dialog} ); } - -export default Game; diff --git a/client/src/views/Game14.tsx b/client/src/views/Game14.tsx index fb0d6b0..b2a1725 100644 --- a/client/src/views/Game14.tsx +++ b/client/src/views/Game14.tsx @@ -1,18 +1,10 @@ -import { useReducer, useState } from 'react'; import { Button, Grid, Stack, Typography, useTheme, } from '@mui/material'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; import { - ChevronLeft, ChevronRight, PlayCircleFilled, SportsEsports, Undo, -} from '@mui/icons-material'; - -import { Link } from 'react-router-dom'; -import { - reducer, initialState, BALLS, PlayerState, isBreakFoulPossible, isFoulPossible, + BALLS, PlayerState, isBreakFoulPossible, isFoulPossible, State, Action, } from '../store/GameState14'; -import Layout from '../components/GameLayout'; -import useCallbackPrompt from '../util/useCallbackPrompt'; -import AlertDialog from '../components/AlertDialog'; import BorderBox from '../components/BorderBox'; import Balls from '../assets/Balls'; @@ -192,39 +184,11 @@ function PlayerStatistics({ state }: PlayerStatisticsProps) { ); } -export default function Game() { - const [state, dispatch] = useReducer(reducer, initialState); - const [showReset, setShowReset] = useState(false); - const isBlocked = state.actions.length !== 0; - const [showPrompt, confirmNavigation, cancelNavigation] = useCallbackPrompt(isBlocked); - - const toolbar = ( - <> - - - - - ); - +interface Game14Props { + state: State, + dispatch: (action: Action) => void, +} +export default function Game14({ state, dispatch }:Game14Props) { const startingPlayerSelect = ( @@ -354,31 +318,13 @@ export default function Game() { ); return ( - - - - - {state.activePlayer === undefined ? startingPlayerSelect : buttons} - - { - if (showPrompt) (cancelNavigation as (() => void))(); - else setShowReset(false); - }} - acceptText="Ok" - onAccept={() => { - dispatch?.({ type: 'RESET' }); - if (showPrompt) (confirmNavigation as (() => void))(); - else setShowReset(false); - }} + + + - + {state.activePlayer === undefined ? startingPlayerSelect : buttons} + ); } diff --git a/client/src/views/Game8.tsx b/client/src/views/Game8.tsx new file mode 100644 index 0000000..1385240 --- /dev/null +++ b/client/src/views/Game8.tsx @@ -0,0 +1,80 @@ +import { + Button, Grid, Stack, Typography, +} from '@mui/material'; +import { Add, Remove } from '@mui/icons-material'; +import { State, Action } from '../store/GameState'; + +const scoreSx = { + fontSize: '60vh', + fontWeight: 400, + lineHeight: 0.6, + userSelect: 'none', +}; + +interface ButtonProps { + onClick: () => void; +} +function AddButton({ onClick }: ButtonProps) { + return ( + + ); +} +function RemoveButton({ onClick }: ButtonProps) { + return ( + + ); +} +interface Game8Props { + state: State, + dispatch: (action: Action) => void, +} +export default function Game8({ state, dispatch } : Game8Props) { + const homePlusOne = () => { dispatch({ type: 'HOME_PLUS_ONE' }); }; + const homeMinusOne = () => { dispatch({ type: 'HOME_MINUS_ONE' }); }; + const guestPlusOne = () => { dispatch({ type: 'GUEST_PLUS_ONE' }); }; + const guestMinusOne = () => { dispatch({ type: 'GUEST_MINUS_ONE' }); }; + + return ( + + + + Heim + + + + Gast + + + + + + {state.scoreHome} + + + + : + + + + {state.scoreGuest} + + + + + + + + + + + + + + + + ); +} diff --git a/firestore/firestore.rules b/firestore/firestore.rules index 4de9f60..a1c2322 100644 --- a/firestore/firestore.rules +++ b/firestore/firestore.rules @@ -13,6 +13,10 @@ service cloud.firestore { return isClubMember(clubId) && request.auth.token.admin == true; } + function isTableAdmin(clubId, tableId) { + return isClubMember(clubId) && request.auth.token.tableId == tableId; + } + match /clubs/{clubId} { allow read: if isClubMember(clubId); allow update: if isClubAdmin(clubId) && @@ -26,9 +30,6 @@ service cloud.firestore { } match /games/{gameId} { - function isTableAdmin(clubId, tableId) { - return isClubMember(clubId) && request.auth.token.tableId == tableId; - } function canReadGame() { let clubId = get(resource.data.club).id; let tableId = get(resource.data.table).id; @@ -39,13 +40,12 @@ service cloud.firestore { let tableId = get(resource.data.table).id; return isTableAdmin(clubId, tableId); } - function isValidAction() { - //TODO: validate actions incrementally - return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['state', 'type']); + function modifiesStateOrMode() { + return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['state', 'mode']); } allow read: if canReadGame(); - allow update: if canWriteGame() && isValidAction(); + allow update: if canWriteGame() && modifiesStateOrMode(); } } } \ No newline at end of file From 401a180756dad4b9f5fa1f999d4a90b3cf812864 Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Thu, 21 Dec 2023 11:22:10 +0700 Subject: [PATCH 7/9] Handle login error when offline --- client/src/util/Validators.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/src/util/Validators.ts b/client/src/util/Validators.ts index e627348..8d20207 100644 --- a/client/src/util/Validators.ts +++ b/client/src/util/Validators.ts @@ -36,6 +36,8 @@ export function AuthValidator(error?: AuthError): Validator { return 'Die E-Mail-Adresse ist ungültig.'; case 'auth/invalid-password': return 'Das Passwort muss mindestens 6 Zeichen lang sein.'; + case 'auth/internal-error': + return 'Du scheinst offline zu sein. Bitte prüfe deine Internetverbindung.'; case 'auth/user-not-found': return 'Es ist kein Nutzer mit dieser E-Mail-Adresse registriert.'; case 'auth/wrong-password': From dd12f31ee827e22a69958eeef7ddd880193960e6 Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Thu, 21 Dec 2023 11:22:34 +0700 Subject: [PATCH 8/9] Enable offline persistency --- client/src/store/Firebase.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/store/Firebase.ts b/client/src/store/Firebase.ts index 8bd8222..5a80b73 100644 --- a/client/src/store/Firebase.ts +++ b/client/src/store/Firebase.ts @@ -1,6 +1,6 @@ import { initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth } from 'firebase/auth'; -import { initializeFirestore, connectFirestoreEmulator } from 'firebase/firestore'; +import { initializeFirestore, connectFirestoreEmulator, enableMultiTabIndexedDbPersistence } from 'firebase/firestore'; import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; import isDevelopment from '../util/environment'; @@ -20,6 +20,7 @@ if (isDevelopment()) { connectAuthEmulator(auth, 'http://localhost:9099', { disa const db = initializeFirestore(app, { ignoreUndefinedProperties: true }); if (isDevelopment()) { connectFirestoreEmulator(db, 'localhost', 8080); } +enableMultiTabIndexedDbPersistence(db); const functions = getFunctions(app); if (isDevelopment()) { connectFunctionsEmulator(functions, 'localhost', 5001); } From ba8d291b05ae526b99d7c2b4a22f38f66bb603f7 Mon Sep 17 00:00:00 2001 From: Stefan Butz Date: Thu, 21 Dec 2023 11:26:35 +0700 Subject: [PATCH 9/9] Fix tests --- client/src/store/Firebase.ts | 10 ++++---- client/src/util/environment.ts | 5 +++- firestore/package-lock.json | 38 --------------------------- firestore/package.json | 3 +-- firestore/test/club.test.ts | 47 ++++++++++++++++++++++------------ firestore/test/game.test.ts | 12 ++++----- firestore/test/run.sh | 32 ----------------------- functions/src/firebase.ts | 1 + 8 files changed, 48 insertions(+), 100 deletions(-) delete mode 100755 firestore/test/run.sh diff --git a/client/src/store/Firebase.ts b/client/src/store/Firebase.ts index 5a80b73..c55f49f 100644 --- a/client/src/store/Firebase.ts +++ b/client/src/store/Firebase.ts @@ -2,7 +2,7 @@ import { initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth } from 'firebase/auth'; import { initializeFirestore, connectFirestoreEmulator, enableMultiTabIndexedDbPersistence } from 'firebase/firestore'; import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; -import isDevelopment from '../util/environment'; +import { isDevelopmentEnv, isTestEnv } from '../util/environment'; const firebaseConfig = { apiKey: 'AIzaSyD4jUVmL7dUKi7l6fQqmxHqzVZR_sidLG8', @@ -16,13 +16,13 @@ const firebaseConfig = { const app = initializeApp(firebaseConfig); const auth = getAuth(app); -if (isDevelopment()) { connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); } +if (isDevelopmentEnv()) { connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); } const db = initializeFirestore(app, { ignoreUndefinedProperties: true }); -if (isDevelopment()) { connectFirestoreEmulator(db, 'localhost', 8080); } -enableMultiTabIndexedDbPersistence(db); +if (isDevelopmentEnv()) { connectFirestoreEmulator(db, 'localhost', 8080); } +if (!isTestEnv()) { enableMultiTabIndexedDbPersistence(db); } const functions = getFunctions(app); -if (isDevelopment()) { connectFunctionsEmulator(functions, 'localhost', 5001); } +if (isDevelopmentEnv()) { connectFunctionsEmulator(functions, 'localhost', 5001); } export { auth, db, functions }; diff --git a/client/src/util/environment.ts b/client/src/util/environment.ts index c1e387a..119a47b 100644 --- a/client/src/util/environment.ts +++ b/client/src/util/environment.ts @@ -1,3 +1,6 @@ -export default function isDevelopment() { +export function isDevelopmentEnv() { return process.env.NODE_ENV === 'development'; } +export function isTestEnv() { + return process.env.NODE_ENV === 'test'; +} diff --git a/firestore/package-lock.json b/firestore/package-lock.json index d10b0ee..ad064ed 100644 --- a/firestore/package-lock.json +++ b/firestore/package-lock.json @@ -10,7 +10,6 @@ "license": "ISC", "devDependencies": { "@firebase/rules-unit-testing": "^2.0.7", - "@simpleclub/firebase-rules-coverage": "^1.0.0", "@types/jest": "^29.5.0", "eslint": "^8.36.0", "eslint-config-airbnb": "^19.0.4", @@ -2857,31 +2856,6 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "dev": true }, - "node_modules/@simpleclub/firebase-rules-coverage": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@simpleclub/firebase-rules-coverage/-/firebase-rules-coverage-1.0.0.tgz", - "integrity": "sha512-nkjNkY9odBd1OSlSUQqgfADCGPDBlr+IE7g69us4c6q729CYe94vANVaIuZGVo/8SQ/yyxDBla7Y/XXXlX+AcQ==", - "dev": true, - "dependencies": { - "meow": "^12.0.1", - "source-map": "^0.7.3" - }, - "bin": { - "firebase-rules-coverage": "src/cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@simpleclub/firebase-rules-coverage/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -11018,18 +10992,6 @@ "node": ">= 0.6" } }, - "node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", diff --git a/firestore/package.json b/firestore/package.json index e71040a..2521d08 100644 --- a/firestore/package.json +++ b/firestore/package.json @@ -7,13 +7,12 @@ "license": "ISC", "scripts": { "deploy": "npx firebase-tools deploy --only firestore:rules", - "test": "npx firebase-tools emulators:exec --only firestore test/run.sh", + "test": "npx firebase-tools emulators:exec --only firestore 'jest -i'", "export": "npx firebase-tools emulators:export ./emulator_data", "lint": "eslint test/" }, "devDependencies": { "@firebase/rules-unit-testing": "^2.0.7", - "@simpleclub/firebase-rules-coverage": "^1.0.0", "@types/jest": "^29.5.0", "eslint": "^8.36.0", "eslint-config-airbnb": "^19.0.4", diff --git a/firestore/test/club.test.ts b/firestore/test/club.test.ts index cda07e1..c1ea0a6 100644 --- a/firestore/test/club.test.ts +++ b/firestore/test/club.test.ts @@ -25,12 +25,23 @@ beforeAll(async () => { }); }); -beforeEach(async () => { +const createClub = async () => { + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, clubPath), { name: clubName }); + }); +}; + +const createTable = async () => { await testEnv.withSecurityRulesDisabled(async (context) => { const db = context.firestore(); await setDoc(doc(db, clubPath), { name: clubName }); await setDoc(doc(db, tablePath), { foo: 'bar' }); }); +}; + +beforeEach(async () => { + await createClub(); }); afterEach(async () => { @@ -42,7 +53,7 @@ afterAll(async () => { }); it('should forbid access to club for non club members', async () => { - const aliceDb = testEnv.authenticatedContext('alice').firestore(); + const aliceDb = testEnv.authenticatedContext('bob').firestore(); await assertFails(getDoc(doc(aliceDb, clubPath))); const unauthenticatedDb = testEnv.unauthenticatedContext().firestore(); @@ -63,7 +74,7 @@ it('should forbid arbitray updates on club', async () => { const adminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); await assertFails(setDoc(doc(adminDb, clubPath), { foo: 'bar' })); - const nonAdminDb = testEnv.authenticatedContext('alice', { clubId }).firestore(); + const nonAdminDb = testEnv.authenticatedContext('bob').firestore(); await assertFails(setDoc(doc(nonAdminDb, clubPath), { foo: 'bar' })); }); @@ -71,38 +82,42 @@ it('should allow update club name for admins', async () => { const adminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); await assertSucceeds(setDoc(doc(adminDb, clubPath), { name: 'foo' })); - const nonAdminDb = testEnv.authenticatedContext('alice', { clubId }).firestore(); + const nonAdminDb = testEnv.authenticatedContext('bob').firestore(); await assertFails(setDoc(doc(nonAdminDb, clubPath), { name: 'foo' })); }); it('should allow read tables for admins ', async () => { + await createTable(); const adminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); await assertSucceeds(getDoc(doc(adminDb, tablePath))); - const nonAdminDb = testEnv.authenticatedContext('alice', { clubId }).firestore(); - assertFails(getDoc(doc(nonAdminDb, tablePath))); + const nonAdminDb = testEnv.authenticatedContext('bob').firestore(); + await assertFails(getDoc(doc(nonAdminDb, tablePath))); }); it('should allow create tables for admins ', async () => { const adminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); - assertSucceeds(setDoc(doc(adminDb, tablePath), {})); + await assertSucceeds(setDoc(doc(adminDb, tablePath), {})); - const nonAdminDb = testEnv.authenticatedContext('alice', { clubId }).firestore(); - assertFails(setDoc(doc(nonAdminDb, tablePath), {})); + const nonAdminDb = testEnv.authenticatedContext('bob').firestore(); + await assertFails(setDoc(doc(nonAdminDb, tablePath), {})); }); it('should allow delete tables for admins', async () => { - const nonAdminDb = testEnv.authenticatedContext('alice', { clubId }).firestore(); - assertFails(deleteDoc(doc(nonAdminDb, tablePath))); - + await createTable(); const adminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); - assertSucceeds(deleteDoc(doc(adminDb, tablePath))); + await assertSucceeds(deleteDoc(doc(adminDb, tablePath))); + + await createTable(); + const nonAdminDb = testEnv.authenticatedContext('bob').firestore(); + await assertFails(deleteDoc(doc(nonAdminDb, tablePath))); }); it('should forbid write tables for admins', async () => { + await createTable(); const adminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); - assertFails(setDoc(doc(adminDb, tablePath), { foo: 'bar' })); + await assertFails(setDoc(doc(adminDb, tablePath), { foo: 'bar' })); - const nonAdminDb = testEnv.authenticatedContext('alice', { clubId }).firestore(); - assertFails(setDoc(doc(nonAdminDb, tablePath), { foo: 'bar' })); + const nonAdminDb = testEnv.authenticatedContext('bob').firestore(); + await assertFails(setDoc(doc(nonAdminDb, tablePath), { foo: 'bar' })); }); diff --git a/firestore/test/game.test.ts b/firestore/test/game.test.ts index 7751f44..b6b93f1 100644 --- a/firestore/test/game.test.ts +++ b/firestore/test/game.test.ts @@ -3,7 +3,7 @@ import { assertFails, assertSucceeds, initializeTestEnvironment, RulesTestEnvironment, } from '@firebase/rules-unit-testing'; import { - setLogLevel, setDoc, doc, getDoc, deleteDoc, updateDoc, + setLogLevel, setDoc, doc, getDoc, updateDoc, } from 'firebase/firestore'; let testEnv: RulesTestEnvironment; @@ -60,17 +60,17 @@ it('should allow read access to game for club and table admins', async () => { await assertFails(getDoc(doc(unauthenticatedDb, gamePath))); }); -it('should allow write access to state and type for table admins', async () => { +it('should allow write access to state and mode for table admins', async () => { const clubAdminDb = testEnv.authenticatedContext('alice', { clubId, admin: true }).firestore(); - await assertFails(updateDoc(doc(clubAdminDb, gamePath), { type: '8' })); + await assertFails(updateDoc(doc(clubAdminDb, gamePath), { mode: '8' })); const tableAdminDb = testEnv.authenticatedContext('alice', { clubId, tableId }).firestore(); - await assertSucceeds(updateDoc(doc(tableAdminDb, gamePath), { type: '8', state: {} })); + await assertSucceeds(updateDoc(doc(tableAdminDb, gamePath), { mode: '8', state: {} })); await assertFails(updateDoc(doc(tableAdminDb, gamePath), { table: 'foo' })); const aliceDb = testEnv.authenticatedContext('alice').firestore(); - await assertFails(updateDoc(doc(aliceDb, gamePath), { type: '8' })); + await assertFails(updateDoc(doc(aliceDb, gamePath), { mode: '8' })); const unauthenticatedDb = testEnv.unauthenticatedContext().firestore(); - await assertFails(updateDoc(doc(unauthenticatedDb, gamePath), { type: '8' })); + await assertFails(updateDoc(doc(unauthenticatedDb, gamePath), { foo: '8' })); }); diff --git a/firestore/test/run.sh b/firestore/test/run.sh deleted file mode 100755 index a8c12b0..0000000 --- a/firestore/test/run.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/sh - -error() { - echo $1 >&2 - exit 1 -} - -# Required variables. Automatically set when executed using firebase-tools emulators:exec -[ -n "${GCLOUD_PROJECT}" ] || error "GCLOUD_PROJECT not set" -[ -n "${FIREBASE_FIRESTORE_EMULATOR_ADDRESS}" ] || error "FIREBASE_FIRESTORE_EMULATOR_ADDRESS not set" - -# E.g. http://localhost:8080/emulator/v1/projects/poolscore-1973:ruleCoverage -COVERAGE_URL="http://${FIREBASE_FIRESTORE_EMULATOR_ADDRESS}/emulator/v1/projects/${GCLOUD_PROJECT}:ruleCoverage" -REPORT_DIR=`mktemp -d` -JSON_REPORT="${REPORT_DIR}/rule_coverage.json" -LCOV_REPORT="${REPORT_DIR}/lcov.info" -RULES_FILE=firestore.rules - - -# Execute Tests -npx jest --runInBand || error "Tests failed" - -# Fetch Coverage Report -curl $COVERAGE_URL > $JSON_REPORT - -# Convert to Lcov -npx firebase-rules-coverage $JSON_REPORT --rules-file $RULES_FILE --output $REPORT_DIR - -# Check coverage -npx lcov-total $LCOV_REPORT --gte=100 - -[ $? -eq 0 ] || error "Coverage <100%" \ No newline at end of file diff --git a/functions/src/firebase.ts b/functions/src/firebase.ts index da45ebd..cafc9db 100644 --- a/functions/src/firebase.ts +++ b/functions/src/firebase.ts @@ -14,3 +14,4 @@ const config = isDevelopment() ? { const app = initializeApp(config); export const auth = getAuth(app); export const db = getFirestore(app); +db.settings({ignoreUndefinedProperties: true});