feat(gateway): upload user icons to image service
This commit is contained in:
@@ -32,6 +32,9 @@ GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
||||
GATEWAY_WORKTREE_ROOT=/path/to/core2026/.worktrees
|
||||
GATEWAY_USER_ICON_DIR=uploads/user-icons
|
||||
GATEWAY_USER_ICON_PUBLIC_URL=http://localhost:13000/user-icons
|
||||
GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net
|
||||
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
||||
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
|
||||
GATEWAY_LOCAL_REGISTRATION_ENABLED=true
|
||||
GATEWAY_LOCAL_ACCOUNT_GRACE_DAYS=7
|
||||
# Optional. Keep the PEM private key outside Git; if omitted, a per-process RSA key is generated.
|
||||
|
||||
@@ -76,6 +76,21 @@ CI=1 pnpm typecheck
|
||||
`../docker_compose_files/development/README.md`의 PostgreSQL·Redis stack을
|
||||
worktree별로 준비할 수 있습니다.
|
||||
|
||||
Gateway 사용자 아이콘은 이미지 서비스의 Git checkout이 아니라 별도 bind
|
||||
저장소로 직접 업로드합니다. `gateway-api`에는 이미지 서비스와 같은
|
||||
`image_upload_core2026_secret`을 `/run/secrets/image_upload_core2026_secret`로
|
||||
mount하고 다음 서버 전용 변수를 설정합니다.
|
||||
|
||||
```text
|
||||
GATEWAY_IMAGE_UPLOAD_URL=https://sam-image.hided.net
|
||||
GATEWAY_IMAGE_UPLOAD_SECRET_FILE=/run/secrets/image_upload_core2026_secret
|
||||
GATEWAY_SHARED_ICON_PUBLIC_URL=https://sam-image.hided.net/icons
|
||||
```
|
||||
|
||||
Gateway가 인증과 50KB·크기·형식을 확인한 뒤 60초짜리 HMAC 요청으로 서버 간
|
||||
PUT을 수행합니다. 공유 비밀값은 `VITE_*`, 브라우저 응답 또는 Cloudflare로
|
||||
전달하지 않습니다.
|
||||
|
||||
```sh
|
||||
cd ../docker_compose_files/development
|
||||
./scripts/prepare-instance.sh main 15433 16379 ../../core2026
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createHash, createHmac, randomUUID } from 'node:crypto';
|
||||
|
||||
export interface UserIconUploadResult {
|
||||
picture: string;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
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,
|
||||
private readonly publicBaseUrl: string,
|
||||
private readonly secret: string,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
private readonly now: () => number = Date.now
|
||||
) {}
|
||||
|
||||
async upload(input: { filename: string; contentType: string; body: Buffer }): Promise<UserIconUploadResult> {
|
||||
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
|
||||
),
|
||||
},
|
||||
body: input.body,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Image repository upload failed with HTTP ${response.status}.`);
|
||||
}
|
||||
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,6 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import sharp from 'sharp';
|
||||
@@ -18,6 +16,13 @@ const MAX_ACTIVE_ICONS = 5;
|
||||
const ICON_UPLOAD_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
const ICON_RETIRE_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const ALLOWED_ICON_FORMATS = new Set(['avif', 'webp', 'jpeg', 'png', 'gif']);
|
||||
const ICON_CONTENT_TYPES: Record<string, string> = {
|
||||
avif: 'image/avif',
|
||||
webp: 'image/webp',
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
gif: 'image/gif',
|
||||
};
|
||||
|
||||
const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): Promise<UserRecord> => {
|
||||
const session = await ctx.sessions.getSession(sessionToken);
|
||||
@@ -68,10 +73,17 @@ const hasActiveSanction = (sanctions: UserSanctions, now: Date): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
const encodeIconPath = (picture: string): string => picture.split('/').map(encodeURIComponent).join('/');
|
||||
|
||||
const buildPictureUrl = (ctx: GatewayApiContext, picture: string, imageServer: number): string =>
|
||||
imageServer === 1
|
||||
? `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
|
||||
: `${ctx.sharedIconPublicUrl.replace(/\/$/, '')}/${encodeIconPath(picture)}`;
|
||||
|
||||
const buildIconUrl = (ctx: GatewayApiContext, user: UserRecord): string | null => {
|
||||
const icon = resolveEffectiveAccountIcon(user);
|
||||
if (icon.imageServer !== 1 || icon.picture === 'default.jpg') return null;
|
||||
return `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`;
|
||||
if (icon.picture === 'default.jpg') return null;
|
||||
return buildPictureUrl(ctx, icon.picture, icon.imageServer);
|
||||
};
|
||||
|
||||
const buildLibraryIcon = (
|
||||
@@ -83,7 +95,7 @@ const buildLibraryIcon = (
|
||||
imageServer: icon.imageServer,
|
||||
createdAt: icon.createdAt,
|
||||
retiredAt: icon.retiredAt ?? null,
|
||||
url: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${encodeURIComponent(icon.picture)}`,
|
||||
url: buildPictureUrl(ctx, icon.picture, icon.imageServer),
|
||||
});
|
||||
|
||||
const listIconSyncProfiles = async (ctx: GatewayApiContext, userId: string) =>
|
||||
@@ -211,24 +223,28 @@ export const accountRouter = router({
|
||||
}
|
||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||
const filename = `${randomBytes(8).toString('hex')}.${extension}`;
|
||||
await fs.mkdir(ctx.userIconDir, { recursive: true });
|
||||
await fs.writeFile(path.join(ctx.userIconDir, filename), buffer, { flag: 'wx' });
|
||||
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,
|
||||
});
|
||||
let stored;
|
||||
try {
|
||||
stored = await ctx.users.addIconForWindow(
|
||||
user.id,
|
||||
filename,
|
||||
1,
|
||||
uploaded.picture,
|
||||
0,
|
||||
now,
|
||||
new Date(now.getTime() - ICON_UPLOAD_COOLDOWN_MS),
|
||||
MAX_ACTIVE_ICONS
|
||||
);
|
||||
} catch (error) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
throw error;
|
||||
}
|
||||
if (!stored.ok) {
|
||||
await fs.rm(path.join(ctx.userIconDir, filename), { force: true });
|
||||
if (stored.reason === 'LIMIT') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
@@ -243,7 +259,7 @@ export const accountRouter = router({
|
||||
const flushPublished = await publishIconFlush(ctx, user.id, 'account-icon-changed');
|
||||
return {
|
||||
ok: true,
|
||||
iconUrl: `${ctx.userIconPublicUrl.replace(/\/$/, '')}/${filename}`,
|
||||
iconUrl: uploaded.publicUrl,
|
||||
revision: stored.revision,
|
||||
icon: buildLibraryIcon(ctx, stored.icon),
|
||||
profiles,
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface GatewayApiConfig {
|
||||
publicBaseUrl: string;
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
imageUploadBaseUrl: string;
|
||||
imageUploadSecretFile: string;
|
||||
sharedIconPublicUrl: string;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
localRegistrationEnabled: boolean;
|
||||
localAccountGraceDays: number;
|
||||
@@ -93,6 +96,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
publicBaseUrl,
|
||||
userIconDir: env.GATEWAY_USER_ICON_DIR ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: env.GATEWAY_USER_ICON_PUBLIC_URL ?? `${publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||
imageUploadBaseUrl: env.GATEWAY_IMAGE_UPLOAD_URL ?? 'https://sam-image.hided.net',
|
||||
imageUploadSecretFile:
|
||||
env.GATEWAY_IMAGE_UPLOAD_SECRET_FILE ?? '/run/secrets/image_upload_core2026_secret',
|
||||
sharedIconPublicUrl: env.GATEWAY_SHARED_ICON_PUBLIC_URL ?? 'https://sam-image.hided.net/icons',
|
||||
adminLocalAccountEnabled: parseBooleanWithFallback(env.GATEWAY_ADMIN_LOCAL_ACCOUNT_ENABLED, false),
|
||||
localRegistrationEnabled: parseBooleanWithFallback(env.GATEWAY_LOCAL_REGISTRATION_ENABLED, true),
|
||||
localAccountGraceDays: parseNumberWithFallback(
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import type { AdminAuthContext } from './adminAuth.js';
|
||||
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
|
||||
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
|
||||
|
||||
export interface GatewayApiContext {
|
||||
users: UserRepository;
|
||||
@@ -26,6 +27,8 @@ export interface GatewayApiContext {
|
||||
publicBaseUrl: string;
|
||||
userIconDir: string;
|
||||
userIconPublicUrl: string;
|
||||
sharedIconPublicUrl: string;
|
||||
userIconUpload?: UserIconUploadStore;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
localRegistrationEnabled: boolean;
|
||||
localAccountGraceDays: number;
|
||||
@@ -51,6 +54,8 @@ export const createGatewayApiContext = (options: {
|
||||
publicBaseUrl: string;
|
||||
userIconDir?: string;
|
||||
userIconPublicUrl?: string;
|
||||
sharedIconPublicUrl?: string;
|
||||
userIconUpload?: UserIconUploadStore;
|
||||
adminLocalAccountEnabled: boolean;
|
||||
localRegistrationEnabled: boolean;
|
||||
localAccountGraceDays: number;
|
||||
@@ -73,6 +78,8 @@ export const createGatewayApiContext = (options: {
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
userIconDir: options.userIconDir ?? 'uploads/user-icons',
|
||||
userIconPublicUrl: options.userIconPublicUrl ?? `${options.publicBaseUrl.replace(/\/$/, '')}/user-icons`,
|
||||
sharedIconPublicUrl: options.sharedIconPublicUrl ?? 'https://sam-image.hided.net/icons',
|
||||
userIconUpload: options.userIconUpload,
|
||||
adminLocalAccountEnabled: options.adminLocalAccountEnabled,
|
||||
localRegistrationEnabled: options.localRegistrationEnabled,
|
||||
localAccountGraceDays: options.localAccountGraceDays,
|
||||
|
||||
@@ -27,6 +27,7 @@ import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
||||
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||
|
||||
export const createGatewayApiServer = async () => {
|
||||
const config = resolveGatewayApiConfigFromEnv();
|
||||
@@ -39,6 +40,15 @@ export const createGatewayApiServer = async () => {
|
||||
? await fs.readFile(config.passwordEncryptionPrivateKeyFile, 'utf8')
|
||||
: undefined;
|
||||
const passwordEnvelope = createPasswordEnvelopeService(privateKeyPem);
|
||||
const imageUploadSecret = (await fs.readFile(config.imageUploadSecretFile, 'utf8')).trim();
|
||||
if (imageUploadSecret.length < 32) {
|
||||
throw new Error('GATEWAY_IMAGE_UPLOAD_SECRET_FILE must contain at least 32 characters.');
|
||||
}
|
||||
const userIconUpload = new RemoteUserIconStore(
|
||||
config.imageUploadBaseUrl,
|
||||
config.sharedIconPublicUrl,
|
||||
imageUploadSecret
|
||||
);
|
||||
const users = createPostgresUserRepository(
|
||||
postgres.prisma as GatewayPrismaClient,
|
||||
createPasswordHasher({ legacyGlobalSalt: config.legacyPasswordGlobalSalt })
|
||||
@@ -103,6 +113,8 @@ export const createGatewayApiServer = async () => {
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
userIconDir: path.resolve(process.cwd(), config.userIconDir),
|
||||
userIconPublicUrl: config.userIconPublicUrl,
|
||||
sharedIconPublicUrl: config.sharedIconPublicUrl,
|
||||
userIconUpload,
|
||||
adminLocalAccountEnabled: config.adminLocalAccountEnabled,
|
||||
localRegistrationEnabled: config.localRegistrationEnabled,
|
||||
localAccountGraceDays: config.localAccountGraceDays,
|
||||
|
||||
@@ -157,6 +157,12 @@ const buildCaller = (
|
||||
}
|
||||
const passwordEnvelope = createPasswordEnvelopeService();
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const userIconUpload = {
|
||||
upload: vi.fn(async ({ filename }: { filename: string }) => ({
|
||||
picture: `users/core2026/${filename}`,
|
||||
publicUrl: `https://sam-image.hided.net/icons/users/core2026/${filename}`,
|
||||
})),
|
||||
};
|
||||
const sealPassword = (password: string) => {
|
||||
const key = passwordEnvelope.getPublicKey();
|
||||
return {
|
||||
@@ -183,6 +189,8 @@ const buildCaller = (
|
||||
publicBaseUrl: 'http://localhost',
|
||||
userIconDir: options.userIconDir,
|
||||
userIconPublicUrl: 'http://localhost/user-icons',
|
||||
sharedIconPublicUrl: 'https://sam-image.hided.net/icons',
|
||||
userIconUpload,
|
||||
adminLocalAccountEnabled: false,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: options.localAccountGraceDays ?? 7,
|
||||
@@ -204,6 +212,7 @@ const buildCaller = (
|
||||
users,
|
||||
sessions,
|
||||
flushPublisher,
|
||||
userIconUpload,
|
||||
sealPassword,
|
||||
setSessionHeader: (sessionToken: string) => {
|
||||
requestHeaders['x-session-token'] = sessionToken;
|
||||
@@ -686,7 +695,7 @@ describe('account self service', () => {
|
||||
it('validates and stores a legacy-sized account icon with a daily change limit', async () => {
|
||||
const iconDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-account-icon-'));
|
||||
try {
|
||||
const { caller, users, sessions, flushPublisher } = buildCaller({
|
||||
const { caller, users, sessions, flushPublisher, userIconUpload } = buildCaller({
|
||||
userIconDir: iconDir,
|
||||
});
|
||||
const user = await users.createUser({
|
||||
@@ -711,11 +720,13 @@ describe('account self service', () => {
|
||||
});
|
||||
const updated = await users.findById(user.id);
|
||||
|
||||
expect(result.iconUrl).toMatch(/^http:\/\/localhost\/user-icons\/[a-f0-9]{16}\.png$/);
|
||||
expect(result.iconUrl).toMatch(/^https:\/\/sam-image\.hided\.net\/icons\/users\/core2026\/[a-f0-9]{16}\.png$/);
|
||||
expect(result.profiles.map((profile) => profile.profileName)).toEqual(['che:default', 'hwe:default']);
|
||||
expect(updated?.imageServer).toBe(1);
|
||||
expect(updated?.imageServer).toBe(0);
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(user.id, 'account-icon-changed');
|
||||
expect(await fs.stat(path.join(iconDir, updated?.picture ?? 'missing'))).toBeTruthy();
|
||||
expect(userIconUpload.upload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ contentType: 'image/png', body: png })
|
||||
);
|
||||
await expect(caller.account.deleteIcon({ sessionToken: session.sessionToken })).rejects.toMatchObject({
|
||||
code: 'TOO_MANY_REQUESTS',
|
||||
});
|
||||
@@ -754,7 +765,7 @@ describe('account self service', () => {
|
||||
|
||||
expect(attempts.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
|
||||
expect(attempts.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
||||
expect(await fs.readdir(iconDir)).toHaveLength(1);
|
||||
expect(await users.listIcons(user.id)).toHaveLength(1);
|
||||
} finally {
|
||||
await fs.rm(iconDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -893,7 +904,7 @@ describe('account self service', () => {
|
||||
{
|
||||
projection: {
|
||||
revision: changed.revision,
|
||||
imageServer: 1,
|
||||
imageServer: 0,
|
||||
},
|
||||
profiles: [{ profileName: 'che:default' }, { profileName: 'hwe:default' }],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RemoteUserIconStore } from '../src/account/remoteUserIconStore.js';
|
||||
|
||||
describe('remote user icon store', () => {
|
||||
it('uses a short-lived path and body-bound HMAC without sending the shared secret', async () => {
|
||||
const body = Buffer.from('icon-body');
|
||||
const secret = 'u'.repeat(32);
|
||||
let captured: { input: string | URL | Request; init?: RequestInit } | undefined;
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
captured = { input, init };
|
||||
return new Response(JSON.stringify({ path: `icons/users/core2026/${'a'.repeat(32)}.png` }), {
|
||||
status: 201,
|
||||
});
|
||||
};
|
||||
const store = new RemoteUserIconStore(
|
||||
'https://sam-image.hided.net/',
|
||||
'https://sam-image.hided.net/icons/',
|
||||
secret,
|
||||
fetchImpl,
|
||||
() => Date.parse('2026-08-06T00:00:00.000Z')
|
||||
);
|
||||
|
||||
const result = await store.upload({
|
||||
filename: `${'a'.repeat(32)}.png`,
|
||||
contentType: 'image/png',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
picture: `users/core2026/${'a'.repeat(32)}.png`,
|
||||
publicUrl: `https://sam-image.hided.net/icons/users/core2026/${'a'.repeat(32)}.png`,
|
||||
});
|
||||
expect(String(captured?.input)).toBe(
|
||||
`https://sam-image.hided.net/v1/uploads/user-icons/core2026/${'a'.repeat(32)}.png`
|
||||
);
|
||||
const headers = captured?.init?.headers as Record<string, string>;
|
||||
expect(headers['x-image-expires']).toBe(String(Date.parse('2026-08-06T00:01:00.000Z') / 1000));
|
||||
expect(Object.values(headers)).not.toContain(secret);
|
||||
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}`
|
||||
)
|
||||
.digest('hex');
|
||||
expect(headers['x-image-signature']).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not return a picture when the image service rejects the grant', async () => {
|
||||
const store = new RemoteUserIconStore(
|
||||
'https://sam-image.hided.net',
|
||||
'https://sam-image.hided.net/icons',
|
||||
'u'.repeat(32),
|
||||
async () => new Response('{}', { status: 401 })
|
||||
);
|
||||
await expect(
|
||||
store.upload({ filename: `${'b'.repeat(32)}.png`, contentType: 'image/png', body: Buffer.from('x') })
|
||||
).rejects.toThrow('HTTP 401');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user