From fe2641ff57a981052bdaae7935f9e7ea8d1e7cd6 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 14 Sep 2026 14:16:23 +1000 Subject: [PATCH] chore: tests --- .github/workflows/ci.yml | 20 ++++++ .../lib/utils/two-factor-challenge.test.ts | 32 ++------- packages/lib/utils/two-factor.test.ts | 69 ++----------------- .../two-factor-enforcement/scope.test.ts | 47 ++----------- 4 files changed, 36 insertions(+), 132 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b1007da..2d79768c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,26 @@ jobs: - name: Build app run: npm run build + unit_tests: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - uses: ./.github/actions/node-install + + - name: Copy env + run: cp .env.example .env + + # Includes the 2FA enforcement drift guard (packages/trpc), which is the + # only check that catches a session route without enforcement middleware. + - name: Run unit tests + run: npm run test -w @documenso/lib -w @documenso/trpc + build_docker: name: Build Docker Image runs-on: ubuntu-latest diff --git a/packages/lib/utils/two-factor-challenge.test.ts b/packages/lib/utils/two-factor-challenge.test.ts index 730fc448b..a442f6f35 100644 --- a/packages/lib/utils/two-factor-challenge.test.ts +++ b/packages/lib/utils/two-factor-challenge.test.ts @@ -104,38 +104,18 @@ describe('ZTwoFactorChallengeMetadataSchema', () => { }); }); -describe('shouldConsumeChallengeAfterFailure', () => { - it('does not consume below the maximum attempts', () => { +// The `>=` boundary matters: an off-by-one here would grant one extra guess +// per challenge (or one extra valid moment past expiry). +describe('challenge consumption boundaries', () => { + it('consumes the challenge at exactly the maximum attempts, not before', () => { expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS - 1)).toBe(false); - expect(shouldConsumeChallengeAfterFailure(1)).toBe(false); - }); - - it('consumes at exactly the maximum attempts', () => { expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS)).toBe(true); }); - it('consumes beyond the maximum attempts', () => { - expect(shouldConsumeChallengeAfterFailure(TWO_FACTOR_CHALLENGE_MAX_ATTEMPTS + 5)).toBe(true); - }); + it('treats the expiry instant itself as expired', () => { + const expiresAt = new Date('2026-01-01T00:10:00.000Z'); - it('honors a custom maximum', () => { - expect(shouldConsumeChallengeAfterFailure(2, 3)).toBe(false); - expect(shouldConsumeChallengeAfterFailure(3, 3)).toBe(true); - }); -}); - -describe('isChallengeExpired', () => { - const expiresAt = new Date('2026-01-01T00:10:00.000Z'); - - it('is not expired before the expiry instant', () => { expect(isChallengeExpired({ expiresAt, now: new Date('2026-01-01T00:09:59.999Z') })).toBe(false); - }); - - it('is expired at exactly the expiry instant', () => { expect(isChallengeExpired({ expiresAt, now: expiresAt })).toBe(true); }); - - it('is expired after the expiry instant', () => { - expect(isChallengeExpired({ expiresAt, now: new Date('2026-01-01T00:10:00.001Z') })).toBe(true); - }); }); diff --git a/packages/lib/utils/two-factor.test.ts b/packages/lib/utils/two-factor.test.ts index e3dad6404..2f4bd11d2 100644 --- a/packages/lib/utils/two-factor.test.ts +++ b/packages/lib/utils/two-factor.test.ts @@ -7,27 +7,10 @@ import { computeTwoFactorEnforcementStatus, evaluateInstanceTwoFactorEnforcementUpdate, isInstanceTwoFactorEnforcementActive, - isTwoFactorDeadlineExpired, isTwoFactorGracePeriodReduction, - isTwoFactorSatisfied, MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS, } from './two-factor'; -describe('isTwoFactorSatisfied', () => { - it('is satisfied only when the user is enrolled and the session is verified', () => { - expect(isTwoFactorSatisfied({ userTwoFactorEnabled: true, sessionTwoFactorVerified: true })).toBe(true); - }); - - it('is not satisfied when the session is unverified', () => { - expect(isTwoFactorSatisfied({ userTwoFactorEnabled: true, sessionTwoFactorVerified: false })).toBe(false); - }); - - it('is not satisfied when the user is not enrolled', () => { - expect(isTwoFactorSatisfied({ userTwoFactorEnabled: false, sessionTwoFactorVerified: true })).toBe(false); - expect(isTwoFactorSatisfied({ userTwoFactorEnabled: false, sessionTwoFactorVerified: false })).toBe(false); - }); -}); - describe('calculateTwoFactorDeadline', () => { it('adds the grace period to a single anchor', () => { const anchor = new Date('2026-01-01T00:00:00.000Z'); @@ -74,28 +57,6 @@ describe('calculateTwoFactorDeadline', () => { }); }); -describe('isTwoFactorDeadlineExpired', () => { - const deadline = new Date('2026-01-08T00:00:00.000Z'); - - it('is not expired before the deadline', () => { - const now = new Date('2026-01-07T23:59:59.999Z'); - - expect(isTwoFactorDeadlineExpired({ deadline, now })).toBe(false); - }); - - it('counts the deadline instant itself as expired', () => { - const now = new Date('2026-01-08T00:00:00.000Z'); - - expect(isTwoFactorDeadlineExpired({ deadline, now })).toBe(true); - }); - - it('is expired after the deadline', () => { - const now = new Date('2026-01-08T00:00:00.001Z'); - - expect(isTwoFactorDeadlineExpired({ deadline, now })).toBe(true); - }); -}); - describe('isTwoFactorGracePeriodReduction', () => { const anchor = new Date('2026-01-01T00:00:00.000Z'); const now = new Date('2026-01-02T00:00:00.000Z'); @@ -741,37 +702,17 @@ describe('evaluateInstanceTwoFactorEnforcementUpdate', () => { describe('calculateTwoFactorDeadlineTimerDelay', () => { const now = new Date('2026-06-01T00:00:00.000Z'); - it('returns the exact delay for a deadline within the timer range', () => { - const deadline = new Date(now.getTime() + 5_000); - - expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(5_000); - }); - - it('returns 0 for a deadline at the current instant (deadline counts as expired)', () => { - expect(calculateTwoFactorDeadlineTimerDelay({ deadline: now, now })).toBe(0); - }); - it('returns 0 for a past deadline', () => { const deadline = new Date(now.getTime() - 60_000); expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(0); }); - it('returns the delay at exactly the setTimeout maximum', () => { - const deadline = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS); + it('returns the delay at exactly the setTimeout maximum and null just past it', () => { + const atMax = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS); + const pastMax = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS + 1); - expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBe(MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS); - }); - - it('returns null when the deadline exceeds the setTimeout maximum', () => { - const deadline = new Date(now.getTime() + MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS + 1); - - expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBeNull(); - }); - - it('returns null for a deadline months away', () => { - const deadline = new Date('2026-12-01T00:00:00.000Z'); - - expect(calculateTwoFactorDeadlineTimerDelay({ deadline, now })).toBeNull(); + expect(calculateTwoFactorDeadlineTimerDelay({ deadline: atMax, now })).toBe(MAX_TWO_FACTOR_DEADLINE_TIMER_DELAY_MS); + expect(calculateTwoFactorDeadlineTimerDelay({ deadline: pastMax, now })).toBeNull(); }); }); diff --git a/packages/trpc/server/two-factor-enforcement/scope.test.ts b/packages/trpc/server/two-factor-enforcement/scope.test.ts index 88af937d8..5d7a82533 100644 --- a/packages/trpc/server/two-factor-enforcement/scope.test.ts +++ b/packages/trpc/server/two-factor-enforcement/scope.test.ts @@ -16,28 +16,12 @@ describe('normalizeTwoFactorScopeResult', () => { expect(normalizeTwoFactorScopeResult(TWO_FACTOR_CTX_TEAM)).toEqual({ type: 'ctxTeam' }); }); - it('normalizes an empty descriptor to no resources (instance assert only)', () => { - expect(normalizeTwoFactorScopeResult({})).toEqual({ type: 'resources', resources: [] }); - }); - - it('normalizes single IDs and ID arrays', () => { - expect(normalizeTwoFactorScopeResult({ envelope: 'envelope_1' })).toEqual({ - type: 'resources', - resources: [{ kind: 'envelope', envelopeIds: ['envelope_1'], documentIds: [], templateIds: [] }], - }); - - expect(normalizeTwoFactorScopeResult({ envelope: ['a', 'b'] })).toEqual({ - type: 'resources', - resources: [{ kind: 'envelope', envelopeIds: ['a', 'b'], documentIds: [], templateIds: [] }], - }); - }); - it('normalizes every resource kind to its resource-IDs variant', () => { const result = normalizeTwoFactorScopeResult({ organisation: 'org_1', organisationReference: 'my-org-url', team: 1, - teamReference: 7, + teamReference: ['my-team-url', 7], envelope: 'envelope_1', document: 2, template: 3, @@ -59,7 +43,7 @@ describe('normalizeTwoFactorScopeResult', () => { { kind: 'organisation', organisationIds: ['org_1'] }, { kind: 'organisationReference', references: ['my-org-url'] }, { kind: 'team', teamIds: [1] }, - { kind: 'teamReference', references: [7] }, + { kind: 'teamReference', references: ['my-team-url', 7] }, { kind: 'envelope', envelopeIds: ['envelope_1'], documentIds: [], templateIds: [] }, { kind: 'document', documentIds: [2] }, { kind: 'template', templateIds: [3] }, @@ -77,13 +61,6 @@ describe('normalizeTwoFactorScopeResult', () => { }); }); - it('accepts a string-or-number teamReference (URL slug or ID)', () => { - expect(normalizeTwoFactorScopeResult({ teamReference: ['my-team-url', 7] })).toEqual({ - type: 'resources', - resources: [{ kind: 'teamReference', references: ['my-team-url', 7] }], - }); - }); - it('merges descriptor arrays per kind so each kind resolves in one batch', () => { expect(normalizeTwoFactorScopeResult([{ envelope: 'a' }, { envelope: ['b'], recipient: 7 }])).toEqual({ type: 'resources', @@ -94,17 +71,9 @@ describe('normalizeTwoFactorScopeResult', () => { }); }); - it('accepts ID sets at the bulk cap', () => { - const ids = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS }, (_, i) => `envelope_${i}`); - - expect(normalizeTwoFactorScopeResult({ envelope: ids })).toEqual({ - type: 'resources', - resources: [{ kind: 'envelope', envelopeIds: ids, documentIds: [], templateIds: [] }], - }); - }); - - it('rejects ID sets above the bulk cap instead of truncating', () => { + it('rejects ID sets above the bulk cap instead of truncating, including after merging', () => { const oversized = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS + 1 }, (_, i) => `envelope_${i}`); + const half = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS / 2 + 1 }, (_, i) => `envelope_${i}`); try { normalizeTwoFactorScopeResult({ envelope: oversized }); @@ -112,21 +81,15 @@ describe('normalizeTwoFactorScopeResult', () => { } catch (error) { expect(AppError.parseError(error).code).toBe(AppErrorCode.LIMIT_EXCEEDED); } - }); - - it('rejects when merged descriptors exceed the bulk cap combined', () => { - const half = Array.from({ length: TWO_FACTOR_ENFORCEMENT_MAX_BULK_IDS / 2 + 1 }, (_, i) => `envelope_${i}`); expect(() => normalizeTwoFactorScopeResult([{ envelope: half }, { envelope: half }])).toThrowError(AppError); }); }); describe('assertScopeBudget', () => { - it('allows counts within the budget', () => { + it('rejects only above the budget', () => { expect(() => assertScopeBudget(TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES)).not.toThrow(); - }); - it('rejects counts above the budget', () => { try { assertScopeBudget(TWO_FACTOR_ENFORCEMENT_MAX_UNIQUE_SCOPES + 1); expect.unreachable();