feat: add license integration (#2346)

Changes:
- Adds integration for the license server.
- Prevent adding flags that the instance is not allowed to add
This commit is contained in:
David Nguyen
2026-01-29 13:30:48 +11:00
committed by GitHub
parent d18dcb4d60
commit 1b0df2d082
29 changed files with 1645 additions and 93 deletions
@@ -0,0 +1,326 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given flags.
*/
const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status: 'ACTIVE',
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags,
},
requestedLicenseKey: 'test-license-key',
derivedStatus: 'ACTIVE',
unauthorizedFlagUsage: false,
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('Enterprise Feature Restrictions', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({
page,
}) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features have asterisks (are restricted)
// These are the enterprise features that should be marked with *
await expect(page.getByText(/Email domains\s¹/)).toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Check that the alert is visible
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible();
// Check that enterprise feature checkboxes are disabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeDisabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({
page,
}) => {
// Create a license with ALL enterprise features enabled
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
embedAuthoring: true,
embedAuthoringWhiteLabel: true,
cfr21: true,
authenticationPortal: true,
billing: true,
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Check that enterprise features do NOT have asterisks
// They should show without the * since the license covers them
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible();
// The plain labels should be visible (without asterisks)
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// The alert should NOT be visible
await expect(
page.getByText('Your current license does not include these features.'),
).not.toBeVisible();
// Check that enterprise feature checkboxes are enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeEnabled();
});
test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({
page,
}) => {
// Create a license with SOME enterprise features (emailDomains and cfr21)
await writeLicenseFile(
createMockLicenseWithFlags({
emailDomains: true,
cfr21: true,
// embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included
}),
);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Features NOT in license should have asterisks
await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible();
await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible();
// Features IN license should NOT have asterisks
await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible();
await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible();
// The plain labels for licensed features should be visible
await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains');
await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR');
// Alert should be visible since some features are restricted
await expect(
page.getByText('Your current license does not include these features.'),
).toBeVisible();
// Licensed features should be enabled
const emailDomainsCheckbox = page.locator('#flag-emailDomains');
await expect(emailDomainsCheckbox).toBeEnabled();
const cfr21Checkbox = page.locator('#flag-cfr21');
await expect(cfr21Checkbox).toBeEnabled();
// Unlicensed features should be disabled
const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring');
await expect(embedAuthoringCheckbox).toBeDisabled();
const authPortalCheckbox = page.locator('#flag-authenticationPortal');
await expect(authPortalCheckbox).toBeDisabled();
});
test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => {
// Ensure no license file exists
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin/claims',
});
// Click Create claim button to open the dialog
await page.getByRole('button', { name: 'Create claim' }).click();
// Wait for dialog to open
await expect(page.getByRole('dialog')).toBeVisible();
// Non-enterprise features should NOT have asterisks
await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible();
await expect(page.getByText(/Branding\s¹/)).not.toBeVisible();
await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible();
// Non-enterprise features should always be enabled
const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments');
await expect(unlimitedDocsCheckbox).toBeEnabled();
const brandingCheckbox = page.locator('#flag-allowCustomBranding');
await expect(brandingCheckbox).toBeEnabled();
const embedSigningCheckbox = page.locator('#flag-embedSigning');
await expect(embedSigningCheckbox).toBeEnabled();
});
});
@@ -0,0 +1,392 @@
import { expect, test } from '@playwright/test';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { TCachedLicense } from '@documenso/lib/types/license';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const LICENSE_FILE_NAME = '.documenso-license.json';
const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json';
/**
* Get the path to the license file.
*
* The server reads from process.cwd() which is apps/remix when the dev server runs.
* Tests run from packages/app-tests, so we need to go up to the root then into apps/remix.
*/
const getLicenseFilePath = () => {
// From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json
return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME);
};
/**
* Get the path to the backup license file.
*/
const getBackupLicenseFilePath = () => {
return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME);
};
/**
* Backup the existing license file if it exists.
*/
const backupLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(licensePath);
await fs.rename(licensePath, backupPath);
} catch (e) {
// File doesn't exist, nothing to backup
console.log(e);
}
};
/**
* Restore the backup license file if it exists.
*/
const restoreLicenseFile = async () => {
const licensePath = getLicenseFilePath();
const backupPath = getBackupLicenseFilePath();
try {
await fs.access(backupPath);
await fs.rename(backupPath, licensePath);
} catch (e) {
// Backup doesn't exist, nothing to restore
console.log(e);
}
};
/**
* Write a license file with the given data.
* Pass null to delete the license file.
*/
const writeLicenseFile = async (data: TCachedLicense | null) => {
const licensePath = getLicenseFilePath();
if (data === null) {
await fs.unlink(licensePath).catch(() => {
// File doesn't exist, ignore
});
} else {
await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8');
}
};
/**
* Create a mock license object with the given status and unauthorized flag.
*/
const createMockLicense = (
status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE',
unauthorizedFlagUsage: boolean,
): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: {
status,
createdAt: new Date(),
name: 'Test License',
periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
cancelAtPeriodEnd: false,
licenseKey: 'test-license-key',
flags: {},
},
requestedLicenseKey: 'test-license-key',
derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status,
unauthorizedFlagUsage,
};
};
/**
* Create a mock license object with no license data (only unauthorized flag).
*/
const createMockUnauthorizedWithoutLicense = (): TCachedLicense => {
return {
lastChecked: new Date().toISOString(),
license: null,
unauthorizedFlagUsage: true,
derivedStatus: 'UNAUTHORIZED',
};
};
// Run tests serially to avoid race conditions with the license file
test.describe.configure({ mode: 'serial' });
// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE.
test.describe.skip('License Status Banner', () => {
test.beforeAll(async () => {
// Backup any existing license file before running tests
await backupLicenseFile();
});
test.afterAll(async () => {
// Restore the backup license file after all tests complete
await restoreLicenseFile();
});
test.beforeEach(async () => {
// Clean up license file before each test to ensure clean state
await writeLicenseFile(null);
});
test.afterEach(async () => {
// Clean up license file after each test
await writeLicenseFile(null);
});
test('[ADMIN]: no banner when license file is missing', async ({ page }) => {
// Ensure no license file exists BEFORE any page loads
await writeLicenseFile(null);
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (no license file)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (no license file means no banner)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
await expect(page.getByText('Missing License')).not.toBeVisible();
});
test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => {
// Create an ACTIVE license BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should not be visible (license is ACTIVE)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner messages should not be visible (license is ACTIVE)
await expect(page.getByText('License payment overdue')).not.toBeVisible();
await expect(page.getByText('License expired')).not.toBeVisible();
await expect(page.getByText('Invalid License Type')).not.toBeVisible();
});
test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => {
// Create a PAST_DUE license BEFORE any page loads
await writeLicenseFile(createMockLicense('PAST_DUE', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (only shows for EXPIRED + unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show PAST_DUE message
await expect(page.getByText('License payment overdue')).toBeVisible();
await expect(
page.getByText('Please update your payment to avoid service disruptions.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => {
// Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', false));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires BOTH expired AND unauthorized)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show EXPIRED message
await expect(page.getByText('License expired')).toBeVisible();
await expect(
page.getByText('Please renew your license to continue using enterprise features.'),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner SHOULD be visible (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
// Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
});
test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({
page,
}) => {
// Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads
await writeLicenseFile(createMockLicense('ACTIVE', true));
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (requires EXPIRED status)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show UNAUTHORIZED message
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({
page,
}) => {
// Create a license file with unauthorized flag but no license data BEFORE any page loads
// Note: Even without license data, the banner shows "Invalid License Type" because the
// license file exists (just with license: null). The "Missing License" message would only
// show if the entire license prop was null, which doesn't happen with a valid file.
await writeLicenseFile(createMockUnauthorizedWithoutLicense());
const { user: adminUser } = await seedUser({
isAdmin: true,
});
// Navigate to admin page - license is read during page load
await apiSignin({
page,
email: adminUser.email,
redirectPath: '/admin',
});
// Verify we're on the admin page
await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible();
// Global banner should NOT be visible (no EXPIRED status, only unauthorized flag)
await expect(
page.getByText('This is an expired license instance of Documenso'),
).not.toBeVisible();
// Admin banner should show Invalid License Type message (unauthorized flag is set)
await expect(page.getByText('Invalid License Type')).toBeVisible();
await expect(
page.getByText(
'Your Documenso instance is using features that are not part of your license.',
),
).toBeVisible();
// Should have the "See Documentation" link
await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible();
});
test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({
page,
}) => {
// Create an EXPIRED license WITH unauthorized usage BEFORE any page loads
await writeLicenseFile(createMockLicense('EXPIRED', true));
const { user } = await seedUser();
// Navigate to documents page - license is read during page load
await apiSignin({
page,
email: user.email,
redirectPath: '/documents',
});
// Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized)
await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible();
});
});
+12 -1
View File
@@ -83,10 +83,21 @@ export default defineConfig({
testMatch: /e2e\/api\/.*\.spec\.ts/,
workers: 10, // Limited by DB connections before it gets flakey.
},
// Run UI Tests
// License tests that share a single license file - must run serially
{
name: 'license',
testMatch: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
},
workers: 1, // Must run serially since they share a license file
},
// Run UI Tests (excluding license tests which have their own project)
{
name: 'ui',
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
testIgnore: /e2e\/license\/.*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },