Files
fx-test/fluxer/packages/api/src/csam/tests/SynchronousCsamScanner.test.tsx
Vish 3b9d759b4b feat: add fluxer upstream source and self-hosting documentation
- Clone of github.com/fluxerapp/fluxer (official upstream)
- SELF_HOSTING.md: full VM rebuild procedure, architecture overview,
  service reference, step-by-step setup, troubleshooting, seattle reference
- dev/.env.example: all env vars with secrets redacted and generation instructions
- dev/livekit.yaml: LiveKit config template with placeholder keys
- fluxer-seattle/: existing seattle deployment setup scripts
2026-03-13 00:55:14 -07:00

632 lines
22 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {SynchronousCsamScanner} from '@fluxer/api/src/csam/SynchronousCsamScanner';
import {
createMockFrameSamples,
createMockMatchResult,
createNoopLogger,
} from '@fluxer/api/src/csam/tests/CsamTestUtils';
import type {ILogger} from '@fluxer/api/src/ILogger';
import type {MediaProxyMetadataResponse} from '@fluxer/api/src/infrastructure/IMediaService';
import {MockCsamScanQueueService} from '@fluxer/api/src/test/mocks/MockCsamScanQueueService';
import {MockMediaService} from '@fluxer/api/src/test/mocks/MockMediaService';
import {MockPhotoDnaHashClient} from '@fluxer/api/src/test/mocks/MockPhotoDnaHashClient';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
describe('SynchronousCsamScanner', () => {
let logger: ILogger;
beforeEach(() => {
logger = createNoopLogger();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('disabled scanner', () => {
it('returns isMatch: false immediately when disabled', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: false,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result).toEqual({isMatch: false});
expect(hashClient.hashFramesSpy).not.toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
});
it('returns isMatch: false for scanBase64 when disabled', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: false,
logger,
});
const result = await scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'});
expect(result).toEqual({isMatch: false});
expect(hashClient.hashFramesSpy).not.toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('unscannable media types', () => {
it('returns isMatch: false for null content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: null});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith(
{bucket: 'test-bucket', key: 'test-key', contentType: null},
'Skipping non-scannable media type',
);
});
it('returns isMatch: false for text/plain content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'text/plain'});
expect(result).toEqual({isMatch: false});
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
});
it('returns isMatch: false for application/json content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'application/json'});
expect(result).toEqual({isMatch: false});
});
it('returns isMatch: false for audio/mp3 content type', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'audio/mp3'});
expect(result).toEqual({isMatch: false});
});
it('skips scanning for unscannable media type in scanBase64', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'text/plain'});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith({mimeType: 'text/plain'}, 'Skipping non-scannable media type');
});
});
describe('scannable media types', () => {
it.each([
['image/jpeg'],
['image/png'],
['image/gif'],
['image/webp'],
['image/bmp'],
['image/tiff'],
['image/avif'],
['image/apng'],
['video/mp4'],
['video/webm'],
['video/quicktime'],
['video/x-msvideo'],
['video/x-matroska'],
])('scans %s content type', async (contentType) => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType});
if (contentType.startsWith('video/')) {
expect(mediaService.extractFramesSpy).toHaveBeenCalled();
} else {
expect(mediaService.getMetadataSpy).toHaveBeenCalled();
}
});
it('handles content type with charset parameter', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/jpeg; charset=utf-8'});
expect(mediaService.getMetadataSpy).toHaveBeenCalled();
});
});
describe('no match scenario', () => {
it('returns isMatch: false when PhotoDNA finds no match', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1', 'hash-2']});
const queueService = new MockCsamScanQueueService({
matchResult: createMockMatchResult({isMatch: false}),
});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(false);
expect(result.frames).toBeDefined();
expect(result.hashes).toEqual(['hash-1', 'hash-2']);
});
it('returns isMatch: false when no frames are extracted from video', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({returnEmptyFrames: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'video/mp4'});
expect(result).toEqual({isMatch: false});
expect(logger.debug).toHaveBeenCalledWith(
{bucket: 'test-bucket', key: 'test-key'},
'No frames extracted from media',
);
});
it('returns isMatch: false when no hashes are generated', async () => {
const hashClient = new MockPhotoDnaHashClient({returnEmpty: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.frames).toBeDefined();
expect(result.hashes).toEqual([]);
expect(logger.debug).toHaveBeenCalledWith('No hashes generated from frames');
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('match detected scenario', () => {
it('returns isMatch: true with matchResult, frames, and hashes when match found', async () => {
const hashes = ['hash-1', 'hash-2'];
const matchResult = createMockMatchResult({isMatch: true});
const hashClient = new MockPhotoDnaHashClient({hashes});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(true);
expect(result.matchResult).toBeDefined();
expect(result.matchResult!.isMatch).toBe(true);
expect(result.matchResult!.matchDetails).toHaveLength(1);
expect(result.frames).toBeDefined();
expect(result.frames!.length).toBeGreaterThan(0);
expect(result.hashes).toEqual(hashes);
});
it('logs warning when CSAM match is detected', async () => {
const matchResult = createMockMatchResult({isMatch: true, trackingId: 'test-tracking-id'});
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(logger.warn).toHaveBeenCalledWith({trackingId: 'test-tracking-id', matchCount: 1}, 'CSAM match detected');
});
});
describe('frame extraction from S3', () => {
it('extracts frames from video using extractFrames', async () => {
const frames = [
{timestamp: 0, mime_type: 'image/jpeg', base64: 'frame0'},
{timestamp: 500, mime_type: 'image/jpeg', base64: 'frame1'},
{timestamp: 1000, mime_type: 'image/jpeg', base64: 'frame2'},
];
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({frames});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'});
expect(mediaService.extractFramesSpy).toHaveBeenCalledWith({
type: 's3',
bucket: 'test-bucket',
key: 'video-key',
});
expect(result.frames).toHaveLength(3);
expect(result.frames![0]).toEqual({timestamp: 0, mimeType: 'image/jpeg', base64: 'frame0'});
});
it('extracts single frame from image using getMetadata', async () => {
const metadata: MediaProxyMetadataResponse = {
format: 'png',
content_type: 'image/png',
content_hash: 'hash123',
size: 2048,
nsfw: false,
base64: 'imagebase64data',
};
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({metadata});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'});
expect(mediaService.getMetadataSpy).toHaveBeenCalledWith({
type: 's3',
bucket: 'test-bucket',
key: 'image-key',
with_base64: true,
isNSFWAllowed: true,
});
expect(result.frames).toHaveLength(1);
expect(result.frames![0]).toEqual({
timestamp: 0,
mimeType: 'image/png',
base64: 'imagebase64data',
});
});
it('throws error when metadata has no base64', async () => {
const metadata: MediaProxyMetadataResponse = {
format: 'png',
content_type: 'image/png',
content_hash: 'hash123',
size: 2048,
nsfw: false,
};
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({metadata});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'}),
).rejects.toThrow('Media proxy returned metadata without base64 for image scan');
});
});
describe('frame extraction failure', () => {
it('throws error when metadata returns null for image', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({returnNullMetadata: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'}),
).rejects.toThrow('Media proxy returned no metadata for image scan');
expect(logger.error).toHaveBeenCalledWith(
{error: expect.any(Error), bucket: 'test-bucket', key: 'image-key'},
'Failed to scan media',
);
});
it('throws error when frame extraction fails for video', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({shouldFailFrameExtraction: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'}),
).rejects.toThrow('Mock frame extraction failure');
expect(logger.error).toHaveBeenCalledWith(
{error: expect.any(Error), bucket: 'test-bucket', key: 'video-key'},
'Failed to scan media',
);
});
it('throws error when metadata fetch fails for image', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({shouldFailMetadata: true});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'image-key', contentType: 'image/png'}),
).rejects.toThrow('Mock metadata fetch failure');
expect(logger.error).toHaveBeenCalled();
});
});
describe('hash generation failure', () => {
it('throws error when hash client fails', async () => {
const hashClient = new MockPhotoDnaHashClient({shouldFail: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'}),
).rejects.toThrow('Mock hash client failure');
expect(hashClient.hashFramesSpy).toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
});
describe('queue service failure', () => {
it('throws error when queue service fails', async () => {
const hashClient = new MockPhotoDnaHashClient({hashes: ['hash-1']});
const queueService = new MockCsamScanQueueService({shouldFail: true});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(
scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'}),
).rejects.toThrow('Mock queue service failure');
expect(hashClient.hashFramesSpy).toHaveBeenCalled();
expect(queueService.submitScanSpy).toHaveBeenCalled();
});
});
describe('scanBase64 method', () => {
it('scans base64 image data successfully', async () => {
const base64Data = Buffer.from('test-image-data').toString('base64');
const hashes = ['base64-hash-1'];
const matchResult = createMockMatchResult({isMatch: false});
const hashClient = new MockPhotoDnaHashClient({hashes});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanBase64({base64: base64Data, mimeType: 'image/png'});
expect(result.isMatch).toBe(false);
expect(result.frames).toHaveLength(1);
expect(result.frames![0]).toEqual({
timestamp: 0,
mimeType: 'image/png',
base64: base64Data,
});
expect(result.hashes).toEqual(hashes);
});
it('returns match result when base64 content matches', async () => {
const base64Data = Buffer.from('suspicious-image').toString('base64');
const matchResult = createMockMatchResult({isMatch: true});
const hashClient = new MockPhotoDnaHashClient({hashes: ['match-hash']});
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanBase64({base64: base64Data, mimeType: 'image/jpeg'});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.isMatch).toBe(true);
expect(logger.warn).toHaveBeenCalled();
});
it('throws error when hash generation fails for base64', async () => {
const hashClient = new MockPhotoDnaHashClient({shouldFail: true});
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await expect(scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'})).rejects.toThrow(
'Mock hash client failure',
);
expect(hashClient.hashFramesSpy).toHaveBeenCalled();
expect(queueService.submitScanSpy).not.toHaveBeenCalled();
});
it('does not call media service for base64 scanning', async () => {
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
await scanner.scanBase64({base64: 'dGVzdA==', mimeType: 'image/png'});
expect(mediaService.getMetadataSpy).not.toHaveBeenCalled();
expect(mediaService.extractFramesSpy).not.toHaveBeenCalled();
});
});
describe('integration with test utilities', () => {
it('works with createMockFrameSamples utility', async () => {
const mockFrames = createMockFrameSamples(3);
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService();
const mediaService = new MockMediaService({
frames: mockFrames.map((f) => ({
timestamp: f.timestamp,
mime_type: f.mimeType,
base64: f.base64,
})),
});
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'video-key', contentType: 'video/mp4'});
expect(result.frames).toHaveLength(3);
expect(hashClient.hashFramesSpy).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({timestamp: 0}),
expect.objectContaining({timestamp: 100}),
expect.objectContaining({timestamp: 200}),
]),
);
});
it('works with createMockMatchResult utility for match', async () => {
const matchResult = createMockMatchResult({
isMatch: true,
matchDetails: [
{
source: 'ncmec',
violations: ['CSAM', 'CGI'],
matchDistance: 0.005,
matchId: 'detail-123',
},
],
});
const hashClient = new MockPhotoDnaHashClient();
const queueService = new MockCsamScanQueueService({matchResult});
const mediaService = new MockMediaService();
const scanner = new SynchronousCsamScanner(hashClient, mediaService, queueService, {
enabled: true,
logger,
});
const result = await scanner.scanMedia({bucket: 'test-bucket', key: 'test-key', contentType: 'image/png'});
expect(result.isMatch).toBe(true);
expect(result.matchResult!.matchDetails[0]!.source).toBe('ncmec');
expect(result.matchResult!.matchDetails[0]!.violations).toContain('CSAM');
});
});
});