From 273249f81a0d0fda2559cd9844b572f4d11edeeb Mon Sep 17 00:00:00 2001 From: Thomas Hintz Date: Thu, 6 Apr 2023 15:32:38 -0700 Subject: [PATCH] Create account tests and improvements. --- cypress/e2e/createAccount.cy.js | 79 ++++++++++++++++++++++++++ src/pages/api/create-account.js | 99 +++++++++++++++++++++++++++++---- src/pages/api/reset-test-db.js | 19 +++++++ 3 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 cypress/e2e/createAccount.cy.js create mode 100644 src/pages/api/reset-test-db.js diff --git a/cypress/e2e/createAccount.cy.js b/cypress/e2e/createAccount.cy.js new file mode 100644 index 0000000..fd263e0 --- /dev/null +++ b/cypress/e2e/createAccount.cy.js @@ -0,0 +1,79 @@ +// cypress/integration/create_account.spec.js + +describe('Create Account Page', () => { + beforeEach(() => { + // Set the base URL and visit the create account page + cy.request('POST', '/api/reset-test-db').then((response) => { + // Expect the response status to be 200, otherwise log an error message and stop the tests + expect(response.status, 'Reset test database successfully').to.eq(200); + + // Visit the create account page if the response status is 200 + cy.visit('/reactors/create-account?csi=cs_test_a1WLk3QvOyIJRFeV21BNIhdtXx26z5rF2x6pIzYKHq32ujVSz4W4fZ0IGI'); + }); + }); + + it('renders the page correctly', () => { + cy.contains('The Reactors - Create Account'); + cy.get('input[name="emailprefilled"]').should('be.disabled'); + cy.get('input[name="emailprefilled"]').should('have.value', 'me@thintz.com'); + cy.get('input[name="password"]').should('have.attr', 'minlength', '12'); + cy.get('input[name="passwordagain"]').should('have.attr', 'minlength', '12'); + }); + + it('shows an error message when passwords do not match', () => { + cy.get('input[name="password"]').type('ValidPassword123'); + cy.get('input[name="passwordagain"]').type('InvalidPassword456'); + cy.get('button[type="submit"]').click(); + cy.contains('There was an error with your submission'); + }); + + it('shows an error message when the password is too short', () => { + cy.get('input[name="password"]').type('Short'); + cy.get('input[name="passwordagain"]').type('Short'); + cy.get('button[type="submit"]').click(); + cy.contains('There was an error with your submission'); + }); + + it('submits the form successfully with valid input', () => { + const validPassword = 'ValidPassword123'; + + cy.get('input[name="password"]').type(validPassword); + cy.get('input[name="passwordagain"]').type(validPassword); + cy.get('button[type="submit"]').click(); + + cy.url().should('eq', `${Cypress.config().baseUrl}/reactors`) + }); + + it.only('should rate limit when sending multiple requests', () => { + const testPassword = 'aVerySecureP@ssword123'; + + // A helper function to submit the create account form + const submitForm = () => { + cy.get('input[name="password"]').type(testPassword); + cy.get('input[name="passwordagain"]').type(testPassword); + cy.get('button[type="submit"').click(); + }; + + submitForm(); + + // Check that the user is redirected (assuming success) + cy.url().should('not.include', '/reactors/create-account'); + cy.visit('/reactors/create-account?csi=cs_test_a1WLk3QvOyIJRFeV21BNIhdtXx26z5rF2x6pIzYKHq32ujVSz4W4fZ0IGI'); + // Send requests up to the rate limit + for (let i = 0; i < 10; i++) { + submitForm(); + + cy.contains('Unexpected Error'); + + // Return to the create account page for the next iteration + cy.visit('/reactors/create-account?csi=cs_test_a1WLk3QvOyIJRFeV21BNIhdtXx26z5rF2x6pIzYKHq32ujVSz4W4fZ0IGI'); + } + + // The 11th request should trigger rate limiting + submitForm(); + + // Check that the user is still on the create account page with a rate-limiting error message + cy.url().should('include', '/reactors/create-account'); + cy.contains('Too many requests.'); + }); +}); diff --git a/src/pages/api/create-account.js b/src/pages/api/create-account.js index aa35ea4..e52bbc6 100644 --- a/src/pages/api/create-account.js +++ b/src/pages/api/create-account.js @@ -1,66 +1,145 @@ import Stripe from 'stripe'; -const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL'); +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); import db from '@/db'; import { scrypt, randomBytes, timingSafeEqual, randomUUID } from 'crypto'; import { promisify } from 'util'; +// Promisify scrypt for better async/await usage const scryptPromise = promisify(scrypt); +// Generate a random salt with a default length of 16 bytes function genSalt(bytes = 16) { return randomBytes(bytes).toString('hex'); }; +// Hash the password with the provided salt and rounds (default 64 rounds) async function hash(salt, password, rounds = 64) { const derivedKey = await scryptPromise(password, salt, rounds); - return derivedKey.toString('hex') + return derivedKey.toString('hex'); } +// Verify the password against the stored hash and salt async function verify(password, hash, salt, rounds = 64) { const keyBuffer = Buffer.from(hash, 'hex'); const derivedKey = await scryptPromise(password, salt, rounds); return timingSafeEqual(keyBuffer, derivedKey); } - +// Generate a URL for redirecting with a custom error message function makeMsg(csi, email, text) { - return `/reactors/create-account?csi=${csi}&msg=${encodeURIComponent(text)}&email=${encodeURIComponent(email)}` + return `/reactors/create-account?csi=${csi}&msg=${encodeURIComponent(text)}&email=${encodeURIComponent(email)}`; +} + +// Create a new user in the database +const createUser = async (email, salt, hashRes) => { + await db.run('insert into users (email, salt, password_hash) values (?, ?, ?);', email, salt, hashRes); + const { id: userId } = await db.get('select id from users where email=?', email); + return userId; +}; + +// Create a new subscription for the user +const createSubscription = async (userId) => { + await db.run('insert into subscriptions (uuid, user_id) values (?, ?);', randomUUID(), userId); +}; + +// Rate-limiting settings +const rateLimitWindow = 60 * 1000 * 3; // 3 minute +const maxRequests = 10; // Maximum number of requests within the rateLimitWindow +const rateLimiter = new Map(); + +const isRateLimited = (ip) => { + const currentTime = Date.now(); + const record = rateLimiter.get(ip); + + if (record) { + const [requestCount, windowStart] = record; + + // If the request is within the rate limit window, update the request count + if (currentTime - windowStart < rateLimitWindow) { + if (requestCount > maxRequests) { + return true; + } + rateLimiter.set(ip, [requestCount + 1, windowStart]); + } else { + // If the request is outside the rate limit window, reset the request count + rateLimiter.set(ip, [1, currentTime]); + } + } else { + // If the IP is not in the rateLimiter, add it + rateLimiter.set(ip, [1, currentTime]); + } + + return false; }; export default async function handler(req, res) { if (req.method === 'POST') { const { email, password, passwordagain, csi } = req.body; + + // Check for rate limiting + const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; + if (isRateLimited(ip)) { + res.redirect(makeMsg(csi, email, 'Too many requests. Please try again later or send a message on the contact page if you believe this is an error.')); + return; + } + + // Validate email, password, and csi if (email && password && password === passwordagain && csi) { + // Check for minimum password length + if (password.length < 12) { + res.redirect(makeMsg(csi, email, 'Please enter a password that is at least 12 characters long.')); + return; + } + + // Retrieve Stripe session and email const session = csi && await stripe.checkout.sessions.retrieve(csi); const emailFromSession = session && session.customer_details.email; + + // Validate session and email if (!session || !emailFromSession || email !== emailFromSession) { + console.error('Unexpected error occurred'); + if (!session) { console.error('unable to get session'); } + if (!emailFromSession) { console.error('unable to get email from session'); } + if (!email === emailFromSession) { console.error('session email does not match form email'); } res.redirect('/reactors/create-account?unexpected_error=true'); + return; } + + // Check if user already exists const existingUser = await db.get('select id from users where email=?', email); if (existingUser) { + console.error('User already exists'); res.redirect('/reactors/create-account?unexpected_error=true'); + return; } - console.log('inserting user'); + + // Create new user and subscription const salt = genSalt(); const hashRes = await hash(salt, password); - await db.run('insert into users (email, salt, password_hash) values (?, ?, ?);', email, salt, hashRes); - const { id: userId } = await db.get('select id from users where email=?', email); - await db.run('insert into subscriptions (uuid, user_id) values (?, ?);', randomUUID(), userId); - console.log('done inserting user'); - res.redirect('/reactors') + const userId = await createUser(email, salt, hashRes); + await createSubscription(userId); + console.log('User created successfully'); + res.redirect('/reactors'); } else { + // Handle missing or invalid form data if (!email || !csi) { + console.error('Missing email or csi'); res.redirect('/reactors/create-account?unexpected_error=true'); + return; } if (!password) { res.redirect(makeMsg(csi, email, 'Please enter a password')); + return; } if (password !== passwordagain) { res.redirect(makeMsg(csi, email, 'Passwords did not match. Please try again.')); + return; } } } else { // Handle any other HTTP method + res.status(405).json({ error: 'Method not allowed. Only POST method is supported.' }); } } diff --git a/src/pages/api/reset-test-db.js b/src/pages/api/reset-test-db.js new file mode 100644 index 0000000..9fd8ed9 --- /dev/null +++ b/src/pages/api/reset-test-db.js @@ -0,0 +1,19 @@ +import fs from 'fs'; + +export default async function handler(req, res) { + if (process.env.NODE_ENV !== 'development') { + return res.status(401).send('Unauthorized'); + } + + if (req.method === 'POST') { + try { + fs.copyFileSync('./test-db.sqlite3', './db.sqlite3'); + res.status(200).end(); + } catch (error) { + console.error(error); + res.status(500).send('Error copying file'); + } + } else { + res.status(405).send('Method Not Allowed'); + } +}