Skip to content

Commit 6d7ea1b

Browse files
authored
fix: bug with decrypting images (#121)
1 parent 42df249 commit 6d7ea1b

11 files changed

+231
-3
lines changed

src/files/files.controller.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('FilesController', () => {
3737
mockStorageService.get.mockResolvedValue(fileData);
3838

3939
const mockRes = {
40+
setHeader: jest.fn(),
4041
write: jest.fn(() => true),
4142
end: jest.fn(),
4243
on: jest.fn(),
@@ -50,6 +51,27 @@ describe('FilesController', () => {
5051
expect(mockRes.end).toHaveBeenCalled();
5152
});
5253

54+
it('should set Content-Length header matching data size', async () => {
55+
const fileData = Buffer.from('binary-file-data');
56+
mockStorageService.get.mockResolvedValue(fileData);
57+
58+
const mockRes = {
59+
setHeader: jest.fn(),
60+
write: jest.fn(() => true),
61+
end: jest.fn(),
62+
on: jest.fn(),
63+
once: jest.fn(),
64+
emit: jest.fn(),
65+
};
66+
67+
await controller.findOne({ id: 'file-123' }, mockRes as any);
68+
69+
expect(mockRes.setHeader).toHaveBeenCalledWith(
70+
'Content-Length',
71+
fileData.length,
72+
);
73+
});
74+
5375
it('should throw NotFoundException when file does not exist', async () => {
5476
mockStorageService.get.mockResolvedValue(undefined);
5577

src/files/files.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class FilesController {
3131
throw new NotFoundException();
3232
}
3333

34+
res.setHeader('Content-Length', data.length);
3435
const stream = new Readable();
3536
stream.push(data);
3637
stream.push(null);

src/main.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NestFactory } from '@nestjs/core';
2+
3+
jest.mock('@nestjs/core', () => ({
4+
NestFactory: {
5+
create: jest.fn().mockResolvedValue({
6+
setGlobalPrefix: jest.fn(),
7+
listen: jest.fn().mockResolvedValue(undefined),
8+
}),
9+
},
10+
}));
11+
12+
describe('bootstrap', () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it('should disable default body parsers to prevent binary data corruption', async () => {
18+
await import('./main');
19+
20+
expect(NestFactory.create).toHaveBeenCalledWith(
21+
expect.anything(),
22+
expect.objectContaining({
23+
bodyParser: false,
24+
}),
25+
);
26+
});
27+
});

src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ async function bootstrap() {
1313

1414
const app = await NestFactory.create(AppModule, {
1515
cors: true,
16+
bodyParser: false,
1617
logger: [logLevel],
1718
});
1819

src/rooms/rooms.controller.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('RoomsController', () => {
3737
mockStorageService.get.mockResolvedValue(roomData);
3838

3939
const mockRes = {
40+
setHeader: jest.fn(),
4041
write: jest.fn(() => true),
4142
end: jest.fn(),
4243
on: jest.fn(),
@@ -50,6 +51,27 @@ describe('RoomsController', () => {
5051
expect(mockRes.end).toHaveBeenCalled();
5152
});
5253

54+
it('should set Content-Length header matching data size', async () => {
55+
const roomData = Buffer.from('room-binary-data');
56+
mockStorageService.get.mockResolvedValue(roomData);
57+
58+
const mockRes = {
59+
setHeader: jest.fn(),
60+
write: jest.fn(() => true),
61+
end: jest.fn(),
62+
on: jest.fn(),
63+
once: jest.fn(),
64+
emit: jest.fn(),
65+
};
66+
67+
await controller.findOne({ id: 'room-123' }, mockRes as any);
68+
69+
expect(mockRes.setHeader).toHaveBeenCalledWith(
70+
'Content-Length',
71+
roomData.length,
72+
);
73+
});
74+
5375
it('should throw NotFoundException when room does not exist', async () => {
5476
mockStorageService.get.mockResolvedValue(undefined);
5577

src/rooms/rooms.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export class RoomsController {
3030
throw new NotFoundException();
3131
}
3232

33+
res.setHeader('Content-Length', data.length);
3334
const stream = new Readable();
3435
stream.push(data);
3536
stream.push(null);

src/scenes/scenes.controller.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('ScenesController', () => {
4040
mockStorageService.get.mockResolvedValue(sceneData);
4141

4242
const mockRes = {
43+
setHeader: jest.fn(),
4344
write: jest.fn(() => true),
4445
end: jest.fn(),
4546
on: jest.fn(),
@@ -53,6 +54,27 @@ describe('ScenesController', () => {
5354
expect(mockRes.end).toHaveBeenCalled();
5455
});
5556

57+
it('should set Content-Length header matching data size', async () => {
58+
const sceneData = Buffer.from('scene-binary-data');
59+
mockStorageService.get.mockResolvedValue(sceneData);
60+
61+
const mockRes = {
62+
setHeader: jest.fn(),
63+
write: jest.fn(() => true),
64+
end: jest.fn(),
65+
on: jest.fn(),
66+
once: jest.fn(),
67+
emit: jest.fn(),
68+
};
69+
70+
await controller.findOne({ id: 'scene-123' }, mockRes as any);
71+
72+
expect(mockRes.setHeader).toHaveBeenCalledWith(
73+
'Content-Length',
74+
sceneData.length,
75+
);
76+
});
77+
5678
it('should throw NotFoundException when scene does not exist', async () => {
5779
mockStorageService.get.mockResolvedValue(undefined);
5880

src/scenes/scenes.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class ScenesController {
3131
throw new NotFoundException();
3232
}
3333

34+
res.setHeader('Content-Length', data.length);
3435
const stream = new Readable();
3536
stream.push(data);
3637
stream.push(null);
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import Keyv from 'keyv';
2+
import KeyvPostgres from '@keyv/postgres';
3+
import { StorageNamespace } from './storage.service';
4+
import { PgMockManager } from '../../test/utils';
5+
6+
/**
7+
* Integration tests using PGlite to verify the regexp_replace touch SQL
8+
* preserves stored data while updating only the expires timestamp.
9+
*
10+
* Uses beforeAll (not beforeEach) because KeyvPostgres triggers async PGlite
11+
* initialization that must complete within an awaited context.
12+
*/
13+
describe('touchPostgres regexp_replace integration (PGlite)', () => {
14+
let store: KeyvPostgres;
15+
let keyv: Keyv;
16+
const ttl = 86400000;
17+
const namespace = StorageNamespace.SCENES;
18+
19+
const getDb = () => PgMockManager.getInstance().getDb();
20+
21+
/** Run the same regexp_replace SQL that touchPostgres uses. */
22+
const runTouchSql = async (fullKey: string, expires: number) => {
23+
return getDb().query(
24+
`UPDATE keyv
25+
SET value = regexp_replace(value, '"expires":\\d+', '"expires":' || $1::text)
26+
WHERE key = $2`,
27+
[expires, fullKey],
28+
);
29+
};
30+
31+
/** Read the raw value column from the keyv table. */
32+
const readRawRow = async (fullKey: string): Promise<string | undefined> => {
33+
const result = await getDb().query<{ value: string }>(
34+
'SELECT value FROM keyv WHERE key = $1',
35+
[fullKey],
36+
);
37+
return result.rows[0]?.value;
38+
};
39+
40+
beforeAll(async () => {
41+
store = new KeyvPostgres();
42+
keyv = new Keyv({ store, namespace, ttl });
43+
// Force PGlite table creation by performing an initial operation
44+
await keyv.set('__init__', 'init');
45+
await keyv.delete('__init__');
46+
});
47+
48+
afterEach(async () => {
49+
await PgMockManager.getInstance().clearDatabase();
50+
});
51+
52+
afterAll(async () => {
53+
await store.disconnect();
54+
});
55+
56+
it('should preserve binary data after touch', async () => {
57+
const original = Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x80, 0x7f]);
58+
await keyv.set('binary-key', original);
59+
60+
const fullKey = `${namespace}:binary-key`;
61+
const newExpires = Date.now() + ttl * 2;
62+
await runTouchSql(fullKey, newExpires);
63+
64+
const retrieved = await keyv.get('binary-key');
65+
expect(Buffer.isBuffer(retrieved)).toBe(true);
66+
expect(Buffer.from(retrieved).equals(original)).toBe(true);
67+
});
68+
69+
it('should update the expires timestamp in the raw row', async () => {
70+
await keyv.set('ts-key', 'hello');
71+
72+
const fullKey = `${namespace}:ts-key`;
73+
const rawBefore = await readRawRow(fullKey);
74+
const expiresBefore = JSON.parse(rawBefore!).expires as number;
75+
76+
const newExpires = expiresBefore + 99999;
77+
await runTouchSql(fullKey, newExpires);
78+
79+
const rawAfter = await readRawRow(fullKey);
80+
const expiresAfter = JSON.parse(rawAfter!).expires as number;
81+
82+
expect(expiresAfter).toBe(newExpires);
83+
expect(expiresAfter).not.toBe(expiresBefore);
84+
});
85+
86+
it('should preserve string data after touch', async () => {
87+
await keyv.set('str-key', 'some important string value');
88+
89+
const fullKey = `${namespace}:str-key`;
90+
await runTouchSql(fullKey, Date.now() + ttl * 2);
91+
92+
const retrieved = await keyv.get('str-key');
93+
expect(retrieved).toBe('some important string value');
94+
});
95+
96+
it('should not modify value when expires is null', async () => {
97+
const fullKey = `${namespace}:no-expires`;
98+
// Insert a row manually with "expires":null — won't match \d+
99+
await getDb().query(`INSERT INTO keyv (key, value) VALUES ($1, $2)`, [
100+
fullKey,
101+
'{"value":"test-data","expires":null}',
102+
]);
103+
104+
const rawBefore = await readRawRow(fullKey);
105+
await runTouchSql(fullKey, Date.now() + ttl);
106+
const rawAfter = await readRawRow(fullKey);
107+
108+
expect(rawAfter).toBe(rawBefore);
109+
});
110+
});

src/storage/storage.service.touch.spec.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,29 @@ describe('StorageService - touch()', () => {
7171
service = module.get<StorageService>(StorageService);
7272
});
7373

74-
it('should refresh TTL via SQL for an existing record', async () => {
74+
it('should refresh TTL via SQL using regexp_replace for an existing record', async () => {
7575
jest.spyOn(Date, 'now').mockReturnValue(1000000000);
7676
(mockStore.query as jest.Mock).mockResolvedValue(createQueryResult(1));
7777

7878
const result = await service.touch(testKey, testNamespace);
7979

8080
expect(result).toBe(true);
8181
expect(mockStore.query).toHaveBeenCalledWith(
82-
expect.stringContaining('UPDATE keyv'),
82+
expect.stringContaining('regexp_replace'),
8383
[1000000000 + mockTtl, `${testNamespace}:${testKey}`],
8484
);
8585
});
8686

87+
it('should not use JSONB operations in touch SQL', async () => {
88+
(mockStore.query as jest.Mock).mockResolvedValue(createQueryResult(1));
89+
90+
await service.touch(testKey, testNamespace);
91+
92+
const sql = (mockStore.query as jest.Mock).mock.calls[0][0] as string;
93+
expect(sql).not.toContain('jsonb_set');
94+
expect(sql).not.toContain('::jsonb');
95+
});
96+
8797
it('should return false when record not found', async () => {
8898
(mockStore.query as jest.Mock).mockResolvedValue(createQueryResult(0));
8999

0 commit comments

Comments
 (0)