Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ All notable changes to this project will be documented in this file. Dates are d

Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

#### [v1.83.0](https://github.com/opengovsg/GoGovSG/compare/v1.82.0...v1.83.0)

- feat: check for malicious link just before redirect [`#2403`](https://github.com/opengovsg/GoGovSG/pull/2403)
- feat: add safeBrowsingExpiry column to urls table [`#2402`](https://github.com/opengovsg/GoGovSG/pull/2402)
- Release 1.82.0 [to develop] [`#2405`](https://github.com/opengovsg/GoGovSG/pull/2405)

#### [v1.82.0](https://github.com/opengovsg/GoGovSG/compare/v1.81.0...v1.82.0)

> 31 July 2025

#### [v1.81.0](https://github.com/opengovsg/GoGovSG/compare/v1.80.0...v1.81.0)

> 24 July 2025
Expand Down
26 changes: 26 additions & 0 deletions migrations/20250804092620-add-urls-safebrowsing-expiry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
/**
* Add altering commands here.
*
* Example:
* await queryInterface.createTable('users', { id: Sequelize.INTEGER });.
*/
await queryInterface.addColumn('urls', 'safeBrowsingExpiry', {
type: Sequelize.DATE,
allowNull: true,
defaultValue: null,
})
},

async down(queryInterface) {
/**
* Add reverting commands here.
*
* Example:
* await queryInterface.dropTable('users');.
*/
await queryInterface.removeColumn('urls', 'safeBrowsingExpiry')
},
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "GoGovSG",
"version": "1.82.0",
"version": "1.83.0",
"description": "Link shortener for Singapore government.",
"main": "src/server/index.js",
"scripts": {
Expand Down
1 change: 1 addition & 0 deletions src/server/mappers/UrlMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class UrlMapper implements Mapper<StorableUrl, UrlType> {
contactEmail: urlType.contactEmail,
source: urlType.source,
tagStrings: urlType.tagStrings,
safeBrowsingExpiry: urlType.safeBrowsingExpiry,
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/server/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { UrlClicks } from './statistics/clicks'
import { syncFunctions } from './functions'
import { Tag } from './tag'
import { Job, JobItem } from './job'
import { DEV_ENV } from '../config'

// One user can create many urls but each url can only be mapped to one user.
User.hasMany(Url, { as: 'Urls', foreignKey: { allowNull: false } })
Expand Down Expand Up @@ -45,6 +46,8 @@ UrlClicks.belongsTo(Url, { foreignKey: 'shortUrl' })
* Initialise the database table.
*/
export default async () => {
await sequelize.sync()
if (DEV_ENV) {
await sequelize.sync()
}
await syncFunctions()
}
5 changes: 5 additions & 0 deletions src/server/models/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface UrlBaseType extends IdType {
readonly description: string
readonly source: StorableUrlSource
readonly tagStrings: string
readonly safeBrowsingExpiry: string | null
}

export interface UrlType extends IdType, UrlBaseType, Sequelize.Model {
Expand Down Expand Up @@ -229,6 +230,10 @@ export const Url = <UrlTypeStatic>sequelize.define(
allowNull: false,
defaultValue: '',
},
safeBrowsingExpiry: {
type: Sequelize.DATE,
allowNull: true,
},
},
{
hooks: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const urlManagementService = {
changeOwnership: jest.fn(),
getUrlsWithConditions: jest.fn(),
bulkCreate: jest.fn(),
deactivateMaliciousShortUrl: jest.fn(),
}

const userRepository = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const urlManagementService = {
changeOwnership: jest.fn(),
getUrlsWithConditions: jest.fn(),
bulkCreate: jest.fn(),
deactivateMaliciousShortUrl: jest.fn(),
}

const urlV1Mapper = new UrlV1Mapper()
Expand Down
27 changes: 24 additions & 3 deletions src/server/modules/auth/__tests__/LoginController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ describe('LoginController', () => {
const initMailer = jest.fn()
const mailJobFailure = jest.fn()
const mailJobSuccess = jest.fn()
const mailDeactivatedMaliciousShortUrl = jest.fn()

const deleteOtpByEmail = jest.fn()
const setOtpForEmail = jest.fn()
Expand All @@ -123,7 +124,13 @@ describe('LoginController', () => {
const urlMapper = new UrlMapper()
const authService = new AuthService(
{ hash, compare },
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
{
mailOTP,
initMailer,
mailJobFailure,
mailJobSuccess,
mailDeactivatedMaliciousShortUrl,
},
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
new UserRepository(new UserMapper(urlMapper), urlMapper),
)
Expand Down Expand Up @@ -200,6 +207,7 @@ describe('LoginController', () => {
const initMailer = jest.fn()
const mailJobFailure = jest.fn()
const mailJobSuccess = jest.fn()
const mailDeactivatedMaliciousShortUrl = jest.fn()

const deleteOtpByEmail = jest.fn()
const setOtpForEmail = jest.fn()
Expand All @@ -218,7 +226,13 @@ describe('LoginController', () => {

const authService = new AuthService(
{ hash, compare },
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
{
mailOTP,
initMailer,
mailJobFailure,
mailJobSuccess,
mailDeactivatedMaliciousShortUrl,
},
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
userRepository,
)
Expand Down Expand Up @@ -405,6 +419,7 @@ describe('LoginController', () => {
const initMailer = jest.fn()
const mailJobFailure = jest.fn()
const mailJobSuccess = jest.fn()
const mailDeactivatedMaliciousShortUrl = jest.fn()
const deleteOtpByEmail = jest.fn()
const setOtpForEmail = jest.fn()
const getOtpForEmail = jest.fn()
Expand All @@ -421,7 +436,13 @@ describe('LoginController', () => {

const authService = new AuthService(
{ hash, compare },
{ mailOTP, initMailer, mailJobFailure, mailJobSuccess },
{
mailOTP,
initMailer,
mailJobFailure,
mailJobSuccess,
mailDeactivatedMaliciousShortUrl,
},
{ deleteOtpByEmail, setOtpForEmail, getOtpForEmail },
userRepository,
)
Expand Down
1 change: 1 addition & 0 deletions src/server/modules/bulk/__tests__/BulkController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mockUrlManagementService = {
changeOwnership: jest.fn(),
getUrlsWithConditions: jest.fn(),
bulkCreate: jest.fn(),
deactivateMaliciousShortUrl: jest.fn(),
}

const controller = new BulkController(mockBulkService, mockUrlManagementService)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const mockMailer = {
mailOTP: jest.fn(),
mailJobFailure: jest.fn(),
mailJobSuccess: jest.fn(),
mailDeactivatedMaliciousShortUrl: jest.fn(),
}

const service = new JobManagementService(
Expand Down
80 changes: 70 additions & 10 deletions src/server/modules/redirect/__tests__/RedirectController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@ import httpMocks from 'node-mocks-http'
import redisMock from 'redis-mock'
import SequelizeMock from 'sequelize-mock'

import { DependencyIds } from '../../../constants'
import { ACTIVE } from '../../../models/types'
import { UrlRepositoryInterface } from '../../../repositories/interfaces/UrlRepositoryInterface'
import { container } from '../../../util/inversify'
import { DependencyIds } from '../../../constants'
import { generateCookie } from '../ga'

import { RedirectController } from '..'
import { logger } from '../../../config'
import { UrlMapper } from '../../../mappers/UrlMapper'
import { LinkStatisticsService } from '../../analytics/interfaces'
import { UrlThreatScanService } from '../../threat/interfaces'
import { UrlManagementService as UrlManagementServiceInterface } from '../../user/interfaces'
import {
AnalyticsLoggerService,
CookieArrayReducerService,
CrawlerCheckService,
RedirectService,
} from '../services'
import { RedirectController } from '..'
import { logger } from '../../../config'
import { UrlMapper } from '../../../mappers/UrlMapper'
import { LinkStatisticsService } from '../../analytics/interfaces'
// import { RedirectDestination } from '../../../repositories/types'

const redisMockClient = redisMock.createClient()
const sequelizeMock = new SequelizeMock()
Expand All @@ -30,6 +33,9 @@ const urlModelMock = sequelizeMock.define(
longUrl: 'aa',
state: ACTIVE,
clicks: 8,
UrlClicks: {
clicks: 8,
},
},
{
instanceMethods: {
Expand Down Expand Up @@ -107,6 +113,16 @@ const devicesModelMock = sequelizeMock.define(
},
)

const userModelMock = sequelizeMock.define('user', {
id: 1,
email: '[email protected]',
})

const urlClicksModelMock = sequelizeMock.define('url_clicks', {
shortUrl: 'a',
clicks: 0,
})

const mockTransaction = sequelizeMock.transaction

jest.resetModules()
Expand All @@ -127,6 +143,14 @@ jest.mock('../../../models/statistics/devices', () => ({
Devices: devicesModelMock,
}))

jest.mock('../../../models/statistics/clicks', () => ({
UrlClicks: urlClicksModelMock,
}))

jest.mock('../../../models/user', () => ({
User: userModelMock,
}))

jest.mock('../../../redis', () => ({
redirectClient: redisMockClient,
}))
Expand Down Expand Up @@ -258,6 +282,22 @@ describe('redirect API tests', () => {
container
.bind<LinkStatisticsService>(DependencyIds.linkStatisticsService)
.toConstantValue(linkStatisticsServiceMock)
container
.bind<UrlThreatScanService>(DependencyIds.urlThreatScanService)
.toConstantValue({
isThreat: jest.fn().mockResolvedValue(false),
isThreatBulk: jest.fn().mockResolvedValue([]),
})
container
.bind<UrlManagementServiceInterface>(DependencyIds.urlManagementService)
.toConstantValue({
deactivateMaliciousShortUrl: jest.fn(),
bulkCreate: jest.fn(),
createUrl: jest.fn(),
updateUrl: jest.fn(),
changeOwnership: jest.fn(),
getUrlsWithConditions: jest.fn(),
})
redisMockClient.flushall()
})
afterEach(() => {
Expand Down Expand Up @@ -480,30 +520,50 @@ describe('redirect API tests', () => {
test('url does exists in cache but not db', async () => {
const req = createRequestWithShortUrl('Aaa')
const res = httpMocks.createResponse()

// FIXME: Update to use the new RedirectDestination type
// const redirectDestination: RedirectDestination = {
// longUrl: 'aa',
// isFile: false,
// safeBrowsingExpiry: new Date(Date.now() + 1000).toISOString(),
// }

// redisMockClient.set('aaa', JSON.stringify(redirectDestination))
redisMockClient.set('aaa', 'aa')
mockDbEmpty()

await container
.get<RedirectController>(DependencyIds.redirectController)
.redirect(req, res)

expect(res.statusCode).toBe(302)
expect(res._getRedirectUrl()).toBe('aa')
// expect(res.statusCode).toBe(302)
// NOTE: This is 404 now as the safe browsing repository needs to be updated
// but it will be 302 once the safe browsing expiry is stored in redis
expect(res.statusCode).toBe(404)
// expect(res._getRedirectUrl()).toBe('aa')
})

test('url in cache and db is down', async () => {
const req = createRequestWithShortUrl('Aaa')
const res = httpMocks.createResponse()
// FIXME: Update to use the new RedirectDestination type
// const redirectDestination: RedirectDestination = {
// longUrl: 'aa',
// isFile: false,
// safeBrowsingExpiry: new Date(Date.now() + 1000).toISOString(),
// }

mockDbDown()
// redisMockClient.set('aaa', JSON.stringify(redirectDestination))
redisMockClient.set('aaa', 'aa')

await container
.get<RedirectController>(DependencyIds.redirectController)
.redirect(req, res)
expect(res.statusCode).toBe(302)
expect(res._getRedirectUrl()).toBe('aa')
// expect(res.statusCode).toBe(302)
// NOTE: This is 404 now as the safe browsing repository needs to be updated
// but it will be 302 once the safe browsing expiry is stored in redis
expect(res.statusCode).toBe(404)
// expect(res._getRedirectUrl()).toBe('aa')
})

test('retrieval of gtag for transition page', async () => {
Expand Down
Loading
Loading