Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nexiom/identity": "workspace:*",
"@nexiom/database": "workspace:*",
"@nexiom/engine": "workspace:*",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.10",
"class-transformer": "^0.5.1",
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ export type {
Verification,
AbacConditions,
} from '@nexiom/identity/src/schema';

// Engine schema — provider catalog and connection tables
export { providers, appConnections } from '@nexiom/database';
260 changes: 260 additions & 0 deletions apps/api/src/modules/engine/connections/callback.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import { describe, it, expect, beforeEach, vi, Mocked } from 'vitest';
import { Test, TestingModule } from '@nestjs/testing';

import { OAuthCallbackController } from './callback.controller.js';
import { EncryptionService, ProviderRegistryService } from '@nexiom/engine';

const VALID_TENANT_ID = '550e8400-e29b-41d4-a716-446655440000';

const { mockOnConflictDoUpdate, mockInsert, mockDb } = vi.hoisted(() => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(true);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
const insert = vi.fn().mockReturnValue({ values });
return {
mockOnConflictDoUpdate: onConflictDoUpdate,
mockInsert: insert,
mockDb: { insert },
};
});

// Mock the database module — prevents real Pool/Drizzle connections
vi.mock('@nexiom/database', () => ({
appConnections: {
tenantId: 'tenantId',
appName: 'appName',
connectionKey: 'connectionKey',
},
}));

import { Request, Response } from 'express';

describe('OAuthCallbackController', () => {
let controller: OAuthCallbackController;
let mockEncryptionService: Mocked<EncryptionService>;
let mockProviderRegistry: { isAllowed: ReturnType<typeof vi.fn> };

const mockRequest = (
provider: string,
session?: Record<string, unknown>,
): Partial<Request> =>
({
params: { provider },
session,
}) as unknown as Partial<Request>;

const mockResponse = (): Partial<Response> => {
const res: Partial<Response> = {};
res.redirect = vi.fn().mockReturnValue(res);
return res;
};

let originalDatabaseUrl: string | undefined;

beforeAll(() => {
originalDatabaseUrl = process.env.DATABASE_URL;
process.env.DATABASE_URL = 'postgres://mock:mock@localhost:5432/mock';
});

afterAll(() => {
if (originalDatabaseUrl) {
process.env.DATABASE_URL = originalDatabaseUrl;
} else {
delete process.env.DATABASE_URL;
}
});

beforeEach(async () => {
mockEncryptionService = {
encrypt: vi.fn(),
decrypt: vi.fn(),
} as unknown as Mocked<EncryptionService>;

mockProviderRegistry = {
isAllowed: vi.fn().mockResolvedValue(true),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [OAuthCallbackController],
providers: [
{
provide: 'DRIZZLE_DB',
useValue: mockDb,
},
{
provide: EncryptionService,
useValue: mockEncryptionService,
},
{
provide: ProviderRegistryService,
useValue: mockProviderRegistry,
},
],
}).compile();

controller = module.get<OAuthCallbackController>(OAuthCallbackController);
vi.clearAllMocks();
// Re-apply default: known providers are allowed
mockProviderRegistry.isAllowed.mockResolvedValue(true);
});

it('should redirect with invalid_provider error if provider is not allowed', async () => {
mockProviderRegistry.isAllowed.mockResolvedValue(false);

const req = mockRequest('unsupported-provider');
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=invalid_provider',
);
});

it('should redirect with auth_failed if grant session is missing', async () => {
const req = mockRequest('salesforce');
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=auth_failed',
);
});

it('should redirect with auth_failed if grant response contains error', async () => {
const req = mockRequest('salesforce', {
grant: { response: { error: 'invalid_grant' } },
});
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=auth_failed',
);
});

it('should redirect with invalid_state if state is missing', async () => {
const req = mockRequest('salesforce', {
grant: { response: { access_token: '123' } }, // No raw.state
});
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=invalid_state',
);
});

it('should redirect with invalid_state if decryption fails', async () => {
mockEncryptionService.decrypt.mockRejectedValue(
new Error('Decryption failed'),
);

const req = mockRequest('salesforce', {
grant: { response: { raw: { state: 'bad-encrypted-state' } } },
});
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=invalid_state',
);
});

it('should redirect with invalid_credentials if access_token is missing', async () => {
mockEncryptionService.decrypt.mockResolvedValue(VALID_TENANT_ID);

const req = mockRequest('salesforce', {
grant: {
response: {
raw: { state: 'encrypted-state', expires_in: 3600 },
// No access_token provided
},
},
});
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=invalid_credentials',
);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockEncryptionService.encrypt).not.toHaveBeenCalled();
expect(mockInsert).not.toHaveBeenCalled();
});

it('should successfully store credentials and redirect on success', async () => {
mockEncryptionService.decrypt.mockResolvedValue(VALID_TENANT_ID);
mockEncryptionService.encrypt.mockResolvedValue('encrypted-credentials');

const req = mockRequest('salesforce', {
grant: {
response: {
access_token: 'acc-123',
refresh_token: 'ref-123',
raw: {
state: 'encrypted-state',
expires_in: 3600,
realmId: 'realm-id',
},
},
},
});
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockEncryptionService.decrypt).toHaveBeenCalledWith(
'encrypted-state',
);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockEncryptionService.encrypt).toHaveBeenCalledWith(
expect.stringContaining('"accessToken":"acc-123"'),
);
expect(mockInsert).toHaveBeenCalled();
// Verify the exact upsert payload
const rawValue = mockInsert.mock.results[0].value as {
values: typeof vi.fn;
};
const { values } = rawValue;
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: VALID_TENANT_ID,
appName: 'salesforce',
connectionKey: 'realm-id',
encryptedCredentials: 'encrypted-credentials',
authType: 'OAUTH2',
}),
);
expect(res.redirect).toHaveBeenCalledWith('/app/connections?success=true');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('should redirect with internal_error if database insert fails', async () => {
mockEncryptionService.decrypt.mockResolvedValue(VALID_TENANT_ID);
mockEncryptionService.encrypt.mockResolvedValue('encrypted-credentials');

// Override the mock to simulate failure
mockOnConflictDoUpdate.mockRejectedValueOnce(new Error('DB Error'));

const req = mockRequest('salesforce', {
grant: {
response: {
access_token: 'acc-123',
raw: { state: 'encrypted-state', expires_in: 3600 },
},
},
});
const res = mockResponse();

await controller.handleCallback(req as Request, res as Response);

expect(res.redirect).toHaveBeenCalledWith(
'/app/connections?error=internal_error',
);
});
Comment on lines +237 to +259

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test exposes a controller gap: no validation that access_token exists before encryption.

This test's mock request omits access_token and refresh_token, yet the controller happily encrypts { accessToken: undefined, refreshToken: undefined, ... } and persists it. The test passes because the controller never validates token presence. Consider adding a guard in the controller to redirect with an error when access_token is missing — and adding a corresponding test case here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/engine/connections/callback.controller.spec.ts` around
lines 170 - 191, The controller's handleCallback currently encrypts and persists
credentials without validating presence of an access_token; add a guard in the
handleCallback method to check the OAuth payload (e.g.,
request.grant.response.raw or the object passed into encryptionService.encrypt)
for a defined access_token and, if missing, immediately redirect to
'/app/connections?error=invalid_credentials' (or consistent error name) instead
of proceeding to encrypt/persist; update or add a unit test in
callback.controller.spec.ts to assert that when access_token is absent the
controller redirects with the chosen error and does not call
encryptionService.encrypt or the DB persistence path (references:
handleCallback, encryptionService.encrypt, mockOnConflictDoUpdate).

});
Loading