import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@toolctrl/db/accounts.server", () => ({ createUniqueLegacyTenantIpAddress: vi.fn(), findLegacyTenantByIdOrSlug: vi.fn(), getLegacyTenantMembershipsForUser: vi.fn(), })); vi.mock("@toolctrl/db/api-keys.server", () => ({ createLegacyApiKeyLog: vi.fn(), findLegacyApiKeyByKey: vi.fn(), setLegacyApiKeyLogStatus: vi.fn(), })); vi.mock("@toolctrl/db/users.server", () => ({ findLegacyUserWithoutPasswordById: vi.fn() })); vi.mock("./.server/subscriptionService", () => ({ getActiveTenantSubscriptions: vi.fn(), getPlanFeatureUsage: vi.fn(), })); vi.mock("../server/IpUtils", () => ({ getClientIPAddress: vi.fn(() => "127.0.0.1") })); vi.mock("jsonwebtoken", () => ({ default: { verify: vi.fn() } })); vi.mock("~/modules/notifications/services/.server/NotificationService", () => ({ default: { sendToRoles: vi.fn() }, })); vi.mock("../stripe.server", () => ({ getOpenInvoices: vi.fn() })); vi.mock("~/locale/i18next.server", () => ({ getTranslations: vi.fn() })); vi.mock("./rateLimitService", () => ({ rateLimitByKey: vi.fn() })); vi.mock("../cache.server", () => ({ cachified: vi.fn() })); vi.mock("../app/CrossHostUrls", () => ({ absoluteAppPath: vi.fn(() => "https://app.example.com/acme/settings/api/keys") })); vi.mock("@toolctrl/config", () => ({ loadApiConfig: vi.fn(() => ({ ok: true, config: { apiKeys: { allowLegacyPlaintextFallback: false }, jwtSecret: "web-hash-secret", routing: {}, }, diagnostics: [], })), loadRequiredWebConfig: vi.fn(() => ({ apiAccessToken: "service-secret", jwtSecret: "web-jwt-secret", routing: {} })), })); import { findLegacyTenantByIdOrSlug } from "@toolctrl/db/accounts.server"; import { createLegacyApiKeyLog, findLegacyApiKeyByKey, setLegacyApiKeyLogStatus } from "@toolctrl/db/api-keys.server"; import { loadApiConfig } from "@toolctrl/config"; import { cachified } from "../cache.server"; import { getTranslations } from "~/locale/i18next.server"; import { rateLimitByKey } from "./rateLimitService"; import { getApiKeyLookupCacheKey, serializeApiLogSearchParams } from "../api/apiKeySecurity.server"; import { getServiceAccessTokenFromRequest, hasValidServiceAccessToken, validateApiKey, validateTenantApiKey } from "./apiService"; describe("service API access token helpers", () => { beforeEach(() => { vi.mocked(findLegacyTenantByIdOrSlug).mockResolvedValue({ id: "tenant_1" } as never); vi.mocked(createLegacyApiKeyLog).mockResolvedValue({ id: "log_1" } as never); vi.mocked(cachified).mockImplementation((async (options: { getFreshValue: () => unknown }) => options.getFreshValue()) as never); vi.mocked(getTranslations).mockResolvedValue({ t: (key: string) => key } as never); }); afterEach(() => { vi.clearAllMocks(); }); it("accepts service tokens from bearer auth and x-api-key headers", () => { const bearerRequest = new Request("https://example.com/v1/accounts", { headers: { Authorization: "Bearer service-secret" } }); const apiKeyRequest = new Request("https://example.com/v1/accounts", { headers: { "X-Api-Key": "service-secret" } }); expect(getServiceAccessTokenFromRequest(bearerRequest)).toBe("service-secret"); expect(hasValidServiceAccessToken(bearerRequest)).toBe(true); expect(hasValidServiceAccessToken(apiKeyRequest)).toBe(true); }); it("rejects missing or mismatched service tokens", () => { expect(hasValidServiceAccessToken(new Request("https://example.com/v1/accounts"))).toBe(false); expect(hasValidServiceAccessToken(new Request("https://example.com/v1/accounts", { headers: { Authorization: "Bearer wrong" } }))).toBe(false); expect(hasValidServiceAccessToken(new Request("https://example.com/v1/accounts", { headers: { Authorization: "Bearer service-secret-extra" } }))).toBe(false); }); it("redacts sensitive API log query params before persistence", () => { const request = new Request( "https://example.com/v1/entities/contact/rows?entity=contact&id=row_1&page=2&token=raw-token&email=person@example.test&next=/dashboard&apiKey=sk_live_secret" ); expect(serializeApiLogSearchParams(request)).toBe( JSON.stringify({ page: "2", token: "[REDACTED]", email: "[REDACTED]", next: "/dashboard", apiKey: "[REDACTED]", }) ); }); it("uses hashed cache keys for stored API-key lookups", async () => { vi.mocked(findLegacyApiKeyByKey).mockResolvedValue(null); await expect(validateApiKey(new Request("https://example.com/v1/widgets", { headers: { "X-Api-Key": "stored-key" } }), {})).rejects.toThrow( "Invalid API Key" ); expect(cachified).toHaveBeenCalledWith(expect.objectContaining({ key: getApiKeyLookupCacheKey("stored-key") })); expect(cachified).not.toHaveBeenCalledWith(expect.objectContaining({ key: "apiKey:stored-key" })); }); it("resolves service-token tenant scope from query or headers", async () => { const queryRequest = new Request("https://example.com/internal/api/workflows/run/workflow_1?tenantId=tenant_1", { headers: { "X-Api-Key": "service-secret" } }); const tenantHeaderRequest = new Request("https://example.com/internal/api/workflows/run/workflow_1", { headers: { "X-Api-Key": "service-secret", "X-Tenant-Id": "tenant_1" } }); const accountHeaderRequest = new Request("https://example.com/internal/api/workflows/run/workflow_1", { headers: { "X-Api-Key": "service-secret", "X-Account-Id": "tenant_1" } }); await expect(validateApiKey(queryRequest, {})).resolves.toEqual({ tenant: { id: "tenant_1" } }); await expect(validateApiKey(tenantHeaderRequest, {})).resolves.toEqual({ tenant: { id: "tenant_1" } }); await expect(validateApiKey(accountHeaderRequest, {})).resolves.toEqual({ tenant: { id: "tenant_1" } }); expect(findLegacyTenantByIdOrSlug).toHaveBeenCalledTimes(3); expect(findLegacyTenantByIdOrSlug).toHaveBeenCalledWith("tenant_1"); }); it("rejects conflicting service-token tenant scopes", async () => { const request = new Request("https://example.com/internal/api/workflows/run/workflow_1?tenantId=tenant_1", { headers: { "X-Api-Key": "service-secret", "X-Tenant-Id": "tenant_2" }, }); await expect(validateApiKey(request, {})).rejects.toThrow("Conflicting tenant scope values"); expect(findLegacyTenantByIdOrSlug).not.toHaveBeenCalled(); }); it("rejects stored API keys that include tenant-scope query or headers", async () => { const queryRequest = new Request("https://example.com/v1/widgets?tenantId=tenant_1", { headers: { "X-Api-Key": "stored-key" } }); const tenantHeaderRequest = new Request("https://example.com/v1/widgets", { headers: { "X-Api-Key": "stored-key", "X-Tenant-Id": "tenant_1" } }); const accountHeaderRequest = new Request("https://example.com/v1/widgets", { headers: { "X-Api-Key": "stored-key", "X-Account-Id": "tenant_1" } }); await expect(validateApiKey(queryRequest, {})).rejects.toThrow("You cannot use tenantId, X-Tenant-Id, or X-Account-Id with a stored API key"); await expect(validateApiKey(tenantHeaderRequest, {})).rejects.toThrow("You cannot use tenantId, X-Tenant-Id, or X-Account-Id with a stored API key"); await expect(validateApiKey(accountHeaderRequest, {})).rejects.toThrow("You cannot use tenantId, X-Tenant-Id, or X-Account-Id with a stored API key"); expect(findLegacyApiKeyByKey).not.toHaveBeenCalled(); }); it("threads API-key hash secret and plaintext-fallback cutover into stored-key lookup", async () => { vi.mocked(loadApiConfig).mockReturnValue({ ok: true, config: { apiKeys: { allowLegacyPlaintextFallback: false }, jwtSecret: "cutover-hash-secret", routing: {}, }, diagnostics: [], } as never); vi.mocked(findLegacyApiKeyByKey).mockResolvedValue(null); const request = new Request("https://example.com/v1/widgets", { headers: { "X-Api-Key": "stored-key" } }); await expect(validateApiKey(request, {})).rejects.toThrow("Invalid API Key"); expect(findLegacyApiKeyByKey).toHaveBeenCalledWith("stored-key", undefined, "cutover-hash-secret", { allowPlaintextFallback: false, }); }); it("updates stored API-key error logs with tenant scope", async () => { vi.mocked(findLegacyApiKeyByKey).mockResolvedValue({ id: "api_key_1", tenantId: "tenant_1", active: true, expires: null, tenant: { id: "tenant_1", name: "Acme", slug: "acme", deactivatedReason: null }, } as never); vi.mocked(createLegacyApiKeyLog).mockResolvedValue({ id: "log_1", tenantId: "tenant_1" } as never); vi.mocked(rateLimitByKey).mockResolvedValue({ error: "tenant quota exceeded" } as never); const request = new Request("https://example.com/v1/widgets", { headers: { "X-Api-Key": "stored-key" } }); await expect(validateTenantApiKey(request, {})).rejects.toMatchObject({ message: "Rate limit exceeded: tenant quota exceeded", status: 429, apiKeyLog: { id: "log_1", tenantId: "tenant_1" }, }); expect(setLegacyApiKeyLogStatus).toHaveBeenCalledWith( { id: "log_1", tenantId: "tenant_1" }, expect.objectContaining({ error: "Rate limit exceeded: tenant quota exceeded", status: 429 }) ); }); });