import { Readable } from 'node:stream'; import { BadRequestException, HttpStatus } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import * as request from 'supertest'; import { MinioClientService } from '../../src/modules/asset/application/asset-minio.service'; import { BlogLive2DAssetService } from '../../src/modules/asset/application/blog-live2d-asset.service'; import { BlogLive2DAssetController } from '../../src/modules/asset/contract/blog-live2d-asset.controller'; /** * Creates the config facade used by Blog Live2D asset tests. * @param overrides - Runtime config values that should replace the default fixture. * @returns ConfigService-compatible object for direct service construction. */ function createConfig(overrides: Record = {}) { const values: Record = { BLOG_LIVE2D_ALLOWED_ORIGINS: 'https://blog.kwitsukasa.top,http://localhost:5999', BLOG_LIVE2D_BUCKET: 'kt-template-online', BLOG_LIVE2D_PREFIX: 'blog/live2d/pio', ...overrides, }; return { get: jest.fn((key: string) => values[key]), }; } /** * Creates a MinIO client facade that records requested object names. * @returns Mocked MinIO client service used by the service and controller tests. */ function createMinio() { return { getDefaultBucket: jest.fn(() => 'kt-template-online'), getObject: jest.fn(async (objectName: string, bucketName?: string) => { const isJson = objectName.endsWith('.json'); return { bucketName, objectName, stat: { etag: 'etag-1', lastModified: new Date('2026-07-04T00:00:00.000Z'), metaData: { 'content-type': isJson ? 'application/json' : 'image/png', }, size: 2, }, stream: Readable.from([isJson ? '{"ok":true}' : 'ok']), }; }), }; } describe('BlogLive2DAssetService', () => { it('allows configured blog referer', () => { const service = new BlogLive2DAssetService( createMinio() as never, createConfig() as never, ); expect(() => service.assertAllowedRequest( 'https://blog.kwitsukasa.top/post/1', undefined, ), ).not.toThrow(); }); it('allows configured origin when referer is absent', () => { const service = new BlogLive2DAssetService( createMinio() as never, createConfig() as never, ); expect(() => service.assertAllowedRequest(undefined, 'http://localhost:5999'), ).not.toThrow(); }); it('rejects external referer', () => { const service = new BlogLive2DAssetService( createMinio() as never, createConfig() as never, ); expect(() => service.assertAllowedRequest('https://example.com/hotlink', undefined), ).toThrow(BadRequestException); }); it('rejects missing or malformed request source', () => { const service = new BlogLive2DAssetService( createMinio() as never, createConfig() as never, ); expect(() => service.assertAllowedRequest(undefined, undefined)).toThrow( BadRequestException, ); expect(() => service.assertAllowedRequest('not-a-url', undefined)).toThrow( BadRequestException, ); }); it('maps the root catalog below the configured MinIO prefix', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await service.getCatalogObject('pio'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/catalog.json', 'kt-template-online', ); }); it('maps nested MOC3 runtime files below the configured MinIO prefix', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await service.getRuntimeObject('pio', 'moc3', [ 'assets', 'model', 'motions', 'breath1.motion3.json', ]); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc3/assets/model/motions/breath1.motion3.json', 'kt-template-online', ); }); it('maps the fixed MOC family entry below the configured MinIO prefix', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await service.getRuntimeObject('pio', 'moc', ['index.json']); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/index.json', 'kt-template-online', ); }); it('maps the fixed MOC texture manifest below the configured MinIO prefix', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await service.getRuntimeObject('pio', 'moc', [ 'textures', 'manifest.json', ]); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/textures/manifest.json', 'kt-template-online', ); }); it('maps missing MinIO runtime objects to HTTP 404', async () => { const minio = createMinio(); minio.getObject.mockRejectedValueOnce( Object.assign(new Error('Not Found'), { code: 'NotFound' }), ); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await expect( service.getRuntimeObject('pio', 'moc', ['manifest.json']), ).rejects.toMatchObject({ status: HttpStatus.NOT_FOUND, message: 'Live2D runtime asset not found', }); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/manifest.json', 'kt-template-online', ); }); it('rejects runtime path traversal before touching MinIO', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await expect( service.getRuntimeObject('pio', 'moc', ['%252e%252e', 'secret.env']), ).rejects.toThrow(BadRequestException); expect(minio.getObject).not.toHaveBeenCalled(); }); it.each([ ['absolute URL', 'moc', ['https://evil.test', 'texture.png']], ['absolute path', 'moc', ['/textures', 'texture.png']], ['backslash path', 'moc', ['textures\\texture.png']], ['dot segment', 'moc', ['.', 'texture.png']], ['family traversal', '../moc', ['texture.png']], ['custom version family', 'v1', ['manifest.json']], ])( 'rejects unsafe %s runtime paths before touching MinIO', async (_name, family, objectPath) => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig() as never, ); await expect( service.getRuntimeObject('pio', family, objectPath), ).rejects.toThrow(BadRequestException); expect(minio.getObject).not.toHaveBeenCalled(); }, ); it('maps the Tia root catalog below the shared Blog Live2D root prefix', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }) as never, ); await service.getCatalogObject('tia'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/tia/catalog.json', 'kt-template-online', ); }); it('maps Tia MOC runtime files below the Tia public root', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }) as never, ); await service.getRuntimeObject('tia', 'moc', [ 'textures', 'default-costume.png', ]); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/tia/moc/textures/default-costume.png', 'kt-template-online', ); }); it('rejects unsupported Live2D characters before touching MinIO', async () => { const minio = createMinio(); const service = new BlogLive2DAssetService( minio as never, createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }) as never, ); await expect( service.getRuntimeObject('evil', 'moc', ['index.json']), ).rejects.toThrow(BadRequestException); expect(minio.getObject).not.toHaveBeenCalled(); }); }); describe('BlogLive2DAssetController', () => { it('streams the Pio root catalog for allowed blog requests', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig(), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { const response = await request(app.getHttpServer()) .get('/blog/live2d/pio/catalog.json') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.OK); expect(response.body).toEqual({ ok: true }); expect(response.headers['content-type']).toContain('application/json'); expect(response.headers['cache-control']).toBe('public, max-age=60'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/catalog.json', 'kt-template-online', ); } finally { await app.close(); } }); it('streams nested Pio runtime assets for allowed blog requests', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig(), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { const response = await request(app.getHttpServer()) .get('/blog/live2d/pio/moc3/assets/textures/default-costume.png') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.OK); expect(Buffer.from(response.body).toString()).toBe('ok'); expect(response.headers['content-type']).toContain('image/png'); expect(response.headers['cache-control']).toBe( 'public, max-age=31536000, immutable', ); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc3/assets/textures/default-costume.png', 'kt-template-online', ); } finally { await app.close(); } }); it('streams fixed Pio MOC JSON assets with a short cache policy', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig(), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { const response = await request(app.getHttpServer()) .get('/blog/live2d/pio/moc/index.json') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.OK); expect(response.body).toEqual({ ok: true }); expect(response.headers['content-type']).toContain('application/json'); expect(response.headers['cache-control']).toBe('public, max-age=60'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/index.json', 'kt-template-online', ); } finally { await app.close(); } }); it('streams fixed Pio MOC texture manifests with a short cache policy', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig(), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { const response = await request(app.getHttpServer()) .get('/blog/live2d/pio/moc/textures/manifest.json') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.OK); expect(response.body).toEqual({ ok: true }); expect(response.headers['content-type']).toContain('application/json'); expect(response.headers['cache-control']).toBe('public, max-age=60'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/pio/moc/textures/manifest.json', 'kt-template-online', ); } finally { await app.close(); } }); it('streams Tia MOC assets for allowed blog requests', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { const response = await request(app.getHttpServer()) .get('/blog/live2d/tia/moc/index.json') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.OK); expect(response.body).toEqual({ ok: true }); expect(response.headers['content-type']).toContain('application/json'); expect(response.headers['cache-control']).toBe('public, max-age=60'); expect(minio.getObject).toHaveBeenCalledWith( 'blog/live2d/tia/moc/index.json', 'kt-template-online', ); } finally { await app.close(); } }); it('rejects unsupported character requests before streaming assets', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig({ BLOG_LIVE2D_ROOT_PREFIX: 'blog/live2d' }), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { await request(app.getHttpServer()) .get('/blog/live2d/evil/catalog.json') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.BAD_REQUEST); await request(app.getHttpServer()) .get('/blog/live2d/evil/moc/index.json') .set('Referer', 'https://blog.kwitsukasa.top/post/1') .expect(HttpStatus.BAD_REQUEST); expect(minio.getObject).not.toHaveBeenCalled(); } finally { await app.close(); } }); it('rejects external hotlink requests before streaming assets', async () => { const minio = createMinio(); const moduleRef = await Test.createTestingModule({ controllers: [BlogLive2DAssetController], providers: [ BlogLive2DAssetService, { provide: MinioClientService, useValue: minio, }, { provide: ConfigService, useValue: createConfig(), }, ], }).compile(); const app = moduleRef.createNestApplication(); await app.init(); try { await request(app.getHttpServer()) .get('/blog/live2d/pio/moc3/assets/model/pio.moc-reconstructed.model3.json') .set('Referer', 'https://example.com/post/1') .expect(HttpStatus.BAD_REQUEST); expect(minio.getObject).not.toHaveBeenCalled(); } finally { await app.close(); } }); });