@accounter/server
Version:
Accounter GraphQL server
317 lines (270 loc) • 11.5 kB
text/typescript
import { createHash } from 'node:crypto';
import { GraphQLError } from 'graphql';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const pgTypedRuntimeMock = vi.hoisted(() => {
const runMocks = {
updateInvitationAcceptanceRun: vi.fn(),
getInvitationForAcceptanceRun: vi.fn(),
getInvitationByTokenRun: vi.fn(),
getUserIdByAuth0UserIdRun: vi.fn(),
insertAcceptedBusinessUserRun: vi.fn(),
updateBusinessUserAuth0IdRun: vi.fn(),
insertAuditLogRun: vi.fn(),
};
const sql = vi.fn((strings: TemplateStringsArray) => {
const query = strings.join(' ');
if (query.includes('SET accepted_at = NOW()')) {
return { run: runMocks.updateInvitationAcceptanceRun };
}
if (query.includes('AND accepted_at IS NULL') && query.includes('FOR UPDATE')) {
return { run: runMocks.getInvitationForAcceptanceRun };
}
if (query.includes('WHERE token_hash = $tokenHash;') && !query.includes('FOR UPDATE')) {
return { run: runMocks.getInvitationByTokenRun };
}
if (query.includes('FROM accounter_schema.business_users') && query.includes('LIMIT 1')) {
return { run: runMocks.getUserIdByAuth0UserIdRun };
}
if (query.includes('INSERT INTO accounter_schema.business_users')) {
return { run: runMocks.insertAcceptedBusinessUserRun };
}
if (query.includes('INSERT INTO accounter_schema.audit_logs')) {
return { run: runMocks.insertAuditLogRun };
}
if (
query.includes('UPDATE accounter_schema.business_users') &&
query.includes('SET auth0_user_id = $auth0UserId')
) {
return { run: runMocks.updateBusinessUserAuth0IdRun };
}
throw new Error(`Unexpected SQL in test: ${query}`);
});
return {
runMocks,
sql,
reset() {
for (const runMock of Object.values(runMocks)) {
runMock.mockReset();
}
},
};
});
vi.mock('@pgtyped/runtime', () => ({
sql: pgTypedRuntimeMock.sql,
}));
const auditLogsProviderMock = {
log: vi.fn(),
};
vi.mock('../../../common/providers/audit-logs.provider.js', () => ({
AuditLogsProvider: class {
log = auditLogsProviderMock.log;
},
}));
import { AcceptInvitationsProvider } from '../accept-invitations.provider.js';
const [
updateInvitationAcceptanceRun,
getInvitationForAcceptanceRun,
getInvitationByTokenRun,
getUserIdByAuth0UserIdRun,
insertAcceptedBusinessUserRun,
updateBusinessUserAuth0IdRun,
] = [
pgTypedRuntimeMock.runMocks.updateInvitationAcceptanceRun,
pgTypedRuntimeMock.runMocks.getInvitationForAcceptanceRun,
pgTypedRuntimeMock.runMocks.getInvitationByTokenRun,
pgTypedRuntimeMock.runMocks.getUserIdByAuth0UserIdRun,
pgTypedRuntimeMock.runMocks.insertAcceptedBusinessUserRun,
pgTypedRuntimeMock.runMocks.updateBusinessUserAuth0IdRun,
];
function activeInvitation(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'inv-1',
user_id: 'invited-user',
business_id: 'business-1',
role_id: 'employee',
email: 'invitee@example.com',
auth0_user_id: 'auth0|invitee',
accepted_at: null,
expires_at: new Date(Date.now() + 60_000).toISOString(),
...overrides,
};
}
describe('AcceptInvitationsProvider', () => {
let dbClient: { query: ReturnType<typeof vi.fn>; release: ReturnType<typeof vi.fn> };
let dbProvider: { pool: { connect: ReturnType<typeof vi.fn> } };
let auth0ManagementProvider: {
unblockUser: ReturnType<typeof vi.fn>;
deleteUser: ReturnType<typeof vi.fn>;
};
let provider: AcceptInvitationsProvider;
beforeEach(() => {
vi.clearAllMocks();
pgTypedRuntimeMock.reset();
pgTypedRuntimeMock.sql.mockClear();
dbClient = {
query: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
};
dbProvider = {
pool: {
connect: vi.fn().mockResolvedValue(dbClient),
},
};
auth0ManagementProvider = {
unblockUser: vi.fn().mockResolvedValue(undefined),
deleteUser: vi.fn().mockResolvedValue(undefined),
};
provider = new AcceptInvitationsProvider(dbProvider as any, auth0ManagementProvider as any, {log: () => {void 0}} as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('inserts business user when authenticated caller already exists', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([activeInvitation()]);
getUserIdByAuth0UserIdRun.mockResolvedValue([{ user_id: 'existing-user' }]);
insertAcceptedBusinessUserRun.mockResolvedValue([]);
updateInvitationAcceptanceRun.mockResolvedValue([]);
const result = await provider.acceptInvitation('token-1', 'auth0|caller', 'invitee@example.com');
expect(result).toEqual({
success: true,
businessId: 'business-1',
roleId: 'employee',
});
expect(dbClient.query).toHaveBeenNthCalledWith(1, 'BEGIN');
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'COMMIT');
expect(getInvitationForAcceptanceRun).toHaveBeenCalledWith(
{
tokenHash: createHash('sha256').update('token-1').digest('hex'),
},
dbClient,
);
expect(insertAcceptedBusinessUserRun).toHaveBeenCalledWith(
{
userId: 'existing-user',
auth0UserId: 'auth0|caller',
ownerId: 'business-1',
roleId: 'employee',
},
dbClient,
);
expect(updateBusinessUserAuth0IdRun).not.toHaveBeenCalled();
expect(auth0ManagementProvider.deleteUser).toHaveBeenCalledWith('auth0|invitee');
expect(auth0ManagementProvider.unblockUser).not.toHaveBeenCalled();
expect(dbClient.release).toHaveBeenCalledOnce();
});
it('updates pending business user when caller is authenticated but not found in business_users', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([activeInvitation()]);
getUserIdByAuth0UserIdRun.mockResolvedValue([]);
updateBusinessUserAuth0IdRun.mockResolvedValue([]);
updateInvitationAcceptanceRun.mockResolvedValue([]);
await provider.acceptInvitation('token-2', 'auth0|caller', 'invitee@example.com');
expect(updateBusinessUserAuth0IdRun).toHaveBeenCalledWith(
{
auth0UserId: 'auth0|caller',
userId: 'invited-user',
ownerId: 'business-1',
},
dbClient,
);
expect(insertAcceptedBusinessUserRun).not.toHaveBeenCalled();
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'COMMIT');
expect(auth0ManagementProvider.deleteUser).toHaveBeenCalledWith('auth0|invitee');
expect(auth0ManagementProvider.unblockUser).not.toHaveBeenCalled();
});
it('uses invitation auth0_user_id when caller is unauthenticated', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([
activeInvitation({ auth0_user_id: 'auth0|from-invitation' }),
]);
updateBusinessUserAuth0IdRun.mockResolvedValue([]);
updateInvitationAcceptanceRun.mockResolvedValue([]);
await provider.acceptInvitation('token-3', null, null);
expect(getUserIdByAuth0UserIdRun).not.toHaveBeenCalled();
expect(updateBusinessUserAuth0IdRun).toHaveBeenCalledWith(
{
auth0UserId: 'auth0|from-invitation',
userId: 'invited-user',
ownerId: 'business-1',
},
dbClient,
);
expect(auth0ManagementProvider.unblockUser).toHaveBeenCalledWith('auth0|from-invitation');
expect(auth0ManagementProvider.deleteUser).not.toHaveBeenCalled();
});
it('unblocks invitation auth0 user when authenticated caller matches invitation auth0_user_id', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([
activeInvitation({ auth0_user_id: 'auth0|same-user' }),
]);
getUserIdByAuth0UserIdRun.mockResolvedValue([{ user_id: 'existing-user' }]);
insertAcceptedBusinessUserRun.mockResolvedValue([]);
updateInvitationAcceptanceRun.mockResolvedValue([]);
await provider.acceptInvitation('token-same', 'auth0|same-user', 'invitee@example.com');
expect(auth0ManagementProvider.unblockUser).toHaveBeenCalledWith('auth0|same-user');
expect(auth0ManagementProvider.deleteUser).not.toHaveBeenCalled();
});
it('throws TOKEN_ALREADY_USED when invitation is already accepted', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([]);
getInvitationByTokenRun.mockResolvedValue([
activeInvitation({ accepted_at: new Date().toISOString() }),
]);
await expect(
provider.acceptInvitation('token-4', 'auth0|caller', 'invitee@example.com'),
).rejects.toMatchObject({
extensions: { code: 'TOKEN_ALREADY_USED' },
});
expect(dbClient.query).toHaveBeenNthCalledWith(1, 'BEGIN');
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK');
expect(dbClient.release).toHaveBeenCalledOnce();
});
it('throws TOKEN_EXPIRED when invitation is expired', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([]);
getInvitationByTokenRun.mockResolvedValue([
activeInvitation({ expires_at: new Date(Date.now() - 60_000).toISOString() }),
]);
await expect(
provider.acceptInvitation('token-5', 'auth0|caller', 'invitee@example.com'),
).rejects.toMatchObject({
extensions: { code: 'TOKEN_EXPIRED' },
});
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK');
});
it('rolls back and throws TOKEN_INVALID when no invitation can be accepted', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([]);
getInvitationByTokenRun.mockResolvedValue([]);
await expect(
provider.acceptInvitation('token-6', 'auth0|caller', 'invitee@example.com'),
).rejects.toMatchObject({
extensions: { code: 'TOKEN_INVALID' },
});
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK');
expect(updateInvitationAcceptanceRun).not.toHaveBeenCalled();
});
it('rolls back and maps Auth0 cleanup failures to GraphQLError', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
getInvitationForAcceptanceRun.mockResolvedValue([activeInvitation()]);
getUserIdByAuth0UserIdRun.mockResolvedValue([{ user_id: 'existing-user' }]);
insertAcceptedBusinessUserRun.mockResolvedValue([]);
auth0ManagementProvider.deleteUser.mockRejectedValueOnce(new Error('provider unavailable'));
await expect(
provider.acceptInvitation('token-7', 'auth0|caller', 'invitee@example.com'),
).rejects.toSatisfy(error => {
return error instanceof GraphQLError && error.extensions?.code === 'AUTH0_ERROR';
});
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK');
expect(updateInvitationAcceptanceRun).not.toHaveBeenCalled();
});
it('rejects authenticated claim when email does not match invitation email', async () => {
getInvitationForAcceptanceRun.mockResolvedValue([activeInvitation({ email: 'invitee@example.com' })]);
await expect(
provider.acceptInvitation('token-8', 'auth0|caller', 'different@example.com'),
).rejects.toMatchObject({
extensions: { code: 'TOKEN_INVALID' },
});
expect(dbClient.query).toHaveBeenNthCalledWith(2, 'ROLLBACK');
expect(getUserIdByAuth0UserIdRun).not.toHaveBeenCalled();
expect(updateBusinessUserAuth0IdRun).not.toHaveBeenCalled();
expect(insertAcceptedBusinessUserRun).not.toHaveBeenCalled();
expect(auth0ManagementProvider.unblockUser).not.toHaveBeenCalled();
expect(auth0ManagementProvider.deleteUser).not.toHaveBeenCalled();
expect(updateInvitationAcceptanceRun).not.toHaveBeenCalled();
});
});