Skip to content

Commit

Permalink
refactor: migrate /spcp/validate to TypeScript (#656)
Browse files Browse the repository at this point in the history
  • Loading branch information
mantariksh authored Nov 18, 2020
1 parent 0dd1536 commit 39196e8
Show file tree
Hide file tree
Showing 17 changed files with 710 additions and 297 deletions.
99 changes: 0 additions & 99 deletions src/app/controllers/spcp.server.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ const { isEmpty } = require('lodash')
const mongoose = require('mongoose')
const crypto = require('crypto')
const { StatusCodes } = require('http-status-codes')
const axios = require('axios')

const { createReqMeta } = require('../utils/request')
const logger = require('../../config/logger').createLoggerWithLabel(module)
Expand Down Expand Up @@ -202,104 +201,6 @@ const handleOOBAuthenticationWith = (ndiConfig, authType, extractUser) => {
}
}

/**
* Generates redirect URL to Official SingPass/CorpPass log in page
* @param {Object} req - Express request object
* @param {Object} res - Express response object
* @param {function} next - Express next function
*/
exports.createSpcpRedirectURL = (authClients) => {
return (req, res, next) => {
const { target, authType, esrvcId } = req.query
let authClient = authClients[authType] ? authClients[authType] : undefined
if (target && authClient && esrvcId) {
req.redirectURL = authClient.createRedirectURL(target, esrvcId)
return next()
} else {
return res
.status(StatusCodes.BAD_REQUEST)
.json({ message: 'Redirect URL malformed' })
}
}
}

const getSubstringBetween = (text, markerStart, markerEnd) => {
const start = text.indexOf(markerStart)
if (start === -1) {
return null
} else {
const end = text.indexOf(markerEnd, start)
return end === -1 ? null : text.substring(start + markerStart.length, end)
}
}

exports.validateESrvcId = (req, res) => {
let { redirectURL } = req
let validateUrl = redirectURL

axios
.get(validateUrl, {
headers: {
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3',
},
timeout: 10000, // 10 seconds
// Throw error if not status 200.
validateStatus: (status) => status === StatusCodes.OK,
})
.then(({ data }) => {
// The successful login page should have the title 'SingPass Login'
// The error page should have the title 'SingPass - System Error Page'
const title = getSubstringBetween(data, '<title>', '</title>')
if (title === null) {
logger.error({
message: 'Could not find title',
meta: {
action: 'validateESrvcId',
...createReqMeta(req),
redirectUrl: redirectURL,
data,
},
})
return res.status(StatusCodes.BAD_GATEWAY).json({
message: 'Singpass returned incomprehensible content',
})
}
if (title.indexOf('Error') === -1) {
return res.status(StatusCodes.OK).json({
isValid: true,
})
}

// The error page should have text like 'System Code:&nbsp<b>138</b>'
const errorCode = getSubstringBetween(
data,
'System Code:&nbsp<b>',
'</b>',
)
return res.status(StatusCodes.OK).json({
isValid: false,
errorCode,
})
})
.catch((err) => {
const { statusCode } = err.response || {}
logger.error({
message: 'Could not contact singpass to validate eservice id',
meta: {
action: 'validateESrvcId',
...createReqMeta(req),
redirectUrl: redirectURL,
statusCode,
},
error: err,
})
return res.status(StatusCodes.SERVICE_UNAVAILABLE).json({
message: 'Failed to contact Singpass',
})
})
}

/**
* Assertion Consumer Endpoint - Authenticates form-filler with SingPass and creates session
* @param {Object} req - Express request object
Expand Down
5 changes: 0 additions & 5 deletions src/app/factories/spcp.factory.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,6 @@ const spcpFactory = ({ isEnabled, props }) => {
corpPassLogin: spcp.corpPassLogin(ndiConfig),
addSpcpSessionInfo: spcp.addSpcpSessionInfo(authClients),
isSpcpAuthenticated: spcp.isSpcpAuthenticated(authClients),
createSpcpRedirectURL: spcp.createSpcpRedirectURL(authClients),
validateESrvcId: spcp.validateESrvcId,
}
} else {
const errMsg = 'SPCP/MyInfo feature is not enabled'
Expand All @@ -85,9 +83,6 @@ const spcpFactory = ({ isEnabled, props }) => {
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }),
addSpcpSessionInfo: (req, res, next) => next(),
isSpcpAuthenticated: (req, res, next) => next(),
createSpcpRedirectURL: (req, res, next) => next(),
validateESrvcId: (req, res) =>
res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ message: errMsg }),
}
}
}
Expand Down
183 changes: 163 additions & 20 deletions src/app/modules/spcp/__tests__/spcp.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { err, ok } from 'neverthrow'
import { err, errAsync, ok, okAsync } from 'neverthrow'
import { mocked } from 'ts-jest/utils'

import { AuthType } from 'src/types'

import expressHandler from 'tests/unit/backend/helpers/jest-express'

import * as SpcpController from '../spcp.controller'
import { CreateRedirectUrlError } from '../spcp.errors'
import {
CreateRedirectUrlError,
FetchLoginPageError,
LoginPageValidationError,
} from '../spcp.errors'
import { SpcpFactory } from '../spcp.factory'

import {
MOCK_ERROR_CODE,
MOCK_ESRVCID,
MOCK_LOGIN_HTML,
MOCK_REDIRECT_URL,
MOCK_TARGET,
} from './spcp.test.constants'
Expand All @@ -19,49 +25,186 @@ jest.mock('../spcp.factory')
const MockSpcpFactory = mocked(SpcpFactory, true)

const MOCK_RESPONSE = expressHandler.mockResponse()
const MOCK_REDIRECT_REQ = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
esrvcId: MOCK_ESRVCID,
},
})

describe('spcp.controller', () => {
beforeEach(() => jest.clearAllMocks())

describe('handleRedirect', () => {
it('should return the redirect URL correctly', () => {
const mockReq = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
esrvcId: MOCK_ESRVCID,
},
})
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)

SpcpController.handleRedirect(mockReq, MOCK_RESPONSE, jest.fn())
SpcpController.handleRedirect(MOCK_REDIRECT_REQ, MOCK_RESPONSE, jest.fn())

expect(MOCK_RESPONSE.status).toHaveBeenLastCalledWith(200)
expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(200)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
redirectURL: MOCK_REDIRECT_URL,
})
})

it('should return 500 if auth client throws an error', () => {
const mockReq = expressHandler.mockRequest({
query: {
target: MOCK_TARGET,
authType: AuthType.SP,
esrvcId: MOCK_ESRVCID,
},
})
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
err(new CreateRedirectUrlError()),
)

SpcpController.handleRedirect(mockReq, MOCK_RESPONSE, jest.fn())
SpcpController.handleRedirect(MOCK_REDIRECT_REQ, MOCK_RESPONSE, jest.fn())

expect(MOCK_RESPONSE.status).toHaveBeenLastCalledWith(500)
expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(500)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
message: 'Sorry, something went wrong. Please try again.',
})
})
})

describe('handleValidate', () => {
it('should return 200 with isValid true if validation passes', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
okAsync(MOCK_LOGIN_HTML),
)
MockSpcpFactory.validateLoginPage.mockReturnValueOnce(
ok({ isValid: true }),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).toHaveBeenCalledWith(
MOCK_LOGIN_HTML,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(200)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
isValid: true,
})
})

it('should return 200 with isValid false if validation fails', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
okAsync(MOCK_LOGIN_HTML),
)
MockSpcpFactory.validateLoginPage.mockReturnValueOnce(
ok({ isValid: false, errorCode: MOCK_ERROR_CODE }),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).toHaveBeenCalledWith(
MOCK_LOGIN_HTML,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(200)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
isValid: false,
errorCode: MOCK_ERROR_CODE,
})
})

it('should return 503 when FetchLoginPageError occurs', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
errAsync(new FetchLoginPageError()),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).not.toHaveBeenCalled()
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(503)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
message: 'Failed to contact SingPass. Please try again.',
})
})

it('should return 502 when LoginPageValidationError occurs', async () => {
MockSpcpFactory.createRedirectUrl.mockReturnValueOnce(
ok(MOCK_REDIRECT_URL),
)
MockSpcpFactory.fetchLoginPage.mockReturnValueOnce(
okAsync(MOCK_LOGIN_HTML),
)
MockSpcpFactory.validateLoginPage.mockReturnValueOnce(
err(new LoginPageValidationError()),
)

await SpcpController.handleValidate(
MOCK_REDIRECT_REQ,
MOCK_RESPONSE,
jest.fn(),
)

expect(MockSpcpFactory.createRedirectUrl).toHaveBeenCalledWith(
AuthType.SP,
MOCK_TARGET,
MOCK_ESRVCID,
)
expect(MockSpcpFactory.fetchLoginPage).toHaveBeenCalledWith(
MOCK_REDIRECT_URL,
)
expect(MockSpcpFactory.validateLoginPage).toHaveBeenCalledWith(
MOCK_LOGIN_HTML,
)
expect(MOCK_RESPONSE.status).toHaveBeenCalledWith(502)
expect(MOCK_RESPONSE.json).toHaveBeenCalledWith({
message: 'Error while contacting SingPass. Please try again.',
})
})
})
})
Loading

0 comments on commit 39196e8

Please sign in to comment.