0ee63c139f
Attachments upload direct-to-storage on attach, so an abandoned attach (removed, cancelled, tab closed) would linger forever. Add IiosMediaObject: a row is recorded when bytes land (MediaService.put); a scheduled sweepOrphans() reaps objects past a grace window (IIOS_MEDIA_ORPHAN_GRACE_MS, default 24h) that no IiosMessagePart references (derived live — no flag to drift). Storage delete via the StoragePort; best-effort + idempotent. Gated on IIOS_MEDIA_GC_INTERVAL_MS (off by default). Migration 20260722193958_media_object_tracking. 10 media tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
5.4 KiB
TypeScript
123 lines
5.4 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
|
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
|
import { resetDb } from '../test-utils/reset-db';
|
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
|
import { DevOpaPort } from '../platform/dev-opa.port';
|
|
import { LocalDiskStorage } from './local-disk.storage';
|
|
import { MediaService } from './media.service';
|
|
import type { PrismaService } from '../prisma/prisma.service';
|
|
|
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
|
const asService = prisma as unknown as PrismaService;
|
|
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
|
|
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService), asService);
|
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
// point storage at an isolated temp dir for the test run
|
|
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
|
|
|
|
beforeAll(async () => { await prisma.$connect(); });
|
|
afterAll(async () => { await prisma.$disconnect(); });
|
|
beforeEach(async () => { await resetDb(prisma); });
|
|
|
|
function tokenFrom(url: string): string {
|
|
return url.split('/').pop()!;
|
|
}
|
|
|
|
describe('MediaService (presigned local storage)', () => {
|
|
it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => {
|
|
const s = svc();
|
|
const bytes = Buffer.from('hello-image-bytes');
|
|
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
|
|
expect(objectKey).toContain('/'); // scopeId/uuid
|
|
|
|
const put = await s.put(tokenFrom(uploadUrl), bytes);
|
|
expect(put.sizeBytes).toBe(bytes.length);
|
|
expect(put.checksumSha256).toHaveLength(64);
|
|
|
|
const { url } = await s.presignDownload(alice, objectKey, 'image/png');
|
|
const got = await s.get(tokenFrom(url));
|
|
expect(got.data.equals(bytes)).toBe(true);
|
|
expect(got.mime).toBe('image/png');
|
|
});
|
|
|
|
it('rejects an over-the-cap upload (OPA policy)', async () => {
|
|
await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
|
});
|
|
|
|
it('rejects a disallowed file type (OPA policy)', async () => {
|
|
await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
|
});
|
|
|
|
it('a PUT larger than the presigned size is refused', async () => {
|
|
const s = svc();
|
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
|
await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects a tampered / non-media token', async () => {
|
|
await expect(svc().get('not-a-real-token')).rejects.toThrow();
|
|
// an upload token cannot be used to download
|
|
const s = svc();
|
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
|
await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow();
|
|
});
|
|
|
|
it('tenant-fences downloads: an object outside my scope is forbidden', async () => {
|
|
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('MediaService orphan sweep', () => {
|
|
async function upload(s: MediaService): Promise<string> {
|
|
const bytes = Buffer.from('orphan-candidate');
|
|
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
|
|
await s.put(tokenFrom(uploadUrl), bytes);
|
|
return objectKey;
|
|
}
|
|
|
|
it('put() records a tracking row for the stored object', async () => {
|
|
const key = await upload(svc());
|
|
const row = await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } });
|
|
expect(row).not.toBeNull();
|
|
expect(row!.mime).toBe('image/png');
|
|
});
|
|
|
|
it('reaps an object that is past the grace window and unreferenced', async () => {
|
|
const s = svc();
|
|
const key = await upload(s);
|
|
// Simulate the grace window having elapsed by sweeping "in the future".
|
|
const deleted = await s.sweepOrphans(Date.now() + 2 * DAY_MS);
|
|
expect(deleted).toBe(1);
|
|
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).toBeNull();
|
|
});
|
|
|
|
it('keeps an object still within the grace window', async () => {
|
|
const s = svc();
|
|
const key = await upload(s);
|
|
expect(await s.sweepOrphans(Date.now())).toBe(0);
|
|
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
|
|
});
|
|
|
|
it('keeps an object a message part references, even past grace', async () => {
|
|
const s = svc();
|
|
const key = await upload(s);
|
|
const scope = await new ActorResolver(asService).resolveScope(alice);
|
|
await prisma.iiosInteraction.create({
|
|
data: {
|
|
scopeId: scope.id,
|
|
kind: 'MESSAGE',
|
|
idempotencyKey: 'ref-1',
|
|
parts: { create: [{ partIndex: 0, kind: 'MEDIA_REF', contentRef: key }] },
|
|
},
|
|
});
|
|
expect(await s.sweepOrphans(Date.now() + 2 * DAY_MS)).toBe(0);
|
|
expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull();
|
|
});
|
|
});
|