diff --git a/.eslintrc b/.eslintrc index 396db1f..dff3441 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,6 +6,11 @@ "parserOptions": { "project": "./tsconfig.json" }, + "rules": { + "jsx-a11y/label-has-associated-control": ["error", { + "controlComponents": ["Input", "DateInput"] + }] + }, "overrides": [ { "files": ["**/app/routes/**", "vite*.config.ts"], diff --git a/app/auth/auth.server.ts b/app/auth/auth.server.ts new file mode 100644 index 0000000..ed12af5 --- /dev/null +++ b/app/auth/auth.server.ts @@ -0,0 +1,35 @@ +import type { Strategy } from 'remix-auth'; +import { Authenticator } from 'remix-auth'; +import { FormStrategy } from 'remix-auth-form'; +import type { UsersService } from '../users/UsersService.server'; +import { credentialsSchema } from './credentials-schema.server'; +import type { SessionData, SessionStorage } from './session.server'; + +export const CREDENTIALS_STRATEGY = 'credentials'; + +function getAuthStrategies(usersService: UsersService): Map> { + const strategies = new Map>(); + + // Add strategy to login via credentials form + strategies.set(CREDENTIALS_STRATEGY, new FormStrategy(async ({ form }): Promise => { + const { username, password } = credentialsSchema.parse({ + username: form.get('username'), + password: form.get('password'), + }); + + const user = await usersService.getUserByCredentials(username, password); + return { userId: user.id }; + })); + + // TODO Add other strategies, like oAuth for SSO + + return strategies; +} + +export function createAuthenticator(usersService: UsersService, sessionStorage: SessionStorage): Authenticator { + const authenticator = new Authenticator(sessionStorage); + const strategies = getAuthStrategies(usersService); + strategies.forEach((strategy, name) => authenticator.use(strategy, name)); + + return authenticator; +} diff --git a/app/auth/credentials-schema.server.ts b/app/auth/credentials-schema.server.ts new file mode 100644 index 0000000..d1df86b --- /dev/null +++ b/app/auth/credentials-schema.server.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const credentialsSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}); diff --git a/app/auth/passwords.server.ts b/app/auth/passwords.server.ts new file mode 100644 index 0000000..90b8cb4 --- /dev/null +++ b/app/auth/passwords.server.ts @@ -0,0 +1,6 @@ +import * as argon2 from 'argon2'; + +export const hashPassword = async (plainTextPassword: string) => argon2.hash(plainTextPassword); + +export const verifyPassword = async (plainTextPassword: string, hashedPassword: string) => + argon2.verify(hashedPassword, plainTextPassword); diff --git a/app/auth/session.server.ts b/app/auth/session.server.ts new file mode 100644 index 0000000..92753b5 --- /dev/null +++ b/app/auth/session.server.ts @@ -0,0 +1,21 @@ +import { createCookieSessionStorage } from '@remix-run/node'; +import { env, isProd } from '../utils/env.server'; + +export type SessionData = { + userId: number; + [key: string]: unknown; +}; + +export const createSessionStorage = () => createCookieSessionStorage({ + cookie: { + name: 'shlink_dashboard_session', + httpOnly: true, + maxAge: 30 * 60, // 30 minutes + path: '/', + sameSite: 'lax', + secrets: env.SHLINK_DASHBOARD_SESSION_SECRETS ?? ['s3cr3t1'], + secure: isProd(), + }, +}); + +export type SessionStorage = ReturnType; diff --git a/app/container/container.server.ts b/app/container/container.server.ts index 5131fb7..ef196f4 100644 --- a/app/container/container.server.ts +++ b/app/container/container.server.ts @@ -1,9 +1,13 @@ import Bottle from 'bottlejs'; +import { Authenticator } from 'remix-auth'; import { apiClientBuilder } from '../api/apiClientBuilder.server'; +import { createAuthenticator } from '../auth/auth.server'; +import { createSessionStorage } from '../auth/session.server'; import { appDataSource } from '../db/data-source.server'; import { ServersService } from '../servers/ServersService.server'; import { SettingsService } from '../settings/SettingsService.server'; import { TagsService } from '../tags/TagsService.server'; +import { UsersService } from '../users/UsersService.server'; const bottle = new Bottle(); @@ -12,7 +16,10 @@ bottle.serviceFactory('em', () => appDataSource.manager); bottle.service(TagsService.name, TagsService, 'em'); bottle.service(ServersService.name, ServersService, 'em'); bottle.service(SettingsService.name, SettingsService, 'em'); +bottle.service(UsersService.name, UsersService, 'em'); bottle.constant('apiClientBuilder', apiClientBuilder); +bottle.serviceFactory('sessionStorage', createSessionStorage); +bottle.serviceFactory(Authenticator.name, createAuthenticator, UsersService.name, 'sessionStorage'); export const { container: serverContainer } = bottle; diff --git a/app/db/data-source.server.ts b/app/db/data-source.server.ts index 16b660c..46ab112 100644 --- a/app/db/data-source.server.ts +++ b/app/db/data-source.server.ts @@ -5,7 +5,7 @@ import { SettingsEntity } from '../entities/Settings'; import { TagEntity } from '../entities/Tag'; import { UserEntity } from '../entities/User'; import type { DbEngine } from '../utils/env.server'; -import { env, isProd } from '../utils/env.server'; +import { env } from '../utils/env.server'; const DEFAULT_PORTS: Record, number | undefined> = { mysql: 3306, @@ -32,7 +32,7 @@ function resolveOptions(): DataSourceOptions { password: env.SHLINK_DASHBOARD_DB_PASSWORD, database: env.SHLINK_DASHBOARD_DB_NAME ?? 'shlink_dashboard', synchronize: false, - logging: !isProd(), + logging: false, entities: [UserEntity, SettingsEntity, TagEntity, ServerEntity], migrations: ['app/db/migrations/*.ts'], // FIXME These won't work when bundling for prod. Revisit }; diff --git a/app/root.tsx b/app/root.tsx index bb2506e..4da667f 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,15 +1,29 @@ +import type { LoaderFunctionArgs } from '@remix-run/node'; import { Links, Meta, Outlet, Scripts } from '@remix-run/react'; +import { Authenticator } from 'remix-auth'; import { MainHeader } from './common/MainHeader'; +import { serverContainer } from './container/container.server'; import { appDataSource } from './db/data-source.server'; import './index.scss'; -export async function loader() { +export async function loader( + { request }: LoaderFunctionArgs, + authenticator: Authenticator = serverContainer[Authenticator.name], +) { if (!appDataSource.isInitialized) { console.log('Initializing database connection...'); await appDataSource.initialize(); console.log('Database connection initialized'); } + const { pathname } = new URL(request.url); + const isPublicRoute = ['/login'].includes(pathname); + if (!isPublicRoute) { + await authenticator.isAuthenticated(request, { + failureRedirect: `/login?redirect-to=${encodeURIComponent(pathname)}`, + }); + } + return {}; } diff --git a/app/routes/login.tsx b/app/routes/login.tsx new file mode 100644 index 0000000..7b4ca52 --- /dev/null +++ b/app/routes/login.tsx @@ -0,0 +1,62 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { useId } from 'react'; +import { Button, Input } from 'reactstrap'; +import { Authenticator } from 'remix-auth'; +import { CREDENTIALS_STRATEGY } from '../auth/auth.server'; +import type { SessionStorage } from '../auth/session.server'; +import { serverContainer } from '../container/container.server'; + +export async function action( + { request }: ActionFunctionArgs, + authenticator: Authenticator = serverContainer[Authenticator.name], +) { + const { searchParams } = new URL(request.url); + return authenticator.authenticate(CREDENTIALS_STRATEGY, request, { + successRedirect: searchParams.get('redirect-to') ?? '/', // TODO Make sure "redirect-to" is a relative URL + failureRedirect: request.url, + }); +} + +export async function loader( + { request }: LoaderFunctionArgs, + authenticator: Authenticator = serverContainer[Authenticator.name], + { getSession, commitSession }: SessionStorage = serverContainer.sessionStorage, +) { + // If the user is already authenticated redirect to home + await authenticator.isAuthenticated(request, { successRedirect: '/' }); + + const session = await getSession(request.headers.get('cookie')); + const error = session.get(authenticator.sessionErrorKey); + return json({ error }, { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function Login() { + const usernameId = useId(); + const passwordId = useId(); + const { error } = useLoaderData(); + + return ( +
+
+ + +
+
+ + +
+ + {!!error &&
Username or password are incorrect
} +
+ ); +} diff --git a/app/routes/server.$serverId.$.tsx b/app/routes/server.$serverId.$.tsx index 3732b3f..9c9338c 100644 --- a/app/routes/server.$serverId.$.tsx +++ b/app/routes/server.$serverId.$.tsx @@ -27,7 +27,7 @@ export async function loader( export default function ShlinkWebComponentContainer() { const [component, setComponent] = useState(null); - const { settings, tagColors } = useLoaderData>(); + const { settings, tagColors } = useLoaderData(); const params = useParams(); const { serverId } = params; const { pathname } = useLocation(); diff --git a/app/users/UsersService.server.ts b/app/users/UsersService.server.ts new file mode 100644 index 0000000..9457b5b --- /dev/null +++ b/app/users/UsersService.server.ts @@ -0,0 +1,22 @@ +import type { EntityManager } from 'typeorm'; +import { verifyPassword } from '../auth/passwords.server'; +import type { User } from '../entities/User'; +import { UserEntity } from '../entities/User'; + +export class UsersService { + constructor(private readonly em: EntityManager) {} + + async getUserByCredentials(username: string, password: string): Promise { + const user = await this.em.findOneBy(UserEntity, { username }); + if (!user) { + throw new Error(`User not found with username ${username}`); + } + + const isPasswordCorrect = await verifyPassword(password, user.password); + if (!isPasswordCorrect) { + throw new Error(`Incorrect password for user ${username}`); + } + + return user; + } +} diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts index aef5228..6ed4907 100644 --- a/app/utils/env.server.ts +++ b/app/utils/env.server.ts @@ -14,6 +14,12 @@ const envVariables = z.object({ SHLINK_DASHBOARD_DB_USER: z.string().optional(), SHLINK_DASHBOARD_DB_PASSWORD: z.string().optional(), SHLINK_DASHBOARD_DB_NAME: z.string().optional(), + + // Sessions + SHLINK_DASHBOARD_SESSION_SECRETS: z.string().transform( + // Split the comma-separated list of secrets + (secrets) => secrets.split(',').map((v) => v.trim()), + ).optional(), }); export const env = envVariables.parse(process.env); diff --git a/package-lock.json b/package-lock.json index f5b58c5..6487c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@shlinkio/shlink-frontend-kit": "^0.5.1", "@shlinkio/shlink-js-sdk": "^1.1.0", "@shlinkio/shlink-web-component": "^0.6.2", + "argon2": "^0.40.1", "bootstrap": "5.2.3", "bottlejs": "^2.0.1", "express": "^4.19.2", @@ -21,6 +22,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "reactstrap": "^9.2.2", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.5.0", "typeorm": "^0.3.20", "zod": "^3.23.8" }, @@ -32,6 +35,7 @@ "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", "@total-typescript/shoehorn": "^0.1.2", + "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.21", "@types/node": "^20.12.11", "@types/react": "^18.3.1", @@ -1677,6 +1681,14 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1817,32 +1829,6 @@ } } }, - "node_modules/@remix-run/dev/node_modules/@remix-run/server-runtime": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.9.2.tgz", - "integrity": "sha512-dX37FEeMVVg7KUbpRhX4hD0nUY0Sscz/qAjU4lYCdd6IzwJGariTmz+bQTXKCjploZuXj09OQZHSOS/ydkUVDA==", - "dev": true, - "dependencies": { - "@remix-run/router": "1.16.1", - "@types/cookie": "^0.6.0", - "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.6.0", - "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3", - "turbo-stream": "^2.0.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "typescript": "^5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@remix-run/express": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@remix-run/express/-/express-2.9.2.tgz", @@ -1888,31 +1874,6 @@ } } }, - "node_modules/@remix-run/node/node_modules/@remix-run/server-runtime": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.9.2.tgz", - "integrity": "sha512-dX37FEeMVVg7KUbpRhX4hD0nUY0Sscz/qAjU4lYCdd6IzwJGariTmz+bQTXKCjploZuXj09OQZHSOS/ydkUVDA==", - "dependencies": { - "@remix-run/router": "1.16.1", - "@types/cookie": "^0.6.0", - "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.6.0", - "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3", - "turbo-stream": "^2.0.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "typescript": "^5.1.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/@remix-run/react": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@remix-run/react/-/react-2.9.2.tgz", @@ -1938,7 +1899,15 @@ } } }, - "node_modules/@remix-run/react/node_modules/@remix-run/server-runtime": { + "node_modules/@remix-run/router": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", + "integrity": "sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@remix-run/server-runtime": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-2.9.2.tgz", "integrity": "sha512-dX37FEeMVVg7KUbpRhX4hD0nUY0Sscz/qAjU4lYCdd6IzwJGariTmz+bQTXKCjploZuXj09OQZHSOS/ydkUVDA==", @@ -1963,14 +1932,6 @@ } } }, - "node_modules/@remix-run/router": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.16.1.tgz", - "integrity": "sha512-es2g3dq6Nb07iFxGk5GuHN20RwBZOsuDQN7izWIisUcv9r+d2C5jQxqmgkdebXgReWfiyUabcki6Fg77mSNrig==", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@remix-run/testing": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/@remix-run/testing/-/testing-2.9.2.tgz", @@ -2551,6 +2512,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -3615,6 +3585,20 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "node_modules/argon2": { + "version": "0.40.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.40.1.tgz", + "integrity": "sha512-DjtHDwd7pm12qeWyfihHoM8Bn5vGcgH6sKwgPqwNYroRmxlrzadHEvMyuvQxN/V8YSyRRKD5x6ito09q1e9OyA==", + "hasInstallScript": true, + "dependencies": { + "@phc/format": "^1.0.0", + "node-addon-api": "^7.1.0", + "node-gyp-build": "^4.8.0" + }, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -10220,7 +10204,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz", "integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==", - "devOptional": true, "engines": { "node": "^16 || ^18 || >= 20" } @@ -10250,6 +10233,16 @@ "node": ">= 10.12.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", + "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -11966,6 +11959,35 @@ "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.5.0", + "resolved": "https://registry.npmjs.org/remix-auth-form/-/remix-auth-form-1.5.0.tgz", + "integrity": "sha512-xWM7T41vi4ZsIxL3f8gz/D6g2mxrnYF7LnG+rG3VqwHh6l13xCoKLraxzWRdbKMVKKQCMISKZRXAeJh9/PQwBA==", + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0", + "remix-auth": "^3.6.0" + } + }, + "node_modules/remix-auth/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/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index fbe2ce9..47cc9dd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@shlinkio/shlink-frontend-kit": "^0.5.1", "@shlinkio/shlink-js-sdk": "^1.1.0", "@shlinkio/shlink-web-component": "^0.6.2", + "argon2": "^0.40.1", "bootstrap": "5.2.3", "bottlejs": "^2.0.1", "express": "^4.19.2", @@ -36,6 +37,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "reactstrap": "^9.2.2", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.5.0", "typeorm": "^0.3.20", "zod": "^3.23.8" }, @@ -47,6 +50,7 @@ "@testing-library/react": "^15.0.7", "@testing-library/user-event": "^14.5.2", "@total-typescript/shoehorn": "^0.1.2", + "@types/bcrypt": "^5.0.2", "@types/express": "^4.17.21", "@types/node": "^20.12.11", "@types/react": "^18.3.1", diff --git a/server.ts b/server.ts index 6d603dd..517a43b 100644 --- a/server.ts +++ b/server.ts @@ -7,12 +7,7 @@ const viteDevServer = isProd() : await import('vite').then( (vite) => vite.createServer({ - server: { - middlewareMode: true, - watch: { - ignored: ['**/home/**', '**/build/**', '**/.idea/**', '**/node_modules/**', '**/.git/**'], - }, - }, + server: { middlewareMode: true }, }), ); diff --git a/test/auth/auth.server.test.ts b/test/auth/auth.server.test.ts new file mode 100644 index 0000000..ec5d491 --- /dev/null +++ b/test/auth/auth.server.test.ts @@ -0,0 +1,53 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import { ReadableStream } from 'node:stream/web'; +import { Authenticator } from 'remix-auth'; +import { createAuthenticator, CREDENTIALS_STRATEGY } from '../../app/auth/auth.server'; +import type { UsersService } from '../../app/users/UsersService.server'; + +describe('auth', () => { + const getUserByCredentials = vi.fn(); + const usersService = fromPartial({ getUserByCredentials }); + + describe('createAuthenticator', () => { + it('creates the authenticator instance', () => { + const authenticator = createAuthenticator(usersService, fromPartial({})); + expect(authenticator).toBeInstanceOf(Authenticator); + }); + }); + + describe('authenticator', () => { + const authenticator = createAuthenticator(usersService, fromPartial({})); + const requestWithBody = (body: string = '') => { + const headers = new Headers(); + headers.set('Content-Type', 'application/x-www-form-urlencoded'); + return fromPartial({ + body: ReadableStream.from(body), + url: 'https://example.com', + headers, + method: 'POST', + }); + }; + + it('throws error when credentials are invalid', async () => { + const callback = () => authenticator.authenticate( + CREDENTIALS_STRATEGY, + requestWithBody(), + ); + + await expect(callback).rejects.toThrow(); + expect(getUserByCredentials).not.toHaveBeenCalled(); + }); + + it('tries to find user with credentials', async () => { + getUserByCredentials.mockResolvedValue({ id: 123 }); + + const result = await authenticator.authenticate( + CREDENTIALS_STRATEGY, + requestWithBody('username=foo&password=bar'), + ); + + expect(result).toEqual({ userId: 123 }); + expect(getUserByCredentials).toHaveBeenCalledWith('foo', 'bar'); + }); + }); +}); diff --git a/test/auth/passwords.server.test.ts b/test/auth/passwords.server.test.ts new file mode 100644 index 0000000..6bada01 --- /dev/null +++ b/test/auth/passwords.server.test.ts @@ -0,0 +1,33 @@ +import { hashPassword, verifyPassword } from '../../app/auth/passwords.server'; + +describe('passwords', () => { + it('can hash and then verify a password', async () => { + const password1 = 'the_password'; + const password2 = 'P4SSW0RD'; + + const [password1hash1, password1hash2, password2hash1, password2hash2] = await Promise.all([ + hashPassword(password1), + hashPassword(password1), + hashPassword(password2), + hashPassword(password2), + ]); + + const [validChecks, invalidChecks] = await Promise.all([ + Promise.all([ + verifyPassword(password1, password1hash1), + verifyPassword(password1, password1hash2), + verifyPassword(password2, password2hash1), + verifyPassword(password2, password2hash2), + ]), + Promise.all([ + verifyPassword(password2, password1hash1), + verifyPassword(password2, password1hash2), + verifyPassword(password1, password2hash1), + verifyPassword(password1, password2hash2), + ]), + ]); + + expect(validChecks.every((isValid) => isValid)).toEqual(true); + expect(invalidChecks.every((isValid) => !isValid)).toEqual(true); + }); +}); diff --git a/test/auth/session.server.test.ts b/test/auth/session.server.test.ts new file mode 100644 index 0000000..fd3d46a --- /dev/null +++ b/test/auth/session.server.test.ts @@ -0,0 +1,11 @@ +import { createSessionStorage } from '../../app/auth/session.server'; + +describe('createSessionStorage', () => { + it('creates a session storage', () => { + const storage = createSessionStorage(); + + expect(storage.destroySession).toEqual(expect.any(Function)); + expect(storage.commitSession).toEqual(expect.any(Function)); + expect(storage.getSession).toEqual(expect.any(Function)); + }); +}); diff --git a/test/routes/login.test.tsx b/test/routes/login.test.tsx new file mode 100644 index 0000000..8c484f3 --- /dev/null +++ b/test/routes/login.test.tsx @@ -0,0 +1,78 @@ +import type { Session } from '@remix-run/node'; +import { json } from '@remix-run/node'; +import { createRemixStub } from '@remix-run/testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Authenticator } from 'remix-auth'; +import { CREDENTIALS_STRATEGY } from '../../app/auth/auth.server'; +import type { SessionStorage } from '../../app/auth/session.server'; +import Login, { action, loader } from '../../app/routes/login'; + +describe('login', () => { + const authenticate = vi.fn(); + const isAuthenticated = vi.fn().mockResolvedValue(undefined); + const authenticator = fromPartial({ authenticate, isAuthenticated }); + const getSession = vi.fn(); + const commitSession = vi.fn().mockResolvedValue(''); + const sessionStorage = fromPartial({ getSession, commitSession }); + + describe('action', () => { + it.each([ + ['http://example.com', '/'], + [`http://example.com?redirect-to=${encodeURIComponent('/foo/bar')}`, '/foo/bar'], + ])('authenticates user and redirects to expected location', (url, expectedSuccessRedirect) => { + const request = fromPartial({ url }); + action(fromPartial({ request }), authenticator); + + expect(authenticate).toHaveBeenCalledWith(CREDENTIALS_STRATEGY, request, { + successRedirect: expectedSuccessRedirect, + failureRedirect: url, + }); + }); + }); + + describe('loader', () => { + it('checks authentication and exposes error from session, if any', async () => { + const error = 'the_error'; + const session = fromPartial({ get: vi.fn().mockReturnValue(error) }); + getSession.mockResolvedValue(session); + + const headers = new Headers(); + headers.set('cookie', 'the_cookies'); + const request = fromPartial({ headers }); + + const response = await loader(fromPartial({ request }), authenticator, sessionStorage); + + expect(await response.json()).toEqual({ error }); + expect(isAuthenticated).toHaveBeenCalled(); + expect(getSession).toHaveBeenCalledWith('the_cookies'); + expect(commitSession).toHaveBeenCalledWith(session); + }); + }); + + describe('', () => { + const setUp = (error: unknown = undefined) => { + const RemixStub = createRemixStub([ + { + path: '/', + Component: Login, + loader: () => json({ error }), + }, + ]); + return render(); + }; + + it('renders expected form controls', async () => { + setUp(); + + await waitFor(() => expect(screen.getByLabelText('Username:')).toBeInTheDocument()); + expect(screen.getByLabelText('Password:')).toBeInTheDocument(); + expect(screen.queryByTestId('error-message')).not.toBeInTheDocument(); + }); + + it('renders error when present', async () => { + setUp('some error'); + await waitFor(() => expect(screen.getByTestId('error-message')).toBeInTheDocument()); + }); + }); +}); diff --git a/test/routes/server.$serverId.tags.colors.test.ts b/test/routes/server.$serverId.tags.colors.test.ts index 31942df..d053293 100644 --- a/test/routes/server.$serverId.tags.colors.test.ts +++ b/test/routes/server.$serverId.tags.colors.test.ts @@ -9,7 +9,6 @@ describe('server.$serverId.tags.colors', () => { beforeEach(() => { tagsService = fromPartial({ updateTagColors }); - vi.clearAllMocks(); }); it('updates tags in action and returns response', async () => { diff --git a/test/servers/ServersService.server.test.ts b/test/servers/ServersService.server.test.ts index 25ea47d..26b2a38 100644 --- a/test/servers/ServersService.server.test.ts +++ b/test/servers/ServersService.server.test.ts @@ -11,7 +11,6 @@ describe('ServersService', () => { beforeEach(() => { em = fromPartial({ findOneBy }); service = new ServersService(em); - vi.clearAllMocks(); }); describe('getByPublicId', () => { diff --git a/test/setup.ts b/test/setup.ts index a812a5d..d839ada 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -14,6 +14,11 @@ axe.configure({ ], }); +// Clears all mocks after every test +afterEach(() => { + vi.clearAllMocks(); +}); + if (typeof HTMLCanvasElement !== 'undefined') { HTMLCanvasElement.prototype.getContext = (() => {}) as any; } diff --git a/test/tags/TagsService.server.test.ts b/test/tags/TagsService.server.test.ts index 5268dfe..39089d3 100644 --- a/test/tags/TagsService.server.test.ts +++ b/test/tags/TagsService.server.test.ts @@ -31,7 +31,6 @@ describe('TagsService', () => { beforeEach(() => { tagsService = new TagsService(em); transaction.mockImplementation((callback) => callback(em)); - vi.clearAllMocks(); }); describe('tagColors', () => { diff --git a/test/users/UsersService.server.test.ts b/test/users/UsersService.server.test.ts new file mode 100644 index 0000000..02559d9 --- /dev/null +++ b/test/users/UsersService.server.test.ts @@ -0,0 +1,41 @@ +import { fromPartial } from '@total-typescript/shoehorn'; +import type { EntityManager } from 'typeorm'; +import { hashPassword } from '../../app/auth/passwords.server'; +import type { User } from '../../app/entities/User'; +import { UsersService } from '../../app/users/UsersService.server'; + +describe('UsersService', () => { + const findOneBy = vi.fn(); + let em: EntityManager; + let usersService: UsersService; + + beforeEach(() => { + em = fromPartial({ findOneBy }); + usersService = new UsersService(em); + }); + + describe('getUserByCredentials', () => { + it('throws when user is not found', async () => { + findOneBy.mockResolvedValue(null); + await expect(() => usersService.getUserByCredentials('foo', 'bar')).rejects.toEqual( + new Error('User not found with username foo'), + ); + }); + + it('throws if password does not match', async () => { + findOneBy.mockResolvedValue(fromPartial({ password: await hashPassword('the right one') })); + await expect(() => usersService.getUserByCredentials('foo', 'bar')).rejects.toEqual( + new Error('Incorrect password for user foo'), + ); + }); + + it('returns user if password is correct', async () => { + const expectedUser = fromPartial({ password: await hashPassword('bar') }); + findOneBy.mockResolvedValue(expectedUser); + + const result = await usersService.getUserByCredentials('foo', 'bar'); + + expect(result).toEqual(expectedUser); + }); + }); +});