|
|
|
|
@@ -24,7 +24,6 @@ const mockCache = {
|
|
|
|
|
set: vi.fn(),
|
|
|
|
|
del: vi.fn(),
|
|
|
|
|
exists: vi.fn(),
|
|
|
|
|
tryLock: vi.fn(),
|
|
|
|
|
withCache: vi.fn(),
|
|
|
|
|
getRedisClient: vi.fn(),
|
|
|
|
|
};
|
|
|
|
|
@@ -33,47 +32,12 @@ vi.mock("@/lib/cache", () => ({
|
|
|
|
|
cache: mockCache,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Helper to set up cache mocks for the new distributed lock flow
|
|
|
|
|
const setupCacheMocksForLockFlow = (options: {
|
|
|
|
|
cachedLicense?: TEnterpriseLicenseDetails | null;
|
|
|
|
|
previousResult?: { active: boolean; features: unknown; lastChecked: Date } | null;
|
|
|
|
|
lockAcquired?: boolean;
|
|
|
|
|
}) => {
|
|
|
|
|
const { cachedLicense, previousResult = null, lockAcquired = true } = options;
|
|
|
|
|
|
|
|
|
|
// cache.get: returns cached license on status key, previous result on previous_result key
|
|
|
|
|
mockCache.get.mockImplementation(async (key: string) => {
|
|
|
|
|
if (key.includes(":status")) {
|
|
|
|
|
return { ok: true, data: cachedLicense ?? null };
|
|
|
|
|
}
|
|
|
|
|
if (key.includes(":previous_result")) {
|
|
|
|
|
return { ok: true, data: previousResult };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, data: null };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// cache.exists: for distinguishing cache miss from cached null
|
|
|
|
|
mockCache.exists.mockImplementation(async (key: string) => {
|
|
|
|
|
if (key.includes(":status") && cachedLicense !== undefined) {
|
|
|
|
|
return { ok: true, data: true };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, data: false };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// cache.tryLock: simulate lock acquisition
|
|
|
|
|
mockCache.tryLock.mockResolvedValue({ ok: true, data: lockAcquired });
|
|
|
|
|
|
|
|
|
|
// cache.set: success by default
|
|
|
|
|
mockCache.set.mockResolvedValue({ ok: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Mock the createCacheKey functions
|
|
|
|
|
vi.mock("@formbricks/cache", () => ({
|
|
|
|
|
createCacheKey: {
|
|
|
|
|
license: {
|
|
|
|
|
status: (identifier: string) => `fb:license:${identifier}:status`,
|
|
|
|
|
previous_result: (identifier: string) => `fb:license:${identifier}:previous_result`,
|
|
|
|
|
fetch_lock: (identifier: string) => `fb:license:${identifier}:fetch_lock`,
|
|
|
|
|
},
|
|
|
|
|
custom: (namespace: string, identifier: string, subResource?: string) => {
|
|
|
|
|
const base = `fb:${namespace}:${identifier}`;
|
|
|
|
|
@@ -130,13 +94,10 @@ describe("License Core Logic", () => {
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
originalProcessEnv = { ...process.env };
|
|
|
|
|
|
|
|
|
|
// Reset all mocks
|
|
|
|
|
vi.resetAllMocks();
|
|
|
|
|
mockCache.get.mockReset();
|
|
|
|
|
mockCache.set.mockReset();
|
|
|
|
|
mockCache.del.mockReset();
|
|
|
|
|
mockCache.exists.mockReset();
|
|
|
|
|
mockCache.tryLock.mockReset();
|
|
|
|
|
mockCache.withCache.mockReset();
|
|
|
|
|
mockLogger.error.mockReset();
|
|
|
|
|
mockLogger.warn.mockReset();
|
|
|
|
|
@@ -144,10 +105,7 @@ describe("License Core Logic", () => {
|
|
|
|
|
mockLogger.debug.mockReset();
|
|
|
|
|
|
|
|
|
|
// Set up default mock implementations for Result types
|
|
|
|
|
// Default: cache miss (no cached license), lock acquired, no previous result
|
|
|
|
|
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
|
|
|
|
mockCache.exists.mockResolvedValue({ ok: true, data: false });
|
|
|
|
|
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
|
|
|
|
|
mockCache.set.mockResolvedValue({ ok: true });
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
|
|
|
|
|
@@ -161,7 +119,7 @@ describe("License Core Logic", () => {
|
|
|
|
|
instanceId: "test-hashed-instance-id",
|
|
|
|
|
createdAt: new Date("2024-01-01"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
// Mock window to be undefined for server-side tests
|
|
|
|
|
vi.stubGlobal("window", undefined);
|
|
|
|
|
});
|
|
|
|
|
@@ -206,14 +164,16 @@ describe("License Core Logic", () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Set up cache to return cached license (cache hit)
|
|
|
|
|
setupCacheMocksForLockFlow({ cachedLicense: mockFetchedLicenseDetails });
|
|
|
|
|
// Mock cache.withCache to return cached license details (simulating cache hit)
|
|
|
|
|
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
|
|
|
|
|
|
|
|
|
|
const license = await getEnterpriseLicense();
|
|
|
|
|
expect(license).toEqual(expectedActiveLicenseState);
|
|
|
|
|
// Should have checked cache but NOT acquired lock or called fetch
|
|
|
|
|
expect(mockCache.get).toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.tryLock).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.withCache).toHaveBeenCalledWith(
|
|
|
|
|
expect.any(Function),
|
|
|
|
|
expect.stringContaining("fb:license:"),
|
|
|
|
|
expect.any(Number)
|
|
|
|
|
);
|
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -221,8 +181,8 @@ describe("License Core Logic", () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Set up cache miss: no cached license, lock acquired
|
|
|
|
|
setupCacheMocksForLockFlow({ cachedLicense: undefined, lockAcquired: true });
|
|
|
|
|
// Mock cache.withCache to execute the function (simulating cache miss)
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
|
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
@@ -232,18 +192,19 @@ describe("License Core Logic", () => {
|
|
|
|
|
const license = await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
|
|
|
// Should have tried to acquire lock and set the cache
|
|
|
|
|
expect(mockCache.tryLock).toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.set).toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.withCache).toHaveBeenCalledWith(
|
|
|
|
|
expect.any(Function),
|
|
|
|
|
expect.stringContaining("fb:license:"),
|
|
|
|
|
expect.any(Number)
|
|
|
|
|
);
|
|
|
|
|
expect(license).toEqual(expectedActiveLicenseState);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should handle grace period logic when previous result exists", async () => {
|
|
|
|
|
// This test verifies the grace period fallback behavior.
|
|
|
|
|
// Due to vitest module caching, full mock verification is limited.
|
|
|
|
|
test("should use previous result if fetch fails and previous result exists and is within grace period", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
|
|
|
|
|
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago, within grace period
|
|
|
|
|
const mockPreviousResult = {
|
|
|
|
|
active: true,
|
|
|
|
|
features: { removeBranding: true, projects: 5 },
|
|
|
|
|
@@ -251,32 +212,37 @@ describe("License Core Logic", () => {
|
|
|
|
|
version: 1,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Set up cache miss for license, but have previous result available
|
|
|
|
|
mockCache.get.mockImplementation(async (key: string) => {
|
|
|
|
|
// Mock cache.withCache to return null (simulating fetch failure)
|
|
|
|
|
mockCache.withCache.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
// Mock cache.get to return previous result when requested
|
|
|
|
|
mockCache.get.mockImplementation(async (key) => {
|
|
|
|
|
if (key.includes(":previous_result")) {
|
|
|
|
|
return { ok: true, data: mockPreviousResult };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, data: null };
|
|
|
|
|
});
|
|
|
|
|
mockCache.exists.mockResolvedValue({ ok: true, data: false });
|
|
|
|
|
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
|
|
|
|
|
mockCache.set.mockResolvedValue({ ok: true });
|
|
|
|
|
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
|
|
|
|
|
|
|
|
|
|
const license = await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
// Should return some license state (exact values depend on mock application)
|
|
|
|
|
expect(license).toHaveProperty("active");
|
|
|
|
|
expect(license).toHaveProperty("fallbackLevel");
|
|
|
|
|
expect(mockCache.withCache).toHaveBeenCalled();
|
|
|
|
|
expect(license).toEqual({
|
|
|
|
|
active: true,
|
|
|
|
|
features: mockPreviousResult.features,
|
|
|
|
|
lastChecked: previousTime,
|
|
|
|
|
isPendingDowngrade: true,
|
|
|
|
|
fallbackLevel: "grace" as const,
|
|
|
|
|
status: "unreachable" as const,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should handle expired grace period when previous result is too old", async () => {
|
|
|
|
|
// This test verifies behavior when previous result is outside grace period.
|
|
|
|
|
test("should return inactive and set new previousResult if fetch fails and previous result is outside grace period", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
|
|
|
|
|
const previousTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); // 5 days ago, outside grace period
|
|
|
|
|
const mockPreviousResult = {
|
|
|
|
|
active: true,
|
|
|
|
|
features: { removeBranding: true },
|
|
|
|
|
@@ -284,33 +250,80 @@ describe("License Core Logic", () => {
|
|
|
|
|
version: 1,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Set up cache miss for license, previous result outside grace period
|
|
|
|
|
mockCache.get.mockImplementation(async (key: string) => {
|
|
|
|
|
// Mock cache.withCache to return null (simulating fetch failure)
|
|
|
|
|
mockCache.withCache.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
// Mock cache.get to return previous result when requested
|
|
|
|
|
mockCache.get.mockImplementation(async (key) => {
|
|
|
|
|
if (key.includes(":previous_result")) {
|
|
|
|
|
return { ok: true, data: mockPreviousResult };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, data: null };
|
|
|
|
|
});
|
|
|
|
|
mockCache.exists.mockResolvedValue({ ok: true, data: false });
|
|
|
|
|
mockCache.tryLock.mockResolvedValue({ ok: true, data: true });
|
|
|
|
|
mockCache.set.mockResolvedValue({ ok: true });
|
|
|
|
|
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
|
|
|
|
|
|
|
|
|
|
const license = await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
// Should return some license state
|
|
|
|
|
expect(license).toHaveProperty("active");
|
|
|
|
|
expect(license).toHaveProperty("fallbackLevel");
|
|
|
|
|
expect(mockCache.withCache).toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining("fb:license:"),
|
|
|
|
|
{
|
|
|
|
|
active: false,
|
|
|
|
|
features: {
|
|
|
|
|
isMultiOrgEnabled: false,
|
|
|
|
|
projects: 3,
|
|
|
|
|
twoFactorAuth: false,
|
|
|
|
|
sso: false,
|
|
|
|
|
whitelabel: false,
|
|
|
|
|
removeBranding: false,
|
|
|
|
|
contacts: false,
|
|
|
|
|
ai: false,
|
|
|
|
|
saml: false,
|
|
|
|
|
spamProtection: false,
|
|
|
|
|
auditLogs: false,
|
|
|
|
|
multiLanguageSurveys: false,
|
|
|
|
|
accessControl: false,
|
|
|
|
|
quotas: false,
|
|
|
|
|
},
|
|
|
|
|
lastChecked: expect.any(Date),
|
|
|
|
|
},
|
|
|
|
|
expect.any(Number)
|
|
|
|
|
);
|
|
|
|
|
expect(license).toEqual({
|
|
|
|
|
active: false,
|
|
|
|
|
features: {
|
|
|
|
|
isMultiOrgEnabled: false,
|
|
|
|
|
projects: 3,
|
|
|
|
|
twoFactorAuth: false,
|
|
|
|
|
sso: false,
|
|
|
|
|
whitelabel: false,
|
|
|
|
|
removeBranding: false,
|
|
|
|
|
contacts: false,
|
|
|
|
|
ai: false,
|
|
|
|
|
saml: false,
|
|
|
|
|
spamProtection: false,
|
|
|
|
|
auditLogs: false,
|
|
|
|
|
multiLanguageSurveys: false,
|
|
|
|
|
accessControl: false,
|
|
|
|
|
quotas: false,
|
|
|
|
|
},
|
|
|
|
|
lastChecked: expect.any(Date),
|
|
|
|
|
isPendingDowngrade: false,
|
|
|
|
|
fallbackLevel: "default" as const,
|
|
|
|
|
status: "unreachable" as const,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should return inactive with default features if fetch fails and no previous result (initial fail)", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Set up cache miss with no previous result
|
|
|
|
|
setupCacheMocksForLockFlow({ cachedLicense: undefined, previousResult: null, lockAcquired: true });
|
|
|
|
|
// Mock cache.withCache to return null (simulating fetch failure)
|
|
|
|
|
mockCache.withCache.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
// Mock cache.get to return no previous result
|
|
|
|
|
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
|
|
|
|
|
|
|
|
|
fetch.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
|
|
|
|
|
|
@@ -331,13 +344,13 @@ describe("License Core Logic", () => {
|
|
|
|
|
accessControl: false,
|
|
|
|
|
quotas: false,
|
|
|
|
|
};
|
|
|
|
|
// Should have set previous_result with default features
|
|
|
|
|
expect(mockCache.set).toHaveBeenCalledWith(
|
|
|
|
|
expect.stringContaining("fb:license:"),
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
{
|
|
|
|
|
active: false,
|
|
|
|
|
features: expectedFeatures,
|
|
|
|
|
}),
|
|
|
|
|
lastChecked: expect.any(Date),
|
|
|
|
|
},
|
|
|
|
|
expect.any(Number)
|
|
|
|
|
);
|
|
|
|
|
expect(license).toEqual({
|
|
|
|
|
@@ -355,7 +368,7 @@ describe("License Core Logic", () => {
|
|
|
|
|
vi.resetAllMocks();
|
|
|
|
|
mockCache.get.mockReset();
|
|
|
|
|
mockCache.set.mockReset();
|
|
|
|
|
mockCache.tryLock.mockReset();
|
|
|
|
|
mockCache.withCache.mockReset();
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
fetch.mockReset();
|
|
|
|
|
|
|
|
|
|
@@ -382,56 +395,306 @@ describe("License Core Logic", () => {
|
|
|
|
|
fallbackLevel: "default" as const,
|
|
|
|
|
status: "no-license" as const,
|
|
|
|
|
});
|
|
|
|
|
// No cache operations should happen when there's no license key
|
|
|
|
|
expect(mockCache.get).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.set).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.tryLock).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.withCache).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should handle fetch throwing an error gracefully", async () => {
|
|
|
|
|
// Set up cache miss with no previous result
|
|
|
|
|
setupCacheMocksForLockFlow({ cachedLicense: undefined, previousResult: null, lockAcquired: true });
|
|
|
|
|
|
|
|
|
|
test("should handle fetch throwing an error and use grace period or return inactive", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Mock cache.withCache to return null (simulating fetch failure)
|
|
|
|
|
mockCache.withCache.mockResolvedValue(null);
|
|
|
|
|
|
|
|
|
|
// Mock cache.get to return no previous result
|
|
|
|
|
mockCache.get.mockResolvedValue({ ok: true, data: null });
|
|
|
|
|
|
|
|
|
|
fetch.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
|
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const license = await getEnterpriseLicense();
|
|
|
|
|
// Should return inactive license (exact status depends on env configuration)
|
|
|
|
|
expect(license.active).toBe(false);
|
|
|
|
|
expect(license.isPendingDowngrade).toBe(false);
|
|
|
|
|
expect(license.fallbackLevel).toBe("default");
|
|
|
|
|
expect(license).toEqual({
|
|
|
|
|
active: false,
|
|
|
|
|
features: null,
|
|
|
|
|
lastChecked: expect.any(Date),
|
|
|
|
|
isPendingDowngrade: false,
|
|
|
|
|
fallbackLevel: "default" as const,
|
|
|
|
|
status: "no-license" as const,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("getLicenseFeatures", () => {
|
|
|
|
|
// These tests use dynamic imports which have issues with vitest mocking.
|
|
|
|
|
// The core getLicenseFeatures functionality is tested through
|
|
|
|
|
// integration tests and the getEnterpriseLicense tests above.
|
|
|
|
|
test("should be exported from license module", async () => {
|
|
|
|
|
test("should return features if license is active", async () => {
|
|
|
|
|
// Set up environment before import
|
|
|
|
|
vi.stubGlobal("window", undefined);
|
|
|
|
|
vi.doMock("@/lib/env", () => ({
|
|
|
|
|
env: {
|
|
|
|
|
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
|
|
|
|
VERCEL_URL: "some.vercel.url",
|
|
|
|
|
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
|
|
|
|
HTTPS_PROXY: undefined,
|
|
|
|
|
HTTP_PROXY: undefined,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
// Mock cache.withCache to return license details
|
|
|
|
|
mockCache.withCache.mockResolvedValue({
|
|
|
|
|
status: "active",
|
|
|
|
|
features: {
|
|
|
|
|
isMultiOrgEnabled: true,
|
|
|
|
|
contacts: true,
|
|
|
|
|
projects: 5,
|
|
|
|
|
whitelabel: true,
|
|
|
|
|
removeBranding: true,
|
|
|
|
|
twoFactorAuth: true,
|
|
|
|
|
sso: true,
|
|
|
|
|
saml: true,
|
|
|
|
|
spamProtection: true,
|
|
|
|
|
ai: true,
|
|
|
|
|
auditLogs: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
// Import after env and mocks are set
|
|
|
|
|
const { getLicenseFeatures } = await import("./license");
|
|
|
|
|
expect(typeof getLicenseFeatures).toBe("function");
|
|
|
|
|
const features = await getLicenseFeatures();
|
|
|
|
|
expect(features).toEqual({
|
|
|
|
|
isMultiOrgEnabled: true,
|
|
|
|
|
contacts: true,
|
|
|
|
|
projects: 5,
|
|
|
|
|
whitelabel: true,
|
|
|
|
|
removeBranding: true,
|
|
|
|
|
twoFactorAuth: true,
|
|
|
|
|
sso: true,
|
|
|
|
|
saml: true,
|
|
|
|
|
spamProtection: true,
|
|
|
|
|
ai: true,
|
|
|
|
|
auditLogs: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should return null if license is inactive", async () => {
|
|
|
|
|
const { getLicenseFeatures } = await import("./license");
|
|
|
|
|
|
|
|
|
|
// Mock cache.withCache to return expired license
|
|
|
|
|
mockCache.withCache.mockResolvedValue({ status: "expired", features: null });
|
|
|
|
|
|
|
|
|
|
const features = await getLicenseFeatures();
|
|
|
|
|
expect(features).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should return null if getEnterpriseLicense throws", async () => {
|
|
|
|
|
const { getLicenseFeatures } = await import("./license");
|
|
|
|
|
|
|
|
|
|
// Mock cache.withCache to throw an error
|
|
|
|
|
mockCache.withCache.mockRejectedValue(new Error("Cache error"));
|
|
|
|
|
|
|
|
|
|
const features = await getLicenseFeatures();
|
|
|
|
|
expect(features).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Cache Key Generation", () => {
|
|
|
|
|
test("getCacheKeys should be exported from license module", async () => {
|
|
|
|
|
const { getCacheKeys } = await import("./license");
|
|
|
|
|
expect(typeof getCacheKeys).toBe("function");
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.resetAllMocks();
|
|
|
|
|
mockCache.get.mockReset();
|
|
|
|
|
mockCache.set.mockReset();
|
|
|
|
|
mockCache.del.mockReset();
|
|
|
|
|
mockCache.withCache.mockReset();
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should use 'browser' as cache key in browser environment", async () => {
|
|
|
|
|
vi.stubGlobal("window", {});
|
|
|
|
|
|
|
|
|
|
// Set up default mock for cache.withCache
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
expect(mockCache.withCache).toHaveBeenCalledWith(
|
|
|
|
|
expect.any(Function),
|
|
|
|
|
expect.stringContaining("fb:license:browser:status"),
|
|
|
|
|
expect.any(Number)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should use 'no-license' as cache key when ENTERPRISE_LICENSE_KEY is not set", async () => {
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
vi.stubGlobal("window", undefined);
|
|
|
|
|
vi.doMock("@/lib/env", () => ({
|
|
|
|
|
env: {
|
|
|
|
|
ENTERPRISE_LICENSE_KEY: undefined,
|
|
|
|
|
VERCEL_URL: "some.vercel.url",
|
|
|
|
|
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
|
|
|
|
HTTPS_PROXY: undefined,
|
|
|
|
|
HTTP_PROXY: undefined,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
// The cache should NOT be accessed if there is no license key
|
|
|
|
|
expect(mockCache.get).not.toHaveBeenCalled();
|
|
|
|
|
expect(mockCache.withCache).not.toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should use hashed license key as cache key when ENTERPRISE_LICENSE_KEY is set", async () => {
|
|
|
|
|
vi.resetModules();
|
|
|
|
|
const testLicenseKey = "test-license-key";
|
|
|
|
|
vi.stubGlobal("window", undefined);
|
|
|
|
|
vi.doMock("@/lib/env", () => ({
|
|
|
|
|
env: {
|
|
|
|
|
ENTERPRISE_LICENSE_KEY: testLicenseKey,
|
|
|
|
|
VERCEL_URL: "some.vercel.url",
|
|
|
|
|
FORMBRICKS_COM_URL: "https://app.formbricks.com",
|
|
|
|
|
HTTPS_PROXY: undefined,
|
|
|
|
|
HTTP_PROXY: undefined,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Set up default mock for cache.withCache
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
|
|
|
|
|
const { hashString } = await import("@/lib/hash-string");
|
|
|
|
|
const expectedHash = hashString(testLicenseKey);
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
expect(mockCache.withCache).toHaveBeenCalledWith(
|
|
|
|
|
expect.any(Function),
|
|
|
|
|
expect.stringContaining(`fb:license:${expectedHash}:status`),
|
|
|
|
|
expect.any(Number)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("Error and Warning Logging", () => {
|
|
|
|
|
// These tests verify that logging functions are called correctly.
|
|
|
|
|
// Due to vitest module caching, these tests may not work reliably
|
|
|
|
|
// in isolation. The logging functionality is tested implicitly
|
|
|
|
|
// through the other tests.
|
|
|
|
|
test("should have logger available for error tracking", async () => {
|
|
|
|
|
// Just verify the mockLogger is set up correctly
|
|
|
|
|
expect(mockLogger.error).toBeDefined();
|
|
|
|
|
expect(mockLogger.warn).toBeDefined();
|
|
|
|
|
expect(mockLogger.info).toBeDefined();
|
|
|
|
|
expect(mockLogger.debug).toBeDefined();
|
|
|
|
|
test("should log warning when setPreviousResult cache.set fails (line 176-178)", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
const mockFetchedLicenseDetails: TEnterpriseLicenseDetails = {
|
|
|
|
|
status: "active",
|
|
|
|
|
features: {
|
|
|
|
|
isMultiOrgEnabled: true,
|
|
|
|
|
contacts: true,
|
|
|
|
|
projects: 10,
|
|
|
|
|
whitelabel: true,
|
|
|
|
|
removeBranding: true,
|
|
|
|
|
twoFactorAuth: true,
|
|
|
|
|
sso: true,
|
|
|
|
|
saml: true,
|
|
|
|
|
spamProtection: true,
|
|
|
|
|
ai: false,
|
|
|
|
|
auditLogs: true,
|
|
|
|
|
multiLanguageSurveys: true,
|
|
|
|
|
accessControl: true,
|
|
|
|
|
quotas: true,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Mock successful fetch from API
|
|
|
|
|
mockCache.withCache.mockResolvedValue(mockFetchedLicenseDetails);
|
|
|
|
|
|
|
|
|
|
// Mock cache.set to fail when saving previous result
|
|
|
|
|
mockCache.set.mockResolvedValue({
|
|
|
|
|
ok: false,
|
|
|
|
|
error: new Error("Redis connection failed"),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
// Verify that the warning was logged
|
|
|
|
|
expect(mockLogger.warn).toHaveBeenCalledWith(
|
|
|
|
|
{ error: new Error("Redis connection failed") },
|
|
|
|
|
"Failed to cache previous result"
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should log error when trackApiError is called (line 196-203)", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Mock cache.withCache to execute the function (simulating cache miss)
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
|
|
|
|
|
// Mock API response with 500 status
|
|
|
|
|
const mockStatus = 500;
|
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
|
|
|
ok: false,
|
|
|
|
|
status: mockStatus,
|
|
|
|
|
json: async () => ({ error: "Internal Server Error" }),
|
|
|
|
|
} as any);
|
|
|
|
|
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
// Verify that the API error was logged with correct structure
|
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
status: mockStatus,
|
|
|
|
|
code: "API_ERROR",
|
|
|
|
|
timestamp: expect.any(String),
|
|
|
|
|
}),
|
|
|
|
|
expect.stringContaining("License API error:")
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should log error when trackApiError is called with different status codes (line 196-203)", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Test with 403 Forbidden
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
const mockStatus = 403;
|
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
|
|
|
ok: false,
|
|
|
|
|
status: mockStatus,
|
|
|
|
|
json: async () => ({ error: "Forbidden" }),
|
|
|
|
|
} as any);
|
|
|
|
|
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
// Verify that the API error was logged with correct structure
|
|
|
|
|
expect(mockLogger.error).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
status: mockStatus,
|
|
|
|
|
code: "API_ERROR",
|
|
|
|
|
timestamp: expect.any(String),
|
|
|
|
|
}),
|
|
|
|
|
expect.stringContaining("License API error:")
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("should log info when trackFallbackUsage is called during grace period", async () => {
|
|
|
|
|
const { getEnterpriseLicense } = await import("./license");
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
const previousTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
|
|
|
|
|
const mockPreviousResult = {
|
|
|
|
|
active: true,
|
|
|
|
|
features: { removeBranding: true, projects: 5 },
|
|
|
|
|
lastChecked: previousTime,
|
|
|
|
|
version: 1,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
mockCache.withCache.mockResolvedValue(null);
|
|
|
|
|
mockCache.get.mockImplementation(async (key) => {
|
|
|
|
|
if (key.includes(":previous_result")) {
|
|
|
|
|
return { ok: true, data: mockPreviousResult };
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, data: null };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
fetch.mockResolvedValueOnce({ ok: false, status: 500 } as any);
|
|
|
|
|
|
|
|
|
|
await getEnterpriseLicense();
|
|
|
|
|
|
|
|
|
|
// Verify that the fallback info was logged
|
|
|
|
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
fallbackLevel: "grace",
|
|
|
|
|
timestamp: expect.any(String),
|
|
|
|
|
}),
|
|
|
|
|
expect.stringContaining("Using license fallback level: grace")
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
@@ -449,8 +712,8 @@ describe("License Core Logic", () => {
|
|
|
|
|
|
|
|
|
|
const fetch = (await import("node-fetch")).default as Mock;
|
|
|
|
|
|
|
|
|
|
// Set up cache miss, lock acquired
|
|
|
|
|
setupCacheMocksForLockFlow({ cachedLicense: undefined, lockAcquired: true });
|
|
|
|
|
// Mock cache.withCache to execute the function (simulating cache miss)
|
|
|
|
|
mockCache.withCache.mockImplementation(async (fn) => await fn());
|
|
|
|
|
|
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
|
|
|
ok: true,
|
|
|
|
|
|