이미지 업로드와 WebPush 응답의 자원 경계를 제한한다
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
import { performSignedImageUpload } from '@sammo-ts/common/images/imageUpload';
|
||||
|
||||
export interface UserIconUploadResult {
|
||||
picture: string;
|
||||
@@ -9,20 +9,6 @@ export interface UserIconUploadStore {
|
||||
upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult>;
|
||||
}
|
||||
|
||||
const signature = (
|
||||
secret: string,
|
||||
expires: string,
|
||||
requestId: string,
|
||||
pathname: string,
|
||||
contentType: string,
|
||||
body: Buffer
|
||||
): string => {
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
return createHmac('sha256', secret)
|
||||
.update(`${expires}.${requestId}.${pathname}.${contentType}.${digest}`)
|
||||
.digest('hex');
|
||||
};
|
||||
|
||||
export class RemoteUserIconStore implements UserIconUploadStore {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
@@ -36,35 +22,17 @@ export class RemoteUserIconStore implements UserIconUploadStore {
|
||||
if (!/^[a-f0-9]{32}\.(?:avif|webp|jpg|png|gif)$/.test(input.filename)) {
|
||||
throw new Error('Invalid user icon filename.');
|
||||
}
|
||||
const pathname = `/v1/uploads/user-icons/core2026/${input.filename}`;
|
||||
const expires = String(Math.floor(this.now() / 1000) + 60);
|
||||
const requestId = randomUUID();
|
||||
const response = await this.fetchImpl(`${this.baseUrl.replace(/\/$/, '')}${pathname}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'content-type': input.contentType,
|
||||
'x-image-client': 'core2026',
|
||||
'x-image-expires': expires,
|
||||
'x-image-request-id': requestId,
|
||||
'x-image-signature': signature(
|
||||
this.secret,
|
||||
expires,
|
||||
requestId,
|
||||
pathname,
|
||||
input.contentType,
|
||||
input.body
|
||||
),
|
||||
await performSignedImageUpload(
|
||||
{
|
||||
...input,
|
||||
kind: 'user-icons',
|
||||
baseUrl: this.baseUrl,
|
||||
secret: this.secret,
|
||||
},
|
||||
body: new Uint8Array(input.body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
this.fetchImpl,
|
||||
this.now
|
||||
);
|
||||
const picture = `users/core2026/${input.filename}`;
|
||||
const payload: unknown = await response.json();
|
||||
if (!payload || typeof payload !== 'object' || !('path' in payload) || payload.path !== `icons/${picture}`) {
|
||||
throw new Error('Image repository returned an unexpected upload path.');
|
||||
}
|
||||
return { picture, publicUrl: `${this.publicBaseUrl.replace(/\/$/, '')}/${picture}` };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
ImageWorkBudget,
|
||||
ImageWorkBusyError,
|
||||
ImageUploadError,
|
||||
isRasterImage,
|
||||
} from '@sammo-ts/common/images/imageUpload';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
@@ -13,6 +19,9 @@ import { isGatewaySessionCurrent } from '../auth/sessionValidity.js';
|
||||
import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common';
|
||||
|
||||
const zSessionToken = z.string().min(1);
|
||||
const iconImageBudget = new ImageWorkBudget();
|
||||
const MAX_IMAGE_PIXELS = 128 * 128 * 256;
|
||||
|
||||
const MAX_ICON_BYTES = 50 * 1024;
|
||||
const MAX_ACTIVE_ICONS = 5;
|
||||
const ICON_RETIRE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -265,52 +274,91 @@ export const accountRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await requireSessionUser(ctx, input.sessionToken);
|
||||
const now = new Date();
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const buffer = decodeImage(input.imageData);
|
||||
const metadata = await sharp(buffer, { animated: true }).metadata();
|
||||
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
||||
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
if (!metadata.width || metadata.width < 64 || metadata.width > 128 || metadata.height !== metadata.width) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
||||
});
|
||||
}
|
||||
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
||||
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||
if (!ctx.userIconUpload) {
|
||||
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '이미지 저장소가 설정되지 않았습니다.' });
|
||||
}
|
||||
const uploaded = await ctx.userIconUpload.upload({
|
||||
filename,
|
||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||
body: buffer,
|
||||
});
|
||||
const stored = await ctx.users.addIconForWindow(user.id, uploaded.picture, 0, now, MAX_ACTIVE_ICONS);
|
||||
if (!stored.ok) {
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||
try {
|
||||
return await iconImageBudget.run(async () => {
|
||||
const now = new Date();
|
||||
const profiles = await listIconSyncProfiles(ctx, user.id);
|
||||
const buffer = decodeImage(input.imageData);
|
||||
if (!isRasterImage(buffer))
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이미지 형식입니다.' });
|
||||
const metadata = await sharp(buffer, { animated: true, limitInputPixels: MAX_IMAGE_PIXELS })
|
||||
.timeout({ seconds: 10 })
|
||||
.metadata()
|
||||
.catch(() => {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 정보를 확인할 수 없습니다.' });
|
||||
});
|
||||
if ((metadata.pages ?? 1) > 256)
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미지 프레임 제한을 초과했습니다.' });
|
||||
const detectedFormat = metadata.mediaType === 'image/avif' ? 'avif' : metadata.format;
|
||||
if (!detectedFormat || !ALLOWED_ICON_FORMATS.has(detectedFormat)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'avif, webp, jpg, gif, png 아이콘만 사용할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
!metadata.width ||
|
||||
metadata.width < 64 ||
|
||||
metadata.width > 128 ||
|
||||
metadata.height !== metadata.width
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '아이콘은 64x64~128x128 범위의 정사각형이어야 합니다.',
|
||||
});
|
||||
}
|
||||
const extension = detectedFormat === 'jpeg' ? 'jpg' : detectedFormat;
|
||||
const filename = `${randomBytes(16).toString('hex')}.${extension}`;
|
||||
if (!ctx.userIconUpload) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '이미지 저장소가 설정되지 않았습니다.',
|
||||
});
|
||||
}
|
||||
const uploaded = await ctx.userIconUpload.upload({
|
||||
filename,
|
||||
contentType: ICON_CONTENT_TYPES[extension]!,
|
||||
body: buffer,
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
|
||||
const stored = await ctx.users.addIconForWindow(
|
||||
user.id,
|
||||
uploaded.picture,
|
||||
0,
|
||||
now,
|
||||
MAX_ACTIVE_ICONS
|
||||
);
|
||||
if (!stored.ok) {
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '전용 아이콘은 최대 5개까지 등록할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾을 수 없습니다.' });
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: uploaded.publicUrl,
|
||||
revision: stored.revision,
|
||||
icon: buildLibraryIcon(ctx, stored.icon),
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ImageWorkBusyError)
|
||||
throw new TRPCError({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
message: '이미지 처리 중입니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
if (error instanceof ImageUploadError)
|
||||
throw new TRPCError({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: '이미지 저장소에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: uploaded.publicUrl,
|
||||
revision: stored.revision,
|
||||
icon: buildLibraryIcon(ctx, stored.icon),
|
||||
profiles,
|
||||
flushPublished,
|
||||
};
|
||||
}),
|
||||
setPreferredIcon: procedure
|
||||
.input(z.object({ sessionToken: zSessionToken, iconId: z.string().uuid() }))
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sendBoundedNotification } from './sendNotification.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
@@ -12,7 +13,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import webPush from 'web-push';
|
||||
|
||||
import { resolveGatewayProfileDisplayName } from '../profileOrder.js';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
import { parsePushEndpoint, PushEndpointPolicyError, validatePushEndpoint } from './endpointPolicy.js';
|
||||
|
||||
export interface WebPushCoordinatorConfig {
|
||||
enabled: boolean;
|
||||
@@ -455,7 +456,7 @@ export class WebPushCoordinator {
|
||||
try {
|
||||
// 기존 DB 구독도 검사한다. DNS 검사는 실제 socket lookup에서 다시 수행한다.
|
||||
parsePushEndpoint(delivery.subscription.endpoint);
|
||||
await webPush.sendNotification(
|
||||
await sendBoundedNotification(
|
||||
{
|
||||
endpoint: delivery.subscription.endpoint,
|
||||
keys: { p256dh: delivery.subscription.p256dh, auth: delivery.subscription.auth },
|
||||
@@ -465,8 +466,7 @@ export class WebPushCoordinator {
|
||||
body: delivery.notification.body,
|
||||
url: delivery.notification.url,
|
||||
tag: delivery.notification.tag,
|
||||
}),
|
||||
{ TTL: 60 * 60, timeout: 10_000, agent: publicPushAgent }
|
||||
})
|
||||
);
|
||||
await this.prisma.$executeRaw(GatewayPrisma.sql`
|
||||
UPDATE web_push_delivery
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { request, type RequestOptions } from 'node:https';
|
||||
import type { ClientRequest, IncomingMessage } from 'node:http';
|
||||
import webPush from 'web-push';
|
||||
import { parsePushEndpoint, publicPushAgent, PushEndpointPolicyError } from './endpointPolicy.js';
|
||||
|
||||
export class PushTransportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly statusCode = 0
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
export const MAX_PUSH_RESPONSE_BYTES = 16 * 1024;
|
||||
type PushRequest = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest;
|
||||
|
||||
/** Keep web-push encryption/VAPID, but bound the entire exchange and discard response content. */
|
||||
export const sendPushRequest = (
|
||||
details: webPush.RequestDetails,
|
||||
requestImpl: PushRequest = request,
|
||||
timeoutMs = 10_000
|
||||
): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const endpoint = parsePushEndpoint(details.endpoint);
|
||||
let response: IncomingMessage | undefined;
|
||||
let req: ClientRequest | undefined;
|
||||
let settled = false;
|
||||
const finish = (error?: PushTransportError | PushEndpointPolicyError) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (error) {
|
||||
response?.destroy();
|
||||
req?.destroy();
|
||||
reject(error);
|
||||
} else resolve();
|
||||
};
|
||||
// Socket inactivity timeouts can be bypassed by a response that drips bytes forever.
|
||||
const timer = setTimeout(() => finish(new PushTransportError('Push service request timed out.')), timeoutMs);
|
||||
try {
|
||||
req = requestImpl(
|
||||
{
|
||||
protocol: 'https:',
|
||||
hostname: endpoint.hostname,
|
||||
port: 443,
|
||||
path: `${endpoint.pathname}${endpoint.search}`,
|
||||
method: details.method,
|
||||
headers: details.headers,
|
||||
agent: publicPushAgent,
|
||||
},
|
||||
(incoming) => {
|
||||
response = incoming;
|
||||
if (settled) {
|
||||
incoming.destroy();
|
||||
return;
|
||||
}
|
||||
if (Number(incoming.headers['content-length'] ?? 0) > MAX_PUSH_RESPONSE_BYTES) {
|
||||
finish(new PushTransportError('Push service response exceeds its size limit.'));
|
||||
return;
|
||||
}
|
||||
let bytes = 0;
|
||||
incoming.on('data', (chunk: Buffer) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > MAX_PUSH_RESPONSE_BYTES)
|
||||
finish(new PushTransportError('Push service response exceeds its size limit.'));
|
||||
});
|
||||
incoming.on('error', () => finish(new PushTransportError('Push service response failed.')));
|
||||
incoming.on('aborted', () => finish(new PushTransportError('Push service response failed.')));
|
||||
incoming.on('end', () => {
|
||||
const status = incoming.statusCode ?? 0;
|
||||
finish(
|
||||
status >= 200 && status < 300
|
||||
? undefined
|
||||
: new PushTransportError(`Push service returned HTTP ${status}.`, status)
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', (error) =>
|
||||
finish(
|
||||
error instanceof PushEndpointPolicyError
|
||||
? error
|
||||
: new PushTransportError('Push service request failed.')
|
||||
)
|
||||
);
|
||||
req.end(details.body ?? undefined);
|
||||
} catch {
|
||||
finish(new PushTransportError('Push service request failed.'));
|
||||
}
|
||||
});
|
||||
|
||||
export const sendBoundedNotification = async (
|
||||
subscription: webPush.PushSubscription,
|
||||
payload: string
|
||||
): Promise<void> => {
|
||||
parsePushEndpoint(subscription.endpoint);
|
||||
const details = webPush.generateRequestDetails(subscription, payload, { TTL: 60 * 60 });
|
||||
await sendPushRequest(details);
|
||||
};
|
||||
@@ -1527,6 +1527,46 @@ describe('account self service', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds pending icon uploads without changing the owned library limit', async () => {
|
||||
const { caller, users, sessions, userIconUpload } = buildCaller();
|
||||
const user = await users.createUser({ username: 'icon-budget', password: 'current-password' });
|
||||
const session = await sessions.createSession(user);
|
||||
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: '#224466' } })
|
||||
.png()
|
||||
.toBuffer();
|
||||
const input = {
|
||||
sessionToken: session.sessionToken,
|
||||
imageData: `data:image/png;base64,${png.toString('base64')}`,
|
||||
};
|
||||
const pending: Array<() => void> = [];
|
||||
userIconUpload.upload.mockImplementation(
|
||||
({ filename }) =>
|
||||
new Promise((resolve) => {
|
||||
pending.push(() =>
|
||||
resolve({
|
||||
picture: `users/core2026/${filename}`,
|
||||
publicUrl: `https://image.example/${filename}`,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
const admitted = Array.from({ length: 4 }, () => caller.account.changeIcon(input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(userIconUpload.upload).toHaveBeenCalledTimes(4));
|
||||
await expect(caller.account.changeIcon(input)).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
|
||||
} finally {
|
||||
pending.forEach((done) => done());
|
||||
await Promise.all(admitted);
|
||||
}
|
||||
expect(await users.listIcons(user.id)).toHaveLength(4);
|
||||
userIconUpload.upload.mockImplementation(async ({ filename }) => ({
|
||||
picture: `users/core2026/${filename}`,
|
||||
publicUrl: `https://image.example/${filename}`,
|
||||
}));
|
||||
await expect(caller.account.changeIcon(input)).resolves.toMatchObject({ ok: true });
|
||||
expect(await users.listIcons(user.id)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('accepts concurrent uploads until the five-icon limit is reached', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-race-'));
|
||||
try {
|
||||
|
||||
@@ -40,9 +40,7 @@ describe('remote user icon store', () => {
|
||||
const pathname = `/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`;
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
const expected = createHmac('sha256', secret)
|
||||
.update(
|
||||
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`
|
||||
)
|
||||
.update(`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/png.${digest}`)
|
||||
.digest('hex');
|
||||
expect(headers['x-image-signature']).toBe(expected);
|
||||
});
|
||||
@@ -58,4 +56,15 @@ describe('remote user icon store', () => {
|
||||
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
|
||||
).rejects.toThrow('HTTP 401');
|
||||
});
|
||||
it('rejects oversized upload response JSON before trusting its path', async () => {
|
||||
const filename = `${'d'.repeat(32)}.png`;
|
||||
const store = new RemoteUserIconStore(
|
||||
'https://image.example',
|
||||
'https://image.example/public',
|
||||
'synthetic-secret',
|
||||
async () =>
|
||||
new Response(JSON.stringify({ path: `icons/users/core2026/${filename}`, extra: 'x'.repeat(20_000) }))
|
||||
);
|
||||
await expect(store.upload({ filename, contentType: 'image/png', body: Buffer.from('x') })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as pushSender from '../src/webPush/sendNotification.js';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import webPush from 'web-push';
|
||||
|
||||
@@ -214,7 +215,7 @@ integration('web push Gateway persistence boundary', () => {
|
||||
notificationId: notification.id,
|
||||
},
|
||||
});
|
||||
const sender = vi.spyOn(webPush, 'sendNotification').mockRejectedValue({ statusCode: 410 });
|
||||
const sender = vi.spyOn(pushSender, 'sendBoundedNotification').mockRejectedValue({ statusCode: 410 });
|
||||
try {
|
||||
coordinator.start();
|
||||
await vi.waitFor(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Agent, request as httpsRequest } from 'node:https';
|
||||
import { createPublicPushLookup, PushEndpointPolicyError } from '../src/webPush/endpointPolicy.js';
|
||||
import { createServer, request, type Server } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sendPushRequest } from '../src/webPush/sendNotification.js';
|
||||
|
||||
const details = {
|
||||
endpoint: 'https://push.example/subscription',
|
||||
method: 'POST' as const,
|
||||
headers: {},
|
||||
body: Buffer.from('encrypted'),
|
||||
};
|
||||
const listen = async (server: Server) => {
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return (server.address() as { port: number }).port;
|
||||
};
|
||||
const close = (server: Server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
const transport =
|
||||
(port: number): Parameters<typeof sendPushRequest>[1] =>
|
||||
(options, callback) =>
|
||||
request({ ...options, protocol: 'http:', hostname: '127.0.0.1', port, agent: undefined }, callback);
|
||||
|
||||
describe('bounded WebPush response transport', () => {
|
||||
it('accepts normal success and preserves terminal HTTP status without retaining sensitive response bodies', async () => {
|
||||
let status = 201;
|
||||
const server = createServer((_, res) => {
|
||||
res.writeHead(status);
|
||||
res.end('private upstream details');
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port))).resolves.toBeUndefined();
|
||||
status = 410;
|
||||
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({
|
||||
statusCode: 410,
|
||||
message: 'Push service returned HTTP 410.',
|
||||
});
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
it.each([true, false])('rejects oversized response with declared length %s', async (declared) => {
|
||||
const server = createServer((_, res) => {
|
||||
if (declared) res.setHeader('content-length', 20_000);
|
||||
res.end(Buffer.alloc(20_000));
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port))).rejects.toThrow('size limit');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
it('ends a continuously dripping response on the whole exchange deadline', async () => {
|
||||
const server = createServer((_, res) => {
|
||||
const timer = setInterval(() => res.write('x'), 10);
|
||||
res.on('close', () => clearInterval(timer));
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port), 100)).rejects.toThrow('timed out');
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
it('preserves terminal public-address policy failures from actual socket lookup', async () => {
|
||||
const agent = new Agent({ lookup: createPublicPushLookup(async () => [{ address: '127.0.0.1', family: 4 }]) });
|
||||
try {
|
||||
await expect(
|
||||
sendPushRequest(details, (options, callback) => httpsRequest({ ...options, agent }, callback))
|
||||
).rejects.toBeInstanceOf(PushEndpointPolicyError);
|
||||
} finally {
|
||||
agent.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not follow redirects', async () => {
|
||||
let calls = 0;
|
||||
const server = createServer((_, res) => {
|
||||
calls++;
|
||||
res.writeHead(307, { location: 'https://elsewhere.example' });
|
||||
res.end();
|
||||
});
|
||||
const port = await listen(server);
|
||||
try {
|
||||
await expect(sendPushRequest(details, transport(port))).rejects.toMatchObject({ statusCode: 307 });
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
await close(server);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user