feat(api): Favoriten — Symbol trotz Zertifikatsfehler holen, Reihenfolge per PUT /favorites/order speichern
- icon-discovery.service.ts: undicis eigenes fetch mit Modul-Singleton
LENIENT_TLS_AGENT (Agent({ connect: { rejectUnauthorized: false } }))
als dispatcher in fetchWithRedirectGuard, der einzigen Ausgangsstelle
fuer HTML-Ermittlung und Icon-Byte-Holen; SSRF-Schutz unveraendert
- undici 7.28.0 (bereits im Lockfile aufgeloest) als direkte Abhaengigkeit
von @tessera/api via pnpm add --offline
- PUT /favorites/order (ReorderFavoritesDto) vor den :id-Routen;
FavoritesService.reorder() setzt position=index fuer die Favoriten
eines Widgets in EINER withTenantTransaction, userId+widgetId in jeder
Bedingung (zweites Netz), eine BadRequestException fuer alle
Abweichungen (T-JDD-06)
- getIcon: X-Content-Type-Options nosniff + restriktive CSP (T-JDD-02)
- 10 neue Tests (3 Dispatcher, 7 reorder); volle API-Suite 68 Dateien/
1101 Tests und type-check gruen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,7 @@
|
|||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.0.0",
|
"rxjs": "^7.0.0",
|
||||||
"tsdav": "2.2.2",
|
"tsdav": "2.2.2",
|
||||||
|
"undici": "7.28.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsUUID,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for `PUT /favorites/order` (260917-jdd).
|
||||||
|
*
|
||||||
|
* `ids` is the FULL id list of a widget's favorites, in the desired display
|
||||||
|
* order — the service requires an exact match against the caller's existing
|
||||||
|
* favorites for this widget (no partial reorder, no foreign/unknown ids).
|
||||||
|
* `ArrayMaxSize(500)` is a DoS cap for the `updateMany` loop inside the
|
||||||
|
* transaction (T-JDD-05); a widget has a handful of links in practice.
|
||||||
|
*/
|
||||||
|
export class ReorderFavoritesDto {
|
||||||
|
@IsUUID()
|
||||||
|
widgetId!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ArrayMaxSize(500)
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
ids!: string[];
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
|
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
|
|
||||||
@@ -35,6 +37,8 @@ import { FavoritesService } from './favorites.service';
|
|||||||
* Routes:
|
* Routes:
|
||||||
* - GET /favorites?widgetId= — list favorites for a widget instance
|
* - GET /favorites?widgetId= — list favorites for a widget instance
|
||||||
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
* - POST /favorites — create a favorite (triggers server-side icon discovery)
|
||||||
|
* - PUT /favorites/order — reorder favorites for a widget instance (260917-jdd)
|
||||||
|
* - GET /favorites/:id/icon — stream a favorite's stored icon bytes
|
||||||
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
* - PATCH /favorites/:id — update a favorite (ownership verified in service)
|
||||||
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
* - DELETE /favorites/:id — delete a favorite (ownership verified in service)
|
||||||
*/
|
*/
|
||||||
@@ -77,6 +81,23 @@ export class FavoritesController {
|
|||||||
return this.favoritesService.create(tenantId, userId, dto);
|
return this.favoritesService.create(tenantId, userId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /favorites/order — persists the display order for a widget's
|
||||||
|
* favorites (260917-jdd). Declared BEFORE the `:id` routes below on
|
||||||
|
* purpose (NestJS route order — a later `:id` route would otherwise
|
||||||
|
* shadow the literal segment "order"; precedent tenders.controller.ts
|
||||||
|
* Z. 636-648).
|
||||||
|
*/
|
||||||
|
@Put('order')
|
||||||
|
async reorder(
|
||||||
|
@Body() dto: ReorderFavoritesDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
const { userId, tenantId } = this.extractContext(req);
|
||||||
|
|
||||||
|
return this.favoritesService.reorder(tenantId, userId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /favorites/:id/icon — streams the stored icon bytes for a favorite
|
* GET /favorites/:id/icon — streams the stored icon bytes for a favorite
|
||||||
* owned by the caller, from Tessera's own origin. This avoids the browser
|
* owned by the caller, from Tessera's own origin. This avoids the browser
|
||||||
@@ -101,6 +122,12 @@ export class FavoritesController {
|
|||||||
|
|
||||||
res.setHeader('Content-Type', contentType);
|
res.setHeader('Content-Type', contentType);
|
||||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||||
|
// 260917-jdd: die Bytes kommen jetzt auch von Hosts ohne gueltiges
|
||||||
|
// Zertifikat. Als <img>-Unterressource ignoriert der Browser diese
|
||||||
|
// Header, aber ein direkt im Tab geoeffnetes SVG laeuft damit ohne
|
||||||
|
// Skript und ohne Tessera-Origin (T-JDD-02).
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||||
res.send(body);
|
res.send(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, HttpException, NotFoundException } from '@nestjs/common';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
|
* FavoritesService.spec — NEU (260911-gwh). Der Bereich `favorites` hatte
|
||||||
@@ -14,9 +14,18 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
|||||||
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
|
* Modellzugriff scheitert mit "Cannot read properties of undefined" (die
|
||||||
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
|
* dkv-Form der Falsifizierung, siehe auth.service.spec.ts:280). Der
|
||||||
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
|
* GEBUNDENE Klient hat ausschliesslich `favoriteLink`/`widgetInstance`.
|
||||||
|
*
|
||||||
|
* 260917-jdd: `withTenantTransaction` kommt zum Mock hinzu (Muster
|
||||||
|
* groups.service.spec.ts Z. 30-35/296-299) — `prisma.__withTenantTransaction`
|
||||||
|
* reicht den gebundenen Klienten als `tx` durch und protokolliert den
|
||||||
|
* Aufruf. Der Fake bekommt zusaetzlich `updateMany` auf `favoriteLink` fuer
|
||||||
|
* `reorder()`.
|
||||||
*/
|
*/
|
||||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
forTenant: vi.fn((unboundClient: any, tenantId: string) => unboundClient.__makeBoundClient(tenantId)),
|
||||||
|
withTenantTransaction: vi.fn((unboundClient: any, tenantId: string, fn: (tx: any) => any) =>
|
||||||
|
unboundClient.__withTenantTransaction(tenantId, fn),
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface FakeFavoriteRow {
|
interface FakeFavoriteRow {
|
||||||
@@ -40,7 +49,7 @@ interface FakeWidgetRow {
|
|||||||
|
|
||||||
interface BoundCall {
|
interface BoundCall {
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
model: 'favoriteLink' | 'widgetInstance';
|
model: 'favoriteLink' | 'widgetInstance' | '$transaction';
|
||||||
method: string;
|
method: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +126,20 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
|||||||
favorites.delete(where.id);
|
favorites.delete(where.id);
|
||||||
return row;
|
return row;
|
||||||
},
|
},
|
||||||
|
// 260917-jdd: reorder() — filtert nach tenantId sowie, falls in
|
||||||
|
// `where` vorhanden, id/userId/widgetId; wendet `data` auf jede
|
||||||
|
// Treffer-Zeile an; liefert { count }.
|
||||||
|
updateMany: async ({ where, data }: any) => {
|
||||||
|
boundCallLog.push({ tenantId, model: 'favoriteLink', method: 'updateMany' });
|
||||||
|
let rows = Array.from(favorites.values()).filter((f) => f.tenantId === tenantId);
|
||||||
|
if (where?.id) rows = rows.filter((f) => f.id === where.id);
|
||||||
|
if (where?.userId) rows = rows.filter((f) => f.userId === where.userId);
|
||||||
|
if (where?.widgetId) rows = rows.filter((f) => f.widgetId === where.widgetId);
|
||||||
|
for (const row of rows) {
|
||||||
|
favorites.set(row.id, { ...row, ...data, updatedAt: new Date() });
|
||||||
|
}
|
||||||
|
return { count: rows.length };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +169,10 @@ function makeFakePrisma(favoriteRows: FakeFavoriteRow[] = [], widgetRows: FakeWi
|
|||||||
widgetInstance: makeScopedWidgetInstance(tenantId),
|
widgetInstance: makeScopedWidgetInstance(tenantId),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
__withTenantTransaction(tenantId: string, fn: (tx: any) => any) {
|
||||||
|
boundCallLog.push({ tenantId, model: '$transaction', method: 'withTenantTransaction' });
|
||||||
|
return fn(fake.__makeBoundClient(tenantId));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return fake;
|
return fake;
|
||||||
}
|
}
|
||||||
@@ -505,4 +532,97 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reorder (260917-jdd)', () => {
|
||||||
|
const makeAltbestand = () =>
|
||||||
|
makeFakePrisma([
|
||||||
|
{ id: 'f1', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'A', url: 'https://a.invalid', iconUrl: null, position: 0 },
|
||||||
|
{ id: 'f2', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'B', url: 'https://b.invalid', iconUrl: null, position: 0 },
|
||||||
|
{ id: 'f3', userId: 'user-a1', tenantId: 't1', widgetId: 'widget-a1', title: 'C', url: 'https://c.invalid', iconUrl: null, position: 0 },
|
||||||
|
{ id: 'f9', userId: 'user-a2', tenantId: 't1', widgetId: 'widget-a1', title: 'D', url: 'https://d.invalid', iconUrl: null, position: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('setzt position 0/1/2 in der uebergebenen Reihenfolge und liefert die Liste so sortiert', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
const result = await service.reorder('t1', 'user-a1', {
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
ids: ['f3', 'f1', 'f2'],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(result.map((r: any) => r.id)).toEqual(['f3', 'f1', 'f2']);
|
||||||
|
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||||
|
expect(prisma.__favorites.get('f1').position).toBe(1);
|
||||||
|
expect(prisma.__favorites.get('f2').position).toBe(2);
|
||||||
|
expect(prisma.__favorites.get('f9').position).toBe(0);
|
||||||
|
expect(vi.mocked(withTenantTransaction)).toHaveBeenCalledWith(prisma, 't1', expect.any(Function));
|
||||||
|
expectBoundCall(prisma, 't1', 'favoriteLink', 'updateMany');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremde id (user-a2) -> BadRequestException, KEINE Position geaendert', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f9'] } as any),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(prisma.__favorites.get('f1').position).toBe(0);
|
||||||
|
expect(prisma.__favorites.get('f2').position).toBe(0);
|
||||||
|
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannte id -> BadRequestException', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f-fehlt'] } as any),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Teilmenge (2 von 3) -> BadRequestException', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2'] } as any),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doppelte ids -> BadRequestException OHNE withTenantTransaction-Aufruf', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
vi.mocked(withTenantTransaction).mockClear();
|
||||||
|
await expect(
|
||||||
|
service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f1', 'f2'] } as any),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fremder Mandant (t2 auf t1-Zeilen) -> BadRequestException, Positionen unveraendert', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reorder('t2', 'user-a1', { widgetId: 'widget-a1', ids: ['f1', 'f2', 'f3'] } as any),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(prisma.__favorites.get('f1').position).toBe(0);
|
||||||
|
expect(prisma.__favorites.get('f2').position).toBe(0);
|
||||||
|
expect(prisma.__favorites.get('f3').position).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wachhund: 0 forTenant-Aufrufe, genau 1 withTenantTransaction-Aufruf fuer den Happy Path', async () => {
|
||||||
|
const prisma = makeAltbestand();
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
vi.mocked(withTenantTransaction).mockClear();
|
||||||
|
await service.reorder('t1', 'user-a1', { widgetId: 'widget-a1', ids: ['f3', 'f1', 'f2'] } as any);
|
||||||
|
|
||||||
|
expect(vi.mocked(forTenant).mock.calls.length).toBe(0);
|
||||||
|
expect(vi.mocked(withTenantTransaction).mock.calls.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||||
|
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||||
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
|||||||
* - Every query is scoped by userId (prevents cross-user access).
|
* - Every query is scoped by userId (prevents cross-user access).
|
||||||
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
* - list() additionally scopes by widgetId so each widget instance has its own set.
|
||||||
* - update() and remove() verify userId ownership before mutating.
|
* - update() and remove() verify userId ownership before mutating.
|
||||||
|
* - reorder() runs as one withTenantTransaction() (T-JDD-03) and scopes
|
||||||
|
* every updateMany by userId AND widgetId (see reorder() doc below).
|
||||||
*
|
*
|
||||||
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
* `create()` prueft zusaetzlich, dass das Ziel-Widget dem Aufrufer gehoert
|
||||||
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
* (T-GWH-05): der Fremdschluessel `FavoriteLink.widgetId` prueft an der
|
||||||
@@ -171,6 +174,74 @@ export class FavoritesService {
|
|||||||
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
await tenantPrisma.favoriteLink.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the display order of a user's favorites for one widget
|
||||||
|
* instance (260917-jdd, PUT /favorites/order).
|
||||||
|
*
|
||||||
|
* Laeuft als EINE Transaktion ueber `withTenantTransaction()` — die
|
||||||
|
* einzige gemessene atomare Form fuer einen Mehrschritt-Zugriff
|
||||||
|
* (prisma-tenant.extension.ts Z. 33-49/104-109); die Array-Form von
|
||||||
|
* `$transaction` auf einem mit `forTenant()` gebundenen Klienten ist
|
||||||
|
* gemessen NICHT atomar, die interaktive Form auf dem gebundenen Klienten
|
||||||
|
* faellt unter Last aus (siehe dortige Messung). `withTenantTransaction()`
|
||||||
|
* setzt KEINE Benutzerdimension in der Sitzung (nur `app.current_tenant`)
|
||||||
|
* — die Regel auf `FavoriteLink` faellt deshalb in ihren `IS NULL`-Zweig
|
||||||
|
* und zeigt den ganzen Mandanten. Darum traegt JEDE Bedingung unten
|
||||||
|
* `userId` UND `widgetId` selbst (zweites Netz, wie der Kopfkommentar
|
||||||
|
* dieser Klasse es fuer alle Methoden vorsieht).
|
||||||
|
*
|
||||||
|
* `updateMany` statt `update({ where: { id } })`, weil `update` nur nach
|
||||||
|
* `id` filtern koennte — der Ownership-Check muesste dann als separater
|
||||||
|
* Lese-Schritt VOR dem Schreiben stehen, mit derselben TOCTOU-Luecke wie
|
||||||
|
* ein fehlendes zweites Netz. `updateMany` traegt die Bedingung direkt in
|
||||||
|
* der Schreiboperation und liefert `count`, das sofort geprueft wird.
|
||||||
|
*
|
||||||
|
* Existenzorakel-Vermeidung (T-JDD-06): EINE BadRequestException mit
|
||||||
|
* DERSELBEN Meldung fuer fremde id, unbekannte id, Teilmenge sowie
|
||||||
|
* fremdes/unbekanntes Widget oder Mandant — kein Fall verraet, welcher
|
||||||
|
* Grund zutraf (Muster T-GWH-05).
|
||||||
|
*
|
||||||
|
* Altbestand: alle Zeilen mit `position = 0` (vor diesem Plan gab es
|
||||||
|
* keine Sortierung) normalisiert sich beim ERSTEN Aufruf zu `0..n-1` —
|
||||||
|
* kein Migrations- oder Sonderpfad noetig.
|
||||||
|
*/
|
||||||
|
async reorder(tenantId: string, userId: string, dto: ReorderFavoritesDto) {
|
||||||
|
if (new Set(dto.ids).size !== dto.ids.length) {
|
||||||
|
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||||
|
}
|
||||||
|
|
||||||
|
return withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||||
|
const existing = await tx.favoriteLink.findMany({
|
||||||
|
where: { userId, widgetId: dto.widgetId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const existingIds = new Set(existing.map((r: { id: string }) => r.id));
|
||||||
|
|
||||||
|
if (
|
||||||
|
existing.length !== dto.ids.length ||
|
||||||
|
dto.ids.some((id) => !existingIds.has(id))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, id] of dto.ids.entries()) {
|
||||||
|
const { count } = await tx.favoriteLink.updateMany({
|
||||||
|
where: { id, userId, widgetId: dto.widgetId },
|
||||||
|
data: { position: index },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count !== 1) {
|
||||||
|
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.favoriteLink.findMany({
|
||||||
|
where: { userId, widgetId: dto.widgetId },
|
||||||
|
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
* Fetches the raw bytes of a favorite's stored icon, scoped to the
|
||||||
* requesting user (T-08-06 — same ownership check as update/remove).
|
* requesting user (T-08-06 — same ownership check as update/remove).
|
||||||
|
|||||||
@@ -1,4 +1,22 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 260917-jdd — `undici` wird gemockt, damit KEIN Test tatsaechlich ins Netz
|
||||||
|
* geht: die produktive Datei ruft ab jetzt `undiciFetch` statt des globalen
|
||||||
|
* `fetch` auf, mit einem Modul-Singleton-`Agent` als `dispatcher`. Die
|
||||||
|
* Mock-Klasse zeichnet nur die uebergebenen `options` auf; `fetch` delegiert
|
||||||
|
* ZUR LAUFZEIT (Pfeilfunktion, nicht beim Laden aufgeloest) an
|
||||||
|
* `globalThis.fetch`, damit alle bestehenden `vi.stubGlobal('fetch', …)`-
|
||||||
|
* Tests wortgleich gruen bleiben.
|
||||||
|
*/
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
Agent: class Agent {
|
||||||
|
constructor(public readonly options: unknown) {}
|
||||||
|
},
|
||||||
|
fetch: (...args: unknown[]) => (globalThis.fetch as any)(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Agent } from 'undici';
|
||||||
import {
|
import {
|
||||||
IconDiscoveryService,
|
IconDiscoveryService,
|
||||||
isPublicHttpUrl,
|
isPublicHttpUrl,
|
||||||
@@ -203,3 +221,69 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
|
|||||||
expect(typeof result).toBe('string');
|
expect(typeof result).toBe('string');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discoverFavoriteIconUrl uebergibt den tolerante-TLS-Agent als dispatcher und redirect: manual', async () => {
|
||||||
|
const html = '<html><head><link rel="icon" href="https://ctl.de/fav.png" /></head></html>';
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get: (n: string) =>
|
||||||
|
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||||
|
},
|
||||||
|
text: async () => html,
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
await service.discoverFavoriteIconUrl('http://8.8.8.8');
|
||||||
|
|
||||||
|
const init = fetchSpy.mock.calls[0][1];
|
||||||
|
expect(init.dispatcher).toBeInstanceOf(Agent);
|
||||||
|
expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } });
|
||||||
|
expect(init.redirect).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchIconBytes uebergibt denselben tolerante-TLS-Agent als dispatcher und redirect: manual', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
await service.fetchIconBytes('http://8.8.8.8/favicon.ico');
|
||||||
|
|
||||||
|
const init = fetchSpy.mock.calls[0][1];
|
||||||
|
expect(init.dispatcher).toBeInstanceOf(Agent);
|
||||||
|
expect(init.dispatcher.options).toEqual({ connect: { rejectUnauthorized: false } });
|
||||||
|
expect(init.redirect).toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discovery und fetchIconBytes teilen DENSELBEN Agent (Modul-Singleton)', async () => {
|
||||||
|
const html = '<html><head></head></html>';
|
||||||
|
const fetchSpy = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
get: (n: string) =>
|
||||||
|
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||||
|
},
|
||||||
|
text: async () => html,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(mockResponse({ contentType: 'image/png' }));
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
await service.discoverFavoriteIconUrl('http://8.8.8.8');
|
||||||
|
await service.fetchIconBytes('http://8.8.8.8/favicon.ico');
|
||||||
|
|
||||||
|
const calls = fetchSpy.mock.calls;
|
||||||
|
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { lookup } from 'dns/promises';
|
import { lookup } from 'dns/promises';
|
||||||
import { isIP } from 'net';
|
import { isIP } from 'net';
|
||||||
|
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||||
@@ -12,6 +13,9 @@ import { isIP } from 'net';
|
|||||||
* - 4000 ms AbortController timeout per request
|
* - 4000 ms AbortController timeout per request
|
||||||
* - 200 000 character HTML cap to prevent memory exhaustion (T-08-09)
|
* - 200 000 character HTML cap to prevent memory exhaustion (T-08-09)
|
||||||
* - Blocked hostnames: localhost, .local, 0.0.0.0
|
* - Blocked hostnames: localhost, .local, 0.0.0.0
|
||||||
|
* - 260917-jdd: Zertifikatsfehler des Zielhosts werden toleriert (siehe
|
||||||
|
* LENIENT_TLS_AGENT unten) — DNS-Pruefung, Redirect-Limit, Timeout und
|
||||||
|
* Groessendeckel bleiben davon unberuehrt.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FALLBACK_ICON_PATH = '/favicon.ico';
|
const FALLBACK_ICON_PATH = '/favicon.ico';
|
||||||
@@ -21,6 +25,27 @@ const MAX_REDIRECTS = 2;
|
|||||||
const MAX_HTML_CHARS = 200000;
|
const MAX_HTML_CHARS = 200000;
|
||||||
const MAX_ICON_BYTES = 1_000_000;
|
const MAX_ICON_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||||
|
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||||
|
* Favoriten nicht verhindern. Dieser Dispatcher gilt AUSSCHLIESSLICH fuer
|
||||||
|
* die beiden Aufrufe in dieser Datei (Dispatcher pro Aufruf, keine
|
||||||
|
* prozessweite Abschaltung der Zertifikatspruefung — insbesondere NICHT
|
||||||
|
* ueber die Node-Umgebungsvariable, die mit NODE_TLS_ beginnt).
|
||||||
|
*
|
||||||
|
* Der Dispatcher wirkt nur zusammen mit undicis EIGENEM `fetch` — Nodes
|
||||||
|
* globales `fetch` ignoriert einen Agent aus dem npm-Paket (andere Klasse,
|
||||||
|
* Node 24 buendelt intern undici 7.25.0). Gemessen 2026-09-17 gegen
|
||||||
|
* self-signed.badssl.com: `undiciFetch(url, { dispatcher: new Agent(...) })`
|
||||||
|
* -> Status 200; `globalThis.fetch` derselben URL -> DEPTH_ZERO_SELF_SIGNED_CERT.
|
||||||
|
* Deshalb der Modulimport oben statt des globalen `fetch`.
|
||||||
|
*
|
||||||
|
* DNS-Pruefung (isPublicHttpUrl), Redirect-Limit (MAX_REDIRECTS), Timeout
|
||||||
|
* und Groessendeckel (MAX_ICON_BYTES/MAX_HTML_CHARS) bleiben davon
|
||||||
|
* unberuehrt (T-JDD-01).
|
||||||
|
*/
|
||||||
|
const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
|
||||||
|
|
||||||
type FetchHtmlResult = {
|
type FetchHtmlResult = {
|
||||||
html: string;
|
html: string;
|
||||||
finalUrl: string;
|
finalUrl: string;
|
||||||
@@ -243,7 +268,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
async function fetchWithRedirectGuard(
|
async function fetchWithRedirectGuard(
|
||||||
pageUrl: URL,
|
pageUrl: URL,
|
||||||
options: { accept: string; timeoutMs: number; userAgent?: string },
|
options: { accept: string; timeoutMs: number; userAgent?: string },
|
||||||
): Promise<{ response: Response; finalUrl: URL } | null> {
|
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
||||||
let currentUrl = pageUrl;
|
let currentUrl = pageUrl;
|
||||||
|
|
||||||
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||||
@@ -255,7 +280,8 @@ async function fetchWithRedirectGuard(
|
|||||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(currentUrl.toString(), {
|
const response = await undiciFetch(currentUrl.toString(), {
|
||||||
|
dispatcher: LENIENT_TLS_AGENT, // 260917-jdd: siehe Kommentar an der Konstante
|
||||||
redirect: 'manual', // SSRF: follow manually so each hop is re-validated
|
redirect: 'manual', // SSRF: follow manually so each hop is re-validated
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
Generated
+11
-6
@@ -82,7 +82,7 @@ importers:
|
|||||||
version: 7.0.1
|
version: 7.0.1
|
||||||
ews-javascript-api:
|
ews-javascript-api:
|
||||||
specifier: 0.15.3
|
specifier: 0.15.3
|
||||||
version: 0.15.3
|
version: 0.15.3(undici@7.28.0)
|
||||||
fast-xml-parser:
|
fast-xml-parser:
|
||||||
specifier: ^5.10.1
|
specifier: ^5.10.1
|
||||||
version: 5.10.1
|
version: 5.10.1
|
||||||
@@ -128,6 +128,9 @@ importers:
|
|||||||
tsdav:
|
tsdav:
|
||||||
specifier: 2.2.2
|
specifier: 2.2.2
|
||||||
version: 2.2.2
|
version: 2.2.2
|
||||||
|
undici:
|
||||||
|
specifier: 7.28.0
|
||||||
|
version: 7.28.0
|
||||||
xlsx:
|
xlsx:
|
||||||
specifier: ^0.18.5
|
specifier: ^0.18.5
|
||||||
version: 0.18.5
|
version: 0.18.5
|
||||||
@@ -6190,11 +6193,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
extend: 3.0.2
|
extend: 3.0.2
|
||||||
|
|
||||||
'@ewsjs/xhr@3.1.3':
|
'@ewsjs/xhr@3.1.3(undici@7.28.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ewsjs/ntlm-client': 3.0.1
|
'@ewsjs/ntlm-client': 3.0.1
|
||||||
axios: 1.18.1
|
axios: 1.18.1
|
||||||
http-cookie-agent: 5.0.4(tough-cookie@4.1.4)
|
http-cookie-agent: 5.0.4(tough-cookie@4.1.4)(undici@7.28.0)
|
||||||
tough-cookie: 4.1.4
|
tough-cookie: 4.1.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- deasync
|
- deasync
|
||||||
@@ -8511,10 +8514,10 @@ snapshots:
|
|||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
ews-javascript-api@0.15.3:
|
ews-javascript-api@0.15.3(undici@7.28.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@azure/msal-node': 2.16.3
|
'@azure/msal-node': 2.16.3
|
||||||
'@ewsjs/xhr': 3.1.3
|
'@ewsjs/xhr': 3.1.3(undici@7.28.0)
|
||||||
'@xmldom/xmldom': 0.8.13
|
'@xmldom/xmldom': 0.8.13
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
moment: 2.30.1
|
moment: 2.30.1
|
||||||
@@ -8987,10 +8990,12 @@ snapshots:
|
|||||||
entities: 4.5.0
|
entities: 4.5.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
http-cookie-agent@5.0.4(tough-cookie@4.1.4):
|
http-cookie-agent@5.0.4(tough-cookie@4.1.4)(undici@7.28.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
tough-cookie: 4.1.4
|
tough-cookie: 4.1.4
|
||||||
|
optionalDependencies:
|
||||||
|
undici: 7.28.0
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user