feat(jts-02): groups.service.ts binden, Transaktionen tragfaehig machen, Absicherung sehend machen
prisma-tenant.extension.ts bekommt withTenantTransaction(prisma, tenantId, fn) — die interaktive Transaktion auf dem UNgebundenen Client mit set_config als erster Anweisung direkt auf tx, die in Aufgabe 1 als einzige der drei gemessenen Formen sowohl die Einzelmessung als auch eine Lastprobe unter echter Nebenlaeufigkeit bestand (die Array-Form auf dem gebundenen Client verteilt jede Operation auf eine eigene Teiltransaktion; die interaktive Form auf dem gebundenen Client brach unter 40 parallelen Aufrufen mit P2028 ab). rls-access-inventory.spec.ts bekommt eine dritte Erkennung fuer Modellzugriffe ueber den Rueckgabeparameter einer interaktiven Transaktion (zwei Formen: direkter Empfaenger.$transaction(async...) und das neue Hilfsmittel withTenantTransaction(...)) — macht das Paar (groups.service.ts, tenantModuleActivation) erstmals sichtbar, das bislang keine Pruefung dieses Projekts je gesehen hat. groups.service.ts: alle zwoelf Methoden inklusive der drei Transaktionen (update() isDefault:true, reassignDefaultBeforeDelete(), ensureDefaultGroup()) laufen jetzt ueber den Mandantenkontext. Zaehler und Transaktion in ensureDefaultGroup() sind gemeinsam gebunden (T-JTS-05). addUserToDefaultGroup() prueft neu, dass der Zielbenutzer zum Mandanten gehoert (Befund E, T-JTS-02) — die Regel auf GroupMembership prueft nachweislich nur die Gruppenseite. groups.service.spec.ts bekommt zwei unterscheidbare Clients ueber demselben Speicher-Fake (Muster aus 260909-ipc, auf die interaktive Form uebertragen) und 13 neue Bindungsnachweise; alle 42 Bestandstests bleiben gruen. Klassifikationsdokument nachgezogen. 737 Tests und die Typpruefung gruen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AMASaSxv5QMY7RncqZriRR
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from './prisma-tenant.extension';
|
||||
import { forTenant, withTenantTransaction } from './prisma-tenant.extension';
|
||||
|
||||
/**
|
||||
* Prueft ohne laufende Datenbank die FORM des Aufrufs, nicht seinen mit
|
||||
@@ -30,6 +30,23 @@ function stripComments(source: string): string {
|
||||
.replace(/^\s*\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schneidet den Quelltext einer einzelnen Top-Level-`export function`
|
||||
* heraus (bis zur naechsten `export function` oder zum Dateiende). Seit
|
||||
* 260909-jts (Aufgabe 2) enthaelt diese Datei ZWEI Funktionen mit
|
||||
* unterschiedlichem, jeweils gemessen begruendetem Transaktionsmuster —
|
||||
* die Array-Form-Garantie unten gilt nachweislich nur fuer `forTenant()`
|
||||
* selbst, nicht mehr fuer die gesamte Datei.
|
||||
*/
|
||||
function extractFunctionSource(source: string, functionName: string): string {
|
||||
const startMarker = `export function ${functionName}`;
|
||||
const startIdx = source.indexOf(startMarker);
|
||||
if (startIdx === -1) return '';
|
||||
const rest = source.slice(startIdx);
|
||||
const nextExportIdx = rest.indexOf('\nexport function ', startMarker.length);
|
||||
return nextExportIdx === -1 ? rest : rest.slice(0, nextExportIdx);
|
||||
}
|
||||
|
||||
describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
it('ruft $transaction mit einem Feld aus genau zwei Eintraegen auf', async () => {
|
||||
const transactionCalls: unknown[] = [];
|
||||
@@ -108,9 +125,87 @@ describe('forTenant() — Array-Form von $transaction (WINDOWS #20)', () => {
|
||||
expect(source).not.toContain('$executeRawUnsafe');
|
||||
});
|
||||
|
||||
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback)', () => {
|
||||
it('nutzt im tatsaechlichen Code die Array-Form von $transaction (kein interaktiver async-Callback) — innerhalb von forTenant() selbst', () => {
|
||||
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
|
||||
expect(source).toMatch(/\$transaction\(\s*\[/);
|
||||
expect(source).not.toMatch(/\$transaction\(\s*async/);
|
||||
const forTenantSource = extractFunctionSource(source, 'forTenant');
|
||||
expect(forTenantSource).not.toBe('');
|
||||
expect(forTenantSource).toMatch(/\$transaction\(\s*\[/);
|
||||
expect(forTenantSource).not.toMatch(/\$transaction\(\s*async/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withTenantTransaction() — interaktive Callback-Form auf dem UNgebundenen Client (260909-jts, Aufgabe 1)', () => {
|
||||
it('nutzt im tatsaechlichen Code die interaktive Callback-Form auf tx, nicht die Array-Form (gemessen: einzige Form, die die Lastprobe bestand)', () => {
|
||||
const source = stripComments(readFileSync(EXTENSION_SOURCE_PATH, 'utf-8'));
|
||||
const withTenantTransactionSource = extractFunctionSource(source, 'withTenantTransaction');
|
||||
expect(withTenantTransactionSource).not.toBe('');
|
||||
expect(withTenantTransactionSource).toMatch(/\$transaction\(async/);
|
||||
});
|
||||
|
||||
it('setzt den Mandantenkontext als erste Anweisung DIREKT AUF tx, nicht auf dem aeusseren Client', async () => {
|
||||
const setConfigCalls: unknown[] = [];
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
setConfigCalls.push(values);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
};
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
|
||||
// Der aeussere Client darf NIE direkt fuer set_config oder die
|
||||
// eigentliche Abfrage herangezogen werden — nur $transaction selbst.
|
||||
$executeRaw: vi.fn(() => {
|
||||
throw new Error('set_config darf nicht auf dem aeusseren Client laufen');
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => {
|
||||
expect(tx).toBe(fakeTx);
|
||||
return 'fn-result';
|
||||
});
|
||||
|
||||
expect(fakePrisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
expect(setConfigCalls).toEqual([['tenant-a']]);
|
||||
expect(result).toBe('fn-result');
|
||||
});
|
||||
|
||||
it('reicht denselben tx-Parameter an fn weiter, sodass mehrere Schritte auf derselben Verbindung laufen', async () => {
|
||||
const touchedByFn: unknown[] = [];
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn(() => Promise.resolve(1)),
|
||||
group: { create: vi.fn(() => Promise.resolve({ id: 'g1' })) },
|
||||
user: { findMany: vi.fn(() => Promise.resolve([])) },
|
||||
};
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
|
||||
};
|
||||
|
||||
await withTenantTransaction(fakePrisma, 'tenant-a', async (tx) => {
|
||||
touchedByFn.push(await tx.group.create({ data: {} }));
|
||||
touchedByFn.push(await tx.user.findMany({ where: {} }));
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(fakeTx.group.create).toHaveBeenCalledTimes(1);
|
||||
expect(fakeTx.user.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(touchedByFn).toEqual([{ id: 'g1' }, []]);
|
||||
});
|
||||
|
||||
it('setzt den Mandantenkontext ueber ein getaggtes Template, nicht ueber zusammengebauten Text (T-02-05)', async () => {
|
||||
const fakeTx: any = {
|
||||
$executeRaw: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
expect(Array.isArray(strings)).toBe(true);
|
||||
expect(values).toContain("tenant-with-quote-' OR 1=1");
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
};
|
||||
const fakePrisma: any = {
|
||||
$transaction: vi.fn((fn: (tx: unknown) => unknown) => fn(fakeTx)),
|
||||
};
|
||||
|
||||
await withTenantTransaction(fakePrisma, "tenant-with-quote-' OR 1=1", async () => 'ok');
|
||||
|
||||
expect(fakeTx.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,51 @@ import { PrismaClient } from '@prisma/client';
|
||||
* Parametrisiert ueber ein getaggtes `$executeRaw`-Template (kein
|
||||
* `$executeRawUnsafe` mit zusammengebautem Text mehr) — die
|
||||
* Injektionsfestigkeit aus T-02-05 bleibt beim Umbau erhalten.
|
||||
*
|
||||
* ERNEUTE PRUEFUNG FUER ETAPPE 2, BEREICH `groups` (260909-jts, gemessen
|
||||
* 2026-09-09 gegen die echte Datenbank, siehe Aufgabe 1 in
|
||||
* `.planning/quick/260909-jts-.../260909-jts-PLAN.md` und den Abschnitt
|
||||
* "Bereich groups" in `docs/mandantentrennung-etappe2-fehlerrichtung.md`):
|
||||
* `groups.service.ts` ist die einzige Datei im gesamten API-Quelltext mit
|
||||
* einer interaktiven Callback-Transaktion (`ensureDefaultGroup`), dazu zwei
|
||||
* Array-Transaktionen (`update`, `reassignDefaultBeforeDelete`) — genau der
|
||||
* im Absatz oben benannte neue Fall. Ergebnis der Messung, mit
|
||||
* `pg_backend_pid()` und `current_tenant_id()` je Teilschritt:
|
||||
*
|
||||
* Form (i) — Array-Form auf dem gebundenen Client: FAELLT DURCH. Zwei
|
||||
* Teilschritte liefen auf ZWEI verschiedenen Verbindungen
|
||||
* (`step1.pid=276749`, `step2.pid=276750`) — exakt die im
|
||||
* Absatz oben beschriebene Aufspaltung in mehrere
|
||||
* Teiltransaktionen. Jeder Teilschritt sah zwar noch den
|
||||
* richtigen Kontext (kein Datenleck), aber die Atomaritaet
|
||||
* der aeusseren Transaktion ist nicht mehr gegeben.
|
||||
* Form (ii) — interaktive Callback-Form auf dem gebundenen Client
|
||||
* (`forTenant(prisma, tenantId).$transaction(async (tx) => ...)`):
|
||||
* bestand die Einzelmessung (gleiche Verbindung, richtiger
|
||||
* Kontext, richtige Zeilenzahl), brach aber unter einer
|
||||
* zusaetzlichen Lastprobe (40 parallele Aufrufe,
|
||||
* alternierend TENANT-A/TENANT-B) mit
|
||||
* `PrismaClientKnownRequestError: Transaction API error:
|
||||
* Unable to start a transaction in the given time.` (P2028)
|
||||
* ab — jeder `tx.$queryRaw`-Aufruf innerhalb der interaktiven
|
||||
* Transaktion loest selbst wieder eine VERSCHACHTELTE
|
||||
* Array-Transaktion aus (weil `$allOperations` bei jedem
|
||||
* Aufruf erneut feuert), und die aeussere plus jede innere
|
||||
* Verschachtelung belegen gleichzeitig eine Verbindung aus
|
||||
* demselben, endlichen Pool.
|
||||
* Form (iii) — interaktive Callback-Form auf dem UNGEBUNDENEN Client,
|
||||
* `set_config` als ERSTE Anweisung direkt auf `tx` (nicht auf
|
||||
* dem aeusseren Client): bestand sowohl die Einzelmessung als
|
||||
* auch die Lastprobe (0 Verletzungen unter 40 parallelen
|
||||
* Aufrufen) — sie belegt pro Aufruf genau EINE Verbindung,
|
||||
* ohne Verschachtelung.
|
||||
*
|
||||
* Entscheidung: `withTenantTransaction()` unten baut Form (iii) nach und
|
||||
* ist das Hilfsmittel fuer alle mehrschrittigen, mandantengebundenen
|
||||
* Aenderungen dieses Bereichs. Fuer den naechsten Bereich mit einer eigenen
|
||||
* Transaktion gilt weiterhin: vor jedem neuen Fall erneut pruefen, nicht
|
||||
* von hier abschreiben — eine andere Lastform oder ein anderer Pool koennte
|
||||
* ein anderes Ergebnis liefern.
|
||||
*/
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
return prisma.$extends({
|
||||
@@ -70,3 +115,32 @@ export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt `fn` als EINE mehrschrittige, mandantengebundene Transaktion aus
|
||||
* (260909-jts, Befund A/Aufgabe 1). Anders als `forTenant()` bindet diese
|
||||
* Funktion NICHT ueber `$extends`/`$allOperations`, sondern oeffnet direkt
|
||||
* eine interaktive Transaktion auf dem UNGEBUNDENEN Basisclient und setzt
|
||||
* `app.current_tenant` als allererste Anweisung ueber ein getaggtes
|
||||
* Roh-Template DIREKT AUF `tx` — nicht auf `prisma`. Jede weitere Anweisung
|
||||
* innerhalb von `fn` bekommt denselben `tx`-Parameter uebergeben und laeuft
|
||||
* dadurch auf DERSELBEN Verbindung wie das `set_config` davor.
|
||||
*
|
||||
* Parametrisiert wie `forTenant()` (getaggtes Template, kein
|
||||
* zusammengebauter Text — T-02-05 bleibt erhalten).
|
||||
*
|
||||
* Fuer Einzeloperationen bleibt `forTenant()` das richtige Werkzeug; dieses
|
||||
* Hilfsmittel ist ausschliesslich fuer Aufrufstellen gedacht, die mehrere
|
||||
* Schritte als EINE Transaktion brauchen (Array- oder interaktive
|
||||
* Callback-Form).
|
||||
*/
|
||||
export function withTenantTransaction<T>(
|
||||
prisma: PrismaClient,
|
||||
tenantId: string,
|
||||
fn: (tx: any) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return (prisma as any).$transaction(async (tx: any) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
|
||||
return fn(tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,22 @@ import { describe, expect, it } from 'vitest';
|
||||
* der Form `const <Name> = forTenant(` und sucht danach `<Name>.<Modell>`.
|
||||
* Aus beiden Mengen ergibt sich je Paar (Datei, Modell) ein Stand:
|
||||
* `gebunden`, `ungebunden` oder `gemischt`.
|
||||
*
|
||||
* Erweitert in Aufgabe 2 (260909-jts, Befund B): Modellzugriffe koennen
|
||||
* auch ueber den Rueckgabeparameter einer INTERAKTIVEN Transaktion laufen
|
||||
* (`empfaenger.$transaction(async (tx) => { ... tx.<Modell> ... })`) —
|
||||
* weder `this.prisma.<Modell>` noch `<gebundener Client>.<Modell>` sehen
|
||||
* das, weil der Parametername (z. B. `tx`) weder mit `this.prisma`
|
||||
* uebereinstimmt noch selbst aus einer `forTenant(`-Zuweisung stammt. Die
|
||||
* dritte Erkennung sammelt je Datei die Empfaenger UND Parameternamen
|
||||
* solcher Transaktionen (zwei Formen: direkt `<empfaenger>.$transaction(
|
||||
* async (tx) => ...)`, oder ueber das Hilfsmittel `withTenantTransaction(
|
||||
* <empfaenger>, tenantId, async (tx) => ...)` aus
|
||||
* `prisma-tenant.extension.ts`) und sucht danach `<tx>.<Modell>`. Die
|
||||
* Zuordnung richtet sich nach dem Empfaenger: eine bereits als gebunden
|
||||
* erkannte Zuweisung ODER jeder Aufruf von `withTenantTransaction(` zaehlt
|
||||
* als gebunden (das Hilfsmittel bindet den Kontext selbst, direkt auf dem
|
||||
* Transaktionsparameter) — alles andere zaehlt als ungebunden.
|
||||
*/
|
||||
|
||||
const API_SRC_DIR = join(__dirname, '..');
|
||||
@@ -39,6 +55,18 @@ const FORTENANT_ASSIGNMENT_EXCEPTIONS = new Set([
|
||||
'apps/api/src/tenant/tenant.guard.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Dateien, in denen eine interaktive Transaktion (`empfaenger.$transaction(
|
||||
* async (tx) => ...)`) bewusst KEINER der beiden erkannten Empfaengerformen
|
||||
* entspricht. Gemessen zur Planungszeit (260909-jts, Aufgabe 1) gibt es im
|
||||
* gesamten API-Quelltext genau eine interaktive Transaktion, in
|
||||
* `groups.service.ts` — nach deren Umstellung auf `withTenantTransaction(`
|
||||
* (Aufgabe 2) entspricht sie der erkannten Hilfsmittel-Form. Die Liste
|
||||
* startet deshalb leer und bleibt es, bis ein begruendeter Ausnahmefall
|
||||
* auftritt.
|
||||
*/
|
||||
const INTERACTIVE_TRANSACTION_EXCEPTIONS = new Set<string>([]);
|
||||
|
||||
const STAND_TOKENS = ['gebunden', 'ungebunden', 'gemischt'] as const;
|
||||
type Stand = (typeof STAND_TOKENS)[number];
|
||||
|
||||
@@ -48,6 +76,8 @@ interface FileAnalysis {
|
||||
boundModels: Set<string>;
|
||||
totalForTenantCalls: number;
|
||||
assignmentFormCalls: number;
|
||||
rawInteractiveTransactionCount: number;
|
||||
matchedInteractiveTransactionCount: number;
|
||||
}
|
||||
|
||||
function listTsFiles(dir: string): string[] {
|
||||
@@ -100,12 +130,72 @@ function analyzeFile(absPath: string, relPath: string): FileAnalysis {
|
||||
// die Definition ist kein Aufruf und braucht keine Zuweisungsform.
|
||||
const totalForTenantCalls = [...source.matchAll(/(?<!function )forTenant\(/g)].length;
|
||||
|
||||
// Dritte Erkennung (260909-jts, Befund B): Modellzugriffe ueber den
|
||||
// Rueckgabeparameter einer interaktiven Transaktion. Rohzahl zuerst
|
||||
// (jedes "<etwas>.$transaction(async" im Quelltext), danach die
|
||||
// strukturierte Erkennung der beiden bekannten Empfaengerformen — die
|
||||
// Differenz ist die offen gehaltene Grenze (siehe
|
||||
// INTERACTIVE_TRANSACTION_EXCEPTIONS oben).
|
||||
//
|
||||
// prisma-tenant.extension.ts definiert `withTenantTransaction()` selbst
|
||||
// und enthaelt deshalb dessen KANONISCHE interaktive `$transaction`-
|
||||
// Anweisung (`(prisma as any).$transaction(async (tx) => ...)`) als
|
||||
// Definition, nicht als Aufrufstelle, die klassifiziert werden muesste —
|
||||
// dieselbe Ausnahme, die `totalForTenantCalls` oben fuer die Definition
|
||||
// von `forTenant()` bereits macht.
|
||||
const isPrismaTenantExtensionFile = relPath.endsWith(
|
||||
'apps/api/src/prisma/prisma-tenant.extension.ts',
|
||||
);
|
||||
const rawInteractiveTransactionCount = isPrismaTenantExtensionFile
|
||||
? 0
|
||||
: [...source.matchAll(/\.\$transaction\(\s*async\b/g)].length;
|
||||
|
||||
// Form 1: `<empfaenger>.$transaction(async (<param>) => ...)`. <empfaenger>
|
||||
// ist gebunden, wenn er in boundNames steht (aus der Zuweisungsform oben).
|
||||
const directInteractiveMatches = [
|
||||
...source.matchAll(
|
||||
/([\w.]+)\.\$transaction\(\s*async\s*\(?\s*(\w+)(?:\s*:\s*[\w<>[\], ]+)?\s*\)?\s*=>/g,
|
||||
),
|
||||
];
|
||||
for (const m of directInteractiveMatches) {
|
||||
const receiver = m[1];
|
||||
const param = m[2];
|
||||
if (!param) continue;
|
||||
const isBound = boundNames.has(receiver);
|
||||
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
||||
for (const mm of source.matchAll(re)) {
|
||||
if (!mm[1]) continue;
|
||||
if (isBound) boundModels.add(mm[1]);
|
||||
else unboundModels.add(mm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Form 2: `withTenantTransaction(<empfaenger>, tenantId, async (<param>)
|
||||
// => ...)` aus prisma-tenant.extension.ts — IMMER gebunden, unabhaengig
|
||||
// vom Empfaenger: das Hilfsmittel bindet den Kontext selbst, direkt auf
|
||||
// dem Transaktionsparameter (siehe dessen Kopfkommentar).
|
||||
const withTenantTransactionMatches = [
|
||||
...source.matchAll(
|
||||
/\bwithTenantTransaction\(\s*[\w.]+\s*,[^,]*,\s*async\s*\(?\s*(\w+)(?:\s*:\s*[\w<>[\], ]+)?\s*\)?\s*=>/g,
|
||||
),
|
||||
];
|
||||
for (const m of withTenantTransactionMatches) {
|
||||
const param = m[1];
|
||||
if (!param) continue;
|
||||
const re = new RegExp(`\\b${param}\\.([a-zA-Z]+)`, 'g');
|
||||
for (const mm of source.matchAll(re)) {
|
||||
if (mm[1]) boundModels.add(mm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: relPath,
|
||||
unboundModels,
|
||||
boundModels,
|
||||
totalForTenantCalls,
|
||||
assignmentFormCalls: assignmentMatches.length,
|
||||
rawInteractiveTransactionCount,
|
||||
matchedInteractiveTransactionCount: directInteractiveMatches.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,4 +346,17 @@ describe('mandantentrennung-zugriffsklassifikation.md deckt den Quelltext vollst
|
||||
}
|
||||
expect(violations, violations.join('\n')).toEqual([]);
|
||||
});
|
||||
|
||||
it('jede interaktive Transaktion (empfaenger.$transaction(async ...)) entspricht einer der erkannten Empfaengerformen oder steht in der begruendeten Ausnahmeliste (260909-jts, Befund B)', () => {
|
||||
const violations: string[] = [];
|
||||
for (const a of analyses) {
|
||||
const unmatched = a.rawInteractiveTransactionCount - a.matchedInteractiveTransactionCount;
|
||||
if (unmatched > 0 && !INTERACTIVE_TRANSACTION_EXCEPTIONS.has(a.file)) {
|
||||
violations.push(
|
||||
`${a.file}: ${unmatched} interaktive Transaktion(en) ausserhalb der erkannten Empfaengerformen`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(violations, violations.join('\n')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user