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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ PROVIDER_ENCRYPTION_SECRET= # (required) Independent encr
# DEPLOYMENT_MODE=

# ---- Cognipeer Support ---------------------------------------
# Help & Support entry points stay hidden until SUPPORT_BASE_URL is set.
# On SaaS the Help & Support entry points appear only when all three values are
# set, so a click can never end in a 503.
# SUPPORT_HANDOFF_SECRET must be at least 32 characters and must equal CRM's
# SUPPORT_HANDOFF_SECRET_CONSOLE. It is server-only — never use NEXT_PUBLIC_.
# On-prem: when SUPPORT_BASE_URL is set but the CRM values are not, Console
# sends the user to the Support login page instead of failing.
# On-prem: SUPPORT_BASE_URL alone is enough; Console then sends the user to the
# Support login page instead of failing.
# SUPPORT_BASE_URL=https://support.cognipeer.com
# SUPPORT_CRM_API_URL=
# SUPPORT_HANDOFF_SECRET=
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ services:
- JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env.local or environment}
- CACHE_PROVIDER=memory
- RATE_LIMIT_PROVIDER=memory
# Self-hosted installs are on-prem: with SUPPORT_BASE_URL alone the Help
# action lands on the Support login instead of failing.
- DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-onprem}
# Cognipeer Support. Leave empty to hide every Support entry point.
# SUPPORT_HANDOFF_SECRET must equal CRM's SUPPORT_HANDOFF_SECRET_CONSOLE.
- SUPPORT_BASE_URL=${SUPPORT_BASE_URL:-}
- SUPPORT_CRM_API_URL=${SUPPORT_CRM_API_URL:-}
- SUPPORT_HANDOFF_SECRET=${SUPPORT_HANDOFF_SECRET:-}
volumes:
- app-data:/app/data
restart: unless-stopped
Expand Down
175 changes: 175 additions & 0 deletions src/__tests__/api/support.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@/lib/services/support/supportHandoff', () => ({
createSupportHandoff: vi.fn(),
isSupportEntryPointEnabled: vi.fn(),
}));

import {
createSupportHandoff,
isSupportEntryPointEnabled,
} from '@/lib/services/support/supportHandoff';
import { supportApiPlugin } from '@/server/api/plugins/support';
import { createFastifyApiTestApp, parseJsonBody } from '../helpers/fastify-api';

const SESSION_HEADERS = {
'x-tenant-db-name': 'tenant_acme',
'x-tenant-id': 'tenant-1',
'x-tenant-slug': 'acme',
'x-user-email': 'ada@example.com',
'x-user-id': 'user-1',
'x-user-role': 'owner',
'x-license-type': 'FREE',
};

const mockedHandoff = vi.mocked(createSupportHandoff);
const mockedEnabled = vi.mocked(isSupportEntryPointEnabled);

async function app() {
return createFastifyApiTestApp(supportApiPlugin);
}

beforeEach(() => {
vi.clearAllMocks();
mockedEnabled.mockReturnValue(true);
mockedHandoff.mockResolvedValue({ ok: true, url: 'https://support.example.com/api/auth/handoff?code=shc_x' });
});

describe('GET /api/support/status', () => {
it('reports whether an entry point should be shown', async () => {
const response = await (await app()).inject({
method: 'GET',
url: '/api/support/status',
headers: SESSION_HEADERS,
});

expect(response.statusCode).toBe(200);
expect(parseJsonBody<{ enabled: boolean }>(response.body).enabled).toBe(true);
});

it('rejects an unauthenticated caller', async () => {
const response = await (await app()).inject({ method: 'GET', url: '/api/support/status' });
expect(response.statusCode).toBe(401);
});
});

describe('POST /api/support/handoff', () => {
it('passes the session identity to the handoff service', async () => {
await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: { locale: 'en' },
});

expect(mockedHandoff).toHaveBeenCalledWith(
expect.objectContaining({
tenantId: 'tenant-1',
userId: 'user-1',
userEmail: 'ada@example.com',
locale: 'en',
}),
);
});

it('forwards diagnostics collected by an error surface', async () => {
await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: {
locale: 'tr',
summary: 'boom',
diagnostics: { category: 'dashboard_error', page: '/dashboard' },
},
});

expect(mockedHandoff).toHaveBeenCalledWith(
expect.objectContaining({
summary: 'boom',
diagnostics: { category: 'dashboard_error', page: '/dashboard' },
}),
);
});

it('defaults to Turkish when no locale is sent', async () => {
await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: {},
});

expect(mockedHandoff).toHaveBeenCalledWith(expect.objectContaining({ locale: 'tr' }));
});

it('rejects an unknown locale instead of guessing', async () => {
const response = await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: { locale: 'de' },
});

expect(response.statusCode).toBe(400);
expect(mockedHandoff).not.toHaveBeenCalled();
});

it('rejects diagnostics that are not an object', async () => {
const response = await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: { diagnostics: ['not', 'an', 'object'] },
});

expect(response.statusCode).toBe(400);
});

it('rejects an unauthenticated caller', async () => {
const response = await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
payload: { locale: 'tr' },
});

expect(response.statusCode).toBe(401);
expect(mockedHandoff).not.toHaveBeenCalled();
});

it('surfaces the service status and keeps the response uncacheable', async () => {
mockedHandoff.mockResolvedValue({
ok: false,
status: 503,
message: 'Support integration is not configured.',
});

const response = await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: { locale: 'tr' },
});

expect(response.statusCode).toBe(503);
});

it('marks a login fallback so the caller can explain the missing diagnostics', async () => {
mockedHandoff.mockResolvedValue({
ok: true,
url: 'https://support.example.com/tr/login?diagnostics=unavailable',
diagnosticsUnavailable: true,
});

const response = await (await app()).inject({
method: 'POST',
url: '/api/support/handoff',
headers: SESSION_HEADERS,
payload: { locale: 'tr' },
});

expect(response.headers['cache-control']).toBe('no-store');
expect(parseJsonBody<{ diagnosticsUnavailable?: boolean }>(response.body).diagnosticsUnavailable)
.toBe(true);
});
});
168 changes: 168 additions & 0 deletions src/__tests__/unit/support-handoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const db = {
findTenantById: vi.fn(),
switchToTenant: vi.fn().mockResolvedValue(undefined),
assertTenantContext: vi.fn(),
findUserById: vi.fn(),
};

vi.mock('@/lib/database', () => ({
getDatabase: vi.fn(async () => db),
}));

const config = {
support: {
baseUrl: 'https://support.example.com',
crmApiUrl: 'https://crm.example.com',
handoffSecret: 'a'.repeat(32),
},
deployment: { mode: 'saas' as 'saas' | 'onprem', isOnPrem: false },
};

vi.mock('@/lib/core/config', () => ({
getConfig: () => config,
}));

import {
createSupportHandoff,
isSupportEntryPointEnabled,
} from '@/lib/services/support/supportHandoff';

const input = {
tenantId: '65f1c0ffee0000000000abcd',
userId: 'user-1',
userEmail: ' Ada@Example.COM ',
locale: 'tr' as const,
};

function crmResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response;
}

beforeEach(() => {
vi.clearAllMocks();
config.support = {
baseUrl: 'https://support.example.com',
crmApiUrl: 'https://crm.example.com',
handoffSecret: 'a'.repeat(32),
};
config.deployment = { mode: 'saas', isOnPrem: false };
db.findTenantById.mockResolvedValue({
_id: input.tenantId,
companyName: 'Acme Inc',
slug: 'acme',
dbName: 'tenant_acme',
});
db.findUserById.mockResolvedValue({ name: 'Ada Lovelace', email: 'ada@example.com' });
});

describe('createSupportHandoff', () => {
it('anchors support identity to the tenant id, not the renameable slug', async () => {
const fetchMock = vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' }));
vi.stubGlobal('fetch', fetchMock);

const result = await createSupportHandoff(input);

expect(result.ok).toBe(true);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.issuer).toBe('console');
expect(body.externalInstallationId).toBe(input.tenantId);
// A null organizationId makes the CRM derive an issuer-scoped key, so a
// slug rename can never orphan the customer's ticket history.
expect(body.context.organizationId).toBeNull();
expect(body.context.workspaceName).toBe('Acme Inc');
});

it('sends a display name so support does not address an email address', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' })));

await createSupportHandoff(input);

const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.context.userName).toBe('Ada Lovelace');
expect(body.email).toBe('ada@example.com');
});

it('falls back to the email when the user record cannot be read', async () => {
db.findUserById.mockRejectedValue(new Error('tenant db unavailable'));
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_x' })));

const result = await createSupportHandoff(input);

expect(result.ok).toBe(true);
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.context.userName).toBe('ada@example.com');
});

it('returns the Support callback URL carrying the single-use code', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(crmResponse({ code: 'shc_abc' })));

const result = await createSupportHandoff(input);

expect(result).toMatchObject({
ok: true,
url: 'https://support.example.com/api/auth/handoff?code=shc_abc&locale=tr',
});
});

it('creates a diagnostic draft first and links it to the handoff', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(crmResponse({ id: 'draft-1' }))
.mockResolvedValueOnce(crmResponse({ code: 'shc_abc' }));
vi.stubGlobal('fetch', fetchMock);

await createSupportHandoff({ ...input, summary: 'boom', diagnostics: { category: 'x' } });

expect(fetchMock.mock.calls[0][0]).toContain('/api/internal-support/v1/diagnostic-drafts');
const handoffBody = JSON.parse(fetchMock.mock.calls[1][1].body);
expect(handoffBody.diagnosticDraftId).toBe('draft-1');
});

it('never leaks the CRM reason to the caller', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(crmResponse({ error: 'secret internal reason' }, 403)),
);

const result = await createSupportHandoff(input);

expect(result).toEqual({ ok: false, status: 403, message: 'Support access denied.' });
});

it('sends an on-prem install to the Support login when CRM is unreachable', async () => {
config.deployment = { mode: 'onprem', isOnPrem: true };
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED')));

const result = await createSupportHandoff(input);

expect(result).toMatchObject({
ok: true,
url: 'https://support.example.com/tr/login?diagnostics=unavailable',
diagnosticsUnavailable: true,
});
});
});

describe('isSupportEntryPointEnabled', () => {
it('hides the entry point when no handoff can be issued on SaaS', () => {
config.support = { baseUrl: 'https://support.example.com', crmApiUrl: '', handoffSecret: '' };
expect(isSupportEntryPointEnabled()).toBe(false);
});

it('keeps it on-prem, where the login fallback still helps', () => {
config.support = { baseUrl: 'https://support.example.com', crmApiUrl: '', handoffSecret: '' };
config.deployment = { mode: 'onprem', isOnPrem: true };
expect(isSupportEntryPointEnabled()).toBe(true);
});

it('stays hidden without a Support URL', () => {
config.support = { baseUrl: '', crmApiUrl: 'https://crm', handoffSecret: 'a'.repeat(32) };
config.deployment = { mode: 'onprem', isOnPrem: true };
expect(isSupportEntryPointEnabled()).toBe(false);
});
});
Loading
Loading