Files
scan-receipts/tests/security/ai_usage_cap.test.ts
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
Brings the working codebase (Next.js app, auth system, Stripe billing,
Docker/deploy config, tests, docs) into version control on top of the
placeholder initial commit, and adds account self-deletion (Danger Zone
in Settings, password + typed-email confirmation, cascading DB cleanup,
Stripe cancellation) per GDPR right-to-erasure.

Excludes local build caches, node_modules, and internal agent scratch
files; .gitignore hardened to keep those out going forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 20:59:04 +02:00

269 lines
11 KiB
TypeScript

/**
* Daily AI-Usage Cap (src/lib/ai/usage.ts) — rein logische Tests.
*
* Kein Netzwerk, keine Datenbank: geprüft wird ausschließlich der in-memory
* 24h-Fenster-Limiter (Pre-Check + Record-nach-Extraktion). Die Scan-Route
* selbst wird hier NICHT aufgerufen (CSRF-/DB-/Extraction-Abhängigkeit) — die
* pure Limiter-Logik ist die vertragliche Einheit.
*
* Determinismus: Alle Fenster-Aussagen laufen über das injizierbare `now`,
* echte Uhrzeit wird in keinem Test verwendet. Jeder Test nutzt eigene Keys,
* damit die modul-globale Map keine Suite-übergreifenden Nebeneffekte hat.
*/
import { describe, test, expect, runAllTests } from "../e2e/runner";
import {
DAILY_SCAN_LIMIT,
DAILY_GUEST_SCAN_LIMIT,
DAILY_PRO_SCAN_LIMIT,
HOURLY_SCAN_LIMIT,
checkDailyUsage,
dailyScanLimitFor,
recordUsage,
usageKeyForUser,
usageKeyForGuest,
} from "../../src/lib/ai/usage";
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
/** Eindeutiger Key pro Testaufruf — isoliert Fenster über die Testgrenzen hinweg. */
let keySeq = 0;
const nextKey = (prefix = "usage") => `${prefix}_${Date.now()}_${keySeq++}`;
// ---------------------------------------------------------------------------
// 1. Pre-Check-Semantik (checkDailyUsage)
// ---------------------------------------------------------------------------
describe("checkDailyUsage — Pre-Check-Semantik", () => {
test("C-1: ohne Verbrauch erlaubt, used=0, remaining=Limit", () => {
const key = nextKey();
const r = checkDailyUsage(key, DAILY_SCAN_LIMIT);
expect(r.allowed).toBe(true);
expect(r.used).toBe(0);
expect(r.remaining).toBe(DAILY_SCAN_LIMIT);
// resetsAt liegt in der Zukunft (neues Fenster würde erst beim Record starten)
expect(r.resetsAt).toBeGreaterThan(Date.now());
});
test("C-2: erlaubt bis zum Limit; Limit erreicht → blocked (Limit+1-Einheit)", () => {
const key = nextKey();
for (let i = 1; i <= DAILY_SCAN_LIMIT; i++) {
recordUsage(key, 1);
}
const atLimit = checkDailyUsage(key, DAILY_SCAN_LIMIT);
expect(atLimit.used).toBe(DAILY_SCAN_LIMIT);
expect(atLimit.remaining).toBe(0);
expect(atLimit.allowed).toBe(false);
// Und eine Einheit davor war noch erlaubt:
const key2 = nextKey();
for (let i = 1; i <= DAILY_SCAN_LIMIT - 1; i++) {
recordUsage(key2, 1);
}
expect(checkDailyUsage(key2, DAILY_SCAN_LIMIT).allowed).toBe(true);
expect(checkDailyUsage(key2, DAILY_SCAN_LIMIT).remaining).toBe(1);
});
test("C-3: allowed hängt am explizit übergebenen Limit", () => {
const key = nextKey();
for (let i = 0; i < DAILY_GUEST_SCAN_LIMIT; i++) {
recordUsage(key, 1);
}
// Gegen das User-Limit (30) noch frei, gegen das Guest-Limit (10) blockiert:
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(true);
expect(checkDailyUsage(key, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(false);
});
test("C-3b: Pro-Cap (150) erlaubt weiter, wo Free (30) schon blockiert", () => {
const key = nextKey();
for (let i = 0; i < DAILY_SCAN_LIMIT; i++) {
recordUsage(key, 1);
}
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(false);
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).allowed).toBe(true);
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).remaining).toBe(
DAILY_PRO_SCAN_LIMIT - DAILY_SCAN_LIMIT
);
for (let i = DAILY_SCAN_LIMIT; i < DAILY_PRO_SCAN_LIMIT; i++) {
recordUsage(key, 1);
}
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).allowed).toBe(false);
expect(checkDailyUsage(key, DAILY_PRO_SCAN_LIMIT).used).toBe(DAILY_PRO_SCAN_LIMIT);
});
test("C-4: ohne explizites Limit gilt der schlüssel-abgeleitete Default", () => {
expect(checkDailyUsage(usageKeyForUser("u_a")).remaining).toBe(DAILY_SCAN_LIMIT);
expect(checkDailyUsage(usageKeyForGuest("guest_a")).remaining).toBe(DAILY_GUEST_SCAN_LIMIT);
});
test("C-5: leerer Key oder ungültiges Limit throttelt nie (Misconfiguration-Sicherheit)", () => {
const r = checkDailyUsage("", DAILY_SCAN_LIMIT);
expect(r.allowed).toBe(true);
const r2 = checkDailyUsage("some_key", 0);
expect(r2.allowed).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 2. Record-Semantik (recordUsage)
// ---------------------------------------------------------------------------
describe("recordUsage — Zähler & Fensterstart", () => {
test("R-1: erhöht den Zähler, used/remaining konsistent", () => {
const key = nextKey();
const r1 = recordUsage(key, 1);
expect(r1.used).toBe(1);
expect(r1.remaining).toBe(DAILY_SCAN_LIMIT - 1);
const r2 = recordUsage(key, 5);
expect(r2.used).toBe(6);
expect(r2.remaining).toBe(DAILY_SCAN_LIMIT - 6);
});
test("R-2: remaining wird bei 0 geklemmt", () => {
const key = nextKey();
const r = recordUsage(key, DAILY_SCAN_LIMIT + 50);
expect(r.used).toBe(DAILY_SCAN_LIMIT + 50);
expect(r.remaining).toBe(0);
});
test("R-3: Multi-Unit-Record zählt Seiten (20-Seiten-PDF = 20 Einheiten)", () => {
const key = nextKey();
const r = recordUsage(key, 20);
expect(r.used).toBe(20);
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).used).toBe(20);
});
test("R-4: ungültige/negative/0 Einheiten werden defensiv zu 1 normalisiert", () => {
const key = nextKey();
expect(recordUsage(key, 2.5).used).toBe(3); // aufgerundet
expect(recordUsage(key, -3).used).toBe(4); // negativ → 1
expect(recordUsage(key, 0).used).toBe(5);
expect(recordUsage(key, NaN).used).toBe(6);
});
test("R-5: erster Record startet das 24h-Fenster (resetsAt = now + 24h)", () => {
const key = nextKey();
const t0 = new Date("2026-03-01T12:00:00.000Z");
recordUsage(key, 1, t0);
const check = checkDailyUsage(key, DAILY_SCAN_LIMIT, new Date(t0.getTime() + 1000));
expect(check.resetsAt).toBe(t0.getTime() + DAY);
});
});
// ---------------------------------------------------------------------------
// 3. Fenster-Reset (injizierbares `now`)
// ---------------------------------------------------------------------------
describe("Fenster-Reset über injiziertes `now`", () => {
test("W-1: nach 24h+1ms ist das Limit wieder frei", () => {
const key = nextKey();
const day1 = new Date("2026-01-01T00:00:00.000Z");
for (let i = 0; i < DAILY_SCAN_LIMIT; i++) {
recordUsage(key, 1, day1);
}
// Am Tag 1 aufgebraucht:
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT, day1).allowed).toBe(false);
// Kurz vor Ablauf weiterhin blockiert:
const almostDay2 = new Date(day1.getTime() + DAY - 1000);
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT, almostDay2).allowed).toBe(false);
// 24h + 1ms später: frisches Fenster:
const day2 = new Date(day1.getTime() + DAY + 1);
const fresh = checkDailyUsage(key, DAILY_SCAN_LIMIT, day2);
expect(fresh.allowed).toBe(true);
expect(fresh.used).toBe(0);
expect(fresh.remaining).toBe(DAILY_SCAN_LIMIT);
// Record im neuen Fenster startet es neu:
const rec = recordUsage(key, 3, day2);
expect(rec.used).toBe(3);
expect(rec.remaining).toBe(DAILY_SCAN_LIMIT - 3);
});
test("W-2: checkDailyUsage mutiert nicht — mehrere Reads liefern gleiche Werte", () => {
const key = nextKey();
const t = new Date("2026-02-02T08:00:00.000Z");
recordUsage(key, 4, t);
const a = checkDailyUsage(key, DAILY_SCAN_LIMIT, t);
const b = checkDailyUsage(key, DAILY_SCAN_LIMIT, t);
expect(b).toEqual(a);
expect(a.used).toBe(4);
});
});
// ---------------------------------------------------------------------------
// 4. Key-Isolation & Key-Format
// ---------------------------------------------------------------------------
describe("Key-Isolation & Key-Format", () => {
test("K-1: User- und Guest-Key sind unabhängige Fenster", () => {
const userKey = usageKeyForUser("user-42");
const guestKey = usageKeyForGuest("guest_bucket_1");
for (let i = 0; i < DAILY_SCAN_LIMIT; i++) {
recordUsage(userKey, 1);
}
expect(checkDailyUsage(userKey, DAILY_SCAN_LIMIT).allowed).toBe(false);
// Guest unberührt:
expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(true);
expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).used).toBe(0);
// Und umgekehrt:
for (let i = 0; i < DAILY_GUEST_SCAN_LIMIT; i++) {
recordUsage(guestKey, 1);
}
expect(checkDailyUsage(guestKey, DAILY_GUEST_SCAN_LIMIT).allowed).toBe(false);
expect(checkDailyUsage(userKey, DAILY_SCAN_LIMIT).used).toBe(DAILY_SCAN_LIMIT);
});
test("K-2: usageKeyForUser/usageKeyForGuest liefern die dokumentierten Formate", () => {
expect(usageKeyForUser("u_42")).toBe("ai:user:u_42");
expect(usageKeyForGuest("guest_abc")).toBe("ai:guest:guest_abc");
// Kein Prefix-Collision-Risiko zwischen beiden Räumen:
expect(usageKeyForUser("guest_x")).toBe("ai:user:guest_x");
expect(usageKeyForGuest("u_1")).toBe("ai:guest:u_1");
});
});
// ---------------------------------------------------------------------------
// 5. Overshoot-Caveat (Seiten = AI-Calls) & Konfiguration
// ---------------------------------------------------------------------------
describe("Overshoot & Konfiguration", () => {
test("O-1: Pre-Check erlaubt, Multi-Seiten-Record sprengt das Budget — dokumentierter Overshoot", () => {
const key = nextKey();
for (let i = 0; i < 5; i++) {
recordUsage(key, 1);
}
// Pre-Check vor dem Upload: noch 25 frei → erlaubt.
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(true);
// 10-Seiten-PDF wird korrekt gebucht:
expect(recordUsage(key, 10).used).toBe(15);
// 20-Seiten-PDF: Pre-Check hätte erlaubt (15 frei), Record überschreitet das
// Budget — das ist der dokumentierte, durch Konkurrenz begrenzte Overshoot.
const overshoot = recordUsage(key, 20);
expect(overshoot.used).toBe(35);
expect(overshoot.remaining).toBe(0);
expect(checkDailyUsage(key, DAILY_SCAN_LIMIT).allowed).toBe(false);
});
test("KONF-1: Limits sind positiv; Guest < Free < Pro; HOURLY dokumentiert", () => {
expect(DAILY_SCAN_LIMIT).toBe(30);
expect(DAILY_GUEST_SCAN_LIMIT).toBe(10);
expect(DAILY_PRO_SCAN_LIMIT).toBe(150);
expect(DAILY_GUEST_SCAN_LIMIT).toBeLessThan(DAILY_SCAN_LIMIT);
expect(DAILY_SCAN_LIMIT).toBeLessThan(DAILY_PRO_SCAN_LIMIT);
expect(HOURLY_SCAN_LIMIT).toBeGreaterThanOrEqual(DAILY_SCAN_LIMIT);
});
test("KONF-2: dailyScanLimitFor — Free hat kein Daily-Cap, Pro hat 150", () => {
expect(dailyScanLimitFor({ isPro: false })).toBeNull();
expect(dailyScanLimitFor({ isPro: true })).toBe(DAILY_PRO_SCAN_LIMIT);
});
});
if (typeof require !== "undefined" && require.main === module) {
runAllTests()
.then((ok) => process.exit(ok ? 0 : 1))
.catch((err) => {
console.error("Fatal runner crash:", err);
process.exit(1);
});
}