diff --git a/.env.production b/.env.production index c9800a26..4bc8765d 100644 --- a/.env.production +++ b/.env.production @@ -2,4 +2,5 @@ # We have to include these at build time, so this file is used to inject them into the build process. NEXT_PUBLIC_API_URL=/api/ NEXT_PUBLIC_SLACK_CLIENT_ID=10831824934.7404945710466 -NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-1BFJYBDC76 \ No newline at end of file +NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-1BFJYBDC76 +NEXT_PUBLIC_RECAPTCHA_KEY=6Le63OUqAAAAABxxDrbaU9OywDLLHqutVwbw7a9d \ No newline at end of file diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 60323ed3..fee067e5 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -7,6 +7,8 @@ on: required: true AWS_SECRET_ACCESS_KEY: required: true + ROLLBAR_ACCESS_TOKEN: + required: true workflow_dispatch: permissions: @@ -22,7 +24,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Download artifact uses: actions/download-artifact@v4 with: @@ -47,4 +49,4 @@ jobs: run: aws configure set aws_access_key_id ${{ secrets.AWS_ACCESS_KEY_ID }} && aws configure set aws_secret_access_key ${{ secrets.AWS_SECRET_ACCESS_KEY }} && aws configure set region us-east-1 - name: Deploy to ECS - run: aws ecs update-service --cluster gearbox --service gearbox --force-new-deployment \ No newline at end of file + run: aws ecs update-service --cluster gearbox --service gearbox --force-new-deployment diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90f805e9..211da336 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,13 @@ name: CI -on: [workflow_call, workflow_dispatch, pull_request] +on: + workflow_call: + inputs: + deploy_id: + type: string + required: true + workflow_dispatch: + pull_request: jobs: build: @@ -17,7 +24,9 @@ jobs: with: tags: ghcr.io/decatur-robotics/gearbox:latest outputs: type=docker,dest=/tmp/gearbox.tar - + build-args: | + DEPLOY_ID=${{ inputs.deploy_id }} + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/notify_rollbar.yml b/.github/workflows/notify_rollbar.yml new file mode 100644 index 00000000..6ad51f01 --- /dev/null +++ b/.github/workflows/notify_rollbar.yml @@ -0,0 +1,35 @@ +name: Notify Rollbar + +on: + workflow_call: + secrets: + ROLLBAR_ACCESS_TOKEN: + required: true + outputs: + deploy_id: + value: ${{ jobs.notify.outputs.deploy_id }} + workflow_dispatch: + +permissions: + packages: write + +jobs: + notify: + runs-on: ubuntu-latest + environment: Production + outputs: + deploy_id: ${{ steps.rollbar_pre_deploy.outputs.deploy_id }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Notify deploy to Rollbar + uses: rollbar/github-deploy-action@2.1.2 + id: rollbar_pre_deploy + with: + environment: "production" + version: ${{ github.sha }} + status: "started" + env: + ROLLBAR_ACCESS_TOKEN: ${{ secrets.ROLLBAR_ACCESS_TOKEN }} + ROLLBAR_USERNAME: ${{ github.actor }} diff --git a/.github/workflows/onpush.yml b/.github/workflows/onpush.yml index af6b3f82..976dcde6 100644 --- a/.github/workflows/onpush.yml +++ b/.github/workflows/onpush.yml @@ -8,8 +8,16 @@ on: workflow_dispatch: jobs: + notify_rollbar: + uses: ./.github/workflows/notify_rollbar.yml + secrets: + ROLLBAR_ACCESS_TOKEN: ${{ secrets.ROLLBAR_ACCESS_TOKEN }} + ci: + needs: notify_rollbar uses: ./.github/workflows/ci.yml + with: + deploy_id: ${{ needs.notify_rollbar.outputs.deploy_id }} cd: needs: @@ -18,3 +26,4 @@ jobs: secrets: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + ROLLBAR_ACCESS_TOKEN: ${{ secrets.ROLLBAR_ACCESS_TOKEN }} diff --git a/Dockerfile b/Dockerfile index 6cbd2ca9..58d2037c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,5 +15,9 @@ RUN npm run build EXPOSE 80 +ARG DEPLOY_ID + +ENV DEPLOY_ID=${DEPLOY_ID} + # ENTRYPOINT [ "bash" ] # Uncomment to operate the terminal in the container CMD ["/usr/local/bin/npm", "run", "start"] \ No newline at end of file diff --git a/components/Container.tsx b/components/Container.tsx index a3ee606d..e3c29d1c 100644 --- a/components/Container.tsx +++ b/components/Container.tsx @@ -74,11 +74,6 @@ export default function Container(props: ContainerProps) { }, [eventSearch]); useEffect(() => { - if (window.location.href.includes("signin")) { - console.log("triggered"); - location.reload(); - } - const loadTeams = async () => { if (!user) { return; @@ -231,7 +226,7 @@ export default function Container(props: ContainerProps) { ) : ( @@ -268,7 +263,7 @@ export default function Container(props: ContainerProps) {

Wait a minute...

You need to sign in first!

- +
diff --git a/components/competition/EditMatchModal.tsx b/components/competition/EditMatchModal.tsx index 4845642f..d6cce8be 100644 --- a/components/competition/EditMatchModal.tsx +++ b/components/competition/EditMatchModal.tsx @@ -47,6 +47,31 @@ export default function EditMatchModal(props: { .then(loadMatches); } + function changeTeamNumber(e: ChangeEvent, index: number) { + e.preventDefault(); + + const teamNumber = +e.target.value; + if (!teamNumber || !props.match?._id) return; + + const reportId = props.match?.reports[index]; + if (!props.reportsById[reportId]._id) return; + + console.log( + `Changing team ${index} for match ${props.match?._id} to ${teamNumber}`, + ); + + api + .changeTeamNumberForReport( + match._id!.toString(), + props.reportsById[reportId]._id, + teamNumber, + ) + .then(() => { + loadMatches(); + loadReports(); + }); + } + return ( {index < 3 ? "Blue" : "Red"} {(index % 3) + 1} - {team} + + changeTeamNumber(e, index)} + type="number" + defaultValue={team} + /> + setNewName(e.target.value)} + defaultValue={newName} + className="input" + /> + ) : ( +

{user?.name}

+ )} + + (null); + const { executeRecaptcha } = useGoogleReCaptcha(); + + const [error, setError] = useState(router.query.error as string); + + useEffect(() => { + if (router.query.error) { + const error = (router.query.error as string).toLowerCase(); + const message = + (error in errorMessages ? errorMessages[error] : error) + + " Try clearing your cookies and then signing in again."; + + setError(message); + } + }, [router.query.error]); + + function signInWithCallbackUrl(provider: string, options?: object) { + const callbackUrl = router.query.callbackUrl as string; + + signIn(provider, { callbackUrl, ...options }); + } + async function logInWithEmail() { + const email = emailRef.current?.value; + + if (!email) { + setError("Email is required"); + return; + } + + if (!executeRecaptcha) { + setError("Recaptcha not available"); + return; + } + + const captchaToken = await executeRecaptcha(); + + signInWithCallbackUrl("email", { email, captchaToken }); + } + return ( -
-
-
-

Sign In

-

Choose a login provider

-
-
- - - -

For Team 4026 Only:

- + + + +
+
+

Email Sign In

+ +
); } + +export default function SignIn() { + return ( + + +
+ +
+
+
+ ); +} diff --git a/scripts/fixTeamMembership.ts b/scripts/fixTeamMembership.ts new file mode 100644 index 00000000..9ab2fc8c --- /dev/null +++ b/scripts/fixTeamMembership.ts @@ -0,0 +1,57 @@ +import CollectionId from "@/lib/client/CollectionId"; +import { getDatabase } from "@/lib/MongoDB"; +import { ObjectId } from "bson"; + +async function fixTeamMembership() { + console.log("Fixing team membership and ownership..."); + + console.log("Getting database..."); + const db = await getDatabase(); + + console.log("Finding teams..."); + const teams = await db.findObjects(CollectionId.Teams, {}); + + console.log(`Found ${teams.length} teams.`); + + const users: { [id: string]: { teams: string[]; owner: string[] } } = {}; + + for (const team of teams) { + console.log( + `Processing team ${team._id}... Users: ${team.users.length}, Owners: ${team.owners.length}`, + ); + + for (const user of team.users) { + if (!users[user]) { + users[user] = { teams: [], owner: [] }; + } + + users[user].teams.push(team._id.toString()); + } + + for (const user of team.owners) { + if (!users[user]) { + users[user] = { teams: [], owner: [] }; + } + + users[user].owner.push(team._id.toString()); + } + } + + console.log(`Found ${Object.keys(users).length} users who are on teams.`); + + for (const userId in users) { + const user = users[userId]; + + console.log( + `Updating user ${userId}... Teams: ${user.teams.length}, Owners: ${user.owner.length}`, + ); + await db.updateObjectById(CollectionId.Users, new ObjectId(userId), { + teams: user.teams, + owner: user.owner, + }); + } + + process.exit(0); +} + +fixTeamMembership(); diff --git a/scripts/loadUsersIntoResend.ts b/scripts/loadUsersIntoResend.ts index 3743745b..09352172 100644 --- a/scripts/loadUsersIntoResend.ts +++ b/scripts/loadUsersIntoResend.ts @@ -1,7 +1,6 @@ import { getDatabase } from "@/lib/MongoDB"; import CollectionId from "@/lib/client/CollectionId"; import ResendUtils from "@/lib/ResendUtils"; -import { User } from "@/lib/Types"; async function loadUsersIntoResend() { console.log("Loading users into Resend..."); diff --git a/scripts/pingRollbar.ts b/scripts/pingRollbar.ts new file mode 100644 index 00000000..e51cbf7a --- /dev/null +++ b/scripts/pingRollbar.ts @@ -0,0 +1,13 @@ +import getRollbar from "@/lib/client/RollbarUtils"; + +console.log("Initializing Rollbar..."); + +// include and initialize the rollbar library with your access token +const rollbar = getRollbar(); + +console.log("Rollbar initialized"); + +// record a generic message and send it to Rollbar +rollbar.info("Pinged manually!"); + +console.log("Sent message to Rollbar"); diff --git a/tests/lib/DbInterfaceAuthAdapter.test.ts b/tests/lib/DbInterfaceAuthAdapter.test.ts index a76bef4d..d1cf93be 100644 --- a/tests/lib/DbInterfaceAuthAdapter.test.ts +++ b/tests/lib/DbInterfaceAuthAdapter.test.ts @@ -1,27 +1,31 @@ import CollectionId from "@/lib/client/CollectionId"; import InMemoryDbInterface from "@/lib/client/dbinterfaces/InMemoryDbInterface"; import DbInterfaceAuthAdapter from "@/lib/DbInterfaceAuthAdapter"; +import { getTestRollbar } from "@/lib/testutils/TestUtils"; import { _id } from "@next-auth/mongodb-adapter"; import { ObjectId } from "bson"; -import { get } from "http"; +import exp from "constants"; +import { Account, Session } from "next-auth"; +import { AdapterSession } from "next-auth/adapters"; -const prototype = DbInterfaceAuthAdapter(undefined as any); +const prototype = DbInterfaceAuthAdapter(undefined as any, undefined as any); -async function getDatabase() {} - -async function getAdapterAndDb() { +async function getDeps() { const db = new InMemoryDbInterface(); await db.init(); + const rollbar = getTestRollbar(); + return { - adapter: DbInterfaceAuthAdapter(Promise.resolve(db)), + adapter: DbInterfaceAuthAdapter(Promise.resolve(db), rollbar), db, + rollbar, }; } describe(prototype.createUser.name, () => { test("Adds a user to the database", async () => { - const { db, adapter } = await getAdapterAndDb(); + const { db, adapter } = await getDeps(); const user = { name: "Test User", @@ -39,7 +43,7 @@ describe(prototype.createUser.name, () => { }); test("Populates fields with default values", async () => { - const { db, adapter } = await getAdapterAndDb(); + const { db, adapter } = await getDeps(); const user = { name: "Test User", @@ -65,7 +69,7 @@ describe(prototype.createUser.name, () => { }); test("Populates missing fields with defaults", async () => { - const { db, adapter } = await getAdapterAndDb(); + const { db, adapter } = await getDeps(); const user = { email: "test@gmail.com", @@ -80,11 +84,26 @@ describe(prototype.createUser.name, () => { expect(foundUser?.name).toBeDefined(); expect(foundUser?.image).toBeDefined(); }); + + test("Does not create a new user if one already exists", async () => { + const { db, adapter } = await getDeps(); + + const user = { + email: "test@gmail.com", + }; + + await adapter.createUser(user); + await adapter.createUser(user); + + expect( + await db.countObjects(CollectionId.Users, { email: user.email }), + ).toBe(1); + }); }); describe(prototype.getUser!.name, () => { test("Returns a user from the database without their _id", async () => { - const { db, adapter } = await getAdapterAndDb(); + const { db, adapter } = await getDeps(); const user = { _id: new ObjectId(), @@ -103,7 +122,7 @@ describe(prototype.getUser!.name, () => { }); test("Returns null if given an id of the wrong length", async () => { - const { adapter } = await getAdapterAndDb(); + const { adapter } = await getDeps(); const foundUser = await adapter.getUser!("1234567890123456789012345"); @@ -111,10 +130,802 @@ describe(prototype.getUser!.name, () => { }); test("Returns null if the user doesn't exist", async () => { - const { adapter } = await getAdapterAndDb(); + const { adapter, rollbar } = await getDeps(); - const foundUser = await adapter.getUser!(new ObjectId().toString()); + const user = await adapter.getUser!(new ObjectId().toString()); - expect(foundUser).toBeNull(); + expect(user).toBeNull(); + }); +}); + +describe(prototype.getUserByEmail!.name, () => { + test("Returns a user from the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + const { _id, ...addedUser } = await db.addObject( + CollectionId.Users, + user as any, + ); + + const foundUser = await adapter.getUserByEmail!(user.email); + + expect(foundUser).toMatchObject(addedUser); + }); + + test("Returns user without their _id", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + await db.addObject(CollectionId.Users, user as any); + + const foundUser = await adapter.getUserByEmail!(user.email); + + const { _id, ...userWithoutId } = user; + + expect(foundUser).toMatchObject(userWithoutId); + }); + + test("Returns null if the user doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const user = await adapter.getUserByEmail!("test@gmail.com"); + + expect(user).toBeNull(); + }); +}); + +describe(prototype.getUserByAccount!.name, () => { + test("Returns a user from the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + const { _id, ...addedUser } = await db.addObject( + CollectionId.Users, + user as any, + ); + + const account: Account = { + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: _id, + }; + + await db.addObject(CollectionId.Accounts, account); + + const foundUser = await adapter.getUserByAccount!(account); + + expect(foundUser).toMatchObject(addedUser); + }); + + test("Returns null if the account doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const account: Account = { + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: new ObjectId() as any, + }; + + const user = await adapter.getUserByAccount!(account); + + expect(user).toBeNull(); + }); + + test("Returns null if the user doesn't exist", async () => { + const { adapter, db, rollbar } = await getDeps(); + + const account: Account = { + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: new ObjectId() as any, + }; + + await db.addObject(CollectionId.Accounts, account); + + const user = await adapter.getUserByAccount!(account); + + expect(user).toBeNull(); + }); +}); + +describe(prototype.updateUser!.name, () => { + test("Updates a user in the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const addedUser = await db.addObject(CollectionId.Users, user as any); + + const updatedUser = { + _id: addedUser._id, + id: addedUser._id!.toString(), + name: "Updated User", + }; + + await adapter.updateUser!(updatedUser); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser).toMatchObject(updatedUser); + }); + + test("Errors if not given an _id", async () => { + const { adapter, rollbar } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + await adapter.updateUser!(user as any); + + expect(rollbar.error).toHaveBeenCalled(); + }); + + test("Errors if the user doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + await adapter.updateUser!(user as any); + + expect(rollbar.error).toHaveBeenCalled(); + }); + + test("Returns the updated user without their _id", async () => { + const { db, adapter } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + const { _id, ...addedUser } = await db.addObject( + CollectionId.Users, + user as any, + ); + + const updatedUser = { + _id, + name: "Updated User", + }; + + const returnedUser = await adapter.updateUser!(updatedUser as any); + const { _id: _, ...expectedUser } = { ...addedUser, ...updatedUser }; + + expect(returnedUser).toMatchObject(expectedUser); + }); + + test("Errors if no _id is provided", async () => { + const { adapter, db, rollbar } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + await db.addObject(CollectionId.Users, user as any); + + await adapter.updateUser!({ name: "Test User 2" } as any); + + expect(rollbar.error).toHaveBeenCalled(); + }); +}); + +describe(prototype.deleteUser!.name, () => { + test("Deletes a user from the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + await db.addObject(CollectionId.Users, user as any); + + await adapter.deleteUser!(user._id.toString()); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser).toBeUndefined(); + }); + + test("Errors but returns null if the user doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const user = await adapter.deleteUser!(new ObjectId().toString()); + + expect(user).toBeNull(); + expect(rollbar.error).toHaveBeenCalled(); + }); + + test("Deletes the user's account", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const account: Account = { + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: user._id as any, + }; + + await Promise.all([ + db.addObject(CollectionId.Users, user as any), + db.addObject(CollectionId.Accounts, account), + ]); + + await adapter.deleteUser!(user._id.toString()); + + const foundAccount = await db.findObject(CollectionId.Accounts, { + _id: account._id, + }); + + expect(foundAccount).toBeUndefined(); + }); + + test("Deletes the user's sessions", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const sessions = [1, 2, 3].map((i) => ({ + _id: new ObjectId(), + userId: user._id, + expires: new Date(), + })); + + await Promise.all([ + db.addObject(CollectionId.Users, user as any), + ...sessions.map((session) => + db.addObject(CollectionId.Sessions, session as any), + ), + ]); + + await adapter.deleteUser!(user._id.toString()); + + const foundSessions = await db.findObjects(CollectionId.Sessions, { + userId: user._id, + }); + + expect(foundSessions).toHaveLength(0); + }); +}); + +describe(prototype.linkAccount!.name, () => { + test("Links an account to a user", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const account: Account = { + _id: new ObjectId(), + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: user._id as any, + }; + + await db.addObject(CollectionId.Users, user as any); + + await adapter.linkAccount(account); + + const foundAccount = await db.findObject(CollectionId.Accounts, { + provider: account.provider, + providerAccountId: account.providerAccountId, + }); + + expect(foundAccount).toEqual(account); + }); + + test("Warns if the account already exists", async () => { + const { adapter, db, rollbar } = await getDeps(); + + const account: Account = { + _id: new ObjectId(), + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: new ObjectId() as any, + }; + + await db.addObject(CollectionId.Accounts, account); + + await adapter.linkAccount!(account); + + expect(rollbar.warn).toHaveBeenCalled(); + }); + + test("Does not create another account if one already exists", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const account: Account = { + _id: new ObjectId(), + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: user._id as any, + }; + + await Promise.all([ + db.addObject(CollectionId.Users, user as any), + db.addObject(CollectionId.Accounts, account), + ]); + + await adapter.linkAccount(account); + + const foundAccounts = await db.findObjects(CollectionId.Accounts, { + provider: account.provider, + providerAccountId: account.providerAccountId, + }); + + expect(foundAccounts).toHaveLength(1); + }); +}); + +describe(prototype.unlinkAccount!.name, () => { + test("Unlinks an account from a user", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const account: Account = { + _id: new ObjectId(), + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: user._id as any, + }; + + await Promise.all([ + db.addObject(CollectionId.Users, user as any), + db.addObject(CollectionId.Accounts, account), + ]); + + await adapter.unlinkAccount(account); + + const foundAccount = await db.findObject(CollectionId.Accounts, { + provider: account.provider, + providerAccountId: account.providerAccountId, + }); + + expect(foundAccount).toBeUndefined(); + }); + + test("Warns if the account doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const account: Account = { + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: new ObjectId() as any, + }; + + await adapter.unlinkAccount!(account); + + expect(rollbar.warn).toHaveBeenCalled(); + }); + + test("Returns null if the account doesn't exist", async () => { + const { adapter } = await getDeps(); + + const account: Account = { + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: new ObjectId() as any, + }; + + const returnedAccount = await adapter.unlinkAccount!(account); + + expect(returnedAccount).toBeNull(); + }); + + test("Does not delete the user", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const account: Account = { + _id: new ObjectId(), + provider: "test", + type: "oauth", + providerAccountId: "1234567890", + userId: user._id as any, + }; + + await Promise.all([ + db.addObject(CollectionId.Users, user as any), + db.addObject(CollectionId.Accounts, account), + ]); + + await adapter.unlinkAccount(account); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser).toEqual(user); + }); +}); + +describe(prototype.getSessionAndUser!.name, () => { + test("Returns a session and user from the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + name: "Test User", + email: "test@gmail.com", + }; + + const { _id, ...addedUser } = await db.addObject( + CollectionId.Users, + user as any, + ); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: _id as any, + expires: new Date(), + }; + + await db.addObject(CollectionId.Sessions, session as any); + + const sessionAndUser = await adapter.getSessionAndUser!( + session.sessionToken, + ); + + expect(sessionAndUser?.session.sessionToken).toBe(session.sessionToken); + expect(sessionAndUser?.user).toMatchObject(addedUser); + }); + + test("Returns null if the session doesn't exist", async () => { + const { adapter } = await getDeps(); + + const session = await adapter.getSessionAndUser!("1234567890"); + + expect(session).toBeNull(); + }); + + test("Returns null if the user doesn't exist", async () => { + const { adapter, db, rollbar } = await getDeps(); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: new ObjectId() as any, + expires: new Date(), + }; + + await db.addObject(CollectionId.Sessions, session as any); + + const sessionAndUser = await adapter.getSessionAndUser!( + session.sessionToken, + ); + + expect(sessionAndUser).toBeNull(); + }); +}); + +describe(prototype.createSession!.name, () => { + test("Creates a session in the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + await db.addObject(CollectionId.Users, user as any); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: user._id as any, + expires: new Date(), + }; + + await adapter.createSession!(session); + + const foundSession = await db.findObject(CollectionId.Sessions, { + sessionToken: session.sessionToken, + }); + + expect(foundSession?.userId).toEqual(session.userId); + }); + + test("Errors if not given a userId", async () => { + const { adapter, rollbar } = await getDeps(); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: undefined as any, + expires: new Date(), + }; + + await adapter.createSession!(session); + expect(rollbar.error).toHaveBeenCalled(); + }); + + test("Warns if the user doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: new ObjectId() as any, + expires: new Date(), + }; + + await adapter.createSession!(session); + + expect(rollbar.warn).toHaveBeenCalled(); + }); +}); + +describe(prototype.updateSession!.name, () => { + test("Updates a session in the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const { _id: userId } = await db.addObject(CollectionId.Users, user as any); + const session: AdapterSession = { + sessionToken: "1234567890", + userId: userId as any, + expires: new Date(), + }; + + const { _id: sessionId } = await db.addObject( + CollectionId.Sessions, + session as any, + ); + + const updatedSession = { + sessionToken: "1234567890", + userId: new ObjectId() as any, + }; + + await adapter.updateSession!(updatedSession); + + const foundSession = await db.findObject(CollectionId.Sessions, { + sessionToken: updatedSession.sessionToken, + }); + + expect(foundSession?.userId).toEqual(updatedSession.userId); + }); + + test("Errors if not given a sessionToken", async () => { + const { adapter, rollbar } = await getDeps(); + + const session: AdapterSession = { + sessionToken: undefined as any, + userId: new ObjectId() as any, + expires: new Date(), + }; + + await adapter.updateSession!(session); + + expect(rollbar.error).toHaveBeenCalled(); + }); + + test("Errors if the session doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: new ObjectId() as any, + expires: new Date(), + }; + + await adapter.updateSession!(session); + + expect(rollbar.error).toHaveBeenCalled(); + }); +}); + +describe(prototype.deleteSession!.name, () => { + test("Deletes a session from the database", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const { _id: userId } = await db.addObject(CollectionId.Users, user as any); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: userId as any, + expires: new Date(), + }; + + const { _id: sessionId } = await db.addObject( + CollectionId.Sessions, + session as any, + ); + + await adapter.deleteSession!(session.sessionToken); + + const foundSession = await db.findObject(CollectionId.Sessions, { + sessionToken: session.sessionToken, + }); + + expect(foundSession).toBeUndefined(); + }); + + test("Warns if the session doesn't exist", async () => { + const { adapter, rollbar } = await getDeps(); + + await adapter.deleteSession!("1234567890"); + + expect(rollbar.warn).toHaveBeenCalled(); + }); + + test("Does not delete the user", async () => { + const { db, adapter } = await getDeps(); + + const user = { + _id: new ObjectId(), + name: "Test User", + email: "test@gmail.com", + }; + + const { _id: userId } = await db.addObject(CollectionId.Users, user as any); + + const session: AdapterSession = { + sessionToken: "1234567890", + userId: userId as any, + expires: new Date(), + }; + + await db.addObject(CollectionId.Sessions, session as any); + + await adapter.deleteSession!(session.sessionToken); + + const foundUser = await db.findObject(CollectionId.Users, { + email: user.email, + }); + + expect(foundUser).toEqual(user); + }); +}); + +describe(prototype.createVerificationToken!.name, () => { + test("Returns token", async () => { + const testToken = { + identifier: "hi", + expires: new Date(), + token: "hello", + }; + const { adapter } = await getDeps(); + const returnToken = await adapter.createVerificationToken!(testToken); + expect(returnToken).toBe(testToken); + }); + + test("Token is added to database", async () => { + const testToken = { + identifier: "hi", + expires: new Date(), + token: "hello", + }; + const { adapter, db } = await getDeps(); + await adapter.createVerificationToken!(testToken); + const foundToken = await db.findObject(CollectionId.VerificationTokens, { + identifier: testToken.identifier, + }); + expect(foundToken?.identifier).toBe(testToken.identifier); + expect(foundToken?.token).toBe(testToken.token); + }); +}); + +describe(prototype.useVerificationToken!.name, () => { + test("Returns token", async () => { + const testToken = { + identifier: "hi", + expires: new Date(), + token: "hello", + }; + + const { adapter, db } = await getDeps(); + + await db.addObject(CollectionId.VerificationTokens, testToken); + const foundToken = await adapter.useVerificationToken!(testToken); + + expect(foundToken?.identifier).toBe(testToken.identifier); + expect(foundToken?.token).toBe(testToken.token); + }); + + test("Token is removed from database", async () => { + const testToken = { + identifier: "hi", + expires: new Date(), + token: "hello", + }; + + const { adapter, db } = await getDeps(); + + await db.addObject(CollectionId.VerificationTokens, testToken); + await adapter.useVerificationToken!(testToken); + const foundToken = await db.findObject(CollectionId.VerificationTokens, { + identifier: testToken.identifier, + token: testToken.token, + }); + + expect(foundToken).toBeUndefined(); + }); + + test("Warns if token doesn't exist", async () => { + const testToken = { + identifier: "hi", + expires: new Date(), + token: "hello", + }; + const { adapter, rollbar } = await getDeps(); + + await adapter.useVerificationToken!(testToken); + + expect(rollbar.warn).toHaveBeenCalled(); }); }); diff --git a/tests/lib/api/ClientApi.test.ts b/tests/lib/api/ClientApi.test.ts index 554eceb3..24b6c6ad 100644 --- a/tests/lib/api/ClientApi.test.ts +++ b/tests/lib/api/ClientApi.test.ts @@ -942,3 +942,168 @@ describe(`${ClientApi.name}.${api.setSlackWebhook.name}`, () => { expect(updatedWebhook?.url).toEqual(webhookUrl); }); }); + +describe(`${ClientApi.name}.${api.changeUserName.name}`, () => { + test(`${ClientApi.name}.${api.changeUserName.name}: Updates user name`, async () => { + const { db, res, user } = await getTestApiUtils(); + + const newName = "Updated User"; + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, [newName])), + ); + + const updatedUser = await db.findObjectById( + CollectionId.Users, + new ObjectId(user._id!), + ); + expect(updatedUser?.name).toEqual(newName); + }); + + test(`${ClientApi.name}.${api.changeUserName.name}: Returns 400 if name is empty`, async () => { + const { db, res, user } = await getTestApiUtils(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, [""])), + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test(`${ClientApi.name}.${api.changeUserName.name}: Returns 400 if name is too long`, async () => { + const { db, res, user } = await getTestApiUtils(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, ["a".repeat(101)])), + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test(`${ClientApi.name}.${api.changeUserName.name}: Returns 400 if name is too short`, async () => { + const { db, res, user } = await getTestApiUtils(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, ["a"])), + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test(`${ClientApi.name}.${api.changeUserName.name}: Returns 400 if name is not alphanumeric (can include spaces)`, async () => { + const { db, res, user } = await getTestApiUtils(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, ["^".repeat(10)])), + ); + + expect(res.status).toHaveBeenCalledWith(400); + res.status.mockClear(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, ["a\\ b"])), + ); + + expect(res.status).toHaveBeenCalledWith(400); + res.status.mockClear(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, [""])), + ); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + test(`${ClientApi.name}.${api.changeUserName.name}: Allows certain special characters`, async () => { + const { db, res, user } = await getTestApiUtils(); + + await api.changeUserName.handler( + ...(await getTestApiParams(res, { db, user }, ["a-b_c'd e"])), + ); + + expect(res.status).toHaveBeenCalledWith(200); + }); +}); + +describe(`${ClientApi.name}.${api.changeTeamNumberForReport.name}`, () => { + test(`${ClientApi.name}.${api.changeTeamNumberForReport.name}: Updates team number for report`, async () => { + const { db, res, user } = await getTestApiUtils(); + + const match: Match = new Match( + 0, + "test-match", + "test-tbaId", + 0, + MatchType.Qualifying, + [], + [], + [], + ); + await db.addObject(CollectionId.Matches, match); + + const report = new Report( + new ObjectId().toString(), + undefined as any, + 0, + AllianceColor.Blue, + "", + ); + await db.addObject(CollectionId.Reports, report); + + const newTeam = 1; + + await api.changeTeamNumberForReport.handler( + ...(await getTestApiParams(res, { db, user }, [ + match._id!.toString(), + report._id!.toString(), + newTeam, + ])), + ); + + const updatedReport = await db.findObjectById( + CollectionId.Reports, + new ObjectId(report._id!), + ); + + expect(updatedReport?.robotNumber).toEqual(newTeam); + }); + + test(`${ClientApi.name}.${api.changeTeamNumberForReport.name}: Updates team number in match`, async () => { + const { db, res, user } = await getTestApiUtils(); + + const report = new Report( + new ObjectId().toString(), + undefined as any, + 0, + AllianceColor.Blue, + "", + ); + const { _id: reportId } = await db.addObject(CollectionId.Reports, report); + + const match: Match = new Match( + 0, + "test-match", + "test-tbaId", + 0, + MatchType.Qualifying, + [1, 2, 3], + [4, 5, 6], + [reportId!.toString()], + ); + const { _id: matchId } = await db.addObject(CollectionId.Matches, match); + + const newTeam = 0; + + await api.changeTeamNumberForReport.handler( + ...(await getTestApiParams(res, { db, user }, [ + matchId!.toString(), + reportId!.toString(), + newTeam, + ])), + ); + + const updatedMatch = await db.findObjectById( + CollectionId.Matches, + new ObjectId(match._id!), + ); + }); +}); diff --git a/tests/lib/slugToId.test.ts b/tests/lib/slugToId.test.ts new file mode 100644 index 00000000..31f7fede --- /dev/null +++ b/tests/lib/slugToId.test.ts @@ -0,0 +1,75 @@ +import InMemoryDbInterface from "@/lib/client/dbinterfaces/InMemoryDbInterface"; +import slugToId, { findObjectBySlugLookUp } from "@/lib/slugToId"; +import { ObjectId } from "bson"; + +beforeEach(() => { + global.slugLookup = new Map(); +}); + +async function getDb() { + const db = new InMemoryDbInterface(); + await db.init(); + return db; +} + +describe(slugToId.name, () => { + test("Returns ID if slug/id pair is not cached", async () => { + const db = await getDb(); + + const slug = "slug"; + const id = new ObjectId(); + + await db.addObject("collection" as any, { _id: id, slug }); + + const slugLookup = (await slugToId(db, "collection" as any, slug)).id; + + expect(slugLookup).toStrictEqual(id); + }); + + test("Returns ID if slug/id pair is cached", async () => { + const db = await getDb(); + + const slug = "slug"; + const id = new ObjectId(); + + global.slugLookup = new Map(); + global.slugLookup.set("collection" as any, new Map()); + global.slugLookup.get("collection" as any)!.set(slug, id); + + const slugLookup = (await slugToId(db, "collection" as any, slug)).id; + + expect(await slugLookup).toStrictEqual(id); + }); +}); + +describe(findObjectBySlugLookUp.name, () => { + test("Returns object when slug/id pair is not cached", async () => { + const db = await getDb(); + + const slug = "slug"; + const id = new ObjectId(); + + await db.addObject("collection" as any, { _id: id, slug }); + + const obj = await findObjectBySlugLookUp(db, "collection" as any, slug); + + expect(obj).toStrictEqual({ _id: id, slug }); + }); + + test("Returns object when slug/id pair is cached", async () => { + const db = await getDb(); + + const slug = "slug"; + const id = new ObjectId(); + + global.slugLookup = new Map(); + global.slugLookup.set("collection" as any, new Map()); + global.slugLookup.get("collection" as any)!.set(slug, id); + + await db.addObject("collection" as any, { _id: id, slug }); + + const obj = await findObjectBySlugLookUp(db, "collection" as any, slug); + + expect(obj).toStrictEqual({ _id: id, slug }); + }); +});