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
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,29 @@ describe('pod file read — content type classification', () => {
},
);
});

// Both byte sources return the bytes in this JSON body, so both must send the
// same Cache-Control header.
describe('pod file read — Cache-Control', () => {
beforeEach(() => jest.clearAllMocks());

test('object-store bytes are sent private, no-store', async () => {
serveFile({ contentType: 'text/plain', bytes: Buffer.from('STORE_SENTINEL_DELTA\n') });
const res = await read();

expect(res.status).toBe(200);
expect(res.body.content).toBe('STORE_SENTINEL_DELTA\n');
expect(res.headers['cache-control']).toBe('private, no-store');
});

test('legacy inline bytes are sent private, no-store', async () => {
serveFile({ contentType: 'text/plain', bytes: Buffer.from('unused') });
getObjectStore().get.mockResolvedValue(null);
File.findByFileName.mockResolvedValue({ data: Buffer.from('LEGACY_SENTINEL_EPSILON\n') });
const res = await read();

expect(res.status).toBe(200);
expect(res.body.content).toBe('LEGACY_SENTINEL_EPSILON\n');
expect(res.headers['cache-control']).toBe('private, no-store');
});
});
28 changes: 28 additions & 0 deletions backend/__tests__/unit/routes/pods.external-links.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,31 @@ describe('pods routes - external links', () => {
await request(app).get('/api/pods/p1/external-links').expect(404);
});
});

describe('pods routes - WeChat QR image', () => {
let app;
let headerAtSend;
beforeEach(() => {
headerAtSend = undefined;
app = express();
app.use(express.json());
app.use((req, res, next) => {
// send() only adds its public default when no header is set, so record
// what the route had set at the moment it handed the file over.
res.sendFile = jest.fn(() => {
headerAtSend = res.getHeader('Cache-Control');
res.status(200).end();
});
next();
});
app.use('/api/pods', routes);
jest.clearAllMocks();
});

it('is sent private, no-store', async () => {
ExternalLink.findById.mockResolvedValue({ type: 'wechat', qrCodePath: '/uploads/qr.png', podId: 'p1' });
Pod.findById.mockResolvedValue({ members: [{ toString: () => 'user1' }] });
await request(app).get('/api/pods/external-link/l1/qrcode').expect(200);
expect(headerAtSend).toBe('private, no-store');
});
});
2 changes: 2 additions & 0 deletions backend/__tests__/unit/routes/uploads.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe('uploads GET /:fileName (ADR-002 Phase 1)', () => {
})
.expect(200);
expect(res.headers['content-type']).toMatch(/image\/png/);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.body)).toEqual(
Buffer.from('hello'),
);
Expand Down Expand Up @@ -195,6 +196,7 @@ describe('uploads GET /:fileName (ADR-002 Phase 1)', () => {

const res = await request(app).get('/api/uploads/legacy.jpg').expect(200);
expect(res.headers['content-type']).toMatch(/image\/jpeg/);
expect(res.headers['cache-control']).toBe('private, no-store');
expect(res.body.toString()).toBe('legacy-bytes');
expect(File.findByFileName).toHaveBeenCalledWith('legacy.jpg');
});
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/agentsRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2104,6 +2104,8 @@ router.get('/pods/:podId/files/:fileName/content', phase4RateLimit, agentRuntime
if (!buffer) {
return res.status(404).json({ message: 'File content not found' });
}
// Set explicitly on every response that carries file bytes, so the header never depends on a default.
res.set('Cache-Control', 'private, no-store');

const ct = String(file.contentType || '');
const isText = isTextualContentType(ct);
Expand Down
3 changes: 3 additions & 0 deletions backend/routes/pods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface AuthReq {
interface Res {
status: (n: number) => Res;
json: (d: unknown) => void;
set: (field: string, value: string) => void;
sendFile: (path: string) => void;
}

Expand Down Expand Up @@ -324,6 +325,8 @@ router.get('/external-link/:linkId/qrcode', auth, async (req: AuthReq, res: Res)
if (!link || link.type !== 'wechat' || !link.qrCodePath) return res.status(404).json({ message: 'QR code not found' });
const pod = await Pod.findById(link.podId) as { members?: Array<{ toString: () => string }> } | null;
if (!pod || !pod.members?.some((m) => m.toString() === req.user?.id)) return res.status(403).json({ message: 'Access denied' });
// Set explicitly: send() defaults to public, max-age=0 when no header is set.
res.set('Cache-Control', 'private, no-store');
return res.sendFile(path.resolve(link.qrCodePath));
} catch (error) {
console.error('Error fetching QR code:', error);
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ const setServedHeaders = (res: Res, fileName: string): void => {
res.set('Content-Type', served.type);
res.set('X-Content-Type-Options', 'nosniff');
res.set('Content-Disposition', served.inline ? 'inline' : 'attachment');
// Set explicitly on every response that carries file bytes, so the header never depends on a default.
res.set('Cache-Control', 'private, no-store');
};

// Size cap is driven by the driver's declared max. multer returns 413 itself
Expand Down
Loading