import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServer.js'
import { DEFAULT_COMPRESSION_THRESHOLD } from '../../../src/charging-station/ui-server/UIServerSecurity.js'
import { ApplicationProtocol, ResponseStatus } from '../../../src/types/index.js'
-import { TEST_UUID } from './UIServerTestConstants.js'
-import { createMockUIServerConfiguration, MockServerResponse } from './UIServerTestUtils.js'
+import { GZIP_STREAM_FLUSH_DELAY_MS, TEST_UUID } from './UIServerTestConstants.js'
+import {
+ createMockUIServerConfiguration,
+ MockServerResponse,
+ waitForStreamFlush,
+} from './UIServerTestUtils.js'
class TestableUIHttpServer extends UIHttpServer {
public addResponseHandler (uuid: UUIDv4, res: MockServerResponse): void {
this.responseHandlers.set(uuid, res as never)
}
+ public getAcceptsGzip (): Map<UUIDv4, boolean> {
+ return Reflect.get(this, 'acceptsGzip') as Map<UUIDv4, boolean>
+ }
+
public getResponseHandlersSize (): number {
return this.responseHandlers.size
}
public setAcceptsGzip (uuid: UUIDv4, value: boolean): void {
- ;(this as unknown as { acceptsGzip: Map<UUIDv4, boolean> }).acceptsGzip.set(uuid, value)
+ this.getAcceptsGzip().set(uuid, value)
}
}
+const createHttpServerConfig = () =>
+ createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
+
+const createLargePayload = (status: ResponseStatus = ResponseStatus.SUCCESS) => ({
+ data: 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100),
+ status,
+})
+
await describe('UIHttpServer test suite', async () => {
await it('Verify sendResponse() deletes handler after sending', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
server.addResponseHandler(TEST_UUID, res)
})
await it('Verify sendResponse() logs error when handler not found', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
})
await it('Verify sendResponse() sets correct status code for failure', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
server.addResponseHandler(TEST_UUID, res)
})
await it('Verify sendResponse() handles send errors gracefully', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
res.end = (): never => {
throw new Error('HTTP response end error')
})
await it('Verify sendResponse() sets correct Content-Type header', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
server.addResponseHandler(TEST_UUID, res)
})
await it('Verify response handlers cleanup', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res1 = new MockServerResponse()
const res2 = new MockServerResponse()
})
await it('Verify handlers cleared on server stop', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
server.addResponseHandler(TEST_UUID, res)
})
await it('Verify response payload serialization', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
const payload = {
hashIdsSucceeded: ['station-1', 'station-2'],
server.sendResponse([TEST_UUID, payload])
expect(res.body).toBeDefined()
- // HTTP server sends only the payload, not [uuid, payload]
const parsedBody = JSON.parse(res.body ?? '{}') as Record<string, unknown>
expect(parsedBody.status).toBe('success')
expect(parsedBody.hashIdsSucceeded).toEqual(['station-1', 'station-2'])
})
await it('Verify response with error details', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
const payload = {
errorMessage: 'Test error',
server.sendResponse([TEST_UUID, payload])
expect(res.body).toBeDefined()
- // HTTP server sends only the payload, not [uuid, payload]
const parsedBody = JSON.parse(res.body ?? '{}') as Record<string, unknown>
expect(parsedBody.status).toBe('failure')
expect(parsedBody.errorMessage).toBe('Test error')
})
await it('Verify valid HTTP configuration', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new UIHttpServer(config)
+ const server = new UIHttpServer(createHttpServerConfig())
expect(server).toBeDefined()
})
})
await describe('Gzip compression', async () => {
- await it('Verify sendResponse() does not compress when acceptsGzip is false', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ await it('Verify no compression when acceptsGzip is false', () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
- const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100)
- const payload = {
- data: largeData,
- status: ResponseStatus.SUCCESS,
- }
server.addResponseHandler(TEST_UUID, res)
server.setAcceptsGzip(TEST_UUID, false)
- server.sendResponse([TEST_UUID, payload])
+ server.sendResponse([TEST_UUID, createLargePayload()])
expect(res.headers['Content-Encoding']).toBeUndefined()
expect(res.headers['Content-Type']).toBe('application/json')
})
- await it('Verify sendResponse() does not compress small responses', () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ await it('Verify no compression for small responses', () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
- const payload = {
- status: ResponseStatus.SUCCESS,
- }
server.addResponseHandler(TEST_UUID, res)
server.setAcceptsGzip(TEST_UUID, true)
- server.sendResponse([TEST_UUID, payload])
+ server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
expect(res.headers['Content-Encoding']).toBeUndefined()
expect(res.headers['Content-Type']).toBe('application/json')
})
- await it('Verify sendResponse() compresses large responses when client accepts gzip', async () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ await it('Verify no compression at exact threshold boundary', () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
- const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100)
- const payload = {
- data: largeData,
+ const basePayload = { data: '', status: ResponseStatus.SUCCESS }
+ const baseSize = Buffer.byteLength(JSON.stringify(basePayload))
+ const paddingNeeded = DEFAULT_COMPRESSION_THRESHOLD - baseSize
+ const boundaryPayload = {
+ data: 'x'.repeat(Math.max(0, paddingNeeded)),
status: ResponseStatus.SUCCESS,
}
server.addResponseHandler(TEST_UUID, res)
server.setAcceptsGzip(TEST_UUID, true)
- server.sendResponse([TEST_UUID, payload])
+ server.sendResponse([TEST_UUID, boundaryPayload])
+
+ expect(res.headers['Content-Encoding']).toBeUndefined()
+ })
+
+ await it('Verify compression headers for large responses', async () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
+ const res = new MockServerResponse()
+
+ server.addResponseHandler(TEST_UUID, res)
+ server.setAcceptsGzip(TEST_UUID, true)
+ server.sendResponse([TEST_UUID, createLargePayload()])
- await new Promise(resolve => {
- setTimeout(resolve, 50)
- })
+ await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS)
expect(res.headers['Content-Encoding']).toBe('gzip')
expect(res.headers['Content-Type']).toBe('application/json')
expect(res.headers.Vary).toBe('Accept-Encoding')
})
- await it('Verify compressed response can be decompressed to original payload', async () => {
- const config = createMockUIServerConfiguration({ type: ApplicationProtocol.HTTP })
- const server = new TestableUIHttpServer(config)
+ await it('Verify compressed response decompresses to original payload', async () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
const res = new MockServerResponse()
- const largeData = 'x'.repeat(DEFAULT_COMPRESSION_THRESHOLD + 100)
- const payload = {
- data: largeData,
- status: ResponseStatus.SUCCESS,
- }
+ const payload = createLargePayload()
server.addResponseHandler(TEST_UUID, res)
server.setAcceptsGzip(TEST_UUID, true)
server.sendResponse([TEST_UUID, payload])
- await new Promise(resolve => {
- setTimeout(resolve, 50)
- })
+ await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS)
expect(res.bodyBuffer).toBeDefined()
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const decompressed = gunzipSync(res.bodyBuffer!).toString('utf8')
+ if (res.bodyBuffer == null) {
+ throw new Error('Expected bodyBuffer to be defined')
+ }
+ const decompressed = gunzipSync(res.bodyBuffer).toString('utf8')
const parsedBody = JSON.parse(decompressed) as Record<string, unknown>
expect(parsedBody.status).toBe('success')
- expect(parsedBody.data).toBe(largeData)
+ expect(parsedBody.data).toBe(payload.data)
+ })
+
+ await it('Verify no compression when acceptsGzip context is missing', () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
+ const res = new MockServerResponse()
+
+ server.addResponseHandler(TEST_UUID, res)
+ server.sendResponse([TEST_UUID, createLargePayload()])
+
+ expect(res.headers['Content-Encoding']).toBeUndefined()
+ expect(res.headers['Content-Type']).toBe('application/json')
+ })
+
+ await it('Verify acceptsGzip context cleanup after response', async () => {
+ const server = new TestableUIHttpServer(createHttpServerConfig())
+ const res = new MockServerResponse()
+
+ server.addResponseHandler(TEST_UUID, res)
+ server.setAcceptsGzip(TEST_UUID, true)
+ expect(server.getAcceptsGzip().has(TEST_UUID)).toBe(true)
+
+ server.sendResponse([TEST_UUID, createLargePayload()])
+
+ await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS)
+
+ expect(server.getAcceptsGzip().has(TEST_UUID)).toBe(false)
})
})
})
import {
createBodySizeLimiter,
createRateLimiter,
- DEFAULT_COMPRESSION_THRESHOLD,
- DEFAULT_MAX_PAYLOAD_SIZE,
DEFAULT_MAX_STATIONS,
- DEFAULT_MAX_TRACKED_IPS,
- DEFAULT_RATE_LIMIT,
- DEFAULT_RATE_WINDOW,
isValidCredential,
isValidNumberOfStations,
} from '../../../src/charging-station/ui-server/UIServerSecurity.js'
+import { waitForStreamFlush } from './UIServerTestUtils.js'
+
+const RATE_WINDOW_EXPIRY_DELAY_MS = 110
await describe('UIServerSecurity test suite', async () => {
await describe('isValidCredential()', async () => {
- await it('should return true for matching credentials', () => {
- const result = isValidCredential('myPassword123', 'myPassword123')
- expect(result).toBe(true)
+ await it('Verify matching credentials return true', () => {
+ expect(isValidCredential('myPassword123', 'myPassword123')).toBe(true)
})
- await it('should return false for non-matching credentials', () => {
- const result = isValidCredential('password1', 'password2')
- expect(result).toBe(false)
+ await it('Verify non-matching credentials return false', () => {
+ expect(isValidCredential('password1', 'password2')).toBe(false)
})
- await it('should handle empty string credentials', () => {
- const result = isValidCredential('', '')
- expect(result).toBe(true)
+ await it('Verify empty string credentials match', () => {
+ expect(isValidCredential('', '')).toBe(true)
})
- await it('should handle different length credentials', () => {
+ await it('Verify different length credentials return false', () => {
// cspell:disable-next-line
- const result = isValidCredential('short', 'verylongpassword')
- expect(result).toBe(false)
+ expect(isValidCredential('short', 'verylongpassword')).toBe(false)
})
})
await describe('createBodySizeLimiter()', async () => {
- await it('should return true when accumulated bytes are under limit', () => {
+ await it('Verify bytes under limit return true', () => {
const limiter = createBodySizeLimiter(1000)
- const result = limiter(500)
- expect(result).toBe(true)
+
+ expect(limiter(500)).toBe(true)
})
- await it('should return false when accumulated bytes exceed limit', () => {
+ await it('Verify accumulated bytes exceeding limit return false', () => {
const limiter = createBodySizeLimiter(1000)
limiter(600)
- const result = limiter(500)
- expect(result).toBe(false)
+
+ expect(limiter(500)).toBe(false)
})
- await it('should return true at exact limit boundary', () => {
+ await it('Verify exact limit boundary returns true', () => {
const limiter = createBodySizeLimiter(1000)
- const result = limiter(1000)
- expect(result).toBe(true)
+
+ expect(limiter(1000)).toBe(true)
})
})
await describe('createRateLimiter()', async () => {
- await it('should allow requests under the limit', () => {
+ await it('Verify requests under limit are allowed', () => {
const limiter = createRateLimiter(5, 1000)
+
for (let i = 0; i < 5; i++) {
expect(limiter('192.168.1.1')).toBe(true)
}
})
- await it('should block requests that exceed the limit', () => {
+ await it('Verify requests exceeding limit are blocked', () => {
const limiter = createRateLimiter(3, 1000)
limiter('192.168.1.1')
limiter('192.168.1.1')
limiter('192.168.1.1')
- const result = limiter('192.168.1.1')
- expect(result).toBe(false)
+
+ expect(limiter('192.168.1.1')).toBe(false)
})
- await it('should reset window after time expires', async () => {
+ await it('Verify window resets after time expires', async () => {
const limiter = createRateLimiter(2, 100)
limiter('10.0.0.1')
limiter('10.0.0.1')
expect(limiter('10.0.0.1')).toBe(false)
- await new Promise(resolve => {
- setTimeout(resolve, 110)
- })
+ await waitForStreamFlush(RATE_WINDOW_EXPIRY_DELAY_MS)
expect(limiter('10.0.0.1')).toBe(true)
})
- await it('should reject new IPs when at max tracked IPs capacity', () => {
+ await it('Verify new IPs rejected when at max tracked capacity', () => {
const limiter = createRateLimiter(10, 60000, 3)
+
expect(limiter('192.168.1.1')).toBe(true)
expect(limiter('192.168.1.2')).toBe(true)
expect(limiter('192.168.1.3')).toBe(true)
expect(limiter('192.168.1.4')).toBe(false)
})
- await it('should still allow existing IPs when at capacity', () => {
+ await it('Verify existing IPs still allowed when at capacity', () => {
const limiter = createRateLimiter(10, 60000, 2)
+
expect(limiter('192.168.1.1')).toBe(true)
expect(limiter('192.168.1.2')).toBe(true)
expect(limiter('192.168.1.1')).toBe(true)
expect(limiter('192.168.1.2')).toBe(true)
})
- await it('should cleanup expired entries when at capacity', async () => {
+ await it('Verify expired entries cleanup when at capacity', async () => {
const limiter = createRateLimiter(10, 50, 2)
expect(limiter('192.168.1.1')).toBe(true)
expect(limiter('192.168.1.2')).toBe(true)
- await new Promise(resolve => {
- setTimeout(resolve, 60)
- })
+ await waitForStreamFlush(60)
expect(limiter('192.168.1.3')).toBe(true)
})
})
await describe('isValidNumberOfStations()', async () => {
- await it('should return true for valid number of stations', () => {
- const result = isValidNumberOfStations(50, DEFAULT_MAX_STATIONS)
- expect(result).toBe(true)
- })
-
- await it('should return false when exceeding max stations', () => {
- const result = isValidNumberOfStations(150, DEFAULT_MAX_STATIONS)
- expect(result).toBe(false)
- })
-
- await it('should return false for zero stations', () => {
- const result = isValidNumberOfStations(0, DEFAULT_MAX_STATIONS)
- expect(result).toBe(false)
- })
-
- await it('should return false for negative stations', () => {
- const result = isValidNumberOfStations(-5, DEFAULT_MAX_STATIONS)
- expect(result).toBe(false)
- })
- })
-
- await describe('Security constants', async () => {
- await it('should have correct DEFAULT_MAX_PAYLOAD_SIZE value', () => {
- expect(DEFAULT_MAX_PAYLOAD_SIZE).toBe(1048576) // 1MB
- })
-
- await it('should have correct DEFAULT_RATE_LIMIT value', () => {
- expect(DEFAULT_RATE_LIMIT).toBe(100)
+ await it('Verify valid number of stations returns true', () => {
+ expect(isValidNumberOfStations(50, DEFAULT_MAX_STATIONS)).toBe(true)
})
- await it('should have correct DEFAULT_RATE_WINDOW value', () => {
- expect(DEFAULT_RATE_WINDOW).toBe(60000) // 60 seconds
+ await it('Verify exceeding max stations returns false', () => {
+ expect(isValidNumberOfStations(150, DEFAULT_MAX_STATIONS)).toBe(false)
})
- await it('should have correct DEFAULT_MAX_STATIONS value', () => {
- expect(DEFAULT_MAX_STATIONS).toBe(100)
+ await it('Verify zero stations returns false', () => {
+ expect(isValidNumberOfStations(0, DEFAULT_MAX_STATIONS)).toBe(false)
})
- await it('should have correct DEFAULT_MAX_TRACKED_IPS value', () => {
- expect(DEFAULT_MAX_TRACKED_IPS).toBe(10000)
+ await it('Verify negative stations returns false', () => {
+ expect(isValidNumberOfStations(-5, DEFAULT_MAX_STATIONS)).toBe(false)
})
- await it('should have correct DEFAULT_COMPRESSION_THRESHOLD value', () => {
- expect(DEFAULT_COMPRESSION_THRESHOLD).toBe(1024) // 1KB
+ await it('Verify exact max stations boundary returns true', () => {
+ expect(isValidNumberOfStations(DEFAULT_MAX_STATIONS, DEFAULT_MAX_STATIONS)).toBe(true)
})
})
})