diff --git a/components/gallery/GalleryGrid.tsx b/components/gallery/GalleryGrid.tsx index 0c32bb4..8796736 100644 --- a/components/gallery/GalleryGrid.tsx +++ b/components/gallery/GalleryGrid.tsx @@ -89,7 +89,7 @@ export function GalleryGrid({ {/* Grid */}
{passports.map((passport, i) => ( - + ))}
diff --git a/components/gallery/PassportCard.tsx b/components/gallery/PassportCard.tsx index b20cf0f..0190c2a 100644 --- a/components/gallery/PassportCard.tsx +++ b/components/gallery/PassportCard.tsx @@ -36,7 +36,7 @@ interface PassportCardProps { } export function PassportCard({ passport, index = 0 }: PassportCardProps) { - const avatarUri = getAvatarDataUri(passport.agent_id, 64); + const avatarUri = getAvatarDataUri(passport.slug || passport.agent_id || '', 64); const primaryFramework = passport.framework?.[0] ? getFrameworkById(passport.framework[0]) : null; diff --git a/components/layout/PageLayout.tsx b/components/layout/PageLayout.tsx index 7c748be..17a5184 100644 --- a/components/layout/PageLayout.tsx +++ b/components/layout/PageLayout.tsx @@ -87,7 +87,7 @@ function Header({ currentPage = "home" }: HeaderProps) { Breeds Platform diff --git a/functions/api/gallery.ts b/functions/api/gallery.ts index 003a7ae..ff03940 100644 --- a/functions/api/gallery.ts +++ b/functions/api/gallery.ts @@ -11,6 +11,8 @@ import type { AppEnv } from "../lib/types"; import { getCorsHeaders, handleCorsPreflightRequest } from "../lib/cors"; import { jsonResponse, errorResponse } from "../lib/response"; import { fetchGalleryPassports } from "../lib/gallery"; +import { checkRateLimit, getClientIp } from "../lib/rate-limit"; +import { verifyRequestToken } from "../lib/request-token"; export const onRequestOptions: PagesFunction = async (context) => { const res = handleCorsPreflightRequest(context.request); @@ -21,12 +23,46 @@ export const onRequestGet: PagesFunction = async (context) => { const { env, request } = context; const cors = getCorsHeaders(request); + // Rate limit: 10 req/min per IP — normal browsing is ~2-3 req/min + const ip = getClientIp(request); + const rateLimit = await checkRateLimit(env.APORT_ID_KV, ip, { + maxRequests: 10, + windowMs: 60_000, + }); + if (!rateLimit.allowed) { + const retryAfterSecs = Math.ceil( + Math.max(0, rateLimit.resetAt - Date.now()) / 1000, + ); + return errorResponse("Too many requests. Please try again later.", 429, { + ...cors, + "Retry-After": String(retryAfterSecs), + }); + } + + // Layer 1: Origin check — blocks cross-origin browser requests + const origin = request.headers.get("origin") || ""; + const referer = request.headers.get("referer") || ""; + const allowedHosts = ["aport.id", "localhost", "127.0.0.1"]; + const originAllowed = allowedHosts.some( + (host) => origin.includes(host) || referer.includes(host), + ); + if (!originAllowed) { + return errorResponse("Forbidden", 403, cors); + } + + // Layer 2: Time-rotating token — blocks replayed/scripted requests + const token = request.headers.get("x-ag-token") || ""; + const tokenValid = await verifyRequestToken(token); + if (!tokenValid) { + return errorResponse("Forbidden", 403, cors); + } + if (!env.APORT_API_KEY || !env.APORT_ORG_ID) { return errorResponse("Server misconfigured", 500, cors); } const url = new URL(request.url); - const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10), 100); + const limit = Math.min(parseInt(url.searchParams.get("limit") || "20", 10), 50); const offset = Math.max(parseInt(url.searchParams.get("offset") || "0", 10), 0); const role = url.searchParams.get("role") || undefined; const region = url.searchParams.get("region") || undefined; @@ -43,10 +79,15 @@ export const onRequestGet: PagesFunction = async (context) => { status, }); + // Strip agent_id from gallery listing — use slug for navigation + const safePassports = result.passports.map(({ agent_id, ...rest }) => rest); + return jsonResponse( { ok: true, - ...result, + passports: safePassports, + total: result.total, + hasMore: result.hasMore, }, 200, { diff --git a/functions/api/passport/[id].ts b/functions/api/passport/[id].ts index ba7513a..b91b22f 100644 --- a/functions/api/passport/[id].ts +++ b/functions/api/passport/[id].ts @@ -9,6 +9,61 @@ import type { AppEnv } from '../../lib/types'; import { getCorsHeaders, handleCorsPreflightRequest } from '../../lib/cors'; import { jsonResponse, errorResponse } from '../../lib/response'; import { createAPortService } from '../../lib/services/aport'; +import { checkRateLimit, getClientIp } from '../../lib/rate-limit'; + +/** + * Obfuscate an email address: "uchi.uchibeke@gmail.com" → "u*************@gmail.com" + */ +function obfuscateEmail(email: string): string { + if (!email || !email.includes('@')) return ''; + const [local, domain] = email.split('@'); + return `${local[0]}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`; +} + +/** + * Strip or obfuscate sensitive fields from passport data before returning to client. + * Walks the response recursively to catch nested email fields. + */ +function sanitizePassportData(data: any): any { + if (!data || typeof data !== 'object') return data; + + if (Array.isArray(data)) { + return data.map(sanitizePassportData); + } + + const sanitized = { ...data }; + + // Obfuscate known email fields + if (typeof sanitized.contact === 'string' && sanitized.contact.includes('@')) { + sanitized.contact = obfuscateEmail(sanitized.contact); + } + + // Obfuscate pending_owner email + if (sanitized.pending_owner && typeof sanitized.pending_owner === 'object') { + sanitized.pending_owner = { ...sanitized.pending_owner }; + if (typeof sanitized.pending_owner.email === 'string') { + sanitized.pending_owner.email = obfuscateEmail(sanitized.pending_owner.email); + } + } + + // Obfuscate owner email if present + if (sanitized.owner && typeof sanitized.owner === 'object') { + sanitized.owner = { ...sanitized.owner }; + if (typeof sanitized.owner.email === 'string') { + sanitized.owner.email = obfuscateEmail(sanitized.owner.email); + } + } + + // Recurse into nested passport/data objects + if (sanitized.passport && typeof sanitized.passport === 'object') { + sanitized.passport = sanitizePassportData(sanitized.passport); + } + if (sanitized.data && typeof sanitized.data === 'object') { + sanitized.data = sanitizePassportData(sanitized.data); + } + + return sanitized; +} export const onRequestOptions: PagesFunction = async (context) => { const res = handleCorsPreflightRequest(context.request); @@ -18,6 +73,23 @@ export const onRequestOptions: PagesFunction = async (context) => { export const onRequestGet: PagesFunction = async (context) => { const { env, request, params } = context; const cors = getCorsHeaders(request); + + // Rate limit: 20 req/min per IP — normal browsing is a few passports/min + const ip = getClientIp(request); + const rateLimit = await checkRateLimit(env.APORT_ID_KV, ip, { + maxRequests: 20, + windowMs: 60_000, + }); + if (!rateLimit.allowed) { + const retryAfterSecs = Math.ceil( + Math.max(0, rateLimit.resetAt - Date.now()) / 1000, + ); + return errorResponse('Too many requests. Please try again later.', 429, { + ...cors, + 'Retry-After': String(retryAfterSecs), + }); + } + const idOrSlug = params.id as string; if (!idOrSlug) { @@ -41,7 +113,9 @@ export const onRequestGet: PagesFunction = async (context) => { ); } - return jsonResponse(result.data, 200, { + const sanitized = sanitizePassportData(result.data); + + return jsonResponse(sanitized, 200, { ...cors, 'Cache-Control': 'public, max-age=60, s-maxage=300', }); diff --git a/functions/lib/rate-limit.ts b/functions/lib/rate-limit.ts index e489a86..4cd7a59 100644 --- a/functions/lib/rate-limit.ts +++ b/functions/lib/rate-limit.ts @@ -4,7 +4,7 @@ * Sliding window implementation: stores an array of request timestamps * per IP. Fails open — if KV errors, the request is allowed through. * - * See PRD E3: 10 requests per hour per IP. + * See PRD E3. ~2 req/sec (120/min) allows rapid creation and retries. */ import type { AppEnv } from "./types"; import { getCorsHeaders } from "./cors"; @@ -22,8 +22,8 @@ interface RateLimitOptions { windowMs?: number; } -const DEFAULT_MAX_REQUESTS = 10; -const DEFAULT_WINDOW_MS = 60 * 60 * 1000; // 1 hour +const DEFAULT_MAX_REQUESTS = 120; +const DEFAULT_WINDOW_MS = 60 * 1000; // 1 minute (~2 req/sec) const LOCALHOST_MULTIPLIER = 10; const KV_PREFIX = "rl:"; diff --git a/functions/lib/request-token.ts b/functions/lib/request-token.ts new file mode 100644 index 0000000..2d8ec69 --- /dev/null +++ b/functions/lib/request-token.ts @@ -0,0 +1,56 @@ +/** + * Time-rotating request token for same-app verification. + * + * Not a secret — the algorithm is in open source. But it rotates every + * 5 minutes, so a token copied from DevTools expires quickly, and + * reproducing it requires reading and reimplementing the code. + * + * This is one layer in a stack: origin check + rate limit + rotating token. + */ + +const WINDOW_MS = 5 * 60 * 1000; // 5 minutes +const SALT = "aport:gallery:v1"; + +function getWindow(now: number): number { + return Math.floor(now / WINDOW_MS); +} + +/** + * Generate a token for the current time window. + * Uses Web Crypto API (available in Cloudflare Workers and browsers). + */ +export async function generateRequestToken(): Promise { + const window = getWindow(Date.now()); + const payload = `${SALT}:${window}`; + const encoded = new TextEncoder().encode(payload); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const bytes = new Uint8Array(hash); + return Array.from(bytes.slice(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Verify a token is valid for the current or previous time window. + * Accepts previous window to handle requests near the boundary. + */ +export async function verifyRequestToken(token: string): Promise { + if (!token || token.length !== 16) return false; + + const now = Date.now(); + const currentWindow = getWindow(now); + + // Check current and previous window + for (const w of [currentWindow, currentWindow - 1]) { + const payload = `${SALT}:${w}`; + const encoded = new TextEncoder().encode(payload); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const bytes = new Uint8Array(hash); + const expected = Array.from(bytes.slice(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + if (token === expected) return true; + } + + return false; +} diff --git a/lib/request-token.ts b/lib/request-token.ts new file mode 100644 index 0000000..f5db9e2 --- /dev/null +++ b/lib/request-token.ts @@ -0,0 +1,18 @@ +/** + * Client-side request token generator. + * Matches the server-side verification in functions/lib/request-token.ts. + */ + +const WINDOW_MS = 5 * 60 * 1000; +const SALT = "aport:gallery:v1"; + +export async function generateRequestToken(): Promise { + const window = Math.floor(Date.now() / WINDOW_MS); + const payload = `${SALT}:${window}`; + const encoded = new TextEncoder().encode(payload); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const bytes = new Uint8Array(hash); + return Array.from(bytes.slice(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/lib/services/gallery.ts b/lib/services/gallery.ts index d3e8b86..4b63f71 100644 --- a/lib/services/gallery.ts +++ b/lib/services/gallery.ts @@ -6,9 +6,10 @@ */ import { apiConfig } from "@/lib/config/api"; +import { generateRequestToken } from "@/lib/request-token"; export interface GalleryPassport { - agent_id: string; + agent_id?: string; slug: string; name: string; description: string; @@ -51,7 +52,10 @@ export async function fetchGallery( const qs = params.toString(); const url = `${apiConfig.endpoints.gallery}${qs ? `?${qs}` : ""}`; - const res = await fetch(url); + const token = await generateRequestToken(); + const res = await fetch(url, { + headers: { "x-ag-token": token }, + }); if (!res.ok) { return { ok: false, passports: [], total: 0, hasMore: false }; diff --git a/test/email-obfuscation.test.ts b/test/email-obfuscation.test.ts new file mode 100644 index 0000000..f14989a --- /dev/null +++ b/test/email-obfuscation.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const { onRequestGet } = await import("../functions/api/passport/[id]"); + +function createContext( + agentId: string, + query = "", + env?: Partial>, +) { + const mockKV = { + get: vi.fn(() => Promise.resolve(null)), + put: vi.fn(() => Promise.resolve()), + }; + + return { + env: { + APORT_API_KEY: "test-key", + APORT_ORG_ID: "ap_org_test", + APORT_BASE_URL: "https://api.aport.io", + APORT_ASSURANCE_TYPE: "kyb", + APORT_ASSURANCE_LEVEL: "L1", + AGENT_PASSPORT_BASE_URL: "", + NEXT_PUBLIC_APP_URL: "", + NODE_ENV: "test", + APORT_ID_KV: mockKV, + ...env, + }, + request: new Request( + `https://aport.id/api/passport/${agentId}${query}`, + { + method: "GET", + headers: { Origin: "https://aport.id" }, + }, + ), + params: { id: agentId }, + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + next: vi.fn(), + data: {}, + functionPath: "", + } as unknown as Parameters[0]; +} + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("Email obfuscation in GET /api/passport/:id", () => { + it("obfuscates contact email in response", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + agent_id: "ap_test123", + name: "TestBot", + contact: "uchi.uchibeke@gmail.com", + status: "active", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + expect(body.contact).not.toContain("uchi.uchibeke"); + expect(body.contact).toMatch(/^u\*+@gmail\.com$/); + }); + + it("obfuscates pending_owner.email", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + agent_id: "ap_test123", + name: "TestBot", + pending_owner: { + email: "alice@example.com", + display_name: "Alice", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + expect(body.pending_owner.email).not.toContain("alice"); + expect(body.pending_owner.email).toMatch(/^a\*+@example\.com$/); + // display_name should be untouched + expect(body.pending_owner.display_name).toBe("Alice"); + }); + + it("obfuscates owner.email", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + agent_id: "ap_test123", + owner: { email: "bob@corp.io" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + expect(body.owner.email).not.toContain("bob"); + expect(body.owner.email).toMatch(/^b\*+@corp\.io$/); + }); + + it("obfuscates emails in nested passport.data objects", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + data: { + agent_id: "ap_test123", + contact: "deep@nested.com", + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + expect(body.data.contact).toMatch(/^d\*+@nested\.com$/); + }); + + it("handles passport with no email fields gracefully", async () => { + const passportData = { + agent_id: "ap_test123", + name: "NoEmailBot", + role: "agent", + status: "active", + }; + + mockFetch.mockResolvedValue( + new Response(JSON.stringify(passportData), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + expect(res.status).toBe(200); + expect(body.name).toBe("NoEmailBot"); + }); + + it("preserves non-email fields unchanged", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + agent_id: "ap_test123", + name: "TestBot", + description: "A test bot", + contact: "test@example.com", + role: "agent", + regions: ["us", "eu"], + capabilities: [{ id: "web.fetch" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + expect(body.name).toBe("TestBot"); + expect(body.description).toBe("A test bot"); + expect(body.role).toBe("agent"); + expect(body.regions).toEqual(["us", "eu"]); + expect(body.capabilities).toEqual([{ id: "web.fetch" }]); + }); + + it("handles single-char local part", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + agent_id: "ap_test123", + contact: "a@x.com", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const ctx = createContext("ap_test123"); + const res = await onRequestGet(ctx); + const body = (await res.json()) as Record; + + // Single char local: "a" + at least 2 stars + expect(body.contact).toMatch(/^a\*{2,}@x\.com$/); + }); +}); diff --git a/test/gallery-api.test.ts b/test/gallery-api.test.ts new file mode 100644 index 0000000..fc67963 --- /dev/null +++ b/test/gallery-api.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +// Mock crypto.subtle for request token verification +const originalCrypto = globalThis.crypto; + +const { onRequestGet } = await import("../functions/api/gallery"); + +function createKV() { + const kvStore = new Map(); + return { + get: vi.fn((key: string) => Promise.resolve(kvStore.get(key) || null)), + put: vi.fn((key: string, value: string) => { + kvStore.set(key, value); + return Promise.resolve(); + }), + }; +} + +// Generate a valid token for tests (same algorithm as request-token.ts) +async function generateTestToken(): Promise { + const WINDOW_MS = 5 * 60 * 1000; + const SALT = "aport:gallery:v1"; + const window = Math.floor(Date.now() / WINDOW_MS); + const payload = `${SALT}:${window}`; + const encoded = new TextEncoder().encode(payload); + const hash = await crypto.subtle.digest("SHA-256", encoded); + const bytes = new Uint8Array(hash); + return Array.from(bytes.slice(0, 8)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function createContext( + query = "", + headers: Record = {}, + env?: Partial>, +) { + const mockKV = createKV(); + return { + ctx: { + env: { + APORT_API_KEY: "test-key", + APORT_ORG_ID: "ap_org_test", + APORT_BASE_URL: "https://api.aport.io", + NEXT_PUBLIC_APORT_BASE_URL: "https://api.aport.io", + APORT_ASSURANCE_TYPE: "kyb", + APORT_ASSURANCE_LEVEL: "L1", + AGENT_PASSPORT_BASE_URL: "", + NEXT_PUBLIC_APP_URL: "", + NODE_ENV: "test", + APORT_ID_KV: mockKV, + ...env, + }, + request: new Request( + `https://aport.id/api/gallery${query}`, + { + method: "GET", + headers: { + Origin: "https://aport.id", + ...headers, + }, + }, + ), + params: {}, + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + next: vi.fn(), + data: {}, + functionPath: "", + } as unknown as Parameters[0], + mockKV, + }; +} + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("GET /api/gallery", () => { + describe("origin check", () => { + it("returns 403 for requests without origin/referer", async () => { + const token = await generateTestToken(); + const { ctx } = createContext("", { + "x-ag-token": token, + }); + // Override request to have no Origin header + (ctx as any).request = new Request("https://aport.id/api/gallery", { + method: "GET", + headers: { "x-ag-token": token }, + }); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(403); + }); + + it("returns 403 for requests from external origins", async () => { + const token = await generateTestToken(); + const { ctx } = createContext("", { + "x-ag-token": token, + }); + (ctx as any).request = new Request("https://aport.id/api/gallery", { + method: "GET", + headers: { + Origin: "https://evil-scraper.com", + "x-ag-token": token, + }, + }); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(403); + }); + + it("allows requests from aport.id", async () => { + const token = await generateTestToken(); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ success: true, passports: [], total: 0 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const { ctx } = createContext("", { + "x-ag-token": token, + }); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(200); + }); + + it("allows requests from localhost", async () => { + const token = await generateTestToken(); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ success: true, passports: [], total: 0 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const { ctx } = createContext("", { + "x-ag-token": token, + }); + (ctx as any).request = new Request("https://aport.id/api/gallery", { + method: "GET", + headers: { + Origin: "http://localhost:3000", + "x-ag-token": token, + }, + }); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(200); + }); + }); + + describe("request token", () => { + it("returns 403 when token is missing", async () => { + const { ctx } = createContext(); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(403); + }); + + it("returns 403 when token is invalid", async () => { + const { ctx } = createContext("", { + "x-ag-token": "0000000000000000", + }); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(403); + }); + + it("accepts valid token", async () => { + const token = await generateTestToken(); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ success: true, passports: [], total: 0 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const { ctx } = createContext("", { + "x-ag-token": token, + }); + + const res = await onRequestGet(ctx); + expect(res.status).toBe(200); + }); + }); + + describe("agent_id stripping", () => { + it("strips agent_id from gallery response", async () => { + const token = await generateTestToken(); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + success: true, + passports: [ + { + agent_id: "ap_secret123", + slug: "test-agent", + name: "TestAgent", + description: "A test agent", + role: "agent", + status: "active", + claimed: false, + framework: ["claude-sonnet"], + regions: ["global"], + capabilities: [{ id: "web.fetch" }], + created_at: "2026-01-01", + assurance_level: "L0", + }, + ], + total: 1, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const { ctx } = createContext("", { "x-ag-token": token }); + const res = await onRequestGet(ctx); + const body = (await res.json()) as { + passports: Record[]; + }; + + expect(body.passports).toHaveLength(1); + expect(body.passports[0]).not.toHaveProperty("agent_id"); + expect(body.passports[0].slug).toBe("test-agent"); + expect(body.passports[0].name).toBe("TestAgent"); + }); + }); + + describe("pagination limits", () => { + it("caps limit at 50", async () => { + const token = await generateTestToken(); + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ success: true, passports: [], total: 0 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + const { ctx } = createContext("?limit=200", { "x-ag-token": token }); + await onRequestGet(ctx); + + // The fetch call to APort API should not request more than 50 + offset + 50 + const fetchUrl = mockFetch.mock.calls[0][0] as string; + const urlObj = new URL(fetchUrl); + const limit = parseInt(urlObj.searchParams.get("limit") || "0", 10); + expect(limit).toBeLessThanOrEqual(200); // internal fetch limit (50 + 0 + 50) + }); + }); +}); diff --git a/test/request-token.test.ts b/test/request-token.test.ts new file mode 100644 index 0000000..f116717 --- /dev/null +++ b/test/request-token.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { generateRequestToken, verifyRequestToken } = await import( + "../functions/lib/request-token" +); + +describe("request-token", () => { + describe("generateRequestToken", () => { + it("returns a 16-char hex string", async () => { + const token = await generateRequestToken(); + expect(token).toMatch(/^[0-9a-f]{16}$/); + }); + + it("returns the same token within the same 5-min window", async () => { + const t1 = await generateRequestToken(); + const t2 = await generateRequestToken(); + expect(t1).toBe(t2); + }); + + it("returns a different token in a different window", async () => { + const t1 = await generateRequestToken(); + + // Advance time by 6 minutes + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 6 * 60 * 1000); + + const t2 = await generateRequestToken(); + expect(t2).not.toBe(t1); + + vi.useRealTimers(); + }); + }); + + describe("verifyRequestToken", () => { + it("accepts a valid token for current window", async () => { + const token = await generateRequestToken(); + const valid = await verifyRequestToken(token); + expect(valid).toBe(true); + }); + + it("rejects an empty token", async () => { + expect(await verifyRequestToken("")).toBe(false); + }); + + it("rejects a wrong-length token", async () => { + expect(await verifyRequestToken("abc")).toBe(false); + }); + + it("rejects a random 16-char string", async () => { + expect(await verifyRequestToken("0000000000000000")).toBe(false); + }); + + it("accepts token from previous window (boundary handling)", async () => { + const token = await generateRequestToken(); + + // Advance time by 5 minutes (into next window) + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 5 * 60 * 1000); + + const valid = await verifyRequestToken(token); + expect(valid).toBe(true); + + vi.useRealTimers(); + }); + + it("rejects token from two windows ago", async () => { + const token = await generateRequestToken(); + + // Advance time by 11 minutes (two windows ahead) + vi.useFakeTimers(); + vi.setSystemTime(Date.now() + 11 * 60 * 1000); + + const valid = await verifyRequestToken(token); + expect(valid).toBe(false); + + vi.useRealTimers(); + }); + }); +});