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: 4 additions & 4 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
Expand Up @@ -46,7 +46,7 @@
},
"dependencies": {
"@babel/runtime": "^7.28.3",
"@defra/forms-engine-plugin": "2.1.10",
"@defra/forms-engine-plugin": "^3.0.0",
"@defra/hapi-tracing": "^1.28.0",
"@elastic/ecs-pino-format": "^1.5.0",
"@hapi/bell": "^13.1.0",
Expand Down
8 changes: 7 additions & 1 deletion src/server/common/helpers/logging/log-codes.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,15 @@ export const LogCodes = {
messageFunc: (messageOptions) =>
`External API call to ${messageOptions.endpoint} for user=${messageOptions.userId || 'unknown'}`
},
EXTERNAL_API_CALL_DEBUG: {
level: 'debug',
messageFunc: (messageOptions) =>
`External ${messageOptions.method} API call to ${messageOptions.endpoint} for identity=${messageOptions.identity || 'unknown'} - stateSummary=${JSON.stringify(messageOptions.stateSummary || 'N/A')}`
},
EXTERNAL_API_ERROR: {
level: 'error',
messageFunc: (messageOptions) => `External API error for ${messageOptions.endpoint}: ${messageOptions.error}`
messageFunc: (messageOptions) =>
`External API error for ${messageOptions.endpoint} for identity: ${messageOptions.identity || 'unknown'} - error: ${messageOptions.error}`
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/server/common/helpers/logging/log-codes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,10 @@ describe('LogCodes', () => {
expect(
logCode.messageFunc({
endpoint: '/api/grants',
identity: 'test',
error: 'Connection failed'
})
).toBe('External API error for /api/grants: Connection failed')
).toBe('External API error for /api/grants for identity: test - error: Connection failed')
})
})

Expand Down
4 changes: 3 additions & 1 deletion src/server/common/helpers/start-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { vi } from 'vitest'
import Wreck from '@hapi/wreck'
const mockLoggerInfo = vi.fn()
const mockLoggerError = vi.fn()
const mockLoggerDebug = vi.fn()

const mockHapiLoggerInfo = vi.fn()
const mockHapiLoggerError = vi.fn()
Expand All @@ -21,7 +22,8 @@ vi.mock('~/src/server/common/helpers/logging/logger.js', async () => {
const { mockLoggerFactoryWithCustomMethods } = await import('~/src/__mocks__')
return mockLoggerFactoryWithCustomMethods({
info: (...args) => mockLoggerInfo(...args),
error: (...args) => mockLoggerError(...args)
error: (...args) => mockLoggerError(...args),
debug: (...args) => mockLoggerDebug(...args)
})
})

Expand Down
36 changes: 30 additions & 6 deletions src/server/common/helpers/state/fetch-saved-state-helper.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { statusCodes } from '~/src/server/common/constants/status-codes.js'
import 'dotenv/config'
import { config } from '~/src/config/config.js'
import { getCacheKey } from './get-cache-key-helper.js'
import { parseSessionKey } from './get-cache-key-helper.js'
import { createApiHeaders } from './backend-auth-helper.js'
import { log, LogCodes } from '../logging/log.js'

const GRANTS_UI_BACKEND_ENDPOINT = config.get('session.cache.apiEndpoint')

export async function fetchSavedStateFromApi(request) {
export async function fetchSavedStateFromApi(key) {
if (!GRANTS_UI_BACKEND_ENDPOINT?.length) {
return null
}
const { userId, organisationId, grantId } = getCacheKey(request)

const { userId, organisationId, grantId } = parseSessionKey(key)

let json = null
const url = new URL('/state/', GRANTS_UI_BACKEND_ENDPOINT)
try {
const url = new URL('/state/', GRANTS_UI_BACKEND_ENDPOINT)
log(LogCodes.SYSTEM.EXTERNAL_API_CALL_DEBUG, {
method: 'GET',
endpoint: url.href,
identity: key
})

url.searchParams.set('userId', userId)
url.searchParams.set('businessId', organisationId)
url.searchParams.set('grantId', grantId)
Expand All @@ -26,6 +34,12 @@ export async function fetchSavedStateFromApi(request) {

if (!response.ok) {
if (response.status === statusCodes.notFound) {
log(LogCodes.SYSTEM.EXTERNAL_API_CALL_DEBUG, {
method: 'GET',
endpoint: url.href,
identity: key,
stateSummary: 'No state found in backend'
})
return null
}
throw new Error(`Failed to fetch saved state: ${response.status}`)
Expand All @@ -34,11 +48,21 @@ export async function fetchSavedStateFromApi(request) {
json = await response.json()

if (!json || typeof json !== 'object') {
request.logger.warn(['fetch-saved-state'], 'Unexpected or empty state format', { json })
log(LogCodes.SYSTEM.EXTERNAL_API_ERROR, {
method: 'GET',
endpoint: url.href,
identity: key,
error: `Unexpected or empty state format: ${json}`
})
return null
}
} catch (err) {
request.logger.error(['fetch-saved-state'], 'Failed to fetch saved state from API', err)
log(LogCodes.SYSTEM.EXTERNAL_API_ERROR, {
method: 'GET',
endpoint: url.href,
identity: key,
error: err.message
})
return null
}

Expand Down
153 changes: 104 additions & 49 deletions src/server/common/helpers/state/fetch-saved-state-helper.test.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,104 @@
import { vi } from 'vitest'
import { mockRequestWithIdentity } from './mock-request-with-identity.test-helper.js'
import {
MOCK_STATE_DATA,
HTTP_STATUS,
TEST_USER_IDS,
ERROR_MESSAGES,
LOG_MESSAGES,
createMockConfig,
createMockConfigWithoutEndpoint
} from './test-helpers/auth-test-helpers.js'
import { mockRequestLogger } from '~/src/__mocks__/logger-mocks.js'

const LOG_TAGS = {
FETCH_SAVED_STATE: 'fetch-saved-state'
global.fetch = vi.fn()

const mockLogger = {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}

global.fetch = vi.fn()
vi.mock('../logging/logger.js', () => ({
createLogger: () => mockLogger
}))

// Mock parseSessionKey
const mockParseSessionKey = vi.fn()
vi.mock('./get-cache-key-helper.js', () => ({
parseSessionKey: mockParseSessionKey
}))

let fetchSavedStateFromApi
let log
let LogCodes

const mockRequest = mockRequestWithIdentity({ params: { slug: TEST_USER_IDS.GRANT_ID } })
const mockRequestWithLogger = {
...mockRequest,
logger: mockRequestLogger()
}
describe('fetchSavedStateFromApi', () => {
const key = `${TEST_USER_IDS.DEFAULT}:${TEST_USER_IDS.ORGANISATION_ID}:${TEST_USER_IDS.GRANT_ID}`

const successfulResponse = {
ok: true,
json: () => MOCK_STATE_DATA.DEFAULT
}
const createSuccessfulResponse = (data = MOCK_STATE_DATA.DEFAULT) => ({
ok: true,
json: () => data
})
const createFailedResponse = (status, statusText = 'Error') => ({
ok: false,
status,
statusText,
json: () => {
throw new Error(ERROR_MESSAGES.NO_CONTENT)
}
})

const createFailedResponse = (status, statusText = 'Error') => ({
ok: false,
status,
statusText,
json: () => {
throw new Error(ERROR_MESSAGES.NO_CONTENT)
}
})
beforeEach(() => {
mockParseSessionKey.mockReturnValue({
userId: TEST_USER_IDS.DEFAULT,
organisationId: TEST_USER_IDS.ORGANISATION_ID,
grantId: TEST_USER_IDS.GRANT_ID
})
})

describe('fetchSavedStateFromApi', () => {
describe('With backend configured correctly', () => {
beforeEach(async () => {
vi.clearAllMocks()
vi.resetModules()
vi.doMock('~/src/config/config.js', createMockConfig)
vi.doMock('../logging/log.js', () => ({
log: vi.fn(),
LogCodes: {
SYSTEM: {
EXTERNAL_API_CALL_DEBUG: { level: 'debug', messageFunc: vi.fn() },
EXTERNAL_API_ERROR: { level: 'error', messageFunc: vi.fn() }
}
}
}))
const helper = await import('~/src/server/common/helpers/state/fetch-saved-state-helper.js?t=' + Date.now())
fetchSavedStateFromApi = helper.fetchSavedStateFromApi
log = (await import('../logging/log.js')).log
LogCodes = (await import('../logging/log.js')).LogCodes
})

afterEach(() => {
vi.doUnmock('~/src/config/config.js')
})

it('returns state when response is valid', async () => {
fetch.mockResolvedValue(successfulResponse)
fetch.mockResolvedValue(createSuccessfulResponse())

const result = await fetchSavedStateFromApi(mockRequest)
const result = await fetchSavedStateFromApi(key)

expect(result).toHaveProperty('state')
expect(fetch).toHaveBeenCalledTimes(1)
expect(log).toHaveBeenCalledWith(
LogCodes.SYSTEM.EXTERNAL_API_CALL_DEBUG,
expect.objectContaining({
method: 'GET',
endpoint: expect.stringContaining('/state/'),
identity: `${TEST_USER_IDS.DEFAULT}:${TEST_USER_IDS.ORGANISATION_ID}:${TEST_USER_IDS.GRANT_ID}`
})
)
})

it('includes authorization header in fetch request', async () => {
fetch.mockResolvedValue(successfulResponse)
fetch.mockResolvedValue(createSuccessfulResponse())

await fetchSavedStateFromApi(mockRequest)
await fetchSavedStateFromApi(key)

expect(fetch).toHaveBeenCalledTimes(1)
expect(fetch).toHaveBeenCalledWith(
Expand All @@ -85,49 +118,71 @@ describe('fetchSavedStateFromApi', () => {
})

it('returns null on 404', async () => {
fetch.mockResolvedValue(createFailedResponse(HTTP_STATUS.NOT_FOUND, ERROR_MESSAGES.NOT_FOUND))
fetch.mockResolvedValue(createFailedResponse(HTTP_STATUS.NOT_FOUND))

const result = await fetchSavedStateFromApi(mockRequest)
const result = await fetchSavedStateFromApi(key)

expect(result).toBeNull()
expect(fetch).toHaveBeenCalledTimes(1)
expect(log).toHaveBeenCalledWith(
LogCodes.SYSTEM.EXTERNAL_API_CALL_DEBUG,
expect.objectContaining({
method: 'GET',
endpoint: expect.stringContaining('/state/'),
identity: `${TEST_USER_IDS.DEFAULT}:${TEST_USER_IDS.ORGANISATION_ID}:${TEST_USER_IDS.GRANT_ID}`,
stateSummary: 'No state found in backend'
})
)
})

it('returns null on non-200 (not 404)', async () => {
fetch.mockResolvedValue(
createFailedResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR, ERROR_MESSAGES.INTERNAL_SERVER_ERROR)
)
fetch.mockResolvedValue(createFailedResponse(HTTP_STATUS.INTERNAL_SERVER_ERROR))

const result = await fetchSavedStateFromApi(mockRequest)
const result = await fetchSavedStateFromApi(key)

expect(result).toBeNull()
expect(fetch).toHaveBeenCalledTimes(1)
expect(log).toHaveBeenCalledWith(
LogCodes.SYSTEM.EXTERNAL_API_ERROR,
expect.objectContaining({
method: 'GET',
endpoint: expect.stringContaining('/state/'),
identity: `${TEST_USER_IDS.DEFAULT}:${TEST_USER_IDS.ORGANISATION_ID}:${TEST_USER_IDS.GRANT_ID}`,
error: 'Failed to fetch saved state: 500'
})
)
})

it('returns null when response JSON is invalid or missing state', async () => {
fetch.mockResolvedValue({ ok: true, json: () => 123 })
it('returns null when response JSON is invalid', async () => {
fetch.mockResolvedValue(createSuccessfulResponse(123))

const result = await fetchSavedStateFromApi(mockRequestWithLogger)
const result = await fetchSavedStateFromApi(key)

expect(result).toBeNull()
expect(mockRequestWithLogger.logger.warn).toHaveBeenCalledWith(
[LOG_TAGS.FETCH_SAVED_STATE],
LOG_MESSAGES.UNEXPECTED_STATE_FORMAT,
expect.any(Object)
expect(log).toHaveBeenCalledWith(
LogCodes.SYSTEM.EXTERNAL_API_ERROR,
expect.objectContaining({
method: 'GET',
endpoint: expect.stringContaining('/state/'),
identity: `${TEST_USER_IDS.DEFAULT}:${TEST_USER_IDS.ORGANISATION_ID}:${TEST_USER_IDS.GRANT_ID}`,
error: 'Unexpected or empty state format: 123'
})
)
})

it('returns null and logs error on fetch failure', async () => {
const networkError = new Error(ERROR_MESSAGES.NETWORK_ERROR)
fetch.mockRejectedValue(networkError)

const result = await fetchSavedStateFromApi(mockRequestWithLogger)
const result = await fetchSavedStateFromApi(key)

expect(result).toBeNull()
expect(mockRequestWithLogger.logger.error).toHaveBeenCalledWith(
[LOG_TAGS.FETCH_SAVED_STATE],
LOG_MESSAGES.FETCH_FAILED,
networkError
expect(log).toHaveBeenCalledWith(
LogCodes.SYSTEM.EXTERNAL_API_ERROR,
expect.objectContaining({
method: 'GET',
endpoint: expect.stringContaining('/state/'),
identity: `${TEST_USER_IDS.DEFAULT}:${TEST_USER_IDS.ORGANISATION_ID}:${TEST_USER_IDS.GRANT_ID}`,
error: 'Network error'
})
)
})
})
Expand All @@ -146,7 +201,7 @@ describe('fetchSavedStateFromApi', () => {
})

it('returns null when GRANTS_UI_BACKEND_ENDPOINT is not configured', async () => {
const result = await fetchSavedStateFromApi(mockRequest)
const result = await fetchSavedStateFromApi(key)

expect(result).toBeNull()
expect(fetch).not.toHaveBeenCalled()
Expand Down
Loading
Loading