mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-09 03:09:33 -06:00
Compare commits
1 Commits
fix/licens
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
626b40e070 |
@@ -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,
|
||||
|
||||
@@ -110,16 +110,11 @@ const getCacheIdentifier = () => {
|
||||
return hashString(env.ENTERPRISE_LICENSE_KEY); // Valid license key
|
||||
};
|
||||
|
||||
const LICENSE_FETCH_LOCK_TTL_MS = 90 * 1000; // 90s lock so only one process fetches when cache is cold
|
||||
const LICENSE_FETCH_POLL_MS = 12 * 1000; // Wait up to 12s for another process to populate cache
|
||||
const LICENSE_FETCH_POLL_INTERVAL_MS = 400;
|
||||
|
||||
export const getCacheKeys = () => {
|
||||
const identifier = getCacheIdentifier();
|
||||
return {
|
||||
FETCH_LICENSE_CACHE_KEY: createCacheKey.license.status(identifier),
|
||||
PREVIOUS_RESULT_CACHE_KEY: createCacheKey.license.previous_result(identifier),
|
||||
FETCH_LOCK_CACHE_KEY: createCacheKey.license.fetch_lock(identifier),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -290,19 +285,6 @@ const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicen
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Try to read cached license from Redis. Returns undefined on miss or error.
|
||||
*/
|
||||
const getCachedLicense = async (): Promise<TEnterpriseLicenseDetails | null | undefined> => {
|
||||
const keys = getCacheKeys();
|
||||
const result = await cache.get<TEnterpriseLicenseDetails | null>(keys.FETCH_LICENSE_CACHE_KEY);
|
||||
if (!result.ok) return undefined;
|
||||
if (result.data !== null && result.data !== undefined) return result.data;
|
||||
const existsResult = await cache.exists(keys.FETCH_LICENSE_CACHE_KEY);
|
||||
if (existsResult.ok && existsResult.data) return null; // cached null
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// API functions
|
||||
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
|
||||
|
||||
@@ -406,10 +388,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* When Redis license cache is empty, only one process should run the expensive fetch
|
||||
* (DB count + API call). Others wait for the cache to be populated or fall back after a timeout.
|
||||
*/
|
||||
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
|
||||
@@ -424,37 +402,13 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
|
||||
}
|
||||
|
||||
fetchLicensePromise = (async () => {
|
||||
const keys = getCacheKeys();
|
||||
const cached = await getCachedLicense();
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const lockResult = await cache.tryLock(keys.FETCH_LOCK_CACHE_KEY, "1", LICENSE_FETCH_LOCK_TTL_MS);
|
||||
const acquired = lockResult.ok && lockResult.data === true;
|
||||
|
||||
if (acquired) {
|
||||
try {
|
||||
const fresh = await fetchLicenseFromServerInternal();
|
||||
await cache.set(keys.FETCH_LICENSE_CACHE_KEY, fresh, CONFIG.CACHE.FETCH_LICENSE_TTL_MS);
|
||||
return fresh;
|
||||
} finally {
|
||||
// Lock expires automatically; no need to release
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + LICENSE_FETCH_POLL_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(LICENSE_FETCH_POLL_INTERVAL_MS);
|
||||
const value = await getCachedLicense();
|
||||
if (value !== undefined) return value;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
{ pollMs: LICENSE_FETCH_POLL_MS },
|
||||
"License cache not populated by holder within poll window; fetching in this process"
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
const fallback = await fetchLicenseFromServerInternal();
|
||||
await cache.set(keys.FETCH_LICENSE_CACHE_KEY, fallback, CONFIG.CACHE.FETCH_LICENSE_TTL_MS);
|
||||
return fallback;
|
||||
})();
|
||||
|
||||
fetchLicensePromise
|
||||
|
||||
@@ -188,8 +188,6 @@ export const FollowUpModal = ({
|
||||
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
|
||||
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
|
||||
attachResponseData: defaultValues?.attachResponseData ?? false,
|
||||
includeVariables: defaultValues?.includeVariables ?? false,
|
||||
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
|
||||
mode: "onChange",
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Survey Follow-Up Create & Edit", async () => {
|
||||
// 3 minutes
|
||||
test.setTimeout(1000 * 60 * 3);
|
||||
|
||||
test("Create a follow-up without optional toggles and verify it saves", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await test.step("Create a new survey", async () => {
|
||||
await page.getByText("Start from scratch").click();
|
||||
await page.getByRole("button", { name: "Create survey", exact: true }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
|
||||
});
|
||||
|
||||
await test.step("Navigate to Follow-ups tab", async () => {
|
||||
await page.getByText("Follow-ups").click();
|
||||
// Verify the empty state is shown
|
||||
await expect(page.getByText("Send automatic follow-ups")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Create a new follow-up without enabling optional toggles", async () => {
|
||||
// Click the "New follow-up" button in the empty state
|
||||
await page.getByRole("button", { name: "New follow-up" }).click();
|
||||
|
||||
// Verify the modal is open
|
||||
await expect(page.getByText("Create a new follow-up")).toBeVisible();
|
||||
|
||||
// Fill in the follow-up name
|
||||
await page.getByPlaceholder("Name your follow-up").fill("Test Follow-Up");
|
||||
|
||||
// Leave trigger as default ("Respondent completes survey")
|
||||
// Leave "Attach response data" toggle OFF (the key scenario for the bug)
|
||||
// Leave "Include variables" and "Include hidden fields" unchecked
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear — this was the bug: previously save failed silently
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step("Verify follow-up appears in the list", async () => {
|
||||
// After creation, the modal closes and the follow-up should appear in the list
|
||||
await expect(page.getByText("Test Follow-Up")).toBeVisible();
|
||||
await expect(page.getByText("Any response")).toBeVisible();
|
||||
await expect(page.getByText("Send email")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Edit the follow-up and verify it saves", async () => {
|
||||
// Click on the follow-up to edit it
|
||||
await page.getByText("Test Follow-Up").click();
|
||||
|
||||
// Verify the edit modal opens
|
||||
await expect(page.getByText("Edit this follow-up")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByPlaceholder("Name your follow-up");
|
||||
await nameInput.clear();
|
||||
await nameInput.fill("Updated Follow-Up");
|
||||
|
||||
// Save the edit
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
|
||||
// Verify the updated name appears in the list
|
||||
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Create a second follow-up with optional toggles enabled", async () => {
|
||||
// Click "+ New follow-up" button (now in the non-empty state header)
|
||||
await page.getByRole("button", { name: /New follow-up/ }).click();
|
||||
|
||||
// Verify the modal is open
|
||||
await expect(page.getByText("Create a new follow-up")).toBeVisible();
|
||||
|
||||
// Fill in the follow-up name
|
||||
await page.getByPlaceholder("Name your follow-up").fill("Follow-Up With Data");
|
||||
|
||||
// Enable "Attach response data" toggle
|
||||
await page.locator("#attachResponseData").click();
|
||||
|
||||
// Check both optional checkboxes
|
||||
await page.locator("#includeVariables").click();
|
||||
await page.locator("#includeHiddenFields").click();
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
|
||||
// Verify both follow-ups appear in the list
|
||||
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
|
||||
await expect(page.getByText("Follow-Up With Data")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
packages/cache/src/cache-keys.ts
vendored
1
packages/cache/src/cache-keys.ts
vendored
@@ -32,7 +32,6 @@ export const createCacheKey = {
|
||||
status: (organizationId: string): CacheKey => makeCacheKey("license", organizationId, "status"),
|
||||
previous_result: (organizationId: string): CacheKey =>
|
||||
makeCacheKey("license", organizationId, "previous_result"),
|
||||
fetch_lock: (organizationId: string): CacheKey => makeCacheKey("license", organizationId, "fetch_lock"),
|
||||
},
|
||||
|
||||
// Rate limiting and security
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@formbricks/i18n-utils": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@preact/preset-vite": "2.10.1",
|
||||
"@tailwindcss/postcss": "4.1.17",
|
||||
@@ -70,4 +69,4 @@
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { type TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
import { LanguageIcon } from "@/components/icons/language-icon";
|
||||
import { mixColor } from "@/lib/color";
|
||||
import { getLanguageDisplayName } from "@/lib/get-language-display-name";
|
||||
import { getI18nLanguage } from "@/lib/i18n-utils";
|
||||
import i18n from "@/lib/i18n.config";
|
||||
import { useClickOutside } from "@/lib/use-click-outside-hook";
|
||||
@@ -80,7 +80,7 @@ export function LanguageSwitch({
|
||||
title={t("common.language_switch")}
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-heading relative flex h-8 w-8 items-center justify-center rounded-md focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
|
||||
"text-heading focus:outline-hidden relative flex h-8 w-8 items-center justify-center rounded-md focus:ring-2 focus:ring-offset-2"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isHovered ? hoverColorWithOpacity : "transparent",
|
||||
@@ -113,7 +113,7 @@ export function LanguageSwitch({
|
||||
onClick={() => {
|
||||
changeLanguage(surveyLanguage.language.code);
|
||||
}}>
|
||||
{getLanguageLabel(surveyLanguage.language.code, "en-US")}
|
||||
{getLanguageDisplayName(surveyLanguage.language.code)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
14
packages/surveys/src/lib/get-language-display-name.ts
Normal file
14
packages/surveys/src/lib/get-language-display-name.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Returns the display name for a language code using Intl.DisplayNames (built-in, 0 KB).
|
||||
* Falls back to the code if the API is unavailable or the tag is unknown.
|
||||
*/
|
||||
export function getLanguageDisplayName(code: string): string {
|
||||
if (typeof Intl === "undefined" || !Intl.DisplayNames) return code;
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(["en-US"], { type: "language" });
|
||||
const name = displayNames.of(code);
|
||||
return name && name !== code ? name : code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -999,9 +999,6 @@ importers:
|
||||
'@formbricks/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../config-eslint
|
||||
'@formbricks/i18n-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../i18n-utils
|
||||
'@formbricks/types':
|
||||
specifier: workspace:*
|
||||
version: link:../types
|
||||
@@ -7663,6 +7660,7 @@ packages:
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@11.1.0:
|
||||
@@ -7672,7 +7670,7 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@13.24.0:
|
||||
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
||||
|
||||
Reference in New Issue
Block a user