feat(game): upload editor images to image service
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import type { RedisConnector } from '@sammo-ts/infra';
|
||||
@@ -94,6 +95,7 @@ const buildContext = (options: {
|
||||
}>;
|
||||
}>;
|
||||
targetPost?: { id: number; isSecret: boolean } | null;
|
||||
contentImageUpload?: GameApiContext['contentImageUpload'];
|
||||
}) => {
|
||||
const me = options.me ?? buildGeneral();
|
||||
const boardPostFindMany = vi.fn(async () => options.posts ?? []);
|
||||
@@ -138,6 +140,7 @@ const buildContext = (options: {
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
...(options.contentImageUpload ? { contentImageUpload: options.contentImageUpload } : {}),
|
||||
accessTokenStore,
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'test-secret',
|
||||
@@ -260,6 +263,30 @@ describe('board router actor, nation, and secret permissions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads normalized editor images through the remote bind store', async () => {
|
||||
const upload = vi.fn(async ({ filename }: { filename: string }) => ({
|
||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||
}));
|
||||
const fixture = buildContext({ contentImageUpload: { upload } });
|
||||
const png = await sharp({
|
||||
create: { width: 64, height: 48, channels: 4, background: '#224466' },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await appRouter.createCaller(fixture.context).board.uploadImage({
|
||||
dataUrl: `data:image/png;base64,${png.toString('base64')}`,
|
||||
});
|
||||
|
||||
expect(result.url).toMatch(
|
||||
/^https:\/\/sam-image\.hided\.net\/uploads\/core2026\/[a-f0-9]{32}\.webp$/
|
||||
);
|
||||
expect(upload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ contentType: 'image/webp', body: expect.any(Buffer) })
|
||||
);
|
||||
expect(result).toMatchObject({ width: 64, height: 48, format: 'webp', animated: false });
|
||||
});
|
||||
|
||||
it('does not reveal whether another nation owns a requested comment target', async () => {
|
||||
const fixture = buildContext({
|
||||
me: buildGeneral({ nationId: 3, officerLevel: 5 }),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { RemoteContentImageStore } from '../src/services/remoteContentImageStore.js';
|
||||
|
||||
describe('remote content image store', () => {
|
||||
it('signs a 60-second body-bound content upload and validates its returned path', async () => {
|
||||
const filename = `${'c'.repeat(32)}.webp`;
|
||||
const body = Buffer.from('content-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: `uploads/core2026/${filename}` }), { status: 201 });
|
||||
};
|
||||
const store = new RemoteContentImageStore(
|
||||
'https://sam-image.hided.net',
|
||||
'https://sam-image.hided.net/uploads/core2026',
|
||||
secret,
|
||||
fetchImpl,
|
||||
() => Date.parse('2026-08-06T00:00:00.000Z')
|
||||
);
|
||||
|
||||
await expect(store.upload({ filename, contentType: 'image/webp', body })).resolves.toEqual({
|
||||
publicUrl: `https://sam-image.hided.net/uploads/core2026/${filename}`,
|
||||
});
|
||||
const headers = captured?.init?.headers as Record<string, string>;
|
||||
const pathname = `/v1/uploads/content/core2026/${filename}`;
|
||||
const digest = createHash('sha256').update(body).digest('hex');
|
||||
expect(headers['x-image-signature']).toBe(
|
||||
createHmac('sha256', secret)
|
||||
.update(
|
||||
`${headers['x-image-expires']}.${headers['x-image-request-id']}.${pathname}.image/webp.${digest}`
|
||||
)
|
||||
.digest('hex')
|
||||
);
|
||||
expect(String(captured?.input)).toBe(`https://sam-image.hided.net${pathname}`);
|
||||
expect(Object.values(headers)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user