180 lines
9.1 KiB
TypeScript
180 lines
9.1 KiB
TypeScript
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 })
|
|
);
|
|
});
|
|
});
|