feat(260924-m4n): Bilderrahmen Stufe 2 - alte Bildspalte data entfernt, storagePath Pflicht
- Migration 20260924120000_dashboard_image_drop_data: Schutzpruefung (bricht ab, solange eine Zeile ohne storagePath existiert; row_security aus, damit ein Eigentuemer ohne BYPASSRLS nicht still 0 Zeilen sieht), dann NOT NULL, DROP COLUMN data, DROP POLICY system_read_policy - Dienst: Bootstrap-Umzug samt forSystem() und Selbstheilung aus data entfernt; Upload vergibt die UUID selbst, Zeile gleich mit Pfad - FORSYSTEM_ALLOWED_CALL_SITES, Tests, Zugriffsklassifikation (per Gate-Schleife gemessen: 61/213/6) nachgezogen - Betriebshandbuch Kap. 4: Hinweis und Wiederherstellungsweg bei Abbruch - Todo 2026-09-22 nach completed/ Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,21 +4,21 @@ import * as path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||
* unterscheidet beide ueber `via`.
|
||||
* Bindung an forTenant() — dasselbe Muster wie dashboard.service.spec.ts
|
||||
* (260910-krx): der gebundene Klient ist ein ZWEITES, von `prisma`
|
||||
* unterscheidbares Objekt ueber DEMSELBEN Speicher, das protokolliert,
|
||||
* welche Aufrufe ueber ihn liefen. Ein vergessener Bindungsaufruf faellt
|
||||
* damit auf (`prisma.dashboardImage` waere dann ohne Protokoll-Eintrag).
|
||||
* `forSystem` steht als Spion daneben: seit Stufe 2 (quick-260924-m4n,
|
||||
* Bootstrap-Umzug entfernt) darf der Dienst ihn nie mehr rufen.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||
forSystem: vi.fn(() => {
|
||||
throw new Error('forSystem darf der Bilderdienst seit Stufe 2 nicht mehr rufen');
|
||||
}),
|
||||
}));
|
||||
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
@@ -39,15 +39,18 @@ import { DashboardImagesService } from './dashboard-images.service';
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-20): KEIN
|
||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||
* desktop.service.spec.ts) ueber den Testschalter
|
||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||
* Umzug beim Start (T-HK4-03).
|
||||
* bei fehlender Datei und das Mitloeschen der Datei.
|
||||
*
|
||||
* Stufe 2 (quick-260924-m4n): die Spalte `data` ist weg, `storagePath` ist
|
||||
* Pflicht. Mit ihr entfallen die Faelle 10b/10c (Selbstheilung aus `data`),
|
||||
* 18 (Zeile ohne `storagePath`) und 21-23 (Bootstrap-Umzug).
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
@@ -57,13 +60,11 @@ interface ImageRow {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array | null;
|
||||
storagePath: string | null;
|
||||
storagePath: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
via: 'tenant' | 'system';
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
@@ -77,7 +78,6 @@ interface FakePrisma {
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
@@ -102,16 +102,21 @@ afterAll(() => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Eine Zeile OHNE Datei auf der Platte; der Pfad folgt der Form, die der
|
||||
* Dienst selbst vergibt (`storagePath` ist seit Stufe 2 Pflicht).
|
||||
*/
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
const id = overrides.id ?? 'img-1';
|
||||
const userId = overrides.userId ?? 'user-1';
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
id,
|
||||
userId,
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data === undefined ? null : overrides.data,
|
||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||
storagePath: overrides.storagePath ?? `user-files/dashboard-images/${userId}/${id}.png`,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
@@ -123,11 +128,10 @@ function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
*/
|
||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||
const row = makeRow(overrides);
|
||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, bytes);
|
||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||
return row;
|
||||
}
|
||||
|
||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||
@@ -148,7 +152,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||
where: { tenantId?: string; userId?: string };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
const where = args.where ?? {};
|
||||
@@ -156,9 +160,6 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
.filter((r) => {
|
||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice()
|
||||
@@ -195,11 +196,11 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
@@ -211,10 +212,7 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
__rows: rows,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
return wrap('tenant', tenantId, userId);
|
||||
},
|
||||
__makeSystemClient() {
|
||||
return wrap('system', '', undefined);
|
||||
return wrap(tenantId, userId);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
@@ -340,37 +338,6 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
||||
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
||||
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
||||
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
||||
const row = makeRow({
|
||||
id: 'img-alt',
|
||||
data: PNG,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
||||
|
||||
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
||||
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
||||
const row = makeRow({
|
||||
id: 'img-weg',
|
||||
data: null,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeStoredRow({ id: 'eigen' }),
|
||||
@@ -399,13 +366,13 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten. Seit
|
||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.via).toBe('tenant');
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
@@ -421,10 +388,15 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||
expect(prisma.__rows[0].data).toBeNull();
|
||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||
// die Zeile.
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(createArgs.data.data).toBeUndefined();
|
||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(createArgs.data.id).toBe(result.id);
|
||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(createArgs.data).not.toHaveProperty('data');
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||
@@ -435,7 +407,7 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
|
||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
const stored = prisma.__rows[0].storagePath ?? '';
|
||||
const stored = prisma.__rows[0].storagePath;
|
||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||
expect(stored).not.toContain('passwd');
|
||||
expect(stored).not.toContain('..');
|
||||
@@ -462,10 +434,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
it('Test 16: getBytes liest den Dateiinhalt unter dem Pfad aus der Zeile', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-16' }, JPEG)]);
|
||||
const result = await makeService(prisma).getBytes('img-16', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(JPEG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||
@@ -479,11 +451,6 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
@@ -504,69 +471,3 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||
const fremderMandant = makeRow({
|
||||
id: 'alt-2',
|
||||
userId: 'user-9',
|
||||
tenantId: 'tenant-2',
|
||||
mimeType: 'image/jpeg',
|
||||
data: Uint8Array.from(JPEG),
|
||||
});
|
||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||
|
||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||
// gebunden ist.
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(findManyArgs.where.storagePath).toBeNull();
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||
|
||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||
});
|
||||
|
||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||
const service = makeService(prisma);
|
||||
await service.onApplicationBootstrap();
|
||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user