diff --git a/README.md b/README.md index a49a6dfdc..6324d3f1f 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,8 @@ After these have been set up, set the environment variables according to the tab |JOB_POLL_ATTEMPTS|No|Number of attempts for long polling of job status before timeout of 408 is returned. Defaults to 12| |JOB_POLL_INTERVAL|No|Interval of time between attempts for long polling of job status in ms. Defaults to 5000ms (5s)| |API_LINK_RANDOM_STR_LENGTH|No|String length of randomly generated shortUrl in API created links. Defaults to 8| -|FF_EXTERNAL_API|No|Boolean, feature flag for enabling the external API. Defaults to false| -|ADMIN_API_EMAIL|No|Email with admin API access. Defaults to none.| +|FF_EXTERNAL_API|No|Boolean, feature flag for enabling the external and admin API. Defaults to false| +|ADMIN_API_EMAILS|No|Emails with admin API access, separated by commas without spaces. Defaults to none.| #### Serverless functions for link migration diff --git a/docker-compose.yml b/docker-compose.yml index 7e86bc70d..f528987a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,7 @@ services: - API_LINK_RANDOM_STR_LENGTH=8 - API_KEY_SALT=$$2b$$10$$9rBKuE4Gb5ravnvP4xjoPu - FF_EXTERNAL_API=true - - ADMIN_API_EMAIL + - ADMIN_API_EMAILS=integration-test-admin@open.gov.sg volumes: - ./public:/usr/src/gogovsg/public - ./src:/usr/src/gogovsg/src diff --git a/src/server/api/admin-v1/index.ts b/src/server/api/admin-v1/index.ts new file mode 100644 index 000000000..11d1c776c --- /dev/null +++ b/src/server/api/admin-v1/index.ts @@ -0,0 +1,30 @@ +import Express from 'express' +import { createValidator } from 'express-joi-validation' +import { container } from '../../util/inversify' +import jsonMessage from '../../util/json' +import { DependencyIds } from '../../constants' +import { AdminApiV1Controller } from '../../modules/api/admin-v1' +import { UrlCheckController } from '../../modules/threat' +import { urlSchema } from './validators' + +const adminApiV1Controller = container.get( + DependencyIds.adminApiV1Controller, +) +const urlCheckController = container.get( + DependencyIds.urlCheckController, +) +const validator = createValidator({ passError: true }) +const router = Express.Router() + +router.post( + '/urls', + validator.body(urlSchema), + urlCheckController.singleUrlCheck, + adminApiV1Controller.createUrl, +) + +router.use((_, res) => { + res.status(404).send(jsonMessage('Resource not found.')) +}) + +export = router diff --git a/src/server/api/admin-v1/validators.ts b/src/server/api/admin-v1/validators.ts new file mode 100644 index 000000000..db5a3a89d --- /dev/null +++ b/src/server/api/admin-v1/validators.ts @@ -0,0 +1,55 @@ +import * as Joi from '@hapi/joi' +import { isValidGovEmail } from '../../util/email' +import { + isBlacklisted, + isCircularRedirects, + isHttps, + isValidShortUrl, + isValidUrl, +} from '../../../shared/util/validation' +import { ogHostname } from '../../config' + +export const urlSchema = Joi.object({ + userId: Joi.number().required(), + shortUrl: Joi.string() + .custom((url: string, helpers) => { + if (!isValidShortUrl(url)) { + return helpers.message({ custom: 'Short URL format is invalid.' }) + } + return url + }) + .optional(), + longUrl: Joi.string() + .custom((url: string, helpers) => { + if (!isHttps(url)) { + return helpers.message({ custom: 'Only HTTPS URLs are allowed.' }) + } + if (!isValidUrl(url)) { + return helpers.message({ custom: 'Long URL format is invalid.' }) + } + if (isCircularRedirects(url, ogHostname)) { + return helpers.message({ + custom: 'Circular redirects are not allowed.', + }) + } + if (isBlacklisted(url)) { + return helpers.message({ + custom: 'Creation of URLs to link shortener sites are not allowed.', + }) + } + return url + }) + .required(), + email: Joi.string() + .custom((email: string, helpers) => { + if (!isValidGovEmail(email)) { + return helpers.message({ + custom: 'Invalid email provided. Email domain is not whitelisted.', + }) + } + return email + }) + .required(), +}) + +export default urlSchema diff --git a/src/server/api/index.ts b/src/server/api/index.ts index 103701b74..30da753ce 100644 --- a/src/server/api/index.ts +++ b/src/server/api/index.ts @@ -80,7 +80,7 @@ async function apiKeyAdminAuthMiddleware( const { userId } = req.body const isAdmin = await apiKeyAuthService.isAdmin(userId) if (!isAdmin) { - res.unauthorized('User is unauthorized') + res.unauthorized(jsonMessage('User is unauthorized')) return } next() @@ -117,6 +117,14 @@ router.use( /* Register APIKey protected endpoints */ if (ffExternalApi) { + router.use( + '/v1/admin', + apiKeyAuthMiddleware, + apiKeyAdminAuthMiddleware, + preprocess, + // eslint-disable-next-line global-require + require('./admin-v1'), + ) // eslint-disable-next-line global-require router.use('/v1', apiKeyAuthMiddleware, preprocess, require('./external-v1')) } diff --git a/src/server/config.ts b/src/server/config.ts index 9ed29e62f..6d0cae35c 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -269,4 +269,6 @@ export const apiKeySalt = process.env.API_KEY_SALT as string export const apiLinkRandomStrLength: number = Number(process.env.API_LINK_RANDOM_STR_LENGTH) || 8 export const ffExternalApi: boolean = process.env.FF_EXTERNAL_API === 'true' -export const apiAdmin: string = process.env.ADMIN_API_EMAIL || '' +export const apiAdmins: string[] = process.env.ADMIN_API_EMAILS + ? process.env.ADMIN_API_EMAILS.split(',') + : [] diff --git a/src/server/constants.ts b/src/server/constants.ts index 03d44e70e..27386c928 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -69,6 +69,7 @@ export const DependencyIds = { bulkService: Symbol.for('bulkService'), apiKeyAuthService: Symbol.for('apiKeyAuthService'), apiV1Controller: Symbol.for('apiV1Controller'), + adminApiV1Controller: Symbol.for('adminApiV1Controller'), } export const ERROR_404_PATH = '404.error.ejs' diff --git a/src/server/inversify.config.ts b/src/server/inversify.config.ts index 4124a1171..2e6308152 100644 --- a/src/server/inversify.config.ts +++ b/src/server/inversify.config.ts @@ -61,6 +61,7 @@ import { } from './modules/analytics/services' import { LinkStatisticsRepository } from './modules/analytics/repositories/LinkStatisticsRepository' import { ApiV1Controller } from './modules/api/external-v1' +import { AdminApiV1Controller } from './modules/api/admin-v1' import { LinkAuditController } from './modules/audit' import { LinkAuditService } from './modules/audit/services' import { UrlHistoryRepository } from './modules/audit/repositories' @@ -142,6 +143,7 @@ export default () => { bindIfUnbound(DependencyIds.directoryController, DirectoryController) bindIfUnbound(DependencyIds.deviceCheckService, DeviceCheckService) bindIfUnbound(DependencyIds.apiV1Controller, ApiV1Controller) + bindIfUnbound(DependencyIds.adminApiV1Controller, AdminApiV1Controller) container .bind(DependencyIds.allowedFileExtensions) diff --git a/src/server/models/user.ts b/src/server/models/user.ts index 5ff56f0db..498abd702 100644 --- a/src/server/models/user.ts +++ b/src/server/models/user.ts @@ -26,7 +26,10 @@ export const User = sequelize.define( validate: { isEmail: true, isLowercase: true, - is: emailValidator.makeRe(), + is: { + args: emailValidator.makeRe(), + msg: 'Email domain is not whitelisted.', + }, }, set(this: Settable, email: string) { // must save email as lowercase diff --git a/src/server/modules/api/admin-v1/AdminApiV1Controller.ts b/src/server/modules/api/admin-v1/AdminApiV1Controller.ts new file mode 100644 index 000000000..1f9f44b81 --- /dev/null +++ b/src/server/modules/api/admin-v1/AdminApiV1Controller.ts @@ -0,0 +1,87 @@ +import Express from 'express' +import { inject, injectable } from 'inversify' +import Sequelize from 'sequelize' + +import { logger } from '../../../config' +import { DependencyIds } from '../../../constants' +import jsonMessage from '../../../util/json' +import { AlreadyExistsError, NotFoundError } from '../../../util/error' + +import { UrlManagementService } from '../../user/interfaces' +import { MessageType } from '../../../../shared/util/messages' +import { StorableUrlSource } from '../../../repositories/enums' + +import { UrlCreationRequest } from '.' +import { UrlV1Mapper } from '../../../mappers/UrlV1Mapper' +import { UserRepositoryInterface } from '../../../repositories/interfaces/UserRepositoryInterface' + +@injectable() +export class AdminApiV1Controller { + private userRepository: UserRepositoryInterface + + private urlManagementService: UrlManagementService + + private urlV1Mapper: UrlV1Mapper + + public constructor( + @inject(DependencyIds.userRepository) + userRepository: UserRepositoryInterface, + @inject(DependencyIds.urlManagementService) + urlManagementService: UrlManagementService, + @inject(DependencyIds.urlV1Mapper) + urlV1Mapper: UrlV1Mapper, + ) { + this.userRepository = userRepository + this.urlManagementService = urlManagementService + this.urlV1Mapper = urlV1Mapper + } + + public createUrl: ( + req: Express.Request, + res: Express.Response, + ) => Promise = async (req, res) => { + const { userId, shortUrl, longUrl, email }: UrlCreationRequest = req.body + + try { + const targetUser = await this.userRepository.findOrCreateWithEmail(email) + const newUrl = await this.urlManagementService.createUrl( + userId, + StorableUrlSource.Api, + shortUrl, + longUrl, + ) + + if (userId !== targetUser.id) { + const url = await this.urlManagementService.changeOwnership( + userId, + newUrl.shortUrl, + targetUser.email, + ) + const apiUrl = this.urlV1Mapper.persistenceToDto(url) + res.ok(apiUrl) + return + } + const apiUrl = this.urlV1Mapper.persistenceToDto(newUrl) + res.ok(apiUrl) + return + } catch (error) { + if (error instanceof NotFoundError) { + res.notFound(jsonMessage(error.message)) + return + } + if (error instanceof AlreadyExistsError) { + res.badRequest(jsonMessage(error.message, MessageType.ShortUrlError)) + return + } + if (error instanceof Sequelize.ValidationError) { + res.badRequest(jsonMessage(error.message)) + return + } + logger.error(`Error creating short URL:\t${error}`) + res.serverError(jsonMessage('Server error.')) + return + } + } +} + +export default AdminApiV1Controller diff --git a/src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts b/src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts new file mode 100644 index 000000000..659130999 --- /dev/null +++ b/src/server/modules/api/admin-v1/__tests__/AdminApiV1Controller.test.ts @@ -0,0 +1,250 @@ +import moment from 'moment' +import httpMocks from 'node-mocks-http' +import { ValidationError } from 'sequelize' +import { createRequestWithUser } from '../../../../../../test/server/api/util' +import { UrlV1Mapper } from '../../../../mappers/UrlV1Mapper' +import { AlreadyExistsError, NotFoundError } from '../../../../util/error' +import { AdminApiV1Controller } from '../AdminApiV1Controller' + +const urlManagementService = { + createUrl: jest.fn(), + updateUrl: jest.fn(), + changeOwnership: jest.fn(), + getUrlsWithConditions: jest.fn(), + bulkCreate: jest.fn(), +} + +const userRepository = { + findById: jest.fn(), + findByEmail: jest.fn(), + findOrCreateWithEmail: jest.fn(), + findOneUrlForUser: jest.fn(), + findUserByUrl: jest.fn(), + findUrlsForUser: jest.fn(), + saveApiKeyHash: jest.fn(), + findUserByApiKey: jest.fn(), + hasApiKey: jest.fn(), +} + +const urlV1Mapper = new UrlV1Mapper() + +const controller = new AdminApiV1Controller( + userRepository, + urlManagementService, + urlV1Mapper, +) + +/** + * Unit tests for Admin API v1 controller. + */ +describe('AdminApiV1Controller', () => { + describe('createUrl', () => { + it('create and sanitize link with same owner and target email for admin API', async () => { + const userId = 1 + const shortUrl = 'abcdef' + const longUrl = 'https://www.agency.sg' + const state = 'ACTIVE' + const source = 'API' + const clicks = 0 + const contactEmail = 'person@open.gov.sg' + const description = 'test description' + const tags: string[] = [] + const tagStrings = '' + const createdAt = moment().toISOString() + const updatedAt = moment().toISOString() + const email = 'person@domain.sg' + + const req = httpMocks.createRequest({ + body: { + userId, + shortUrl, + longUrl, + email, + }, + }) + + const res: any = httpMocks.createResponse() + res.ok = jest.fn() + const result = { + shortUrl, + longUrl, + state, + source, + clicks, + contactEmail, + description, + tags, + tagStrings, + createdAt, + updatedAt, + } + urlManagementService.createUrl.mockResolvedValue(result) + userRepository.findOrCreateWithEmail.mockResolvedValue({ + id: userId, + email, + urls: undefined, + }) + + await controller.createUrl(req, res) + expect(userRepository.findOrCreateWithEmail).toHaveBeenCalledWith(email) + expect(urlManagementService.createUrl).toHaveBeenCalledWith( + userId, + source, + shortUrl, + longUrl, + ) + expect(res.ok).toHaveBeenCalledWith({ + shortUrl, + longUrl, + state, + clicks, + createdAt, + updatedAt, + }) + }) + + it('create, sanitize and transfer link to target email for admin API', async () => { + const userId = 1 + const shortUrl = 'abcdef' + const longUrl = 'https://www.agency.sg' + const state = 'ACTIVE' + const source = 'API' + const clicks = 0 + const contactEmail = 'person@open.gov.sg' + const description = 'test description' + const tags: string[] = [] + const tagStrings = '' + const createdAt = moment().toISOString() + const updatedAt = moment().toISOString() + const email = 'person@domain.sg' + + const req = httpMocks.createRequest({ + body: { + userId, + shortUrl, + longUrl, + email, + }, + }) + + const res: any = httpMocks.createResponse() + res.ok = jest.fn() + const result = { + shortUrl, + longUrl, + state, + source, + clicks, + contactEmail, + description, + tags, + tagStrings, + createdAt, + updatedAt, + } + urlManagementService.createUrl.mockResolvedValue(result) + userRepository.findOrCreateWithEmail.mockResolvedValue({ + id: 2, + email, + urls: undefined, + }) + urlManagementService.changeOwnership.mockResolvedValue(result) + + await controller.createUrl(req, res) + expect(userRepository.findOrCreateWithEmail).toHaveBeenCalledWith(email) + expect(urlManagementService.createUrl).toHaveBeenCalledWith( + userId, + source, + shortUrl, + longUrl, + ) + expect(urlManagementService.changeOwnership).toHaveBeenCalledWith( + userId, + shortUrl, + email, + ) + expect(res.ok).toHaveBeenCalledWith({ + shortUrl, + longUrl, + state, + clicks, + createdAt, + updatedAt, + }) + }) + + it('reports server error with user creation', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.serverError = jest.fn() + + userRepository.findOrCreateWithEmail.mockRejectedValue(new Error()) + + await controller.createUrl(req, res) + expect(res.serverError).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + + it('reports not found on NotFoundError', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.notFound = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue(new NotFoundError('')) + + await controller.createUrl(req, res) + expect(res.notFound).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + + it('reports bad request on AlreadyExistsError', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.badRequest = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue( + new AlreadyExistsError(''), + ) + + await controller.createUrl(req, res) + expect(res.badRequest).toHaveBeenCalledWith({ + message: expect.any(String), + type: expect.any(String), + }) + }) + + it('reports bad request on Sequelize.ValidationError', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.badRequest = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue( + new ValidationError('', []), + ) + + await controller.createUrl(req, res) + expect(res.badRequest).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + + it('reports server error on generic Error', async () => { + const req = createRequestWithUser(undefined) + const res: any = httpMocks.createResponse() + res.serverError = jest.fn() + + userRepository.findOrCreateWithEmail.mockResolvedValue({}) + urlManagementService.createUrl.mockRejectedValue(new Error()) + + await controller.createUrl(req, res) + expect(res.serverError).toHaveBeenCalledWith({ + message: expect.any(String), + }) + }) + }) +}) diff --git a/src/server/modules/api/admin-v1/index.ts b/src/server/modules/api/admin-v1/index.ts new file mode 100644 index 000000000..b483cb5d0 --- /dev/null +++ b/src/server/modules/api/admin-v1/index.ts @@ -0,0 +1,29 @@ +import { StorableUrl } from '../../../repositories/types' + +export { AdminApiV1Controller } from './AdminApiV1Controller' + +type LongUrlProperty = { + longUrl: string +} +type UserIdProperty = { + userId: number +} + +type ShortUrlProperty = { + shortUrl: string +} + +type EmailProperty = { + email: string +} + +type ShortUrlOperationProperty = UserIdProperty & ShortUrlProperty + +export type UrlCreationRequest = ShortUrlOperationProperty & + LongUrlProperty & + EmailProperty + +export type UrlV1DTO = Pick< + StorableUrl, + 'shortUrl' | 'longUrl' | 'state' | 'clicks' | 'createdAt' | 'updatedAt' +> diff --git a/src/server/modules/api/external-v1/ApiV1Controller.ts b/src/server/modules/api/external-v1/ApiV1Controller.ts index 31819835b..13df7650c 100644 --- a/src/server/modules/api/external-v1/ApiV1Controller.ts +++ b/src/server/modules/api/external-v1/ApiV1Controller.ts @@ -65,9 +65,10 @@ export class ApiV1Controller { } if (error instanceof Sequelize.ValidationError) { res.badRequest(jsonMessage(error.message)) + return } logger.error(`Error creating short URL:\t${error}`) - res.badRequest(jsonMessage('Server error.')) + res.serverError(jsonMessage('Server error.')) return } } diff --git a/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts b/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts index c482d6e76..44e96da11 100644 --- a/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts +++ b/src/server/modules/api/external-v1/__tests__/ApiV1Controller.test.ts @@ -127,15 +127,15 @@ describe('ApiV1Controller', () => { }) }) - it('reports bad request on generic Error', async () => { + it('reports server error on generic Error', async () => { const req = createRequestWithUser(undefined) const res: any = httpMocks.createResponse() - res.badRequest = jest.fn() + res.serverError = jest.fn() urlManagementService.createUrl.mockRejectedValue(new Error()) await controller.createUrl(req, res) - expect(res.badRequest).toHaveBeenCalledWith({ + expect(res.serverError).toHaveBeenCalledWith({ message: expect.any(String), }) }) diff --git a/src/server/modules/user/services/ApiKeyAuthService.ts b/src/server/modules/user/services/ApiKeyAuthService.ts index 40da196be..32080eca9 100644 --- a/src/server/modules/user/services/ApiKeyAuthService.ts +++ b/src/server/modules/user/services/ApiKeyAuthService.ts @@ -9,7 +9,7 @@ import dogstatsd, { import { UserRepositoryInterface } from '../../../repositories/interfaces/UserRepositoryInterface' import { API_KEY_SEPARATOR, DependencyIds } from '../../../constants' import { StorableUser } from '../../../repositories/types' -import { apiAdmin, apiEnv, apiKeySalt, apiKeyVersion } from '../../../config' +import { apiAdmins, apiEnv, apiKeySalt, apiKeyVersion } from '../../../config' const BASE64_ENCODING = 'base64' @injectable() @@ -46,7 +46,7 @@ class ApiKeyAuthService implements ApiKeyAuthServiceInterface { isAdmin: (userId: number) => Promise = async (userId: number) => { const user = await this.userRepository.findById(userId) if (!user) return false - return apiAdmin === user.email + return apiAdmins.includes(user.email) } hasApiKey: (userId: number) => Promise = async (userId: number) => { diff --git a/test/integration/api/admin-v1/Urls.test.ts b/test/integration/api/admin-v1/Urls.test.ts new file mode 100644 index 000000000..f4c3acc86 --- /dev/null +++ b/test/integration/api/admin-v1/Urls.test.ts @@ -0,0 +1,209 @@ +import { API_ADMIN_V1_URLS } from '../../config' +import { + DATETIME_REGEX, + createIntegrationTestAdminUser, + createIntegrationTestUser, + deleteIntegrationTestUser, + generateRandomString, +} from '../../util/helpers' +import { postJson } from '../../util/requests' + +async function createLinkUrl( + link: { + shortUrl?: string + longUrl?: string + email?: string + }, + apiKey: string, +) { + const res = await postJson(API_ADMIN_V1_URLS, link, undefined, apiKey) + return res +} + +/** + * Integration tests for Admin API v1. + */ +describe('Admin API v1 Integration Tests', () => { + let email: string + let apiKey: string + const longUrl = 'https://example.com' + const validEmail = 'integration-test-user@test.gov.sg' + + beforeAll(async () => { + ;({ email, apiKey } = await createIntegrationTestAdminUser()) + }) + + afterAll(async () => { + await deleteIntegrationTestUser(email) + }) + + it('should not be able to create urls without API key header', async () => { + const res = await postJson( + API_ADMIN_V1_URLS, + { longUrl }, + undefined, + undefined, + ) + expect(res.status).toBe(401) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'Authorization header is missing', + }) + }) + + it('should not be able to create urls with invalid API key', async () => { + const res = await postJson( + API_ADMIN_V1_URLS, + { longUrl }, + undefined, + 'this-is-an-invalid-api-key', + ) + expect(res.status).toBe(401) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'Invalid API Key', + }) + }) + + it('should not be able to create urls with unauthorized API key', async () => { + const testUser = await createIntegrationTestUser() + const res = await postJson( + API_ADMIN_V1_URLS, + { longUrl }, + undefined, + testUser.apiKey, + ) + expect(res.status).toBe(401) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: `User is unauthorized`, + }) + await deleteIntegrationTestUser(testUser.email) + }) + + it('should be able to create link url with longUrl, shortUrl, and validEmail', async () => { + const shortUrl = await generateRandomString(8) + const res = await createLinkUrl( + { shortUrl, longUrl, email: validEmail }, + apiKey, + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + shortUrl: expect.stringMatching(/^[a-z0-9]{8}$/), + longUrl, + clicks: 0, + state: 'ACTIVE', + createdAt: expect.stringMatching(DATETIME_REGEX), + updatedAt: expect.stringMatching(DATETIME_REGEX), + }) + }) + + it('should not be able to create link url with longUrl and shortUrl, without validEmail', async () => { + const shortUrl = await generateRandomString(8) + const res = await createLinkUrl({ shortUrl, longUrl }, apiKey) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'ValidationError: "email" is required', + }) + }) + + it('should not be able to create link url with longUrl and shortUrl, with invalid email', async () => { + const shortUrl = await generateRandomString(8) + const invalidEmail = 'integration-test-user@nongov.sg' + const res = await createLinkUrl( + { shortUrl, longUrl, email: invalidEmail }, + apiKey, + ) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: + 'ValidationError: Invalid email provided. Email domain is not whitelisted.', + }) + }) + + it('should be able to create link url with longUrl and validEmail, without shortUrl', async () => { + const res = await createLinkUrl({ longUrl, email: validEmail }, apiKey) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + shortUrl: expect.stringMatching(/^[a-z0-9]{8}$/), + longUrl, + clicks: 0, + state: 'ACTIVE', + createdAt: expect.stringMatching(DATETIME_REGEX), + updatedAt: expect.stringMatching(DATETIME_REGEX), + }) + }) + + it('should not be able to create link url with longUrl, without shortUrl and validEmail', async () => { + const res = await createLinkUrl({ longUrl }, apiKey) + expect(res.status).toBe(400) + const json = await res.json() + expect(json).toBeTruthy() + expect(json).toEqual({ + message: 'ValidationError: "email" is required', + }) + }) + + it('should not be able to create link url with invalid email', async () => { + const res = await createLinkUrl( + { longUrl, email: 'invalid-email-value' }, + apiKey, + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: + 'ValidationError: Invalid email provided. Email domain is not whitelisted.', + }) + }) + + it('should not be able to create link url without longUrl, shortUrl, and email', async () => { + const res = await createLinkUrl({}, apiKey) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: 'ValidationError: "longUrl" is required. "email" is required', + }) + }) + + it('should not be able to create link url with invalid longUrl', async () => { + const invalidLongUrl = 'this-is-an-invalid-url' + const res = await createLinkUrl( + { longUrl: invalidLongUrl, email: validEmail }, + apiKey, + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: 'ValidationError: Only HTTPS URLs are allowed.', + }) + }) + + it('should not be able to create link url with invalid shortUrl', async () => { + const invalidShortUrl = 'foo%bar' + const res = await createLinkUrl( + { shortUrl: invalidShortUrl, longUrl, email: validEmail }, + apiKey, + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body).toEqual({ + message: 'ValidationError: Short URL format is invalid.', + }) + }) + + it('should be able to create link url with longUrl, shortUrl, and same email as admin', async () => { + const shortUrl = await generateRandomString(8) + const res = await createLinkUrl({ shortUrl, longUrl, email }, apiKey) + expect(res.status).toBe(200) + }) +}) diff --git a/test/integration/config.ts b/test/integration/config.ts index d33e389b4..488ee7e6b 100644 --- a/test/integration/config.ts +++ b/test/integration/config.ts @@ -8,6 +8,7 @@ export const IMAGE_FILE_PATH = './test/integration/assets/go-logo.png' export const API_USER_URL = 'http://localhost:8080/api/user/url' export const API_EXTERNAL_V1_URLS = 'http://localhost:8080/api/v1/urls' +export const API_ADMIN_V1_URLS = 'http://localhost:8080/api/v1/admin/urls' export const API_LOGIN_OTP = 'http://localhost:8080/api/login/otp' export const API_LOGIN_VERIFY = 'http://localhost:8080/api/login/verify' diff --git a/test/integration/util/helpers.ts b/test/integration/util/helpers.ts index 92c6aa34b..80195d462 100644 --- a/test/integration/util/helpers.ts +++ b/test/integration/util/helpers.ts @@ -37,6 +37,21 @@ export const createIntegrationTestUser: () => Promise<{ return { email: integrationTestEmail, apiKey } } +export const createIntegrationTestAdminUser: () => Promise<{ + email: string + apiKey: string +}> = async () => { + const testAdminEmail = 'integration-test-admin@open.gov.sg' + + const randomApiString = crypto.randomBytes(32).toString('base64') + const hash = await bcrypt.hash(randomApiString, API_KEY_SALT) + const apiKey = `test_v1_${randomApiString}` + const apiKeyHash = `test_v1_${hash.replace(API_KEY_SALT, '')}` + + await createDbUser(testAdminEmail, apiKeyHash) + return { email: testAdminEmail, apiKey } +} + export const deleteIntegrationTestUser: (email: string) => Promise = async (email) => { await deleteDbUser(email)