From bed539ed550a42311c0f9fce14e6adf0ba919031 Mon Sep 17 00:00:00 2001 From: Peter Briggs <146312751+pwbriggs@users.noreply.github.com> Date: Sat, 11 May 2024 09:33:25 -0700 Subject: [PATCH 1/6] feat: :passport_control: initial work on auth and login Install & set up remix-auth, by adding Cred and Session tables to the database, installing & setting up out first strategy, remix-auth-form (with lots of backend code to check passwords, etc.). This does not include a way to create user accounts, nor proper playwright tests (some tests should fail currently). This is not a breaking change because we don't use the database in the app yet for any purpose that would conflict with this change. --- app/components/profile.tsx | 13 +++ app/root.tsx | 3 + app/routes/login.tsx | 80 +++++++++++++++++++ app/routes/my-schedule.tsx | 11 ++- app/scripts/accounts.server.ts | 3 + app/scripts/auth.server.ts | 39 +++++++++ app/scripts/password.server.ts | 30 +++++++ app/scripts/session.server.ts | 58 ++++++++++++++ app/styles/global-wide.css | 3 +- package-lock.json | 44 ++++++++++ package.json | 4 + .../20240321005217_auth/migration.sql | 24 ++++++ prisma/schema.prisma | 20 +++++ tests/auth.spec.ts | 7 ++ 14 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 app/components/profile.tsx create mode 100644 app/routes/login.tsx create mode 100644 app/scripts/accounts.server.ts create mode 100644 app/scripts/auth.server.ts create mode 100644 app/scripts/password.server.ts create mode 100644 app/scripts/session.server.ts create mode 100644 prisma/migrations/20240321005217_auth/migration.sql create mode 100644 tests/auth.spec.ts diff --git a/app/components/profile.tsx b/app/components/profile.tsx new file mode 100644 index 0000000..bed4a7a --- /dev/null +++ b/app/components/profile.tsx @@ -0,0 +1,13 @@ +import { Link } from "@remix-run/react"; + +export function ProfileMenu() { + // For now we just always display a login link: this is terrible UX, but simple. + // TODO show here if the user is logged in or not. + return +} + +function GoToLogin() { + return ( + Log in + ); +} diff --git a/app/root.tsx b/app/root.tsx index bdbee41..51994bf 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -13,6 +13,8 @@ import { } from "@remix-run/react"; import { SpeedInsights } from "@vercel/speed-insights/remix" +import { ProfileMenu } from "~/components/profile"; + import faviconSvg from "~/images/favicon.svg"; import logo from "~/images/icon.svg"; @@ -53,6 +55,7 @@ export default function App() {
  • Your Schedule
  • All Events
  • Settings
  • +
  • diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..41770b3 --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,80 @@ +import { Form, useActionData } from "@remix-run/react"; +import { authenticator } from "~/scripts/auth.server"; +import { json } from "@remix-run/node"; + +import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; + +export const meta: MetaFunction = () => { + return [ + { title: "Log in | BVS" }, + { name: "description", content: "Log in to your account for personalized content." }, + ]; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + // If the user is already authenticated redirect to home + return await authenticator.isAuthenticated(request, { + successRedirect: getNextUrl(request), + }); +} + +function getNextUrl(request: Request) { + try { + const url = new URL(request.url).searchParams.get("next"); + if (typeof url !== "string") { + return "/"; + } + try { + return new URL(url).pathname; // No cross-origin redirects happening here! + } catch { + return url; + } + } catch (e) { + return "/"; + } +} + +export async function action({ request }: ActionFunctionArgs) { + try { + return await authenticator.authenticate("usernamePassword", request, { + successRedirect: getNextUrl(request), + throwOnError: true + }); + } catch (e) { + switch ((e as Error).message) { + case "badUsername": + return json({ formError: { message: "User not found" } }); + break; + default: + return json({ + formError: { + message: `Unknown authentication error "${(e as Error).message}"` + } + }); + } + } +} + +export default function Login() { + const actionData = useActionData(); + return ( +
    +
    +
    + +
    {actionData?.formError && {actionData.formError.message}}
    +
    + ); +} diff --git a/app/routes/my-schedule.tsx b/app/routes/my-schedule.tsx index d4d6872..bf91c2f 100644 --- a/app/routes/my-schedule.tsx +++ b/app/routes/my-schedule.tsx @@ -1,4 +1,6 @@ -import type { MetaFunction } from "@remix-run/node"; +import { authenticator } from "~/scripts/auth.server"; + +import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; export const meta: MetaFunction = () => { return [ @@ -7,6 +9,13 @@ export const meta: MetaFunction = () => { ]; }; +export async function loader({ request }: LoaderFunctionArgs) { + // If the user is already authenticated redirect to home + return await authenticator.isAuthenticated(request, { + failureRedirect: "/login?next=https://google.com/my-schedule", + }); +} + export default function MySchedule() { return (

    My Schedule

    diff --git a/app/scripts/accounts.server.ts b/app/scripts/accounts.server.ts new file mode 100644 index 0000000..99bb409 --- /dev/null +++ b/app/scripts/accounts.server.ts @@ -0,0 +1,3 @@ +export async function userExists(username: string) { + return await prisma.user.findUnique({ where: { username } }) !== null; +} diff --git a/app/scripts/auth.server.ts b/app/scripts/auth.server.ts new file mode 100644 index 0000000..04d56c8 --- /dev/null +++ b/app/scripts/auth.server.ts @@ -0,0 +1,39 @@ +import { Authenticator } from "remix-auth"; +import { FormStrategy } from "remix-auth-form"; +import { sessionStorage } from "~/scripts/session.server"; +import { usernamePasswordLogin } from "~/scripts/password.server"; +import { prisma } from "~/scripts/prisma.server"; + +import type { Session } from "~/scripts/session.server"; +import type { CredType } from "@prisma/client"; + +export const authenticator = new Authenticator(sessionStorage); + +// Tell the Authenticator to use the form strategy +authenticator.use( + new FormStrategy(async ({ form }) => { + let username = form.get("username"); + let password = form.get("password"); + if (typeof username !== "string" || typeof password !== "string") { + throw new TypeError("badFormData"); + } + const session = await usernamePasswordLogin( + username, + password, + await getCredsByType(username, "USERNAME_PASSWORD") + ); + return session; + }), + "usernamePassword" +); + +async function getCredsByType(username: string, type: CredType) { + return await prisma.cred.findMany({ + where: { + user: { + username + }, + type + } + }); +} diff --git a/app/scripts/password.server.ts b/app/scripts/password.server.ts new file mode 100644 index 0000000..344802e --- /dev/null +++ b/app/scripts/password.server.ts @@ -0,0 +1,30 @@ +import bcrypt from "bcryptjs"; +import { userExists } from "./accounts.server"; + +import type { Cred } from "@prisma/client"; +import type { Session } from "~/scripts/session.server"; + +export async function usernamePasswordLogin( + username: string, + password: string, + passwordCreds: Cred[] +) { + if (passwordCreds.length == 0) { + if (!await userExists(username)) { + throw new Error("badUsername"); + } + throw new Error("noPasswords"); + } + if (password.length == 0) { + throw new Error("missingPassword"); + } + + for (const cred of passwordCreds) { + if (await bcrypt.compare(password, (cred.cred as { hash: string }).hash)) { + let session: Session = { username }; + return session; + } + } + + throw new Error("badPassword"); +} diff --git a/app/scripts/session.server.ts b/app/scripts/session.server.ts new file mode 100644 index 0000000..de393fa --- /dev/null +++ b/app/scripts/session.server.ts @@ -0,0 +1,58 @@ +import { createSessionStorage } from "@remix-run/node"; + +export type Session = { + username: string +} + +function databaseSessionStorage(cookie: any) { + return createSessionStorage({ + cookie, + async createData(data) { + const id = `${Date.now()}_${Math.floor(Math.random() * 2 ** 31)}`; + await prisma.session.create({ + data: { + id, + data + } + }); + return id; + }, + async readData(id) { + return await prisma.session.findUnique({ + where: { id } + }) + }, + async updateData(id, data) { + await prisma.session.update({ + where: { id }, + data: { + data + } + }); + }, + async deleteData(id) { + await prisma.session.delete({ + where: { id } + }); + }, + }); +} + +const sessionSecret = process.env.NODE_ENV === "production" ? process.env.SESSION_SECRET : "dev"; +if (!sessionSecret) { + throw new Error("SESSION_SECRET must be set in prod"); +} + +export const sessionStorage = databaseSessionStorage({ + cookie: { + name: "BVS_session", + secure: process.env.NODE_ENV === "production", + secrets: [sessionSecret], + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, + httpOnly: true, + }, +}); + +export const { getSession, commitSession, destroySession } = sessionStorage; diff --git a/app/styles/global-wide.css b/app/styles/global-wide.css index 5e5ba4c..d0043b1 100644 --- a/app/styles/global-wide.css +++ b/app/styles/global-wide.css @@ -24,10 +24,11 @@ header.bvs-header > nav a { padding: 0.8rem var(--edge-padding); text-decoration: none; font-weight: bold; + border-bottom: 0.2rem solid transparent; } header.bvs-header > nav a.active { - border-bottom: 0.2rem solid; + border-bottom-color: white; } footer.bvs-footer { diff --git a/package-lock.json b/package-lock.json index 592daa9..2375aa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@remix-run/react": "^2.9.1", "@vercel/remix": "^2.9.1", "@vercel/speed-insights": "^1.0.10", + "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.19.2", @@ -21,12 +22,15 @@ "morgan": "^1.10.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.4.0", "source-map-support": "^0.5.21" }, "devDependencies": { "@playwright/test": "^1.44.0", "@remix-run/dev": "^2.9.1", "@remix-run/eslint-config": "^2.9.1", + "@types/bcryptjs": "^2.4.6", "@types/compression": "^1.7.2", "@types/express": "^4.17.17", "@types/morgan": "^1.9.4", @@ -2340,6 +2344,12 @@ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -3849,6 +3859,11 @@ "node": ">= 0.8" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -10028,6 +10043,27 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remix-auth": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/remix-auth/-/remix-auth-3.6.0.tgz", + "integrity": "sha512-mxlzLYi+/GKQSaXIqIw15dxAT1wm+93REAeDIft2unrKDYnjaGhhpapyPhdbALln86wt9lNAk21znfRss3fG7Q==", + "dependencies": { + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@remix-run/react": "^1.0.0 || ^2.0.0", + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0" + } + }, + "node_modules/remix-auth-form": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/remix-auth-form/-/remix-auth-form-1.4.0.tgz", + "integrity": "sha512-PirsVtv2AbJ7Lg+OjE+rjlW9AnkNYmqfmNIqTg0Mh1wur22ls5hxf2icVXVCRRhpcpV+FyoDxh03LtIyRj646A==", + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0", + "remix-auth": "^3.6.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11532,6 +11568,14 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/uvu": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", diff --git a/package.json b/package.json index d555ee3..30200e7 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@remix-run/react": "^2.9.1", "@vercel/remix": "^2.9.1", "@vercel/speed-insights": "^1.0.10", + "bcryptjs": "^2.4.3", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.19.2", @@ -39,12 +40,15 @@ "morgan": "^1.10.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.4.0", "source-map-support": "^0.5.21" }, "devDependencies": { "@playwright/test": "^1.44.0", "@remix-run/dev": "^2.9.1", "@remix-run/eslint-config": "^2.9.1", + "@types/bcryptjs": "^2.4.6", "@types/compression": "^1.7.2", "@types/express": "^4.17.17", "@types/morgan": "^1.9.4", diff --git a/prisma/migrations/20240321005217_auth/migration.sql b/prisma/migrations/20240321005217_auth/migration.sql new file mode 100644 index 0000000..45806cd --- /dev/null +++ b/prisma/migrations/20240321005217_auth/migration.sql @@ -0,0 +1,24 @@ +-- CreateEnum +CREATE TYPE "CredType" AS ENUM ('USERNAME_PASSWORD'); + +-- CreateTable +CREATE TABLE "Cred" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "type" "CredType" NOT NULL, + "cred" JSONB NOT NULL, + "created" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Cred_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "data" JSONB NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Cred" ADD CONSTRAINT "Cred_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0a153a9..28af809 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,7 @@ model User { managingRoles Role[] @relation("roleManager") permissionSets UserPermissionSet[] positions Position[] + Cred Cred[] } enum UserPermissionSet { @@ -31,6 +32,25 @@ enum UserPermissionSet { CREATE_EVENT } +model Cred { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id]) + userId Int + type CredType + cred Json + created DateTime @default(now()) +} + +enum CredType { + USERNAME_PASSWORD +} + +/// A session on the website +model Session { + id String @id + data Json +} + model Role { id Int @id @default(autoincrement()) name String @unique /// For URLs etc. diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts new file mode 100644 index 0000000..bffaf6b --- /dev/null +++ b/tests/auth.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; + +test.describe("Auth system", () => { + test('has actual tests', async ({ page }) => { + await expect(false).toBeTruthy(); + }); +}); From 24bf808befb55e9bb42092dc9b231a255ce8b612 Mon Sep 17 00:00:00 2001 From: Peter Briggs <146312751+pwbriggs@users.noreply.github.com> Date: Sat, 11 May 2024 09:33:25 -0700 Subject: [PATCH 2/6] fix: :ambulance: add missing prisma imports Add missing imports of prisma, which exists as a global in development only. --- app/scripts/accounts.server.ts | 2 ++ app/scripts/session.server.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/app/scripts/accounts.server.ts b/app/scripts/accounts.server.ts index 99bb409..1a86ca7 100644 --- a/app/scripts/accounts.server.ts +++ b/app/scripts/accounts.server.ts @@ -1,3 +1,5 @@ +import { prisma } from "~/scripts/prisma.server"; + export async function userExists(username: string) { return await prisma.user.findUnique({ where: { username } }) !== null; } diff --git a/app/scripts/session.server.ts b/app/scripts/session.server.ts index de393fa..4d9f87f 100644 --- a/app/scripts/session.server.ts +++ b/app/scripts/session.server.ts @@ -1,3 +1,4 @@ +import { prisma } from "~/scripts/prisma.server"; import { createSessionStorage } from "@remix-run/node"; export type Session = { From 3ea20ef7b6ed4d0775e2165d047e7e4a94baa011 Mon Sep 17 00:00:00 2001 From: Peter Briggs <146312751+pwbriggs@users.noreply.github.com> Date: Sat, 11 May 2024 09:33:25 -0700 Subject: [PATCH 3/6] fix: :adhesive_bandage: fix redirect link in your schedule login Oops, forgot to remove a test of the "next page after login" redirect sanitizer. --- app/routes/my-schedule.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/my-schedule.tsx b/app/routes/my-schedule.tsx index bf91c2f..6e79c80 100644 --- a/app/routes/my-schedule.tsx +++ b/app/routes/my-schedule.tsx @@ -12,7 +12,7 @@ export const meta: MetaFunction = () => { export async function loader({ request }: LoaderFunctionArgs) { // If the user is already authenticated redirect to home return await authenticator.isAuthenticated(request, { - failureRedirect: "/login?next=https://google.com/my-schedule", + failureRedirect: "/login?next=/my-schedule", }); } From fab0ce0240368dbeac0a707c510b08f94683a18a Mon Sep 17 00:00:00 2001 From: Peter Briggs <146312751+pwbriggs@users.noreply.github.com> Date: Sat, 11 May 2024 09:33:25 -0700 Subject: [PATCH 4/6] refactor: :art: clarify username vs user id Change the utility function userExists to be called usernameExists, because users can technically be identified with a username or user id. Also make usernameExists use a new (and exported) util function, getUserByUsername, which just runs a prisma query. --- app/scripts/accounts.server.ts | 8 ++++++-- app/scripts/password.server.ts | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/scripts/accounts.server.ts b/app/scripts/accounts.server.ts index 1a86ca7..67e4e3a 100644 --- a/app/scripts/accounts.server.ts +++ b/app/scripts/accounts.server.ts @@ -1,5 +1,9 @@ import { prisma } from "~/scripts/prisma.server"; -export async function userExists(username: string) { - return await prisma.user.findUnique({ where: { username } }) !== null; +export async function getUserByUsername(username: string) { + return await prisma.user.findUnique({ where: { username } }); +} + +export async function usernameExists(username: string) { + return await getUserByUsername(username) !== null; } diff --git a/app/scripts/password.server.ts b/app/scripts/password.server.ts index 344802e..5f3470c 100644 --- a/app/scripts/password.server.ts +++ b/app/scripts/password.server.ts @@ -1,5 +1,5 @@ import bcrypt from "bcryptjs"; -import { userExists } from "./accounts.server"; +import { usernameExists } from "./accounts.server"; import type { Cred } from "@prisma/client"; import type { Session } from "~/scripts/session.server"; @@ -10,7 +10,7 @@ export async function usernamePasswordLogin( passwordCreds: Cred[] ) { if (passwordCreds.length == 0) { - if (!await userExists(username)) { + if (!await usernameExists(username)) { throw new Error("badUsername"); } throw new Error("noPasswords"); From 854eeb244cc66ac5e3881c60cdcf18bf3efd08eb Mon Sep 17 00:00:00 2001 From: Peter Briggs <146312751+pwbriggs@users.noreply.github.com> Date: Sat, 11 May 2024 09:33:26 -0700 Subject: [PATCH 5/6] refactor: :recycle: put entire user object inside session Optimization & ease of use, plus we already need to query the User table to check if the user exists, so why not bundle the request for the happy path? --- app/scripts/password.server.ts | 11 ++++++----- app/scripts/session.server.ts | 5 +++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/scripts/password.server.ts b/app/scripts/password.server.ts index 5f3470c..e27c122 100644 --- a/app/scripts/password.server.ts +++ b/app/scripts/password.server.ts @@ -1,5 +1,5 @@ import bcrypt from "bcryptjs"; -import { usernameExists } from "./accounts.server"; +import { getUserByUsername } from "~/scripts/accounts.server"; import type { Cred } from "@prisma/client"; import type { Session } from "~/scripts/session.server"; @@ -9,10 +9,11 @@ export async function usernamePasswordLogin( password: string, passwordCreds: Cred[] ) { + const user = await getUserByUsername(username); + if (user == null) { + throw new Error("badUsername"); + } if (passwordCreds.length == 0) { - if (!await usernameExists(username)) { - throw new Error("badUsername"); - } throw new Error("noPasswords"); } if (password.length == 0) { @@ -21,7 +22,7 @@ export async function usernamePasswordLogin( for (const cred of passwordCreds) { if (await bcrypt.compare(password, (cred.cred as { hash: string }).hash)) { - let session: Session = { username }; + let session: Session = { user }; return session; } } diff --git a/app/scripts/session.server.ts b/app/scripts/session.server.ts index 4d9f87f..bbe3121 100644 --- a/app/scripts/session.server.ts +++ b/app/scripts/session.server.ts @@ -1,9 +1,10 @@ import { prisma } from "~/scripts/prisma.server"; import { createSessionStorage } from "@remix-run/node"; +import type { User } from "@prisma/client"; export type Session = { - username: string -} + user: User +}; function databaseSessionStorage(cookie: any) { return createSessionStorage({ From a3912fa21f07ce9d495bfac2683730ab7c15c29e Mon Sep 17 00:00:00 2001 From: Peter Briggs <146312751+pwbriggs@users.noreply.github.com> Date: Sat, 11 May 2024 09:33:26 -0700 Subject: [PATCH 6/6] test: :white_check_mark: expect unauthenticated requests to My Schedule to redirect Add a test that expects unauthenticated requests to My Schedule (/my-schedule) to redirect to the login page. --- tests/auth.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/auth.spec.ts b/tests/auth.spec.ts index bffaf6b..a796000 100644 --- a/tests/auth.spec.ts +++ b/tests/auth.spec.ts @@ -4,4 +4,9 @@ test.describe("Auth system", () => { test('has actual tests', async ({ page }) => { await expect(false).toBeTruthy(); }); + + test("redirects unauthenticated requests to My Schedule", async ({ page }) => { + await page.goto("/my-schedule"); + await expect(page).toHaveURL("/login?next=/my-schedule"); + }); });