364 lines
9.3 KiB
TypeScript
364 lines
9.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import type { TestContext } from 'node:test';
|
|
import type { Transaction } from 'sequelize';
|
|
|
|
import db from '../../src/db/models/index.ts';
|
|
import AccessPolicy from '../../src/services/access-policy.ts';
|
|
import AccessPolicyAuditService from '../../src/services/access-policy-audit.ts';
|
|
import type {
|
|
CurrentUser,
|
|
PermissionRecord,
|
|
ProjectCreatePayload,
|
|
ProjectModelRecord,
|
|
ProductionPresentationVisibility,
|
|
RoleModelRecord,
|
|
UserModelRecord,
|
|
UserProductionPresentationAccessPayload,
|
|
UserRecord,
|
|
} from '../../src/types/index.ts';
|
|
|
|
const suffix = `${Date.now()}-${process.pid}`;
|
|
|
|
void test.after(async () => {
|
|
await db.sequelize.close();
|
|
});
|
|
|
|
async function authenticateWithTimeout(timeoutMs = 1500): Promise<void> {
|
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timeoutId = setTimeout(
|
|
() => reject(new Error(`Database unavailable after ${timeoutMs}ms`)),
|
|
timeoutMs,
|
|
);
|
|
});
|
|
|
|
try {
|
|
await Promise.race([db.sequelize.authenticate(), timeout]);
|
|
} finally {
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withTransaction(
|
|
t: TestContext,
|
|
callback: (transaction: Transaction) => Promise<void>,
|
|
): Promise<void> {
|
|
try {
|
|
await authenticateWithTimeout();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'unknown error';
|
|
t.skip(`Database unavailable: ${message}`);
|
|
return;
|
|
}
|
|
|
|
const transaction = await db.sequelize.transaction();
|
|
try {
|
|
await callback(transaction);
|
|
} finally {
|
|
await transaction.rollback();
|
|
}
|
|
}
|
|
|
|
async function createRole(
|
|
name: string,
|
|
transaction: Transaction,
|
|
): Promise<RoleModelRecord> {
|
|
return db.roles.create({ name }, { transaction });
|
|
}
|
|
|
|
function createPermission(
|
|
name: string,
|
|
transaction: Transaction,
|
|
): Promise<PermissionRecord> {
|
|
return db.permissions.create({ name }, { transaction });
|
|
}
|
|
|
|
interface CreateUserOptions {
|
|
email: string;
|
|
role: RoleModelRecord;
|
|
}
|
|
|
|
async function createUser(
|
|
{ email, role }: CreateUserOptions,
|
|
transaction: Transaction,
|
|
): Promise<UserModelRecord> {
|
|
const user = await db.users.create(
|
|
{
|
|
email,
|
|
password: 'not-used-in-test',
|
|
emailVerified: true,
|
|
},
|
|
{ transaction },
|
|
);
|
|
await user.setApp_role(role, { transaction });
|
|
return user;
|
|
}
|
|
|
|
interface CreateProjectOptions {
|
|
slug: string;
|
|
visibility: ProductionPresentationVisibility;
|
|
}
|
|
|
|
async function createProject(
|
|
{ slug, visibility }: CreateProjectOptions,
|
|
transaction: Transaction,
|
|
): Promise<ProjectModelRecord> {
|
|
const data: ProjectCreatePayload = {
|
|
id: undefined,
|
|
name: `Test ${slug}`,
|
|
slug,
|
|
description: undefined,
|
|
logo_url: undefined,
|
|
favicon_url: undefined,
|
|
og_image_url: undefined,
|
|
design_width: undefined,
|
|
design_height: undefined,
|
|
production_presentation_visibility: visibility,
|
|
importHash: null,
|
|
createdById: null,
|
|
updatedById: null,
|
|
};
|
|
|
|
return db.projects.create(data, { transaction });
|
|
}
|
|
|
|
function requireUserModelRecord(
|
|
user: UserModelRecord | null,
|
|
): UserModelRecord {
|
|
if (!user) {
|
|
throw new Error('Expected test user to exist.');
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
function toCurrentUser(user: UserRecord): CurrentUser {
|
|
const currentUser: CurrentUser = { id: user.id };
|
|
|
|
if (typeof user.email === 'string') {
|
|
currentUser.email = user.email;
|
|
}
|
|
|
|
if (user.app_role !== undefined) {
|
|
currentUser.app_role = user.app_role;
|
|
}
|
|
|
|
if (user.custom_permissions !== undefined) {
|
|
currentUser.custom_permissions = user.custom_permissions;
|
|
}
|
|
|
|
if (user.app_role_permissions !== undefined) {
|
|
currentUser.app_role_permissions = user.app_role_permissions;
|
|
}
|
|
|
|
return currentUser;
|
|
}
|
|
|
|
function createProductionAccessPayload(
|
|
userId: string,
|
|
projectId: string,
|
|
): UserProductionPresentationAccessPayload {
|
|
const now = new Date();
|
|
|
|
return {
|
|
userId,
|
|
projectId,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
createdById: null,
|
|
updatedById: null,
|
|
};
|
|
}
|
|
|
|
void test('guest can view public production presentation only', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const publicProject = await createProject(
|
|
{
|
|
slug: `test-public-runtime-access-${suffix}`,
|
|
visibility: 'public',
|
|
},
|
|
transaction,
|
|
);
|
|
const privateProject = await createProject(
|
|
{
|
|
slug: `test-private-runtime-access-${suffix}`,
|
|
visibility: 'private',
|
|
},
|
|
transaction,
|
|
);
|
|
|
|
assert.equal(
|
|
await AccessPolicy.canViewProductionPresentation(
|
|
null,
|
|
publicProject.slug,
|
|
{ transaction },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await AccessPolicy.canViewProductionPresentation(
|
|
null,
|
|
privateProject.slug,
|
|
{ transaction },
|
|
),
|
|
false,
|
|
);
|
|
});
|
|
});
|
|
|
|
void test('public user can view granted private production presentation', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const publicRole = await createRole('Public', transaction);
|
|
const publicUser = await createUser(
|
|
{
|
|
email: `public-granted-${suffix}@example.test`,
|
|
role: publicRole,
|
|
},
|
|
transaction,
|
|
);
|
|
const privateProject = await createProject(
|
|
{
|
|
slug: `test-private-granted-runtime-access-${suffix}`,
|
|
visibility: 'private',
|
|
},
|
|
transaction,
|
|
);
|
|
|
|
await db.production_presentation_access.create(
|
|
createProductionAccessPayload(publicUser.id, privateProject.id),
|
|
{ transaction },
|
|
);
|
|
|
|
const authUser = await db.users.findOne({
|
|
where: { id: publicUser.id },
|
|
include: [
|
|
{ association: 'app_role', include: [{ association: 'permissions' }] },
|
|
{ association: 'custom_permissions' },
|
|
],
|
|
transaction,
|
|
});
|
|
|
|
const authUserRecord = requireUserModelRecord(authUser);
|
|
|
|
assert.equal(
|
|
await AccessPolicy.canViewProductionPresentation(
|
|
toCurrentUser(authUserRecord.get({ plain: true })),
|
|
privateProject.slug,
|
|
{ transaction },
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
void test('internal user with permission can use admin api and view private presentation', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const role = await createRole('Content Reviewer', transaction);
|
|
const permission = await createPermission(
|
|
`TEST_READ_PROJECTS_${suffix}`,
|
|
transaction,
|
|
);
|
|
await role.setPermissions([permission], { transaction });
|
|
|
|
const internalUser = await createUser(
|
|
{
|
|
email: `internal-access-${suffix}@example.test`,
|
|
role,
|
|
},
|
|
transaction,
|
|
);
|
|
const privateProject = await createProject(
|
|
{
|
|
slug: `test-private-internal-runtime-access-${suffix}`,
|
|
visibility: 'private',
|
|
},
|
|
transaction,
|
|
);
|
|
|
|
const authUser = await db.users.findOne({
|
|
where: { id: internalUser.id },
|
|
include: [
|
|
{ association: 'app_role', include: [{ association: 'permissions' }] },
|
|
{ association: 'custom_permissions' },
|
|
],
|
|
transaction,
|
|
});
|
|
const plainUser = toCurrentUser(
|
|
requireUserModelRecord(authUser).get({ plain: true }),
|
|
);
|
|
|
|
assert.equal(AccessPolicy.canUseAdminApi(plainUser), true);
|
|
assert.equal(
|
|
await AccessPolicy.canViewProductionPresentation(
|
|
plainUser,
|
|
privateProject.slug,
|
|
{ transaction },
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
void test('audit finds and cleanup removes stale Public grants', async (t) => {
|
|
await withTransaction(t, async (transaction) => {
|
|
const publicRole = await createRole('Public', transaction);
|
|
const internalRole = await createRole('Tour Designer', transaction);
|
|
const permission = await createPermission(
|
|
`TEST_READ_USERS_${suffix}`,
|
|
transaction,
|
|
);
|
|
await publicRole.setPermissions([permission], { transaction });
|
|
|
|
const publicUser = await createUser(
|
|
{
|
|
email: `public-stale-${suffix}@example.test`,
|
|
role: publicRole,
|
|
},
|
|
transaction,
|
|
);
|
|
await publicUser.setCustom_permissions([permission], { transaction });
|
|
|
|
const internalUser = await createUser(
|
|
{
|
|
email: `internal-stale-grant-${suffix}@example.test`,
|
|
role: internalRole,
|
|
},
|
|
transaction,
|
|
);
|
|
const privateProject = await createProject(
|
|
{
|
|
slug: `test-private-stale-grant-${suffix}`,
|
|
visibility: 'private',
|
|
},
|
|
transaction,
|
|
);
|
|
await db.production_presentation_access.create(
|
|
createProductionAccessPayload(internalUser.id, privateProject.id),
|
|
{ transaction },
|
|
);
|
|
|
|
const report = await AccessPolicyAuditService.findViolations({
|
|
transaction,
|
|
});
|
|
|
|
assert.ok(report.publicRolePermissions.length >= 1);
|
|
assert.ok(report.publicUsersWithCustomPermissions.length >= 1);
|
|
assert.ok(report.productionPresentationAccessForNonPublicUsers.length >= 1);
|
|
|
|
const cleanup = await AccessPolicyAuditService.cleanupViolations({
|
|
transaction,
|
|
});
|
|
assert.ok(cleanup.removedPublicRolePermissions >= 1);
|
|
assert.ok(cleanup.clearedPublicUserCustomPermissions >= 1);
|
|
assert.ok(cleanup.removedNonPublicProductionPresentationGrants >= 1);
|
|
|
|
const after = await AccessPolicyAuditService.findViolations({
|
|
transaction,
|
|
});
|
|
assert.equal(AccessPolicyAuditService.hasViolations(after), false);
|
|
});
|
|
});
|