Compare commits

...

4 Commits

Author SHA1 Message Date
99467f1379 fix(antispam): harden spam guard with signed form tokens and proxy-safe IP extraction
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 19s
Build & Deploy / 🧪 QA (push) Successful in 1m17s
Build & Deploy / 🏗️ Build (push) Successful in 2m33s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 51s
Build & Deploy / 🔔 Notify (push) Successful in 2s
- Rate limit now uses the last X-Forwarded-For hop (appended by Traefik)
  instead of the client-controlled first hop, which bots rotated freely
- Time-trap anchored to server time via HMAC-signed form token
  (FORM_TOKEN_SECRET) instead of client-supplied form_loaded_at
- Fix RequestQuoteForm regression: FormData was built from scratch without
  honeypot/token hidden inputs and inputs lacked name attributes, so every
  legitimate quote request was silently blocked as spam
2026-09-02 10:06:07 +02:00
6b3f9b9b25 chore(gitignore): ignore .kilo local agent config and debug artifacts
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 20s
Build & Deploy / 🧪 QA (push) Successful in 1m21s
Build & Deploy / 🏗️ Build (push) Successful in 2m24s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 48s
Build & Deploy / 🔔 Notify (push) Successful in 2s
2026-09-01 17:17:46 +02:00
2c51831a81 feat(antispam): add server-side contact form spam guard (honeypot, time-trap, rate limit, link cap) 2026-09-01 17:15:40 +02:00
db0963a7db chore(test): remove non-business logic config tests
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 25s
Build & Deploy / 🧪 QA (push) Successful in 1m24s
Build & Deploy / 🏗️ Build (push) Successful in 2m37s
Build & Deploy / 🚀 Deploy (push) Successful in 26s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 49s
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-08-17 18:31:53 +02:00
13 changed files with 419 additions and 50 deletions

View File

@@ -53,6 +53,9 @@ GATEKEEPER_PASSWORD=klz2026
# Required for the Payload CMS AI Chat Agent # Required for the Payload CMS AI Chat Agent
MISTRAL_API_KEY= MISTRAL_API_KEY=
# Secret used to sign contact-form anti-spam tokens (server-anchored time-trap)
FORM_TOKEN_SECRET=
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Payload Infrastructure (Dockerized) # Payload Infrastructure (Dockerized)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────

7
.gitignore vendored
View File

@@ -44,3 +44,10 @@ next-env.d.ts
# scratch # scratch
scratch/ scratch/
# kilo (local agent config & session data)
.kilo/
# debug artifacts
contact-page-debug.html
*.debug.html

View File

@@ -4,6 +4,16 @@ import { sendEmail } from '@/lib/mail/mailer';
import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail'; import { render, ContactFormNotification, ConfirmationMessage } from '@mintel/mail';
import React from 'react'; import React from 'react';
import { getServerAppServices } from '@/lib/services/create-services.server'; import { getServerAppServices } from '@/lib/services/create-services.server';
import { createFormToken } from '@/lib/antispam/form-token';
import { parseClientIp } from '@/lib/antispam/client-ip';
/**
* Issues a server-signed form token used by the anti-spam guard to prove the
* submission originates from a real page load (server-anchored time-trap).
*/
export async function issueFormTokenAction(): Promise<string> {
return createFormToken(Date.now());
}
export async function sendContactFormAction(formData: FormData) { export async function sendContactFormAction(formData: FormData) {
const services = getServerAppServices(); const services = getServerAppServices();
@@ -25,11 +35,23 @@ export async function sendContactFormAction(formData: FormData) {
// Track attempt // Track attempt
services.analytics.track('contact-form-attempt'); services.analytics.track('contact-form-attempt');
// Anti-spam Honeypot Check // Anti-spam guard: honeypot, signed form token (server-anchored time-trap), email validation, link limit, IP rate limit
const honeypot = formData.get('company_website') as string; const { checkContactSubmission } = await import('@/lib/antispam/contact-guard');
if (honeypot) { const verdict = checkContactSubmission({
logger.warn('Spam detected via honeypot in contact request', { email: formData.get('email') }); honeypot: (formData.get('company_website') as string) || null,
// Silently succeed to fool the bot without doing actual work formToken: (formData.get('form_token') as string) || null,
now: Date.now(),
ip: parseClientIp(requestHeaders.get('x-forwarded-for')),
email: (formData.get('email') as string) || null,
message: (formData.get('message') as string) || null,
});
if (!verdict.allowed) {
logger.warn('Spam blocked by anti-spam guard', {
reason: verdict.reason,
email: formData.get('email'),
});
// Silently succeed to fool bots without doing actual work
return { success: true }; return { success: true };
} }

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui'; import { Button, Heading, Card, Input, Textarea, Label } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics'; import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events'; import { AnalyticsEvents } from '@/components/analytics/analytics-events';
@@ -12,6 +12,18 @@ export default function ContactForm() {
const { trackEvent } = useAnalytics(); const { trackEvent } = useAnalytics();
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false); const [hasStarted, setHasStarted] = useState(false);
const [formToken, setFormToken] = useState<string | null>(null);
// Server-signed anti-spam token: proves the submission comes from a real page load
React.useEffect(() => {
let cancelled = false;
issueFormTokenAction().then((token) => {
if (!cancelled) setFormToken(token);
});
return () => {
cancelled = true;
};
}, []);
const handleFocus = (fieldId: string) => { const handleFocus = (fieldId: string) => {
// Initial form start // Initial form start
@@ -152,6 +164,8 @@ export default function ContactForm() {
style={{ display: 'none' }} style={{ display: 'none' }}
aria-hidden="true" aria-hidden="true"
/> />
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
<input type="hidden" name="form_token" value={formToken ?? ''} />
<div className="space-y-1 md:space-y-2"> <div className="space-y-1 md:space-y-2">
<Label htmlFor="contact-name">{t('form.name')}</Label> <Label htmlFor="contact-name">{t('form.name')}</Label>
<Input <Input

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Input, Textarea, Button } from '@/components/ui'; import { Input, Textarea, Button } from '@/components/ui';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
import { useAnalytics } from '@/components/analytics/useAnalytics'; import { useAnalytics } from '@/components/analytics/useAnalytics';
import { AnalyticsEvents } from '@/components/analytics/analytics-events'; import { AnalyticsEvents } from '@/components/analytics/analytics-events';
@@ -18,6 +18,18 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
const [request, setRequest] = useState(''); const [request, setRequest] = useState('');
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [hasStarted, setHasStarted] = useState(false); const [hasStarted, setHasStarted] = useState(false);
const [formToken, setFormToken] = useState<string | null>(null);
// Server-signed anti-spam token: proves the submission comes from a real page load
React.useEffect(() => {
let cancelled = false;
issueFormTokenAction().then((token) => {
if (!cancelled) setFormToken(token);
});
return () => {
cancelled = true;
};
}, []);
const handleFocus = (fieldId: string) => { const handleFocus = (fieldId: string) => {
// Initial form start // Initial form start
@@ -38,14 +50,13 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
}); });
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setStatus('submitting'); setStatus('submitting');
const formData = new FormData(); // Build from the real form element so honeypot + form_token hidden inputs are included
const formData = new FormData(e.currentTarget);
formData.append('name', 'Product Inquiry'); // Default name for product inquiries formData.append('name', 'Product Inquiry'); // Default name for product inquiries
formData.append('email', email);
formData.append('message', request);
formData.append('productName', productName); formData.append('productName', productName);
try { try {
@@ -172,8 +183,10 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
tabIndex={-1} tabIndex={-1}
autoComplete="off" autoComplete="off"
style={{ display: 'none' }} style={{ display: 'none' }}
aria-hidden="true" aria-hidden="true"
/> />
{/* Anti-spam time-trap anchor: server-signed token issued at page load */}
<input type="hidden" name="form_token" value={formToken ?? ''} />
<div className="space-y-2 !mt-0"> <div className="space-y-2 !mt-0">
<div className="space-y-1 !mt-0"> <div className="space-y-1 !mt-0">
@@ -183,6 +196,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
<Input <Input
type="email" type="email"
id={emailId} id={emailId}
name="email"
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
@@ -198,6 +212,7 @@ export default function RequestQuoteForm({ productName }: RequestQuoteFormProps)
</label> </label>
<Textarea <Textarea
id={requestId} id={requestId}
name="message"
required required
rows={3} rows={3}
value={request} value={request}

View File

@@ -3,10 +3,22 @@
import * as React from 'react'; import * as React from 'react';
import { m } from 'framer-motion'; import { m } from 'framer-motion';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { sendContactFormAction } from '@/app/actions/contact'; import { sendContactFormAction, issueFormTokenAction } from '@/app/actions/contact';
export function ContactForm() { export function ContactForm() {
const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle'); const [status, setStatus] = React.useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [formToken, setFormToken] = React.useState<string | null>(null);
// Server-signed anti-spam token: proves the submission comes from a real page load
React.useEffect(() => {
let cancelled = false;
issueFormTokenAction().then((token) => {
if (!cancelled) setFormToken(token);
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@@ -132,6 +144,7 @@ export function ContactForm() {
</div> </div>
<input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" /> <input type="text" name="company_website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input type="hidden" name="form_token" value={formToken ?? ''} />
<Button <Button
type="submit" type="submit"

18
lib/antispam/client-ip.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Extracts the client IP from the X-Forwarded-For header.
*
* Our edge proxy (Traefik) APPENDS the real peer IP to any client-supplied
* chain, so the LAST hop is the only trustworthy entry. The first hop is
* attacker-controlled: reading it lets bots rotate fake IPs per request and
* defeat IP-based rate limiting entirely.
*/
export function parseClientIp(xForwardedFor: string | null | undefined): string | null {
if (!xForwardedFor) {
return null;
}
const hops = xForwardedFor
.split(',')
.map((hop) => hop.trim())
.filter(Boolean);
return hops.length > 0 ? hops[hops.length - 1] : null;
}

View File

@@ -0,0 +1,91 @@
/**
* Server-side anti-spam guard for contact form submissions.
* Pure, dependency-free, in-memory rate limiting (sufficient for a single app instance).
*/
import { verifyFormToken } from './form-token';
export const MIN_FILL_TIME_MS = 3_000;
export const RATE_LIMIT_MAX_SUBMISSIONS = 3;
export const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1_000;
export const MAX_MESSAGE_LINKS = 5;
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const LINK_PATTERN = /(https?:\/\/|www\.)\S+/gi;
export type SpamReason =
| 'honeypot'
| 'invalid_token'
| 'expired_token'
| 'too_fast'
| 'invalid_email'
| 'too_many_links'
| 'rate_limited';
export interface ContactSubmissionInput {
honeypot: string | null;
formToken: string | null;
now: number;
ip: string | null;
email: string | null;
message: string | null;
}
export interface ContactSubmissionVerdict {
allowed: boolean;
reason?: SpamReason;
}
const submissionTimestampsByIp = new Map<string, number[]>();
export function resetRateLimiter(): void {
submissionTimestampsByIp.clear();
}
function isRateLimited(ip: string, now: number): boolean {
const timestamps = (submissionTimestampsByIp.get(ip) ?? []).filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS,
);
if (timestamps.length >= RATE_LIMIT_MAX_SUBMISSIONS) {
submissionTimestampsByIp.set(ip, timestamps);
return true;
}
timestamps.push(now);
submissionTimestampsByIp.set(ip, timestamps);
return false;
}
export function checkContactSubmission(input: ContactSubmissionInput): ContactSubmissionVerdict {
if (input.honeypot) {
return { allowed: false, reason: 'honeypot' };
}
// Server-signed token: rejects direct POSTs that never loaded the form and
// anchors the time-trap to server-verified issuance time.
const token = verifyFormToken(input.formToken, input.now);
if (!token.ok) {
return {
allowed: false,
reason: token.reason === 'expired' ? 'expired_token' : 'invalid_token',
};
}
if (input.now - token.issuedAt! < MIN_FILL_TIME_MS) {
return { allowed: false, reason: 'too_fast' };
}
if (!input.email || !EMAIL_PATTERN.test(input.email)) {
return { allowed: false, reason: 'invalid_email' };
}
const message = input.message ?? '';
if ((message.match(LINK_PATTERN) ?? []).length > MAX_MESSAGE_LINKS) {
return { allowed: false, reason: 'too_many_links' };
}
if (input.ip && isRateLimited(input.ip, input.now)) {
return { allowed: false, reason: 'rate_limited' };
}
return { allowed: true };
}

View File

@@ -0,0 +1,64 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Server-signed form token: proves a submission originates from a real page
* load and anchors the time-trap to SERVER time instead of a client-supplied
* timestamp (which bots can forge arbitrarily).
*
* Token format: `<issuedAtMs>.<hmac-sha256(issuedAt, secret)>` (base64url).
*/
export const MAX_FORM_AGE_MS = 24 * 60 * 60 * 1000;
export type FormTokenFailureReason = 'malformed' | 'forged' | 'expired';
export interface FormTokenVerification {
ok: boolean;
issuedAt?: number;
reason?: FormTokenFailureReason;
}
export function getFormTokenSecret(): string {
return process.env.FORM_TOKEN_SECRET || 'insecure-dev-form-token-secret';
}
function sign(issuedAt: number, secret: string): string {
return createHmac('sha256', secret).update(String(issuedAt)).digest('base64url');
}
export function createFormToken(now: number, secret: string = getFormTokenSecret()): string {
return `${now}.${sign(now, secret)}`;
}
export function verifyFormToken(
token: string | null,
now: number,
secret: string = getFormTokenSecret(),
): FormTokenVerification {
if (!token) {
return { ok: false, reason: 'malformed' };
}
const parts = token.split('.');
if (parts.length !== 2) {
return { ok: false, reason: 'malformed' };
}
const issuedAt = Number(parts[0]);
if (!Number.isFinite(issuedAt) || issuedAt <= 0) {
return { ok: false, reason: 'malformed' };
}
const expected = Buffer.from(sign(issuedAt, secret));
const provided = Buffer.from(parts[1]);
if (expected.length !== provided.length || !timingSafeEqual(expected, provided)) {
return { ok: false, reason: 'forged' };
}
const age = now - issuedAt;
if (age < 0 || age > MAX_FORM_AGE_MS) {
return { ok: false, reason: 'expired' };
}
return { ok: true, issuedAt };
}

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { checkContactSubmission, resetRateLimiter } from '@/lib/antispam/contact-guard';
import { parseClientIp } from '@/lib/antispam/client-ip';
import { createFormToken } from '@/lib/antispam/form-token';
process.env.FORM_TOKEN_SECRET = 'test-secret';
const NOW = 1_000_000_000_000;
const TOKEN_SECRET = 'test-secret';
const validInput = () => ({
honeypot: '',
formToken: createFormToken(NOW - 10_000, TOKEN_SECRET),
now: NOW,
ip: '1.2.3.4',
email: 'customer@example.com',
message: 'I would like a quote for 50 cables.',
});
describe('client IP extraction from X-Forwarded-For', () => {
it('uses the last hop appended by our own proxy, not the client-controlled first hop', () => {
expect(parseClientIp('203.0.113.7, 198.51.100.2')).toBe('198.51.100.2');
});
it('handles a single hop', () => {
expect(parseClientIp('203.0.113.7')).toBe('203.0.113.7');
});
it('returns null for missing or empty headers', () => {
expect(parseClientIp(null)).toBeNull();
expect(parseClientIp('')).toBeNull();
expect(parseClientIp(' ')).toBeNull();
});
});
describe('contact form anti-spam guard', () => {
beforeEach(() => resetRateLimiter());
it('allows a legitimate submission', () => {
const result = checkContactSubmission(validInput());
expect(result.allowed).toBe(true);
});
it('blocks submissions where the honeypot field is filled', () => {
const result = checkContactSubmission({ ...validInput(), honeypot: 'http://spam.example' });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('honeypot');
});
it('blocks submissions without a form token (direct POST without loading the form)', () => {
const result = checkContactSubmission({ ...validInput(), formToken: null });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid_token');
});
it('blocks submissions with a forged form token', () => {
const result = checkContactSubmission({ ...validInput(), formToken: '1234567890.forged' });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid_token');
});
it('blocks submissions sent faster than a human could fill the form', () => {
const freshToken = createFormToken(NOW - 1_000, TOKEN_SECRET);
const result = checkContactSubmission({ ...validInput(), formToken: freshToken });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('too_fast');
});
it('blocks submissions with a stale token (page loaded more than a day ago)', () => {
const staleToken = createFormToken(NOW - 25 * 60 * 60 * 1000, TOKEN_SECRET);
const result = checkContactSubmission({ ...validInput(), formToken: staleToken });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('expired_token');
});
it('blocks submissions with an invalid email address', () => {
const result = checkContactSubmission({ ...validInput(), email: 'not-an-email' });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('invalid_email');
});
it('blocks messages containing more than 5 links (typical SEO spam)', () => {
const spammy = Array.from({ length: 6 }, (_, i) => `https://spam${i}.example`).join(' ');
const result = checkContactSubmission({ ...validInput(), message: spammy });
expect(result.allowed).toBe(false);
expect(result.reason).toBe('too_many_links');
});
it('rate-limits an IP after 3 submissions within 10 minutes', () => {
for (let i = 0; i < 3; i++) {
expect(checkContactSubmission(validInput()).allowed).toBe(true);
}
const result = checkContactSubmission(validInput());
expect(result.allowed).toBe(false);
expect(result.reason).toBe('rate_limited');
});
it('does not rate-limit different IPs independently', () => {
for (let i = 0; i < 3; i++) {
checkContactSubmission(validInput());
}
const result = checkContactSubmission({ ...validInput(), ip: '5.6.7.8' });
expect(result.allowed).toBe(true);
});
});

View File

@@ -1,15 +0,0 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
describe('Docker Configuration Project Name Integrity', () => {
it('ensures docker-compose.yml has no legacy klz project fallbacks', () => {
const composeContent = fs.readFileSync(path.resolve(process.cwd(), 'docker-compose.yml'), 'utf-8');
expect(composeContent).not.toContain('klz');
});
it('ensures docker-compose.dev.yml has no legacy klz project fallbacks', () => {
const devComposeContent = fs.readFileSync(path.resolve(process.cwd(), 'docker-compose.dev.yml'), 'utf-8');
expect(devComposeContent).not.toContain('klz');
});
});

53
tests/form-token.test.ts Normal file
View File

@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import {
createFormToken,
verifyFormToken,
MAX_FORM_AGE_MS,
} from '@/lib/antispam/form-token';
const SECRET = 'test-secret';
const NOW = 1_000_000_000_000;
describe('signed form token', () => {
it('issues a token that verifies with its issuance timestamp', () => {
const token = createFormToken(NOW, SECRET);
const result = verifyFormToken(token, NOW + 10_000, SECRET);
expect(result.ok).toBe(true);
expect(result.issuedAt).toBe(NOW);
});
it('rejects a token that was tampered with', () => {
const token = createFormToken(NOW, SECRET);
const [issuedAt, signature] = token.split('.');
const tampered = `${issuedAt}.${signature.slice(0, -2)}xx`;
const result = verifyFormToken(tampered, NOW + 10_000, SECRET);
expect(result.ok).toBe(false);
});
it('rejects a token forged with a different secret', () => {
const token = createFormToken(NOW, 'attacker-secret');
const result = verifyFormToken(token, NOW + 10_000, SECRET);
expect(result.ok).toBe(false);
});
it('rejects a token with a manipulated issuance timestamp', () => {
const token = createFormToken(NOW, SECRET);
const [, signature] = token.split('.');
const forged = `${NOW - 60_000}.${signature}`;
const result = verifyFormToken(forged, NOW, SECRET);
expect(result.ok).toBe(false);
});
it('rejects malformed tokens', () => {
expect(verifyFormToken('', NOW, SECRET).ok).toBe(false);
expect(verifyFormToken('garbage', NOW, SECRET).ok).toBe(false);
expect(verifyFormToken('123.456.789', NOW, SECRET).ok).toBe(false);
});
it('rejects tokens older than the maximum form age', () => {
const token = createFormToken(NOW, SECRET);
const result = verifyFormToken(token, NOW + MAX_FORM_AGE_MS + 1, SECRET);
expect(result.ok).toBe(false);
expect(result.reason).toBe('expired');
});
});

View File

@@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest';
import { config } from '../proxy';
describe('Proxy Middleware Config Matcher', () => {
const matcherRegex = new RegExp(`^${config.matcher[0]}`);
it('matches localized page routes for next-intl processing', () => {
expect(matcherRegex.test('/de')).toBe(true);
expect(matcherRegex.test('/en/contact')).toBe(true);
expect(matcherRegex.test('/karriere')).toBe(true);
});
it('excludes static assets with any file extension including uppercase .JPG and subdirectories', () => {
// These should NOT match the middleware (expect test to be false so middleware is bypassed)
expect(matcherRegex.test('/assets/photos/DJI_0048.JPG')).toBe(false);
expect(matcherRegex.test('/assets/videos/web/hero-kabelpflug-poster.jpg')).toBe(false);
expect(matcherRegex.test('/assets/logo-white.png')).toBe(false);
expect(matcherRegex.test('/_next/image')).toBe(false);
});
});