PSA/packages/scheduling/tests/timeEntryDelegationAuth.authorization.test.ts
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

300 lines
10 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const hasPermissionMock = vi.hoisted(() => vi.fn());
const featureFlagMock = vi.hoisted(() => vi.fn());
const reportsToMock = vi.hoisted(() => vi.fn());
const resolveBundleRulesMock = vi.hoisted(() => vi.fn(async () => []));
vi.mock('@alga-psa/auth', () => ({
hasPermission: (...args: unknown[]) => hasPermissionMock(...args),
}));
vi.mock('@alga-psa/core', () => ({
isFeatureFlagEnabled: (...args: unknown[]) => featureFlagMock(...args),
}));
vi.mock('@alga-psa/db', () => ({
User: {
getReportsToSubordinateIds: (...args: unknown[]) => reportsToMock(...args),
},
}));
vi.mock('@alga-psa/authorization/bundles/service', () => ({
resolveBundleNarrowingRulesForEvaluation: (...args: unknown[]) => resolveBundleRulesMock(...args),
}));
vi.mock('@alga-psa/authorization/kernel', () => {
class BuiltinAuthorizationKernelProvider {
relationshipRules?: Array<{ template: string }>;
mutationGuards?: Array<(input: any) => { allowed: boolean }>;
constructor(config?: { relationshipRules?: Array<{ template: string }>; mutationGuards?: Array<(input: any) => { allowed: boolean }> }) {
this.relationshipRules = config?.relationshipRules;
this.mutationGuards = config?.mutationGuards;
}
}
class BundleAuthorizationKernelProvider {
resolveRules: (input: unknown) => Promise<Array<Record<string, unknown>>>;
constructor(config: { resolveRules: (input: unknown) => Promise<Array<Record<string, unknown>>> }) {
this.resolveRules = config.resolveRules;
}
}
class RequestLocalAuthorizationCache {}
const createAuthorizationKernel = (config: {
builtinProvider?: BuiltinAuthorizationKernelProvider;
bundleProvider?: BundleAuthorizationKernelProvider;
}) => ({
authorizeResource: async (input: any) => {
const rules = config.bundleProvider ? await config.bundleProvider.resolveRules(input) : [];
const managedOnly = config.builtinProvider?.relationshipRules?.some((rule) => rule.template === 'managed');
const builtinAllowed = managedOnly
? Array.isArray(input.subject.managedUserIds) && input.subject.managedUserIds.includes(input.record.ownerUserId)
: true;
let bundleAllowed = true;
const matchingRules = rules.filter(
(rule) => rule.resource === input.resource.type && rule.action === input.resource.action
);
for (const rule of matchingRules) {
if (rule.templateKey === 'own') {
bundleAllowed = bundleAllowed && input.record.ownerUserId === input.subject.userId;
}
if (rule.templateKey === 'managed') {
bundleAllowed =
bundleAllowed &&
Array.isArray(input.subject.managedUserIds) &&
input.subject.managedUserIds.includes(input.record.ownerUserId);
}
if (rule.templateKey === 'own_or_managed') {
const isOwn = input.record.ownerUserId === input.subject.userId;
const isManaged =
Array.isArray(input.subject.managedUserIds) &&
input.subject.managedUserIds.includes(input.record.ownerUserId);
bundleAllowed = bundleAllowed && (isOwn || isManaged);
}
}
return {
allowed: builtinAllowed && bundleAllowed,
reasons: [],
scope: { allowAll: builtinAllowed && bundleAllowed, denied: !(builtinAllowed && bundleAllowed), constraints: [] },
redactedFields: [],
};
},
authorizeMutation: async (input: any) => {
let builtinAllowed = true;
for (const guard of config.builtinProvider?.mutationGuards ?? []) {
const result = guard(input);
if (!result.allowed) {
builtinAllowed = false;
break;
}
}
const rules = config.bundleProvider ? await config.bundleProvider.resolveRules(input) : [];
const matchingRules = rules.filter(
(rule) => rule.resource === input.resource.type && rule.action === input.resource.action
);
let bundleAllowed = true;
for (const rule of matchingRules) {
if (
rule.constraintKey === 'not_self_approver' &&
input.record.ownerUserId === input.subject.userId
) {
bundleAllowed = false;
}
}
return {
allowed: builtinAllowed && bundleAllowed,
reasons: [],
scope: { allowAll: builtinAllowed && bundleAllowed, denied: !(builtinAllowed && bundleAllowed), constraints: [] },
redactedFields: [],
};
},
});
return {
BuiltinAuthorizationKernelProvider,
BundleAuthorizationKernelProvider,
RequestLocalAuthorizationCache,
createAuthorizationKernel,
};
});
import {
assertCanActOnBehalf,
assertCanApproveSubject,
resolveManagedSubjectUserIds,
} from '../src/actions/timeEntryDelegationAuth';
type TestUser = {
user_id: string;
user_type: 'internal' | 'client';
clientId?: string | null;
};
function buildDb(managedIds: string[]) {
return ((table: string) => {
if (table === 'teams') {
return {
join: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
select: vi.fn(async () => managedIds.map((user_id) => ({ user_id }))),
};
}
if (table === 'users') {
return {
where: vi.fn().mockReturnThis(),
select: vi.fn(async () => []),
};
}
return {
where: vi.fn().mockReturnThis(),
select: vi.fn(async () => []),
first: vi.fn(async () => undefined),
};
}) as any;
}
describe('time authorization delegation and approval contracts', () => {
beforeEach(() => {
hasPermissionMock.mockReset();
featureFlagMock.mockReset();
reportsToMock.mockReset();
resolveBundleRulesMock.mockReset();
featureFlagMock.mockResolvedValue(false);
reportsToMock.mockResolvedValue([]);
resolveBundleRulesMock.mockResolvedValue([]);
});
it('T017: preserves self, manager, reports-to, and tenant-wide delegation semantics', async () => {
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
const db = buildDb(['u-2']);
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-1', db)).resolves.toBe('self');
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
if (resource !== 'timesheet') return false;
if (action === 'approve') return true;
if (action === 'read_all') return false;
return false;
});
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-2', db)).resolves.toBe('manager');
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
if (resource !== 'timesheet') return false;
if (action === 'approve') return true;
if (action === 'read_all') return true;
return false;
});
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-9', db)).resolves.toBe('tenant-wide');
featureFlagMock.mockResolvedValue(true);
reportsToMock.mockResolvedValue(['u-77']);
const managedIds = await resolveManagedSubjectUserIds(db, 'tenant-1', actor as any);
expect(managedIds).toEqual(expect.arrayContaining(['u-2', 'u-77']));
});
it('T018: premium bundle rules can narrow delegation but cannot broaden beyond builtin model', async () => {
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
if (resource !== 'timesheet') return false;
if (action === 'approve') return true;
if (action === 'read_all') return true;
return false;
});
resolveBundleRulesMock.mockResolvedValueOnce([
{
resource: 'time_entry',
action: 'read',
templateKey: 'own',
},
]);
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).rejects.toThrow(
'Permission denied: Cannot access other users time sheets'
);
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
if (resource !== 'timesheet') return false;
if (action === 'approve') return true;
if (action === 'read_all') return false;
return false;
});
resolveBundleRulesMock.mockResolvedValueOnce([
{
resource: 'time_entry',
action: 'read',
templateKey: 'own_or_managed',
},
]);
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-999', buildDb([]))).rejects.toThrow(
'Permission denied: Cannot access other users time sheets'
);
});
it('T019: self-approval is allowed by default and denied only by configured not-self-approver bundle rules', async () => {
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
if (resource !== 'timesheet') return false;
if (action === 'approve') return true;
if (action === 'read_all') return true;
return false;
});
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-1', buildDb([]))).resolves.toBe('self');
resolveBundleRulesMock.mockResolvedValueOnce([
{
resource: 'time_entry',
action: 'approve',
constraintKey: 'not_self_approver',
},
]);
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-1', buildDb([]))).rejects.toThrow(
'Permission denied: Cannot approve your own time submissions'
);
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).resolves.toBe('tenant-wide');
});
it('T026: delegation and approval checks fail closed when bundle narrowing resolution errors', async () => {
const actor: TestUser = { user_id: 'u-1', user_type: 'internal' };
hasPermissionMock.mockImplementation(async (_user: unknown, resource: string, action: string) => {
if (resource !== 'timesheet') return false;
if (action === 'approve') return true;
if (action === 'read_all') return true;
return false;
});
resolveBundleRulesMock.mockRejectedValueOnce(new Error('bundle lookup failed'));
await expect(assertCanActOnBehalf(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).rejects.toThrow(
'Permission denied: Cannot access other users time sheets'
);
resolveBundleRulesMock
.mockResolvedValueOnce([])
.mockRejectedValueOnce(new Error('bundle lookup failed'));
await expect(assertCanApproveSubject(actor as any, 'tenant-1', 'u-2', buildDb(['u-2']))).rejects.toThrow(
'Permission denied: Cannot approve time submissions'
);
});
});