HTML Login web form with Express JS & Cypress

HTML Login web form with Express JS & Cypress

This is a documentation for using a simple HTML login web form that is integrated with an Express JS server. In addition to that, a Cypress test is created to automate the process of testing this application.


The Express JS Server code

const minimist = require('minimist')
const morgan = require('morgan')
const bodyParser = require('body-parser')
const session = require('express-session')
const express = require('express')

const app = express()

// get port from passed in args from scripts/start.js
const port = minimist(process.argv.slice(2)).port

const matchesUsernameAndPassword = (body = {}) => {
  return body.username === 'jane.lane' && body.password === 'password123'
}

const ensureLoggedIn = (req, res, next) => {
  if (req.session.user) {
    next()
  } else {
    res.redirect('/unauthorized')
  }
}

// parse regular form submission bodies
const urlencodedParser = bodyParser.urlencoded({ extended: false })

app.use(morgan('dev'))

// store a session cookie called
// 'cypress-session-cookie'
app.use(session({
  name: 'cypress-session-cookie',
  secret: 'sekret',
  resave: false,
  saveUninitialized: false,
}))

// app.use((req, res, next) => {
//   console.log('session info is', req.session)
//   next()
// })

app.set('views', __dirname)
app.set('view engine', 'hbs')

app.get('/', (req, res) => res.redirect('/login'))

// this is the standard HTML login page
app.get('/login', (req, res) => {
  res.render('./login.hbs')
})

// specify that the urlencodedParser should only
// be used on this one route when its coming from
// the login form
app.post('/login', urlencodedParser, (req, res) => {
  // if this matches the secret username and password
  if (matchesUsernameAndPassword(req.body)) {
    req.session.user = 'jane.lane'

    res.redirect('/dashboard')
  } else {
    // render login with errors
    res.render('./login.hbs', {
      error: 'Username and/or password is incorrect',
    })
  }
})

app.get('/dashboard', ensureLoggedIn, (req, res) => {
  res.render('./dashboard.hbs', {
    user: req.session.user,
  })
})

app.get('/users', ensureLoggedIn, (req, res) => {
  res.render('./users.hbs')
})

app.get('/admin', ensureLoggedIn, (req, res) => {
  res.render('./admin.hbs')
})

app.get('/unauthorized', (req, res) => {
  res.render('./unauthorized.hbs')
})

app.listen(port)

Step to set up the project for running this server code

  1. Open Visual Studio Code and create a project folder.
  2. Make sure that Node.js is installed on your device
  3. Create a package.json
    npm init -y
  4. Install Dependencies
    npm install express minimist morgan body-parser express-session hbs
  5. Create a new file named server.js in the root of your project folder.
  6. Paste the provided Express server code into server.js.
  7. You should be now ready to run the local server with the following command:
    npm start — –port=7077
  8. Open your browser and navigate to:
    http://localhost:7077
  9. By default it will redirect you to the login page:
    http://localhost:7077/login
  10. Test the login functionality:
    Use the username jane.lane and password password123 to log in.
  11. Successful login should lead you to the dashboard page:
    http://localhost:7077/dashboard

Express JS Middlewares used in this server code:

1. minimist

  • What it does:
    minimist is a lightweight library used to parse command-line arguments passed to a Node.js application.
  • Why it’s used in your server code:
    It allows you to retrieve values for custom arguments passed when starting your server. For example:
    node server.js --port=7077
    Here, minimist extracts port=7077 so the server can dynamically bind to the specified port.
  • Example Usage in Code:
    const port = minimist(process.argv.slice(2)).port;
    This extracts the port value (7077 in the example above) from the command-line arguments.

2. morgan

  • What it does:
    morgan is a HTTP request logger middleware for Express. It logs information about incoming requests, such as the HTTP method, URL, response status, and response time.
  • Why it’s used in your server code:
    It helps with debugging and monitoring by providing detailed logs of all HTTP requests processed by your server.
  • Example Logs:
    GET /login 200 4.512 ms
    POST /login 302 5.123 ms
    These logs can help you track activity on your server and identify potential issues.
  • How it’s added:
    app.use(morgan('dev'));
    The 'dev' format provides concise colored output in the console.

3. body-parser

  • What it does:
    body-parser is a middleware that parses incoming request bodies in a middleware before your handlers (e.g., req.body), making the data accessible in JSON or URL-encoded formats.
  • Why it’s used in your server code:
    It processes form data sent via HTTP POST requests, such as login credentials (username and password) submitted in a form.
    • The urlencodedParser handles URL-encoded data (like data submitted from HTML forms with application/x-www-form-urlencoded).
  • Example Usage in Code:
    const urlencodedParser = bodyParser.urlencoded({ extended: false }); app.post('/login', urlencodedParser, (req, res) => { console.log(req.body); // Access form fields like req.body.username and req.body.password });

4. express-session

  • What it does:
    express-session is middleware that manages sessions in your application. Sessions allow you to store user data (like authentication status) across multiple requests from the same client.
  • Why it’s used in your server code:
    It keeps track of whether a user is logged in. Once a user logs in, their session stores information like their username, and this data persists across subsequent requests.
  • Key Options in Your Code:
    app.use(session({ name: 'cypress-session-cookie', // Custom cookie name secret: 'sekret', // Secret used to sign the session ID cookie resave: false, // Prevents resaving session if no changes are made saveUninitialized: false, // Does not create session until something is stored }));
    • A session cookie named cypress-session-cookie is sent to the client.
    • The session data can then be accessed using req.session.
  • Example Usage in Code:
    req.session.user = 'jane.lane'; // Set session data
    if (req.session.user) { console.log('User is logged in'); }

Cypress script

Check out the whole script here:
/// <reference types="cypress" />

// This recipe is very similar to the 'Logging In - XHR web form'
// except that is uses regular HTML form submission
// instead of using XHR's.

// We are going to test a few things:
// 1. Test unauthorized routes using cy.visit + cy.request
// 2. Test using a regular form submission (old school POSTs)
// 3. Test error states
// 4. Test authenticated session states
// 5. Use cy.request for much faster performance
// 6. Create a custom command

// Be sure to run `npm start` to start the server
// before running the tests below.

describe('Logging In - HTML Web Form', function () {
  // we can use these values to log in
  const username = 'jane.lane'
  const password = 'password123'

  context('Unauthorized', function () {
    it('is redirected on visit to /dashboard when no session', function () {
      // we must have a valid session cookie to be logged
      // in else we are redirected to /unauthorized
      cy.visit('/dashboard')
      cy.get('h3').should(
        'contain',
        'You are not logged in and cannot access this page'
      )

      cy.url().should('include', 'unauthorized')
    })

    it('is redirected using cy.request', function () {
      // instead of visiting the page above we can test this by issuing
      // a cy.request, checking the status code and redirectedToUrl property.

      // See docs for cy.request: https://on.cypress.io/api/request

      // the 'redirectedToUrl' property is a special Cypress property under the hood
      // that normalizes the url the browser would normally follow during a redirect
      cy.request({
        url: '/dashboard',
        followRedirect: false, // turn off following redirects automatically
      }).then((resp) => {
        // should have status code 302
        expect(resp.status).to.eq(302)

        // when we turn off following redirects Cypress will also send us
        // a 'redirectedToUrl' property with the fully qualified URL that we
        // were redirected to.
        expect(resp.redirectedToUrl).to.eq('http://localhost:7077/unauthorized')
      })
    })
  })

  context('HTML form submission', function () {
    beforeEach(function () {
      cy.visit('/login')
    })

    it('displays errors on login', function () {
      // incorrect username on purpose
      cy.get('input[name=username]').type('jane.lae')
      cy.get('input[name=password]').type('password123{enter}')

      // we should have visible errors now
      cy.get('p.error')
      .should('be.visible')
      .and('contain', 'Username and/or password is incorrect')

      // and still be on the same URL
      cy.url().should('include', '/login')
    })

    it('redirects to /dashboard on success', function () {
      cy.get('input[name=username]').type(username)
      cy.get('input[name=password]').type(password)
      cy.get('form').submit()

      // we should be redirected to /dashboard
      cy.url().should('include', '/dashboard')
      cy.get('h1').should('contain', 'jane.lane')

      // and our cookie should be set to 'cypress-session-cookie'
      cy.getCookie('cypress-session-cookie').should('exist')
    })
  })

  context('HTML form submission with cy.request', function () {
    it('can bypass the UI and yet still test log in', function () {
      // oftentimes once we have a proper e2e test around logging in
      // there is NO more reason to actually use our UI to log in users
      // doing so wastes is slow because our entire page has to load,
      // all associated resources have to load, we have to fill in the
      // form, wait for the form submission and redirection process
      //
      // with cy.request we can bypass this because it automatically gets
      // and sets cookies under the hood. This acts exactly as if the requests
      // came from the browser
      cy.request({
        method: 'POST',
        url: '/login', // baseUrl will be prepended to this url
        form: true, // indicates the body should be form urlencoded and sets Content-Type: application/x-www-form-urlencoded headers
        body: {
          username,
          password,
        },
      })

      // just to prove we have a session
      cy.getCookie('cypress-session-cookie').should('exist')
    })
  })

  context('Reusable "login" custom command', function () {
    // typically we'd put this in cypress/support/commands.js
    // but because this custom command is specific to this example
    // we'll keep it here
    Cypress.Commands.add('loginByForm', (username, password) => {
      Cypress.log({
        name: 'loginByForm',
        message: `${username} | ${password}`,
      })

      return cy.request({
        method: 'POST',
        url: '/login',
        form: true,
        body: {
          username,
          password,
        },
      })
    })

    beforeEach(function () {
      // login before each test
      cy.loginByForm(username, password)
    })

    it('can visit /dashboard', function () {
      // after cy.request, the session cookie has been set
      // and we can visit a protected page
      cy.visit('/dashboard')
      cy.get('h1').should('contain', 'jane.lane')
    })

    it('can visit /users', function () {
      // or another protected page
      cy.visit('/users')
      cy.get('h1').should('contain', 'Users')
    })

    it('can simply request other authenticated pages', function () {
      // instead of visiting each page and waiting for all
      // the associated resources to load, we can instead
      // just issue a simple HTTP request and make an
      // assertion about the response body
      cy.request('/admin')
      .its('body')
      .should('include', '<h1>Admin</h1>')
    })
  })
})

Overview

This script demonstrates how to test authentication workflows in a web application using Cypress. The tests simulate login scenarios through HTML form submissions, handle unauthorized access, and optimize testing using Cypress commands like cy.visit and cy.request. It also showcases reusable custom commands for efficient testing.

Key Features

  1. Unauthorized Route Testing:
    • Validate redirections for unauthenticated users.
    • Use cy.visit and cy.request for testing.
  2. HTML Form Submission Testing:
    • Test login success and error states.
  3. Performance Optimization:
    • Leverage cy.request to bypass UI and directly interact with APIs for faster tests.
  4. Custom Command Creation:
    • Reuse login functionality through a custom Cypress command.

Script Breakdown

Setup

  • Base URL: Ensure the server is running via npm start before executing the tests.
  • Credentials:
    const username = 'jane.lane'; const password = 'password123';

Test Cases

1. Unauthorized Access

Tests the behavior when accessing protected routes without a session.

Test: Redirect on Unauthenticated Access
  • Scenario: Attempt to visit /dashboard without a valid session.
  • Assertions:
    • URL is redirected to /unauthorized.
    • Error message: “You are not logged in and cannot access this page”.
  • Code:
    cy.visit('/dashboard');
    cy.get('h3').should('contain', 'You are not logged in and cannot access this page');
    cy.url().should('include', 'unauthorized');
Test: Redirect Validation Using cy.request
  • Scenario: Use cy.request to verify redirection behavior.
  • Assertions:
    • Status code: 302.
    • Redirected URL: http://localhost:7077/unauthorized.
  • Code:
    cy.request({ url: '/dashboard', followRedirect: false, }).then((resp) => { expect(resp.status).to.eq(302); expect(resp.redirectedToUrl).to.eq('http://localhost:7077/unauthorized'); });

2. HTML Form Submission

Simulates login scenarios through standard HTML form submissions.

Test: Error States
  • Scenario: Attempt login with incorrect credentials.
  • Assertions:
    • Error message: “Username and/or password is incorrect”.
    • URL remains /login.
  • Code:
    cy.get('input[name=username]').type('jane.lae'); cy.get('input[name=password]').type('password123{enter}'); cy.get('p.error').should('be.visible').and('contain', 'Username and/or password is incorrect'); cy.url().should('include', '/login');
Test: Successful Login
  • Scenario: Login with valid credentials.
  • Assertions:
    • Redirect to /dashboard.
    • Session cookie cypress-session-cookie is set.
  • Code:
    cy.get('input[name=username]').type(username); cy.get('input[name=password]').type(password); cy.get('form').submit(); cy.url().should('include', '/dashboard'); cy.getCookie('cypress-session-cookie').should('exist');

3. Optimized Login Using cy.request

  • Scenario: Use cy.request to authenticate without UI interactions.
  • Advantages:
    • Faster test execution.
    • Avoids loading unnecessary resources.
  • Code:
    cy.request({ method: 'POST', url: '/login', form: true, body: { username, password }, });
    cy.getCookie('cypress-session-cookie').should('exist');

4. Custom Command for Reusable Login

  • Command: loginByForm
    • Defined using Cypress.Commands.add.
    • Sends a POST request to log in.
  • Code:
    Cypress.Commands.add('loginByForm', (username, password) =>
    { Cypress.log({ name: 'loginByForm', message: `${username} | ${password}` }); return cy.request({ method: 'POST', url: '/login', form: true, body: { username, password }, }); });
Tests Using the Custom Command
  • Pre-condition: Log in before each test using cy.loginByForm(username, password).
Test: Visit Protected Pages
  • /dashboard:
    cy.visit('/dashboard');
    cy.get('h1').should('contain', 'jane.lane');
  • /users:
    cy.visit('/users');
    cy.get('h1').should('contain', 'Users');
Test: Direct API Requests
  • Scenario: Use cy.request to validate content of /admin.
  • Code:
    cy.request('/admin').its('body').should('include', '<h1>Admin</h1>');

cypress.config.js

Make sure to update the cypress.config.js file with the following piece of code in order to be able to run the cypress script:

const { defineConfig } = require('cypress')

module.exports = defineConfig({
  fixturesFolder: false,
  e2e: {
    baseUrl: 'http://localhost:7077',
    supportFile: false,
  },
})
Scroll to Top