Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code fd14f7ba61 fix(auth): move cache recompute outside try/catch transaction block in signUpOnNewWorkspace
https://sonarly.com/issue/19882?type=bug

New users signing up via Google OAuth encounter a crash when the post-commit cache recomputation query times out, causing the error handler to attempt rolling back an already-committed transaction.

Fix: Two changes to `signUpOnNewWorkspace` in `sign-in-up.service.ts`:

1. **Moved `invalidateAndRecompute` from `try` block to `finally` block** — The cache recomputation was positioned after `commitTransaction()` but still inside the `try` block. If it failed (e.g., query timeout), the error fell into the `catch` block which attempted `rollbackTransaction()` on an already-committed transaction, causing the `TransactionNotStartedError`. Moving it to `finally` (after `queryRunner.release()`) ensures cache recomputation runs after the transaction is fully settled, and any failure propagates cleanly without triggering the rollback logic.

2. **Added `isTransactionActive` guard before `rollbackTransaction()`** — This defensive check (already used in 5+ places in the codebase, e.g., `workflow-trigger.workspace-service.ts`, `workspace.service.ts`) prevents the `TransactionNotStartedError` if the transaction was already committed or rolled back by the time the catch block runs.

3. **Added `manager.update` to queryRunner mock** — The mock was missing the `update` method used during logo file update in the transaction.

4. **Added test** — Verifies that when `invalidateAndRecompute` throws after a committed transaction, `rollbackTransaction` is NOT called and `release` IS called.
2026-03-31 08:11:26 +00:00
2 changed files with 74 additions and 4 deletions
@@ -49,12 +49,14 @@ const createSignInUpServiceForTests = () => {
const queryRunnerMock = {
manager: {
save: jest.fn(),
update: jest.fn(),
},
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
isTransactionActive: true,
};
const service = new SignInUpService(
@@ -278,6 +280,72 @@ describe('SignInUpService workspace-creation policy', () => {
});
});
it('should not attempt rollback when cache recompute fails after committed transaction', async () => {
const {
service,
mockUserRepository,
mockWorkspaceRepository,
mockConfigurationValues,
} = createSignInUpServiceForTests();
mockConfigurationValues.IS_MULTIWORKSPACE_ENABLED = true;
mockConfigurationValues.IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS =
false;
mockWorkspaceRepository.count.mockResolvedValue(0);
mockWorkspaceRepository.create.mockReturnValue({
id: 'workspace-id',
subdomain: 'test',
});
mockUserRepository.count.mockResolvedValue(0);
const queryRunner = (service as any).dataSource.createQueryRunner();
queryRunner.manager.save.mockResolvedValue({
id: 'workspace-id',
universalIdentifier: 'test-uid',
});
jest
.spyOn((service as any).applicationService, 'createWorkspaceCustomApplication')
.mockResolvedValue({ universalIdentifier: 'custom-app-uid' });
jest
.spyOn((service as any).userWorkspaceService, 'create')
.mockResolvedValue(undefined);
jest
.spyOn((service as any).onboardingService, 'setOnboardingConnectAccountPending')
.mockResolvedValue(undefined);
jest
.spyOn((service as any).onboardingService, 'setOnboardingInviteTeamPending')
.mockResolvedValue(undefined);
const cacheError = new Error('Query read timeout');
jest
.spyOn((service as any).workspaceCacheService, 'invalidateAndRecompute')
.mockRejectedValue(cacheError);
queryRunner.commitTransaction.mockImplementation(() => {
queryRunner.isTransactionActive = false;
});
await expect(
service.signUpOnNewWorkspace({
type: 'newUserWithPicture',
newUserWithPicture: {
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
picture: '',
locale: 'en',
isEmailVerified: true,
},
}),
).rejects.toThrow(cacheError);
expect(queryRunner.rollbackTransaction).not.toHaveBeenCalled();
expect(queryRunner.release).toHaveBeenCalled();
});
it('keeps single-workspace SIGNUP_DISABLED behavior after first workspace exists', async () => {
const { service, mockWorkspaceRepository, mockConfigurationValues } =
createSignInUpServiceForTests();
@@ -593,16 +593,18 @@ export class SignInUpService {
);
await queryRunner.commitTransaction();
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
return { user, workspace };
} catch (error) {
await queryRunner.rollbackTransaction();
if (queryRunner.isTransactionActive) {
await queryRunner.rollbackTransaction();
}
throw error;
} finally {
await queryRunner.release();
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'flatApplicationMaps',
]);
}
}