Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/services/apiService.service-token.test.ts
T

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 })
);
});
});