Files
ApiCore/ApiCore-Backend/test/erp-export.e2e-spec.ts
T
Nick SzaunigandClaude Opus 5.5 fe7b61c3c1 fix(api-keys): gültige Schlüssel nie durch die Fehlversuchs-Sperre abweisen
Befunde aus dem Sicherheitsreview des Branches:

- MEDIUM: Die Sperre lief vor der Schlüsselsuche und zählte auch falsch
  aufgebaute Tokens. Wer die gemeinsame Adresse (Cluster-Ausgang, Proxy)
  nutzt oder per X-Forwarded-For vortäuscht, hätte ERPNext und den Shop
  15 Minuten aussperren können. Jetzt: erst Suche über den Hash; ein
  gültiger, nicht widerrufener, nicht abgelaufener Schlüssel kommt immer
  durch; 429 nur für unbekannte, widerrufene und abgelaufene Schlüssel;
  falsch aufgebaute Tokens sofort 401 ohne Zählung. Beim Start warnt
  ApiCore, wenn TRUST_PROXY in Produktion fehlt (im Cluster gesetzt).
- LOW: Bei vollem Speicher verdrängt der Limiter zuerst nicht gesperrte
  Einträge, gesperrte erst zuletzt.
- LOW: Bewegungs-Cursor mit Jahr außerhalb 2000-2100 ergab 500, jetzt 400.
- LOW/INFO: Vertrag legt fest, dass ERPNext Produkte und Chargen bei jedem
  Lauf vollständig neu liest (Voll-Spiegel) und Bewegungen inkrementell;
  remainingQuantity ist live, Bewegungen kommen 5 Minuten verzögert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 12:00:03 +02:00

704 lines
23 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { encodeCursor } from '../src/erp-export/erp-cursor';
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/api-keys.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface CreatedKeyBody {
apiKey: { id: string };
token: string;
}
interface PageBody<T> {
contractVersion: number;
generatedAt: string;
items: T[];
nextCursor: string | null;
hasMore: boolean;
settledUntil?: string;
}
type Item = Record<string, unknown> & { id: string };
interface HarvestYearsBody {
contractVersion: number;
years: {
year: number;
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}[];
}
const BASE = '/api/v1/export/inventar';
const ENDPOINTS = ['produkte', 'chargen', 'bewegungen', 'ernten'] as const;
/**
* Inventar-Spiegel für ERPNext (T8): nur lesend, nur mit erp-inventory:read,
* nur freigegebene Felder, stabil und seitenweise. Der ERPNext-Importer wird
* nach docs/erp-inventar-export.md gebaut und verlässt sich auf diese Form.
*/
describe('ERP-Inventar-Export (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let adminToken: string;
let readonlyToken: string;
let erpKey: string;
let shopKey: string;
let honeyProductId: string;
let candleProductId: string;
let bottlingId: string;
let productionBatchId: string;
// Laufeigener Zeitraum weit in der Vergangenheit: so finden sich die
// Bewegungen dieses Laufs zwischen denen anderer Läufe und Tests.
const movementBase = Date.UTC(2001, 0, 1) + (Date.now() % 1_000_000_000);
const at = (seconds: number) => new Date(movementBase + seconds * 1000);
const movementIds: Record<string, string> = {};
const server = () => app.getHttpServer();
// Jeder Test tritt als eigener Client auf (X-Forwarded-For, im Test wird
// ein Proxy-Hop vertraut): diese Datei stellt mehr Anfragen, als der
// globale Throttler einer einzelnen Adresse je Minute erlaubt.
let clientCounter = 0;
let clientIp = '198.51.100.1';
beforeEach(() => {
clientCounter += 1;
clientIp = `198.51.100.${clientCounter}`;
});
function get(path: string, token: string, ip = clientIp) {
return request(server())
.get(`${BASE}/${path}`)
.set('Authorization', `Bearer ${token}`)
.set('X-Forwarded-For', ip);
}
async function createKey(scopes: string[]): Promise<CreatedKeyBody> {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `ERP-Test ${suffix}`, scopes })
.expect(201);
return response.body as CreatedKeyBody;
}
async function allItems(path: string): Promise<Item[]> {
const items: Item[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query = nach ? `?limit=500&nach=${nach}` : '?limit=500';
const body = (await get(`${path}${query}`, erpKey).expect(200))
.body as PageBody<Item>;
items.push(...body.items);
if (!body.hasMore) return items;
nach = body.nextCursor;
}
throw new Error('zu viele Seiten');
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const owner = await prisma.user.create({
data: {
email: `erp-admin-${suffix}@example.com`,
passwordHash,
name: 'Erika Imkerin',
role: { connect: { key: 'ADMIN' } },
},
});
await prisma.user.create({
data: {
email: `erp-ro-${suffix}@example.com`,
passwordHash,
name: 'Nur Lesen',
role: { connect: { key: 'READONLY' } },
},
});
adminToken = (await login(app, `erp-admin-${suffix}@example.com`, password))
.accessToken;
readonlyToken = (await login(app, `erp-ro-${suffix}@example.com`, password))
.accessToken;
erpKey = (await createKey(['erp-inventory:read'])).token;
shopKey = (await createKey(['shop-sync:read', 'shop-sync:write'])).token;
const apiary = await prisma.apiary.create({
data: { name: `Waldrand ${suffix}`, notes: 'Schlüssel unter dem Stein' },
});
const harvest = await prisma.harvest.create({
data: {
apiaryId: apiary.id,
userId: owner.id,
date: new Date('2026-07-14T00:00:00.000Z'),
honeyType: 'Sommertracht',
netWeight: 41,
notes: 'Volk 7 schwach',
},
});
const extraction = await prisma.extraction.create({
data: {
date: new Date('2026-07-20T00:00:00.000Z'),
userId: owner.id,
harvests: { create: [{ harvestId: harvest.id }] },
},
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HB-${suffix}`,
extractionId: extraction.id,
honeyType: 'Sommertracht',
amount: 50,
},
});
honeyProductId = (
await prisma.product.create({
data: {
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
description: 'Interne Notiz: Lieferant Gläser Huber',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
},
})
).id;
candleProductId = (
await prisma.product.create({
data: {
name: `Bienenwachskerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
})
).id;
bottlingId = (
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId: honeyProductId,
userId: owner.id,
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarSize: 500,
jarCount: 60,
totalAmount: 30,
lotNumber: `L-ERP-${suffix}`,
},
})
).id;
productionBatchId = (
await prisma.productionBatch.create({
data: {
productId: candleProductId,
userId: owner.id,
lotNumber: `K-ERP-${suffix}`,
date: new Date('2026-09-01T00:00:00.000Z'),
quantity: 20,
notes: 'Docht von Firma Meier',
},
})
).id;
const rawLot = await prisma.rawMaterialLot.create({
data: {
kind: 'BEESWAX',
lotNumber: `W-ERP-${suffix}`,
date: new Date('2026-07-20T00:00:00.000Z'),
quantity: 1000,
},
});
const book = async (
key: string,
data: Parameters<PrismaService['batchMovement']['create']>[0]['data'],
) => {
movementIds[key] = (await prisma.batchMovement.create({ data })).id;
};
const personal = {
notes: 'an Nachbar Müller verschenkt',
userId: owner.id,
};
await book('hofladen', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
createdAt: at(1),
...personal,
});
await book('eigenbedarf', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
quantity: -2,
createdAt: at(2),
...personal,
});
await book('verkauf', {
bottlingBatchId: bottlingId,
type: 'SHOP_SALE',
quantity: -3,
createdAt: at(2),
});
await book('bruch', {
productionBatchId,
type: 'WITHDRAWAL',
reason: 'BRUCH',
quantity: -1,
createdAt: at(3),
...personal,
});
await book('korrektur', {
bottlingBatchId: bottlingId,
type: 'CORRECTION',
quantity: 1,
createdAt: at(4),
...personal,
});
await book('rohstoff', {
rawMaterialLotId: rawLot.id,
type: 'WITHDRAWAL',
reason: 'SONSTIGES',
quantity: -10,
createdAt: at(5),
});
// Gerade eben gebucht: noch innerhalb der Wartezeit.
await book('frisch', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'PROBE',
quantity: -1,
});
});
afterAll(async () => {
await app.close();
});
describe('Zugang', () => {
it.each(ENDPOINTS)('%s ist mit dem ERP-Schlüssel lesbar', async (path) => {
await get(path, erpKey).expect(200);
});
it.each(ENDPOINTS)('%s verlangt einen Schlüssel (401)', async (path) => {
await request(server()).get(`${BASE}/${path}`).expect(401);
});
it('lehnt fehlerhafte und unbekannte Schlüssel ab (401)', async () => {
await get('produkte', 'ack_nope').expect(401);
await get('produkte', `ack_abcd1234_${'Z'.repeat(43)}`).expect(401);
});
it.each(ENDPOINTS)(
'%s bleibt dem Shop-Schlüssel verschlossen (403)',
async (path) => {
await get(path, shopKey).expect(403);
},
);
it('verweigert Benutzern ohne das Recht den Export (403)', async () => {
await get('produkte', readonlyToken).expect(403);
});
it('öffnet dem ERP-Schlüssel nichts außer dem Export', async () => {
const auth = { Authorization: `Bearer ${erpKey}` };
for (const path of [
'/api/v1/shop-sync',
'/api/v1/shop-sync/stock',
'/api/v1/products',
'/api/v1/harvests',
'/api/v1/bottling-batches',
'/api/v1/export/colonies.csv',
'/api/v1/users',
'/api/v1/auth/me',
]) {
await request(server()).get(path).set(auth).expect(403);
}
await request(server())
.post('/api/v1/shop-sync/movements')
.set(auth)
.send({ requests: [] })
.expect(403);
});
it('sperrt einen widerrufenen Schlüssel sofort (401)', async () => {
const created = await createKey(['erp-inventory:read']);
await get('produkte', created.token).expect(200);
await request(server())
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await get('produkte', created.token).expect(401);
});
});
describe('Produkte', () => {
it('liefert nur die freigegebenen Felder, ohne Beschreibung', async () => {
const items = await allItems('produkte');
const honey = items.find((p) => p.id === honeyProductId);
const candle = items.find((p) => p.id === candleProductId);
expect(Object.keys(honey ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
expect(honey).toMatchObject({
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
});
expect(candle).toMatchObject({
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
});
expect(JSON.stringify(items)).not.toContain('Lieferant Gläser Huber');
});
it('blättert stabil nach id', async () => {
const first = (await get('produkte?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
expect(first.contractVersion).toBe(1);
expect(first.items).toHaveLength(1);
expect(first.hasMore).toBe(true);
const second = (
await get(`produkte?limit=1&nach=${first.nextCursor}`, erpKey).expect(
200,
)
).body as PageBody<Item>;
expect(second.items).toHaveLength(1);
expect(second.items[0].id > first.items[0].id).toBe(true);
});
});
describe('Chargen', () => {
it('liefert Abfüllung und Herstellungscharge mit Rest aus dem Bewegungsbuch', async () => {
const items = await allItems('chargen');
const bottling = items.find((b) => b.id === bottlingId);
const production = items.find((b) => b.id === productionBatchId);
expect(bottling).toEqual({
id: bottlingId,
batchType: 'BOTTLING',
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
initialQuantity: 60,
// 60 - 6 Hofladen - 2 Eigenbedarf - 3 Shop + 1 Inventur - 1 Probe
remainingQuantity: 49,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
});
expect(production).toEqual({
id: productionBatchId,
batchType: 'PRODUCTION',
lotNumber: `K-ERP-${suffix}`,
productId: candleProductId,
date: '2026-09-01T00:00:00.000Z',
bestBeforeDate: null,
initialQuantity: 20,
remainingQuantity: 19,
fillWeightGrams: null,
honeyType: null,
});
// Abfüllungen vor Herstellungschargen.
const kinds = items.map((b) => b.batchType);
expect(kinds.lastIndexOf('BOTTLING')).toBeLessThan(
kinds.indexOf('PRODUCTION'),
);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Docht von Firma Meier');
expect(raw).not.toContain('Erika Imkerin');
// Rohstoff-Chargen sind keine Ware.
expect(raw).not.toContain(`W-ERP-${suffix}`);
});
it('blättert ohne Lücken und Dubletten', async () => {
const all = (await allItems('chargen')).map((b) => b.id);
const limit = Math.max(1, Math.ceil(all.length / 3));
const seen: string[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query: string = nach
? `?limit=${limit}&nach=${nach}`
: `?limit=${limit}`;
const body = (await get(`chargen${query}`, erpKey).expect(200))
.body as PageBody<Item>;
expect(body.items.length).toBeLessThanOrEqual(limit);
seen.push(...body.items.map((b) => b.id));
nach = body.nextCursor;
if (!body.hasMore) break;
}
expect(new Set(seen).size).toBe(seen.length);
expect(seen).toEqual(
expect.arrayContaining([bottlingId, productionBatchId]),
);
// Andere Testdateien legen parallel Chargen an oder löschen welche:
// verglichen wird die Reihenfolge der Chargen, die beide Abrufe sahen.
const inBoth = new Set(all.filter((id) => seen.includes(id)));
expect(seen.filter((id) => inBoth.has(id))).toEqual(
all.filter((id) => inBoth.has(id)),
);
});
});
describe('Bewegungen', () => {
async function myMovements(): Promise<{ items: Item[]; cursor: string }> {
const mine = new Set(Object.values(movementIds));
const found: Item[] = [];
let query = `bewegungen?limit=2&seit=${at(0).toISOString()}`;
for (let page = 0; page < 40; page++) {
const body = (await get(query, erpKey).expect(200))
.body as PageBody<Item>;
found.push(...body.items.filter((m) => mine.has(m.id)));
const last = body.items[body.items.length - 1];
const passedMine =
last !== undefined &&
new Date(last.createdAt as string).getTime() > at(5).getTime();
if (!body.hasMore || passedMine) {
return { items: found, cursor: body.nextCursor! };
}
query = `bewegungen?limit=2&nach=${body.nextCursor}`;
}
throw new Error('zu viele Seiten');
}
it('liefert Entnahmen mit Grund, Shop-Verkäufe und Inventur in stabiler Reihenfolge', async () => {
const { items } = await myMovements();
// Zeitpunkt, dann id: eigenbedarf und verkauf haben denselben Zeitpunkt.
const sameTime = [movementIds.eigenbedarf, movementIds.verkauf].sort();
expect(items.map((m) => m.id)).toEqual([
movementIds.hofladen,
...sameTime,
movementIds.bruch,
movementIds.korrektur,
]);
expect(items.find((m) => m.id === movementIds.hofladen)).toEqual({
id: movementIds.hofladen,
createdAt: at(1).toISOString(),
batchType: 'BOTTLING',
batchId: bottlingId,
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
});
expect(items.find((m) => m.id === movementIds.bruch)).toMatchObject({
batchType: 'PRODUCTION',
batchId: productionBatchId,
productId: candleProductId,
reason: 'BRUCH',
quantity: -1,
});
expect(items.find((m) => m.id === movementIds.verkauf)).toMatchObject({
type: 'SHOP_SALE',
reason: null,
quantity: -3,
});
expect(items.find((m) => m.id === movementIds.korrektur)).toMatchObject({
type: 'CORRECTION',
quantity: 1,
});
});
it('lässt Rohstoffe, Notizen, Benutzer und noch offene Buchungen weg', async () => {
const { items } = await myMovements();
const ids = items.map((m) => m.id);
expect(ids).not.toContain(movementIds.rohstoff);
expect(ids).not.toContain(movementIds.frisch);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Müller');
expect(raw).not.toContain('Erika');
expect(raw).not.toContain('userId');
});
it('meldet, bis wann die Liste vollständig ist', async () => {
const before = Date.now();
const body = (await get('bewegungen?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
const settled = new Date(body.settledUntil!).getTime();
expect(settled).toBeLessThanOrEqual(before - 4 * 60 * 1000);
expect(settled).toBeGreaterThan(before - 6 * 60 * 1000);
});
it('setzt mit dem letzten Cursor fort, ohne etwas doppelt zu liefern', async () => {
const { cursor } = await myMovements();
const body = (
await get(`bewegungen?limit=500&nach=${cursor}`, erpKey).expect(200)
).body as PageBody<Item>;
const mine = new Set(Object.values(movementIds));
// Nur Bewegungen hinter dem Cursor - die eigenen lagen davor.
expect(body.items.filter((m) => mine.has(m.id))).toEqual([]);
});
});
describe('Parameter', () => {
it.each([
['produkte?limit=0'],
['produkte?limit=501'],
['produkte?limit=zwei'],
['produkte?nach=kaputt!'],
['produkte?seite=2'],
['chargen?nach=bm90LWpzb24'],
['bewegungen?seit=gestern'],
['ernten?jahr=1800'],
['ernten?jahr=dieses'],
])('lehnt %s ab (400)', async (path) => {
await get(path, erpKey).expect(400);
});
it('lehnt einen Cursor eines anderen Endpunkts ab (400)', async () => {
const productCursor = encodeCursor({ k: 'produkte', id: honeyProductId });
await get(`bewegungen?nach=${productCursor}`, erpKey).expect(400);
});
it('lehnt einen Bewegungs-Cursor mit unmöglichem Jahr ab (400 statt 500)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: '+275760-09-13T00:00:00.000Z',
id: movementIds.hofladen,
});
await get(`bewegungen?nach=${cursor}`, erpKey).expect(400);
});
it('verträgt ein fernes seit (leere Seite statt Fehler)', async () => {
const body = (
await get('bewegungen?seit=9999-12-31T00:00:00Z', erpKey).expect(200)
).body as PageBody<Item>;
expect(body.items).toEqual([]);
});
it('lehnt seit und nach zusammen ab (400)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: at(1).toISOString(),
id: movementIds.hofladen,
});
await get(
`bewegungen?seit=${at(0).toISOString()}&nach=${cursor}`,
erpKey,
).expect(400);
});
});
describe('Ernten', () => {
it('summiert das Nettogewicht eines Jahres und weist Ernten ohne Gewicht aus', async () => {
const year = 2003;
const read = async () =>
(
(await get(`ernten?jahr=${year}`, erpKey).expect(200))
.body as HarvestYearsBody
).years;
const before = await read();
expect(before).toHaveLength(1);
const owner = await prisma.user.findFirstOrThrow({
where: { email: `erp-admin-${suffix}@example.com` },
});
const apiary = await prisma.apiary.create({
data: { name: `Ernte-Stand ${suffix}` },
});
for (const [day, netWeight] of [
['2003-06-01', 12.5],
['2003-07-15', 7.25],
['2003-08-30', null],
['2004-01-01', 99],
] as const) {
await prisma.harvest.create({
data: {
apiaryId: apiary.id,
userId: owner.id,
date: new Date(`${day}T00:00:00.000Z`),
netWeight,
},
});
}
const [after] = await read();
expect(after.year).toBe(year);
expect(after.netWeightKg - before[0].netWeightKg).toBeCloseTo(19.75, 3);
expect(after.harvestCount - before[0].harvestCount).toBe(3);
expect(
after.harvestsWithoutNetWeight - before[0].harvestsWithoutNetWeight,
).toBe(1);
});
it('liefert ohne Jahr alle Jahre aufsteigend, ohne Standorte', async () => {
const body = (await get('ernten', erpKey).expect(200))
.body as HarvestYearsBody;
const years = body.years.map((y) => y.year);
expect(years).toEqual([...years].sort((a, b) => a - b));
expect(years).toContain(2026);
const raw = JSON.stringify(body);
expect(raw).not.toContain('Waldrand');
expect(raw).not.toContain('Volk 7');
});
});
describe('Fehlversuche', () => {
it('sperrt nach zu vielen unbekannten Schlüsseln weitere ungültige mit 429, andere Adressen nicht', async () => {
const attacker = '203.0.113.50';
const wrong = `ack_abcd1234_${'Y'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await get('produkte', wrong, attacker).expect(401);
}
const blocked = await get('produkte', wrong, attacker).expect(429);
expect(blocked.body).toMatchObject({
statusCode: 429,
message: expect.stringContaining('Fehlversuche') as string,
});
await get('produkte', wrong, '203.0.113.51').expect(401);
});
it('lässt einen gültigen Schlüssel auch von einer gesperrten Adresse durch', async () => {
// Sonst könnte jeder, der die gemeinsame Adresse (Cluster-Ausgang,
// Proxy) nutzt oder fälscht, ERPNext oder den Shop aussperren.
const shared = '203.0.113.60';
const wrong = `ack_abcd1234_${'X'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await get('produkte', wrong, shared).expect(401);
}
await get('produkte', wrong, shared).expect(429);
await get('produkte', erpKey, shared).expect(200);
await request(server())
.get('/api/v1/shop-sync/stock')
.set('Authorization', `Bearer ${shopKey}`)
.set('X-Forwarded-For', shared)
.expect(200);
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht', async () => {
const ip = '203.0.113.70';
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures + 5; i++) {
await get('produkte', 'ack_x', ip).expect(401);
}
await get('produkte', `ack_abcd1234_${'W'.repeat(43)}`, ip).expect(401);
});
});
});