Skip to content

Commit

Permalink
New server models, more auth testing, more fixtures
Browse files Browse the repository at this point in the history
  • Loading branch information
francisli committed Mar 30, 2024
1 parent 417353a commit 38bd765
Show file tree
Hide file tree
Showing 9 changed files with 296 additions and 59 deletions.
52 changes: 52 additions & 0 deletions server/models/base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
class Base {
constructor(fields, data) {
if (!data) {
return null;
}
return new Proxy(this, {
get(target, property, receiver) {
// if the property is in the schema, return it from the data object
if (Object.hasOwn(fields, property)) {
return data[property];
}
// otherwise, dispatch it to the target with the proxy as this
const { get, value } =
Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(target),
property,
) ?? {};
// handle property accessor
if (get) {
return get.call(receiver);
}
// handle function
if (value) {
return value.bind(receiver);
}
// otherwise, return value directly off of target
return target[property];
},
set(target, property, value, receiver) {
// if the property is in the schema, set it in the data object
if (Object.hasOwn(fields, property)) {
data[property] = value;
return true;
}
// otherwise, set on target with proxy as this
const descriptor =
Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(target),
property,
) ?? {};
const { set } = descriptor;
if (set) {
set.call(receiver, value);
return true;
}
target[property] = value;
return true;
},
});
}
}
export default Base;
43 changes: 43 additions & 0 deletions server/models/user.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Prisma } from '@prisma/client';
import bcrypt from 'bcrypt';
import crypto from 'crypto';

import Base from './base.js';

class User extends Base {
constructor(data) {
super(Prisma.UserScalarFieldEnum, data);
this.test = 'foo';
}

set test2(newValue) {
this._test2 = newValue;
}

get isActive() {
return this.isApproved && this.isEmailVerified;
}

get isApproved() {
return !!this.approvedAt;
}

get isEmailVerified() {
return !!this.emailVerifiedAt;
}

generateEmailVerificationToken() {
const buffer = crypto.randomBytes(3);
this.emailVerificationToken = buffer.toString('hex').toUpperCase();
}

async setPassword(password) {
this.hashedPassword = await bcrypt.hash(password, 10);
}

async comparePassword(password) {
return bcrypt.compare(password, this.hashedPassword);
}
}

export default User;
5 changes: 2 additions & 3 deletions server/plugins/auth.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fp from 'fastify-plugin';

// the use of fastify-plugin is required to be able
// to export the decorators to the outer scope
import User from '../models/user.js';

export default fp(async function (fastify) {
// set up secure encrypted cookie-based sessions
Expand All @@ -22,7 +21,7 @@ export default fp(async function (fastify) {
if (id) {
const user = await fastify.prisma.user.findUnique({ where: { id } });
if (user) {
request.user = user;
request.user = new User(user);
} else {
// session data is invalid, delete
request.session.delete();
Expand Down
26 changes: 13 additions & 13 deletions server/routes/api/v1/auth/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import bcrypt from 'bcrypt';
import User from '../../../../models/user.js';

export default async function (fastify, _opts) {
// add a login route that handles the actual login
Expand Down Expand Up @@ -32,19 +32,19 @@ export default async function (fastify, _opts) {
},
async (request, reply) => {
const { email, password } = request.body;
try {
const user = await fastify.prisma.user.findUnique({ where: { email } });
if (!user) {
return reply.notFound();
}
const result = await bcrypt.compare(password, user.hashedPassword);
if (!result) {
return reply.unauthorized();
}
request.session.set('userId', user.id);
} catch (error) {
return error;
let user = await fastify.prisma.user.findUnique({ where: { email } });
if (!user) {
return reply.notFound();
}
user = new User(user);
const result = await user.comparePassword(password);
if (!result) {
return reply.unauthorized();
}
if (!user.isActive) {
return reply.forbidden();
}
request.session.set('userId', user.id);
},
);

Expand Down
68 changes: 26 additions & 42 deletions server/routes/api/v1/users/index.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,7 @@
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { StatusCodes } from 'http-status-codes';

import mailer from '../../../../helpers/email/mailer.js';

// async function preHandler (request, reply) {
// const { id } = request.params
// const user = await fastify.prisma.user.findUnique({
// where: { id: Number(id) }
// })

// if (!user) {
// reply.code(404).send({
// code: 'USER_NOT_FOUND',
// message: `The user #${id} not found!`
// })
// } else {
// reply.send(user)
// }
// }
import User from '../../../../models/user.js';

// User template for the routes
export default async function (fastify, _opts) {
Expand All @@ -32,18 +16,20 @@ export default async function (fastify, _opts) {
required: ['firstName', 'lastName', 'email'],
properties: {
firstName: { type: 'string' },
middleName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string', format: 'email' },
hashedPassword: { type: 'string' },
licenseNumber: { type: 'string' },
},
},
response: {
201: {
[StatusCodes.CREATED]: {
type: 'object',
properties: {
id: { type: 'string' },
firstName: { type: 'string' },
middleName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string', format: 'email' },
role: { type: 'string' },
Expand All @@ -54,28 +40,26 @@ export default async function (fastify, _opts) {
},
},
async (request, reply) => {
const { firstName, lastName, email, password, licenseNumber } =
request.body;

const {
firstName,
middleName,
lastName,
email,
password,
licenseNumber,
} = request.body;

let data = { firstName, middleName, lastName, email, licenseNumber };
const user = new User(data);
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);

await user.setPassword(password);
// Generate verification token
const buffer = crypto.randomBytes(3);
const emailVerificationToken = buffer.toString('hex').toUpperCase();
user.generateEmailVerificationToken();
// Set role
user.role = 'FIRST_RESPONDER';

// Create user in db
const user = await fastify.prisma.user.create({
data: {
firstName,
lastName,
email,
role: 'FIRST_RESPONDER',
hashedPassword,
licenseNumber,
emailVerificationToken,
},
});
data = await fastify.prisma.user.create({ data });

// Format email
let mailOptions = {
Expand All @@ -85,7 +69,7 @@ export default async function (fastify, _opts) {
html: `
<p>Hi ${firstName},</p>
<p>Enter the 6-character code to verify your email.</p>
<p><b>${emailVerificationToken}</b></p>
<p><b>${user.emailVerificationToken}</b></p>
<p>Please allow our admins to review and confirm your identity. Thanks for helping us keep your account secure.</p>
<p>Best,<br/>Sf Lifeline</p>
`,
Expand All @@ -100,7 +84,7 @@ export default async function (fastify, _opts) {
}
});

reply.code(201).send(user);
reply.code(StatusCodes.CREATED).send(data);
},
);

Expand All @@ -110,7 +94,7 @@ export default async function (fastify, _opts) {
{
schema: {
response: {
200: {
[StatusCodes.OK]: {
type: 'array',
items: {
type: 'object',
Expand Down Expand Up @@ -198,7 +182,7 @@ export default async function (fastify, _opts) {
},
},
response: {
200: {
[StatusCodes.OK]: {
type: 'object',
properties: {
id: { type: 'number' },
Expand Down Expand Up @@ -242,7 +226,7 @@ export default async function (fastify, _opts) {
},
},
response: {
200: {
[StatusCodes.OK]: {
type: 'object',
properties: {
message: { type: 'string' },
Expand Down
15 changes: 15 additions & 0 deletions server/test/fixtures/db/User.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,20 @@ items:
firstName: Admin
lastName: User
email: [email protected]
emailVerifiedAt: <%= new Date().toISOString() %>
role: ADMIN
hashedPassword: $2b$10$ICaCk3VVZUCtO9HySahquuQusQhEnRpXHdzxaceUUJPk0DTwN2e/W # test
approvedAt: <%= new Date().toISOString() %>
user2:
firstName: Unverified
lastName: Email
email: [email protected]
role: FIRST_RESPONDER
hashedPassword: $2b$10$ICaCk3VVZUCtO9HySahquuQusQhEnRpXHdzxaceUUJPk0DTwN2e/W # test
user3:
firstName: Unapproved
lastName: User
email: [email protected]
emailVerifiedAt: <%= new Date().toISOString() %>
role: FIRST_RESPONDER
hashedPassword: $2b$10$ICaCk3VVZUCtO9HySahquuQusQhEnRpXHdzxaceUUJPk0DTwN2e/W # test
75 changes: 75 additions & 0 deletions server/test/models/base.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, it } from 'node:test';
import * as assert from 'node:assert';

import Base from '../../models/base.js';

class Test extends Base {
constructor(data) {
super({ property0: 'property0' }, data);
this.property1 = 'value1';
}

get property2() {
return this.property0 + this.property1;
}

set property2(newValue) {
const tokens = newValue.split(',');
this.property0 = tokens[0];
this.property1 = tokens[1];
}

function1() {
return `${this.property0} accessed through function 1`;
}
}

describe('Base', () => {
describe('get', () => {
it('proxies an underlying scalar field in the schema', () => {
const test = new Test({ property0: 'value0' });
assert.deepStrictEqual(test.property0, 'value0');
});

it('gets a normal property', () => {
const test = new Test({ property0: 'value0' });
assert.deepStrictEqual(test.property1, 'value1');
});

it('binds getters to the proxy so they can access proxied scalar fields', () => {
const test = new Test({ property0: 'value0' });
assert.deepStrictEqual(test.property2, 'value0value1');
});

it('binds functions to the proxy so they can access proxied scalar fields', () => {
const test = new Test({ property0: 'value0' });
assert.deepStrictEqual(
test.function1(),
'value0 accessed through function 1',
);
});
});

describe('set', () => {
it('proxies to the underlying scalar field in the schema', () => {
const data = { property0: 'value0' };
const test = new Test(data);
test.property0 = 'newvalue0';
assert.deepStrictEqual(data.property0, 'newvalue0');
});

it('sets a normal property', () => {
const test = new Test({ property0: 'value0' });
test.property1 = 'newvalue1';
assert.deepStrictEqual(test.property1, 'newvalue1');
});

it('binds setters to the proxy so they can set proxied scalar fields', () => {
const data = { property0: 'value0' };
const test = new Test(data);
test.property2 = 'newvalue0,newvalue1';
assert.deepStrictEqual(data.property0, 'newvalue0');
assert.deepStrictEqual(test.property1, 'newvalue1');
});
});
});
Loading

0 comments on commit 38bd765

Please sign in to comment.