diff --git a/apps/api/src/email/templates/access-granted.spec.ts b/apps/api/src/email/templates/access-granted.spec.ts new file mode 100644 index 0000000000..d872d46111 --- /dev/null +++ b/apps/api/src/email/templates/access-granted.spec.ts @@ -0,0 +1,39 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { AccessGrantedEmail } from './access-granted'; + +const baseProps = { + toName: 'Chang Liu', + organizationName: 'Acme Security', + expiresAt: new Date('2026-12-31T00:00:00Z'), + portalUrl: 'https://portal.example.com/access/token', +}; + +describe('AccessGrantedEmail', () => { + it('omits all NDA copy when access was granted via allow-list bypass', () => { + const html = renderToStaticMarkup( + AccessGrantedEmail({ ...baseProps, ndaBypassed: true }), + ); + + // No NDA was signed, so the email must not reference one. + expect(html).not.toContain('NDA'); + expect(html).not.toContain('signed'); + // The access confirmation itself is still present. + expect(html).toContain('is now active'); + expect(html).toContain('Acme Security'); + }); + + it('includes NDA copy for the standard NDA-signed flow', () => { + const html = renderToStaticMarkup( + AccessGrantedEmail({ ...baseProps, ndaBypassed: false }), + ); + + expect(html).toContain('Your NDA has been signed'); + expect(html).toContain('download your signed NDA'); + }); + + it('defaults to the NDA-signed copy when ndaBypassed is omitted', () => { + const html = renderToStaticMarkup(AccessGrantedEmail({ ...baseProps })); + + expect(html).toContain('Your NDA has been signed'); + }); +}); diff --git a/apps/api/src/email/templates/access-granted.tsx b/apps/api/src/email/templates/access-granted.tsx index a94bfce64b..39f156ab05 100644 --- a/apps/api/src/email/templates/access-granted.tsx +++ b/apps/api/src/email/templates/access-granted.tsx @@ -18,6 +18,11 @@ interface Props { organizationName: string; expiresAt: Date; portalUrl: string; + /** + * When true, access was granted without an NDA (the requester's email or + * domain is on the trust portal allow list). NDA-specific copy is omitted. + */ + ndaBypassed?: boolean; } export const AccessGrantedEmail = ({ @@ -25,6 +30,7 @@ export const AccessGrantedEmail = ({ organizationName, expiresAt, portalUrl, + ndaBypassed = false, }: Props) => { return ( @@ -60,9 +66,18 @@ export const AccessGrantedEmail = ({ - Your NDA has been signed and your access to{' '} - {organizationName}'s policy documentation is now - active. + {ndaBypassed ? ( + <> + Your access to {organizationName}'s policy + documentation is now active. + + ) : ( + <> + Your NDA has been signed and your access to{' '} + {organizationName}'s policy documentation is + now active. + + )} @@ -85,10 +100,12 @@ export const AccessGrantedEmail = ({ - - You can download your signed NDA for your records from the - confirmation page or by accessing the portal above. - + {!ndaBypassed && ( + + You can download your signed NDA for your records from the + confirmation page or by accessing the portal above. + + )}
{ - const { toEmail, toName, organizationName, expiresAt, portalUrl } = params; + const { + toEmail, + toName, + organizationName, + expiresAt, + portalUrl, + ndaBypassed, + } = params; const { id } = await triggerEmail({ to: toEmail, @@ -48,6 +56,7 @@ export class TrustEmailService { organizationName, expiresAt, portalUrl, + ndaBypassed, }), trustPortal: true, }); diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts index 87fb26a09d..f5bab9ac1a 100644 --- a/apps/api/src/trust-portal/trust-access.service.spec.ts +++ b/apps/api/src/trust-portal/trust-access.service.spec.ts @@ -14,6 +14,8 @@ jest.mock('@db', () => ({ }, trustAccessGrant: { findUnique: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), }, trustAccessRequest: { findFirst: jest.fn(), @@ -62,6 +64,8 @@ const mockDb = db as unknown as { }; trustAccessGrant: { findUnique: jest.Mock; + findFirst: jest.Mock; + update: jest.Mock; }; trustAccessRequest: { findFirst: jest.Mock; @@ -262,6 +266,10 @@ describe('TrustAccessService approveRequest NDA bypass', () => { expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1); expect(txMock.trustNDAAgreement.create).not.toHaveBeenCalled(); expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1); + // The granted email must omit NDA copy since no NDA was signed. + expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith( + expect.objectContaining({ ndaBypassed: true }), + ); expect(emailService.sendNdaSigningEmail).not.toHaveBeenCalled(); expect(txMock.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -286,6 +294,9 @@ describe('TrustAccessService approveRequest NDA bypass', () => { expect(txMock.trustAccessGrant.create).toHaveBeenCalledTimes(1); expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith( + expect.objectContaining({ ndaBypassed: true }), + ); expect(txMock.auditLog.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -310,3 +321,127 @@ describe('TrustAccessService approveRequest NDA bypass', () => { expect(result.message).toBe('NDA signing email sent'); }); }); + +describe('TrustAccessService resendAccessGrantEmail NDA copy', () => { + const emailService = { + sendAccessGrantedEmail: jest.fn(), + }; + const service = new TrustAccessService( + {} as any, + emailService as any, + {} as any, + {} as any, + {} as any, + ); + jest + .spyOn(service as any, 'buildPortalAccessUrl') + .mockResolvedValue('https://portal.example.com/access/token'); + + const baseGrant = { + id: 'tag_1', + subjectEmail: 'chang.liu@client.com', + status: 'active', + expiresAt: new Date(Date.now() + 86_400_000), + accessToken: 'existing-token', + accessTokenExpiresAt: new Date(Date.now() + 86_400_000), + accessRequest: { + name: 'Chang Liu', + organization: { name: 'Acme Security' }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('marks the resent email as bypassed when the grant has no NDA agreement', async () => { + mockDb.trustAccessGrant.findFirst.mockResolvedValue({ + ...baseGrant, + ndaAgreement: null, + }); + + await service.resendAccessGrantEmail('org_1', 'tag_1'); + + expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith( + expect.objectContaining({ ndaBypassed: true }), + ); + }); + + it('keeps NDA copy when the grant has a signed NDA agreement', async () => { + mockDb.trustAccessGrant.findFirst.mockResolvedValue({ + ...baseGrant, + ndaAgreement: { status: 'signed' }, + }); + + await service.resendAccessGrantEmail('org_1', 'tag_1'); + + expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith( + expect.objectContaining({ ndaBypassed: false }), + ); + }); +}); + +describe('TrustAccessService signNda NDA copy', () => { + const ndaPdfService = { + generateNdaPdf: jest.fn().mockResolvedValue(Buffer.from('pdf')), + uploadNdaPdf: jest.fn().mockResolvedValue('org_1/nda/nda_1.pdf'), + getSignedUrl: jest.fn().mockResolvedValue('https://s3.example.com/nda.pdf'), + }; + const emailService = { + sendAccessGrantedEmail: jest.fn(), + }; + const service = new TrustAccessService( + ndaPdfService as any, + emailService as any, + {} as any, + {} as any, + {} as any, + ); + jest + .spyOn(service as any, 'buildPortalAccessUrl') + .mockResolvedValue('https://portal.example.com/access/token'); + + beforeEach(() => { + jest.clearAllMocks(); + mockDb.trustNDAAgreement.findUnique.mockResolvedValue({ + id: 'nda_1', + organizationId: 'org_1', + accessRequestId: 'tar_1', + status: 'pending', + signTokenExpiresAt: new Date(Date.now() + 86_400_000), + grant: null, + accessRequest: { + requestedDurationDays: 30, + organization: { name: 'Acme Security' }, + }, + }); + mockDb.$transaction.mockImplementation( + (cb: (tx: unknown) => Promise) => + cb({ + trustAccessGrant: { + create: jest + .fn() + .mockResolvedValue({ id: 'tag_1', expiresAt: new Date() }), + }, + trustNDAAgreement: { + update: jest.fn().mockResolvedValue({ id: 'nda_1' }), + }, + }), + ); + }); + + it('sends the granted email with NDA copy (ndaBypassed: false) after signing', async () => { + await service.signNda( + 'sign-token', + 'Chang Liu', + 'chang.liu@client.com', + '1.2.3.4', + 'jest-agent', + ); + + expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendAccessGrantedEmail).toHaveBeenCalledWith( + expect.objectContaining({ ndaBypassed: false }), + ); + }); +}); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index e2b9ee3824..6af1bc5c04 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -763,6 +763,7 @@ export class TrustAccessService { organizationName: request.organization.name, expiresAt: result.grant.expiresAt, portalUrl, + ndaBypassed: true, }); return { @@ -976,6 +977,9 @@ export class TrustAccessService { }, }, }, + ndaAgreement: { + select: { status: true }, + }, }, }); @@ -1028,6 +1032,9 @@ export class TrustAccessService { organizationName: grant.accessRequest.organization.name, expiresAt: grant.expiresAt, portalUrl, + // A bypassed grant has no NDA agreement; an NDA-signed grant links a + // 'signed' one. Mirror the original email's copy when resending. + ndaBypassed: grant.ndaAgreement?.status !== 'signed', }); return { message: 'Access email resent successfully' }; @@ -1239,6 +1246,7 @@ export class TrustAccessService { organizationName: nda.accessRequest.organization.name, expiresAt: result.grant.expiresAt, portalUrl, + ndaBypassed: false, }); const pdfUrl = await this.ndaPdfService.getSignedUrl(pdfKey);