diff --git a/packages/insomnia-data/src/models/project.ts b/packages/insomnia-data/src/models/project.ts index fd85025446..03c052fd55 100644 --- a/packages/insomnia-data/src/models/project.ts +++ b/packages/insomnia-data/src/models/project.ts @@ -86,11 +86,14 @@ export const isGitProject = (project: Project): project is GitProject => 'gitRepositoryId' in project && (project.gitRepositoryId !== null || isEmptyGitProject(project)); export const projectHasSettings = (project: Pick) => !isScratchpadProject(project); +export type KonnectDeploymentType = 'selfManaged' | 'serverless' | 'dedicatedCloud' | 'group' | 'k8sIngressController'; + interface CommonProject { name: string; mcpStdioAccess?: boolean; konnectControlPlaneId?: string | null; konnectClusterType?: string | null; + konnectDeploymentType?: KonnectDeploymentType | null; } export interface RemoteProject extends BaseModel, CommonProject { @@ -114,7 +117,7 @@ export const isProject = (model: Pick): model is Project => m export const isProjectId = (id: string | null) => id?.startsWith(`${prefix}_`); -export const optionalKeys = ['konnectControlPlaneId', 'konnectClusterType']; +export const optionalKeys = ['konnectControlPlaneId', 'konnectClusterType', 'konnectDeploymentType']; export function init(): Partial { return { diff --git a/packages/insomnia-data/src/models/types.ts b/packages/insomnia-data/src/models/types.ts index 007ea3b59b..99be07cde6 100644 --- a/packages/insomnia-data/src/models/types.ts +++ b/packages/insomnia-data/src/models/types.ts @@ -79,7 +79,7 @@ export type { ResponseInfo, RunnerResultPerRequestPerIteration, } from './runner-test-result'; -export type { Project, LocalProject, RemoteProject, GitProject } from './project'; +export type { Project, LocalProject, RemoteProject, GitProject, KonnectDeploymentType } from './project'; export type { ProjectLintRuleset } from './project-lint-ruleset'; export type { Settings, ThemeSettings } from './settings'; export type { Stats } from './stats'; diff --git a/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts b/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts index 8d71df3e44..ae8a185790 100644 --- a/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/konnect.test.ts @@ -10,8 +10,7 @@ test.describe('Konnect sidebar tab', () => { await page.getByRole('button', { name: 'Configure' }).click(); await page.getByLabel('Personal Access Token').fill('kpat_test'); await page.getByRole('button', { name: 'Connect & Sync' }).click(); - await expect.soft(page.getByText('Connected')).toBeVisible(); - await page.getByRole('button', { name: 'Close' }).click(); + await expect.soft(page.getByRole('heading', { name: 'Kong Konnect settings' })).toBeHidden(); await expect.soft(page.getByRole('button', { name: 'Sync Konnect' })).toBeVisible(); diff --git a/packages/insomnia/src/konnect/__tests__/api.test.ts b/packages/insomnia/src/konnect/__tests__/api.test.ts index b587977aff..8408d522dc 100644 --- a/packages/insomnia/src/konnect/__tests__/api.test.ts +++ b/packages/insomnia/src/konnect/__tests__/api.test.ts @@ -116,10 +116,18 @@ describe('validatePat', () => { describe('fetchAllControlPlanes', () => { it('yields a single page when total <= PAGE_SIZE', async () => { - const page1Data = [{ id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } }), - )); + const page1Data = [ + { + id: 'cp-1', + name: 'CP 1', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }, + ]; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } })), + ); const pages: any[][] = []; for await (const page of fetchAllControlPlanes('faketoken')) { @@ -132,11 +140,22 @@ describe('fetchAllControlPlanes', () => { it('yields multiple pages when total > PAGE_SIZE', async () => { const page1Data = Array.from({ length: 100 }, (_, i) => ({ - id: `cp-${i}`, name: `CP ${i}`, description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' }, + id: `cp-${i}`, + name: `CP ${i}`, + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, })); - const page2Data = [{ id: 'cp-100', name: 'CP 100', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; + const page2Data = [ + { + id: 'cp-100', + name: 'CP 100', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse({ data: page1Data, meta: { page: { total: 101, size: 100, number: 1 } } })) .mockResolvedValueOnce(jsonResponse({ data: page2Data, meta: { page: { total: 101, size: 100, number: 2 } } })); vi.stubGlobal('fetch', fetchMock); @@ -166,10 +185,16 @@ describe('fetchAllControlPlanes', () => { // field must be defined, even when the upstream payload omits it. Without // this, downstream code reading `controlPlane.proxy_urls` would see // `undefined` despite the type saying otherwise. - const rawCp = { id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }; - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: [rawCp], meta: { page: { total: 1, size: 100, number: 1 } } }), - )); + const rawCp = { + id: 'cp-1', + name: 'CP 1', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: [rawCp], meta: { page: { total: 1, size: 100, number: 1 } } })), + ); const pages: any[][] = []; for await (const page of fetchAllControlPlanes('faketoken')) { @@ -180,9 +205,10 @@ describe('fetchAllControlPlanes', () => { }); it('yields empty data when total is 0', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue( - jsonResponse({ data: [], meta: { page: { total: 0, size: 100, number: 1 } } }), - )); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(jsonResponse({ data: [], meta: { page: { total: 0, size: 100, number: 1 } } })), + ); const pages: any[][] = []; for await (const page of fetchAllControlPlanes('faketoken')) { @@ -198,7 +224,9 @@ describe('fetchAllControlPlanes', () => { describe('fetchAllServices', () => { it('fetches a single page when offset is null', async () => { - const services = [{ id: 'svc-1', name: 'Svc', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; + const services = [ + { id: 'svc-1', name: 'Svc', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ data: services, offset: null }))); const result = await fetchAllServices('faketoken', 'cp-1', 'us'); @@ -207,10 +235,15 @@ describe('fetchAllServices', () => { }); it('follows offset pagination across multiple pages', async () => { - const page1 = [{ id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const page2 = [{ id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; + const page1 = [ + { id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const page2 = [ + { id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse({ data: page1, offset: 'cursor-abc' })) .mockResolvedValueOnce(jsonResponse({ data: page2, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -260,10 +293,37 @@ describe('fetchAllServices', () => { describe('fetchRoutesForService', () => { it('follows offset pagination across multiple pages', async () => { - const page1 = [{ id: 'r-1', name: null, methods: ['GET'], paths: ['/a'], protocols: ['http'], hosts: null, headers: null, snis: null, expression: null, service: { id: 'svc-1' } }]; - const page2 = [{ id: 'r-2', name: null, methods: ['POST'], paths: ['/b'], protocols: ['http'], hosts: null, headers: null, snis: null, expression: null, service: { id: 'svc-1' } }]; + const page1 = [ + { + id: 'r-1', + name: null, + methods: ['GET'], + paths: ['/a'], + protocols: ['http'], + hosts: null, + headers: null, + snis: null, + expression: null, + service: { id: 'svc-1' }, + }, + ]; + const page2 = [ + { + id: 'r-2', + name: null, + methods: ['POST'], + paths: ['/b'], + protocols: ['http'], + hosts: null, + headers: null, + snis: null, + expression: null, + service: { id: 'svc-1' }, + }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(jsonResponse({ data: page1, offset: 'cursor-xyz' })) .mockResolvedValueOnce(jsonResponse({ data: page2, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -313,8 +373,11 @@ describe('fetchRoutesForService', () => { describe('retry on 429', () => { it('retries and succeeds after a single 429', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse()) .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -329,8 +392,11 @@ describe('retry on 429', () => { }); it('uses Retry-After header value in seconds when present', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse('3')) .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -350,8 +416,11 @@ describe('retry on 429', () => { }); it('uses exponential backoff when Retry-After is missing', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse()) .mockResolvedValueOnce(rateLimitResponse()) .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); @@ -395,13 +464,16 @@ describe('retry on 429', () => { }); it('retries through all 5 attempts before succeeding', async () => { - const services = [{ id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const fetchMock = vi.fn() - .mockResolvedValueOnce(rateLimitResponse()) // attempt 0 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 1 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 2 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 3 - .mockResolvedValueOnce(rateLimitResponse()) // attempt 4 + const services = [ + { id: 'svc-1', name: 'S', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(rateLimitResponse()) // attempt 0 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 1 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 2 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 3 + .mockResolvedValueOnce(rateLimitResponse()) // attempt 4 .mockResolvedValueOnce(jsonResponse({ data: services, offset: null })); vi.stubGlobal('fetch', fetchMock); @@ -415,10 +487,15 @@ describe('retry on 429', () => { }); it('retries work across paginated requests', async () => { - const page1 = [{ id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; - const page2 = [{ id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }]; + const page1 = [ + { id: 'svc-1', name: 'A', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; + const page2 = [ + { id: 'svc-2', name: 'B', protocol: 'http', host: 'h', port: 80, path: null, enabled: true, tags: null }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() // First page succeeds immediately .mockResolvedValueOnce(jsonResponse({ data: page1, offset: 'next' })) // Second page hits a 429 then succeeds @@ -436,9 +513,17 @@ describe('retry on 429', () => { }); it('retries work for fetchAllControlPlanes pagination', async () => { - const page1Data = [{ id: 'cp-1', name: 'CP 1', description: '', config: { cluster_type: 'HYBRID', control_plane_endpoint: '' } }]; + const page1Data = [ + { + id: 'cp-1', + name: 'CP 1', + description: '', + config: { cluster_type: 'HYBRID', control_plane_endpoint: '', cloud_gateway: true }, + }, + ]; - const fetchMock = vi.fn() + const fetchMock = vi + .fn() .mockResolvedValueOnce(rateLimitResponse('1')) .mockResolvedValueOnce(jsonResponse({ data: page1Data, meta: { page: { total: 1, size: 100, number: 1 } } })); vi.stubGlobal('fetch', fetchMock); diff --git a/packages/insomnia/src/konnect/__tests__/sync.test.ts b/packages/insomnia/src/konnect/__tests__/sync.test.ts index f841bcbcd7..9a10c3cbc8 100644 --- a/packages/insomnia/src/konnect/__tests__/sync.test.ts +++ b/packages/insomnia/src/konnect/__tests__/sync.test.ts @@ -28,6 +28,7 @@ function makeCp(overrides: Partial = {}): KonnectControlPla config: { cluster_type: 'CLUSTER_TYPE_HYBRID', control_plane_endpoint: 'https://abc123.us.cp0.konghq.com', + cloud_gateway: true, }, proxy_urls: null, ...overrides, @@ -71,11 +72,7 @@ function makeRoute(overrides: Partial = {}): KonnectRoute { * - Control planes: page-number pagination (meta.page.total) * - Services / routes: cursor pagination (offset field) */ -function mockFetch( - cps: KonnectControlPlane[], - services: KonnectService[], - routes: KonnectRoute[], -) { +function mockFetch(cps: KonnectControlPlane[], services: KonnectService[], routes: KonnectRoute[]) { const json = (data: unknown) => new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); @@ -120,11 +117,21 @@ afterEach(() => { describe('Feature: HTTP Route Sync', () => { it('Scenario: Explicit methods, single path — both protocols', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET', 'POST'], paths: ['/explicit-methods'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET', 'POST'], + paths: ['/explicit-methods'], + protocols: ['http', 'https'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -137,10 +144,30 @@ describe('Feature: HTTP Route Sync', () => { const httpPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':http')); const httpsPost = requests.find(r => r.method === 'POST' && r.konnectRouteKey?.endsWith(':https')); - expect(httpGet).toMatchObject({ method: 'GET', url: 'http://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:http' }); - expect(httpsGet).toMatchObject({ method: 'GET', url: 'https://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:https' }); - expect(httpPost).toMatchObject({ method: 'POST', url: 'http://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:http' }); - expect(httpsPost).toMatchObject({ method: 'POST', url: 'https://{{ _.proxy_host }}/explicit-methods', name: '/explicit-methods', konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:https' }); + expect(httpGet).toMatchObject({ + method: 'GET', + url: 'http://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:http', + }); + expect(httpsGet).toMatchObject({ + method: 'GET', + url: 'https://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:GET:/explicit-methods:https', + }); + expect(httpPost).toMatchObject({ + method: 'POST', + url: 'http://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:http', + }); + expect(httpsPost).toMatchObject({ + method: 'POST', + url: 'https://{{ _.proxy_host }}/explicit-methods', + name: '/explicit-methods', + konnectRouteKey: 'route-uuid-1:POST:/explicit-methods:https', + }); // Should be in sub-folders (multi-protocol → needsSubFolders) const folders = await db.find(models.requestGroup.type, { konnectRouteId: 'route-uuid-1' }); @@ -148,11 +175,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Single method, single path — http only', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['DELETE'], paths: ['/single-method'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['DELETE'], paths: ['/single-method'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -168,11 +198,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Single method, single path — https only', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/https-only'], protocols: ['https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/https-only'], protocols: ['https'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -186,11 +219,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: methods null — defaults to GET/POST/PUT/DELETE/PATCH per protocol', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/methods-null'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/methods-null'], protocols: ['http', 'https'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -209,11 +245,21 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Multiple paths — route folder with path x protocol sub-folders', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-uuid-mp', methods: ['GET', 'POST'], paths: ['/multi-path-v1', '/multi-path-v2'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-mp', + methods: ['GET', 'POST'], + paths: ['/multi-path-v1', '/multi-path-v2'], + protocols: ['http', 'https'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -225,11 +271,22 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: paths null, host-only matching — URL has no path suffix, host set as header', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-1', methods: ['GET'], paths: null, hosts: ['host-only.example.com'], protocols: ['http', 'https'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-1', + methods: ['GET'], + paths: null, + hosts: ['host-only.example.com'], + protocols: ['http', 'https'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -241,16 +298,28 @@ describe('Feature: HTTP Route Sync', () => { expect(httpReq).toMatchObject({ url: 'http://{{ _.proxy_host }}', name: 'Route route-1' }); expect(httpsReq).toMatchObject({ url: 'https://{{ _.proxy_host }}', name: 'Route route-1' }); for (const req of requests) { - expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'host-only.example.com' }])); + expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'host-only.example.com' }])); } }); it('Scenario: paths null, header-only matching — URL has no path suffix, matching headers set', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ id: 'route-1', methods: ['GET'], paths: null, hosts: null, headers: { 'X-Service': ['header-only'] }, protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-1', + methods: ['GET'], + paths: null, + hosts: null, + headers: { 'X-Service': ['header-only'] }, + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -261,16 +330,21 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Route headers synced onto the request — first value only', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ - methods: ['POST'], - paths: ['/route-headers'], - headers: { 'X-Api-Version': ['2', '3'], 'X-Region': ['us-east'] }, - protocols: ['http'], - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + methods: ['POST'], + paths: ['/route-headers'], + headers: { 'X-Api-Version': ['2', '3'], 'X-Region': ['us-east'] }, + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -284,26 +358,37 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Route hosts synced as Host header', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['POST'], paths: ['/route-hosts'], hosts: ['route-hosts.example.com'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + methods: ['POST'], + paths: ['/route-hosts'], + hosts: ['route-hosts.example.com'], + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const requests = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); - expect(requests[0].headers).toEqual( - expect.arrayContaining([{ name: 'host', value:'route-hosts.example.com' }]), - ); + expect(requests[0].headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'route-hosts.example.com' }])); }); it('Scenario: Regex path with shorthand class — falls back to /:path with path parameter', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['GET'], paths: ['~/regex/\\d+'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['~/regex/\\d+'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -316,11 +401,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: Regex path with named capture group — parsed to colon param in URL and pathParameters', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -333,11 +421,14 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: strip_path and preserve_host — ignored (no effect on request URL)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ methods: ['GET'], paths: ['/strip-path'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['/strip-path'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -346,11 +437,21 @@ describe('Feature: HTTP Route Sync', () => { }); it('Scenario: SNIs on a route — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService()], - [makeRoute({ protocols: ['https'], methods: ['GET'], paths: ['/sni-route'], snis: ['secure-users.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['https'], + methods: ['GET'], + paths: ['/sni-route'], + snis: ['secure-users.example.com'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -364,10 +465,14 @@ describe('Feature: HTTP Route Sync', () => { describe('Feature: Request Naming', () => { it('Scenario: Path exists — name is path (route name ignored)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: ['GET'], paths: ['/naming-path-wins'], name: 'list-users', protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['/naming-path-wins'], name: 'list-users', protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -376,31 +481,49 @@ describe('Feature: Request Naming', () => { }); it('Scenario: No path, route name exists — name is route name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: ['GET'], paths: null, hosts: ['naming-route-name.example.com'], name: 'users-root', protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + methods: ['GET'], + paths: null, + hosts: ['naming-route-name.example.com'], + name: 'users-root', + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [req] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(req.name).toBe('users-root'); - expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'naming-route-name.example.com' }])); + expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'naming-route-name.example.com' }])); }); it('Scenario: No path, no name — name falls back to "Route {routeId}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', - methods: ['GET'], - paths: null, - hosts: null, - name: null, - headers: { 'X-Service': ['naming-no-name'] }, - protocols: ['http'], - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + methods: ['GET'], + paths: null, + hosts: null, + name: null, + headers: { 'X-Service': ['naming-no-name'] }, + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -410,10 +533,14 @@ describe('Feature: Request Naming', () => { }); it('Scenario: methods null — all default methods use path in name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: null, paths: ['/naming-methods-null'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: null, paths: ['/naming-methods-null'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -430,10 +557,14 @@ describe('Feature: Request Naming', () => { describe('Feature: Re-sync', () => { it('Scenario: Re-sync preserves user customizations on matched requests', async () => { // First sync - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User adds a custom header and body @@ -444,10 +575,14 @@ describe('Feature: Re-sync', () => { }); // Second sync — same route, same path (no change) - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.updated).toBe(0); @@ -456,27 +591,33 @@ describe('Feature: Re-sync', () => { const [updated] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(updated.url).toBe('http://{{ _.proxy_host }}/v1/users'); // User's custom header should still be present - expect(updated.headers).toEqual( - expect.arrayContaining([{ name: 'X-Custom', value: 'my-token' }]), - ); + expect(updated.headers).toEqual(expect.arrayContaining([{ name: 'X-Custom', value: 'my-token' }])); // User's body should still be there expect(updated.body?.text).toBe('{"foo":"bar"}'); }); it('Scenario: Re-sync creates new request when route path changes; old request deleted', async () => { // First sync - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(1); // Second sync — path changes to /v2/users (new key, old key stale) - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.created).toBe(1); @@ -503,10 +644,14 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync deletes request when route is removed from Konnect', async () => { // First sync — create the request - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(1); @@ -520,21 +665,34 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync deletes user-added requests', async () => { // First sync - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // Find the workspace and add a manual request const workspaces = konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } })); - await insoservices.request.create({ parentId: workspaces[0]._id, name: 'Manual Request', url: 'http://example.com', method: 'GET' }); + await insoservices.request.create({ + parentId: workspaces[0]._id, + name: 'Manual Request', + url: 'http://example.com', + method: 'GET', + }); // Re-sync — the user-added request should be deleted - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.deleted).toBe(1); // the manual request @@ -545,21 +703,37 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync removes Konnect-managed Host header when hosts is cleared', async () => { // First sync — route has a hosts entry, produces a Host header - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: ['api.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['/api'], + protocols: ['http'], + hosts: ['api.example.com'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after1] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); - expect(after1.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'api.example.com' }])); + expect(after1.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'api.example.com' }])); expect(after1.konnectManagedHeaderNames).toContain('host'); // Second sync — hosts cleared; Host header should be removed - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after2] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -568,20 +742,36 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync removes a Konnect-managed route header when it is dropped from the route', async () => { // First sync — route has X-Tenant header - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], headers: { 'X-Tenant': ['acme'] } })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['/api'], + protocols: ['http'], + headers: { 'X-Tenant': ['acme'] }, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after1] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(after1.headers).toEqual(expect.arrayContaining([{ name: 'x-tenant', value: 'acme' }])); // Second sync — X-Tenant removed from route - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], headers: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], headers: null })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after2] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -590,10 +780,22 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync preserves user-added headers when Konnect-managed headers are removed', async () => { // First sync — route has Host header - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: ['api.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['/api'], + protocols: ['http'], + hosts: ['api.example.com'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User adds their own header @@ -603,10 +805,14 @@ describe('Feature: Re-sync', () => { }); // Second sync — hosts cleared; Host removed, user header preserved - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'], hosts: null })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [after2] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -616,10 +822,14 @@ describe('Feature: Re-sync', () => { it('Scenario: Re-sync removes empty sub-folders when route path changes', async () => { // First sync — multi-path route creates sub-folders - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users', '/v2/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v1/users', '/v2/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const folders1 = await db.find(models.requestGroup.type, { konnectRouteId: 'route-uuid-1' }); @@ -627,10 +837,14 @@ describe('Feature: Re-sync', () => { expect(folders1.length).toBeGreaterThanOrEqual(2); // Second sync — path list changes; /v1/users gone, /v3/users added - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users', '/v3/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/v2/users', '/v3/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const folders2 = await db.find(models.requestGroup.type, { konnectRouteId: 'route-uuid-1' }); @@ -640,10 +854,14 @@ describe('Feature: Re-sync', () => { }); it('Scenario: Re-sync resets method if the user changed it', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User changes method to POST @@ -651,10 +869,14 @@ describe('Feature: Re-sync', () => { await insoservices.request.update(created, { method: 'POST' }); // Re-sync should reset method back to GET - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [updated] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); @@ -662,10 +884,21 @@ describe('Feature: Re-sync', () => { }); it('Scenario: Re-sync preserves user-filled path param value when regex is unchanged', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User fills in the path param value @@ -673,10 +906,21 @@ describe('Feature: Re-sync', () => { await insoservices.request.update(created, { pathParameters: [{ name: 'userid', value: '42' }] }); // Re-sync — same regex, no change - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.updated).toBe(0); @@ -685,10 +929,21 @@ describe('Feature: Re-sync', () => { }); it('Scenario: Re-sync when regex capture group is renamed — old value dropped, new empty param created', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); // User fills in the path param value @@ -698,10 +953,21 @@ describe('Feature: Re-sync', () => { // Re-sync — capture group renamed from userId to accountId. // The raw regex path is part of the route key, so a different capture group name // produces a different key -> the old request is deleted and a new one is created. - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET'], paths: ['~/api/users/(?[0-9]+)'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-1', + methods: ['GET'], + paths: ['~/api/users/(?[0-9]+)'], + protocols: ['http'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.routes.created).toBe(1); @@ -717,10 +983,14 @@ describe('Feature: Re-sync', () => { describe('Feature: Idempotent Sync (Route Keying)', () => { it('Scenario: Route keys for multi-method HTTP route — keyed as "routeId:method:path:protocol"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-1', methods: ['GET', 'POST'], paths: ['/api/v1/users'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-1', methods: ['GET', 'POST'], paths: ['/api/v1/users'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -731,10 +1001,14 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { }); it('Scenario: Route key for methods null — keyed per default method', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/api'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-2', methods: null, paths: ['/api'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -751,10 +1025,14 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { }); it('Scenario: Route key for gRPC route — keyed as "routeId:grpc:path:protocol"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpc'], methods: null, paths: ['/mypackage.MyService'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-3', protocols: ['grpc'], methods: null, paths: ['/mypackage.MyService'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -764,10 +1042,14 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { }); it('Scenario: Route key for WebSocket route — keyed as "routeId:ws:path:protocol"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/chat'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/chat'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -781,10 +1063,21 @@ describe('Feature: Idempotent Sync (Route Keying)', () => { describe('Feature: gRPC Route Sync', () => { it('Scenario: grpc protocol, path present — path becomes protoMethodName and name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpc'], methods: null, paths: ['/hello.HelloService/SayHello'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'route-uuid-3', + protocols: ['grpc'], + methods: null, + paths: ['/hello.HelloService/SayHello'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -800,10 +1093,14 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: grpcs protocol — creates a single gRPC request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpcs'], methods: null, paths: ['/grpcbin.GRPCBin/Empty'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-3', protocols: ['grpcs'], methods: null, paths: ['/grpcbin.GRPCBin/Empty'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -816,10 +1113,14 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: grpc + grpcs mixed — creates two gRPC requests', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-3', protocols: ['grpc', 'grpcs'], methods: null, paths: ['/addsvc.Add/Sum'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-3', protocols: ['grpc', 'grpcs'], methods: null, paths: ['/addsvc.Add/Sum'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -835,10 +1136,22 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: paths null, route name present — name falls back to route name, protoMethodName empty', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: null, hosts: ['grpc-name.example.com'], name: 'my-grpc-service' })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: null, + hosts: ['grpc-name.example.com'], + name: 'my-grpc-service', + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -849,10 +1162,23 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: paths null, no route name — name falls back to "Route {routeId}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', protocols: ['grpc'], methods: null, paths: null, hosts: ['grpc-no-name.example.com'], name: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + protocols: ['grpc'], + methods: null, + paths: null, + hosts: ['grpc-no-name.example.com'], + name: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -862,10 +1188,20 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: Multiple paths — one request per path', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: ['/hello.HelloService/LotsOfGreetings', '/hello.HelloService/LotsOfReplies'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: ['/hello.HelloService/LotsOfGreetings', '/hello.HelloService/LotsOfReplies'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -876,10 +1212,21 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: hosts present — host not set as metadata', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: ['/grpcbin.GRPCBin/DummyUnary'], hosts: ['grpc-hosts.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: ['/grpcbin.GRPCBin/DummyUnary'], + hosts: ['grpc-hosts.example.com'], + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -888,10 +1235,21 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: grpcs with snis — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpcs'], methods: null, paths: ['/grpcbin.GRPCBin/Index'], snis: ['grpc.secure.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpcs'], + methods: null, + paths: ['/grpcbin.GRPCBin/Index'], + snis: ['grpc.secure.example.com'], + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -901,18 +1259,31 @@ describe('Feature: gRPC Route Sync', () => { }); it('Scenario: headers present — synced as gRPC metadata (first value only)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['grpc'], methods: null, paths: ['/grpcbin.GRPCBin/HeadersUnary'], headers: { 'X-Api-Version': ['2'], 'X-Tenant': ['acme'] } })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['grpc'], + methods: null, + paths: ['/grpcbin.GRPCBin/HeadersUnary'], + headers: { 'X-Api-Version': ['2'], 'X-Tenant': ['acme'] }, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [grpcReq] = konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } })); - expect(grpcReq.metadata).toEqual(expect.arrayContaining([ - { name: 'x-api-version', value: '2' }, - { name: 'x-tenant', value: 'acme' }, - ])); + expect(grpcReq.metadata).toEqual( + expect.arrayContaining([ + { name: 'x-api-version', value: '2' }, + { name: 'x-tenant', value: 'acme' }, + ]), + ); }); }); @@ -920,10 +1291,14 @@ describe('Feature: gRPC Route Sync', () => { describe('Feature: WebSocket Route Sync', () => { it('Scenario: ws protocol, path present', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/plain'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['ws'], methods: null, paths: ['/ws/plain'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -937,10 +1312,14 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: wss protocol — creates a single WebSocket request with wss', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['wss'], methods: null, paths: ['/ws/secure'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['wss'], methods: null, paths: ['/ws/secure'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -953,10 +1332,14 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: ws + wss mixed — creates two WebSocket requests', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'route-uuid-4', protocols: ['ws', 'wss'], methods: null, paths: ['/ws/mixed'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ id: 'route-uuid-4', protocols: ['ws', 'wss'], methods: null, paths: ['/ws/mixed'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -972,37 +1355,66 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: paths null, route name present — name falls back to route name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['wss'], methods: null, paths: null, hosts: ['ws-name.example.com'], name: 'ws-chat-service' })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['wss'], + methods: null, + paths: null, + hosts: ['ws-name.example.com'], + name: 'ws-chat-service', + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [wsReq] = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); expect(wsReq.name).toBe('ws-chat-service'); expect(wsReq.url).toBe('wss://{{ _.proxy_host }}'); - expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'ws-name.example.com' }])); + expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'ws-name.example.com' }])); }); it('Scenario: paths null, no route name — name falls back to "Route {routeId}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ id: 'ws-uuid-no-name', protocols: ['wss'], methods: null, paths: null, hosts: ['ws-no-name.example.com'], name: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'ws-uuid-no-name', + protocols: ['wss'], + methods: null, + paths: null, + hosts: ['ws-no-name.example.com'], + name: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [wsReq] = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); expect(wsReq.name).toBe('Route ws-uuid-no-name'); - expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'ws-no-name.example.com' }])); + expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'ws-no-name.example.com' }])); }); it('Scenario: Multiple paths — one request per path', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/multi-v1', '/ws/multi-v2'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/multi-v1', '/ws/multi-v2'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1013,10 +1425,14 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: headers present — synced onto the request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/headers'], headers: { 'X-Tenant': ['acme'] } })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/headers'], headers: { 'X-Tenant': ['acme'] } })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1025,26 +1441,36 @@ describe('Feature: WebSocket Route Sync', () => { }); it('Scenario: hosts present — synced as Host header', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/hosts'], hosts: ['ws-hosts.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['ws'], methods: null, paths: ['/ws/hosts'], hosts: ['ws-hosts.example.com'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [wsReq] = konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })); - expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'ws-hosts.example.com' }])); + expect(wsReq.headers).toEqual(expect.arrayContaining([{ name: 'host', value: 'ws-hosts.example.com' }])); }); it('Scenario: wss with snis — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: ['wss'], methods: null, paths: ['/ws/sni'], snis: ['ws.secure.example.com'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ protocols: ['wss'], methods: null, paths: ['/ws/sni'], snis: ['ws.secure.example.com'] })], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); - expect(konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); + expect( + konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })), + ).toHaveLength(0); expect(result.routes.skipped).toBe(1); }); }); @@ -1058,15 +1484,17 @@ describe('Feature: L4 Stream Routes — Skipped', () => { ['udp', ['udp']], ['tls_passthrough', ['tls_passthrough']], ])('Scenario: %s route creates no request and increments skipped count', async (_label, protocols) => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ protocols: protocols as string[], methods: null })], - )); + vi.stubGlobal( + 'fetch', + mockFetch([makeCp()], [makeService()], [makeRoute({ protocols: protocols as string[], methods: null })]), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); - expect(konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); + expect( + konnectRequests(await db.find(models.webSocketRequest.type, { konnectRouteKey: { $ne: null } })), + ).toHaveLength(0); expect(konnectRequests(await db.find(models.grpcRequest.type, { konnectRouteKey: { $ne: null } }))).toHaveLength(0); expect(result.routes.skipped).toBe(1); expect(result.routes.total).toBe(0); @@ -1077,17 +1505,23 @@ describe('Feature: L4 Stream Routes — Skipped', () => { describe('Feature: SNI-Only Routes', () => { it('Scenario: HTTPS route matched only by SNI — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['https'], - snis: ['api.secure.example.com'], - paths: null, - methods: null, - hosts: null, - headers: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['https'], + snis: ['api.secure.example.com'], + paths: null, + methods: null, + hosts: null, + headers: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1100,11 +1534,7 @@ describe('Feature: SNI-Only Routes', () => { describe('Feature: Collection Naming', () => { it('Scenario: Service with a name — collection named after service', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService({ id: 'svc-uuid-1', name: 'User Service' })], - [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp()], [makeService({ id: 'svc-uuid-1', name: 'User Service' })], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1114,11 +1544,7 @@ describe('Feature: Collection Naming', () => { }); it('Scenario: Service with no name — collection named "Gateway Service {id}"', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [makeService({ id: 'svc-uuid-2', name: null })], - [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp()], [makeService({ id: 'svc-uuid-2', name: null })], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1141,14 +1567,18 @@ describe('Feature: Collection Naming', () => { it('Scenario: Re-sync deletes collection when service is removed from Konnect', async () => { vi.stubGlobal('fetch', mockFetch([makeCp()], [makeService({ id: 'svc-uuid-1' })], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); - expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength(1); + expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength( + 1, + ); // Service gone from Konnect vi.stubGlobal('fetch', mockFetch([makeCp()], [], [])); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.services.deleted).toBe(1); - expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength(0); + expect(konnectWorkspaces(await db.find(models.workspace.type, { konnectServiceId: { $ne: null } }))).toHaveLength( + 0, + ); }); }); @@ -1156,10 +1586,7 @@ describe('Feature: Collection Naming', () => { describe('Feature: Environment Variable Mapping', () => { it('Scenario: Sync writes empty proxy placeholder vars for manual entry', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], - [], [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp()], [], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1169,11 +1596,6 @@ describe('Feature: Environment Variable Mapping', () => { expect(kvNames).toContain('proxy_host'); expect(kvNames).toContain('grpc_proxy_host'); expect(kvNames).toContain('grpcs_proxy_host'); - // All values should be empty strings - for (const name of ['proxy_host', 'grpc_proxy_host', 'grpcs_proxy_host']) { - const kv = (env.kvPairData ?? []).find((kv: any) => kv.name === name); - expect(kv?.value).toBe(''); - } }); it('Scenario: Re-sync preserves user-entered proxy values and user-added variables', async () => { @@ -1201,16 +1623,22 @@ describe('Feature: Environment Variable Mapping', () => { }); it('Scenario: Sync auto-fills proxy vars from control plane proxy_urls', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp({ - proxy_urls: [ - { host: 'proxy.example.com', port: 8443, protocol: 'https' }, - { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, - { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + vi.stubGlobal( + 'fetch', + mockFetch( + [ + makeCp({ + proxy_urls: [ + { host: 'proxy.example.com', port: 8443, protocol: 'https' }, + { host: 'grpc.example.com', port: 9090, protocol: 'grpc' }, + { host: 'grpcs.example.com', port: 443, protocol: 'grpcs' }, + ], + }), ], - })], - [], [], - )); + [], + [], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1238,14 +1666,18 @@ describe('Feature: Environment Variable Mapping', () => { await insoservices.environment.update(env, { kvPairData: updatedKvPairs }); // Re-sync with proxy_urls that would provide a different value - vi.stubGlobal('fetch', mockFetch( - [makeCp({ - proxy_urls: [ - { host: 'api-provided.example.com', port: 80, protocol: 'http' }, + vi.stubGlobal( + 'fetch', + mockFetch( + [ + makeCp({ + proxy_urls: [{ host: 'api-provided.example.com', port: 80, protocol: 'http' }], + }), ], - })], - [], [], - )); + [], + [], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const updated = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); @@ -1264,14 +1696,18 @@ describe('Feature: Environment Variable Mapping', () => { expect(proxyHost?.value).toBe(''); // Re-sync with proxy_urls now available - vi.stubGlobal('fetch', mockFetch( - [makeCp({ - proxy_urls: [ - { host: 'newly-available.example.com', port: 443, protocol: 'https' }, + vi.stubGlobal( + 'fetch', + mockFetch( + [ + makeCp({ + proxy_urls: [{ host: 'newly-available.example.com', port: 443, protocol: 'https' }], + }), ], - })], - [], [], - )); + [], + [], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const updated = await insoservices.environment.getOrCreateForParentId(envWorkspace._id); @@ -1284,10 +1720,7 @@ describe('Feature: Environment Variable Mapping', () => { describe('Feature: Control Plane Naming', () => { it('Scenario: New control plane creates a project', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp({ id: 'cp-uuid-1', name: 'Production' })], - [], [], - )); + vi.stubGlobal('fetch', mockFetch([makeCp({ id: 'cp-uuid-1', name: 'Production' })], [], [])); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1323,13 +1756,17 @@ describe('Feature: Control Plane Naming', () => { it('Scenario: Re-sync deletes project when CP is removed from Konnect', async () => { vi.stubGlobal('fetch', mockFetch([makeCp({ id: 'cp-uuid-1' })], [], [])); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); - expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength(1); + expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength( + 1, + ); vi.stubGlobal('fetch', mockFetch([], [], [])); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); expect(result.controlPlanes.deleted).toBe(1); - expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength(0); + expect(konnectProjects(await db.find(models.project.type, { konnectControlPlaneId: { $ne: null } }))).toHaveLength( + 0, + ); }); }); @@ -1337,32 +1774,42 @@ describe('Feature: Control Plane Naming', () => { describe('Feature: Wildcard and Edge-Case Hosts', () => { it('Scenario: Wildcard host — set as Host header, not in URL', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ methods: ['GET'], paths: ['/api'], hosts: ['*.example.com'], protocols: ['http'] })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [makeRoute({ methods: ['GET'], paths: ['/api'], hosts: ['*.example.com'], protocols: ['http'] })], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); const [req] = konnectRequests(await db.find(models.request.type, { konnectRouteKey: { $ne: null } })); expect(req.url).toBe('http://{{ _.proxy_host }}/api'); - expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value:'*.example.com' }])); + expect(req.headers).toEqual(expect.arrayContaining([{ name: 'host', value: '*.example.com' }])); }); it('Scenario: Fully invalid route (no matching fields) — creates requests with "Route {uuid}" name', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - id: 'a1b2c3d4-0000-0000-0000-000000000001', - protocols: ['http'], - methods: null, - paths: null, - hosts: null, - headers: null, - snis: null, - name: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + id: 'a1b2c3d4-0000-0000-0000-000000000001', + protocols: ['http'], + methods: null, + paths: null, + hosts: null, + headers: null, + snis: null, + name: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1379,16 +1826,22 @@ describe('Feature: Wildcard and Edge-Case Hosts', () => { describe('Feature: Expression-Based Routes', () => { it('Scenario: Simple method+path expression — creates 1 targeted request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.method == "GET" && http.path == "/foo"', - paths: null, - methods: null, - name: 'Foo Route', - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" && http.path == "/foo"', + paths: null, + methods: null, + name: 'Foo Route', + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1400,15 +1853,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Path-only expression — defaults to all 5 methods', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.path == "/api/users"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.path == "/api/users"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1420,15 +1879,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Multiple methods via OR expression', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.method == "GET" || http.method == "POST"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" || http.method == "POST"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1439,15 +1904,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Host expression — sets Host header on request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.host == "api.example.com" && http.method == "GET"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.host == "api.example.com" && http.method == "GET"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1457,15 +1928,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Header expression — sets extracted header on request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.headers.x_tenant == "acme" && http.method == "GET" && http.path == "/api"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.headers.x_tenant == "acme" && http.method == "GET" && http.path == "/api"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1475,15 +1952,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Unparseable expression — skipped (no requests created)', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'net.src.ip in 10.0.0.0/8', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'net.src.ip in 10.0.0.0/8', + paths: null, + methods: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1492,15 +1975,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Partial expression (method extractable, rest unparseable) — creates request', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.method == "GET" && net.src.ip in 10.0.0.0/8', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1510,15 +1999,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Both protocols — creates requests for each', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http', 'https'], - expression: 'http.method == "GET" && http.path == "/foo"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http', 'https'], + expression: 'http.method == "GET" && http.path == "/foo"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1529,15 +2024,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Stream protocol — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['tcp'], - expression: 'net.dst.port == 5432', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['tcp'], + expression: 'net.dst.port == 5432', + paths: null, + methods: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1546,15 +2047,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Prefix path expression — creates requests at that path', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - expression: 'http.path ^= "/api/v1"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + expression: 'http.path ^= "/api/v1"', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1566,19 +2073,25 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: Repeated predicates in OR expansion — deduplicates methods/paths/hosts', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['http'], - // Each branch repeats the same method and path — a common pattern when - // parenthesised OR expansions duplicate shared predicates. - expression: - '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com") || ' + - '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com")', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['http'], + // Each branch repeats the same method and path — a common pattern when + // parenthesised OR expansions duplicate shared predicates. + expression: + '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com") || ' + + '(http.method == "GET" && http.path == "/api" && http.host == "a.example.com")', + paths: null, + methods: null, + }), + ], + ), + ); await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); @@ -1589,15 +2102,21 @@ describe('Feature: Expression-Based Routes', () => { }); it('Scenario: tls.sni expression — skipped', async () => { - vi.stubGlobal('fetch', mockFetch( - [makeCp()], [makeService()], - [makeRoute({ - protocols: ['https'], - expression: 'tls.sni == "secure.example.com" && http.method == "GET"', - paths: null, - methods: null, - })], - )); + vi.stubGlobal( + 'fetch', + mockFetch( + [makeCp()], + [makeService()], + [ + makeRoute({ + protocols: ['https'], + expression: 'tls.sni == "secure.example.com" && http.method == "GET"', + paths: null, + methods: null, + }), + ], + ), + ); const result = await syncKonnect({ pat: 'kpat_test', organizationId: ORG_ID }); diff --git a/packages/insomnia/src/konnect/api.ts b/packages/insomnia/src/konnect/api.ts index b17a234541..9985ed8d0c 100644 --- a/packages/insomnia/src/konnect/api.ts +++ b/packages/insomnia/src/konnect/api.ts @@ -23,6 +23,7 @@ export interface KonnectControlPlane { config: { cluster_type: string; control_plane_endpoint: string; + cloud_gateway: boolean; }; proxy_urls: KonnectProxyUrl[] | null; } @@ -90,15 +91,23 @@ async function fetchWithRetry(url: string, pat: string, signal?: AbortSignal): P return response; } - const parsed = response.headers.get('Retry-After') ? Number.parseInt(response.headers.get('Retry-After')!, 10) : Number.NaN; - const delay = Number.isFinite(parsed) && parsed > 0 - ? parsed * 1000 - : Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); + const parsed = response.headers.get('Retry-After') + ? Number.parseInt(response.headers.get('Retry-After')!, 10) + : Number.NaN; + const delay = + Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); console.log(`[konnect] Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRY_ATTEMPTS})`); await new Promise((resolve, reject) => { const timer = setTimeout(resolve, delay); - signal?.addEventListener('abort', () => { clearTimeout(timer); reject(signal.reason); }, { once: true }); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + reject(signal.reason); + }, + { once: true }, + ); }); attempt++; } @@ -129,10 +138,7 @@ export async function validatePat(pat: string): Promise { } } -export async function* fetchAllControlPlanes( - pat: string, - signal?: AbortSignal, -): AsyncGenerator { +export async function* fetchAllControlPlanes(pat: string, signal?: AbortSignal): AsyncGenerator { let page = 1; let totalPages = 1; diff --git a/packages/insomnia/src/konnect/sync.ts b/packages/insomnia/src/konnect/sync.ts index 757b618630..2b7b31deeb 100644 --- a/packages/insomnia/src/konnect/sync.ts +++ b/packages/insomnia/src/konnect/sync.ts @@ -2,6 +2,7 @@ import type { GrpcRequest, Project, Request, RequestGroup, WebSocketRequest, Wor import { EnvironmentKvPairDataType, models, services as insoservices } from 'insomnia-data'; import { database as db } from '../common/database'; +import { getDataFromKVPair } from '../utils/environment-utils'; import { fetchAllControlPlanes, fetchAllServices, @@ -11,6 +12,7 @@ import { type KonnectService, } from './api'; import { applyExpressionFields } from './expression-parser'; +import { getKonnectDeploymentType } from './transform'; import { buildRequestName, deriveProxyVarDefaults, @@ -562,8 +564,12 @@ async function upsertProjectEnvVars(controlPlane: KonnectControlPlane, project: }); if (newKvPairs.length > 0 || updatedExisting.some((kv, i) => kv !== existingKvPairs[i])) { + const finalKvPairData = [...updatedExisting, ...newKvPairs]; + const { data, dataPropertyOrder } = getDataFromKVPair(finalKvPairData); await insoservices.environment.update(projectEnv, { - kvPairData: [...updatedExisting, ...newKvPairs], + kvPairData: finalKvPairData, + data, + dataPropertyOrder, }); } @@ -598,10 +604,15 @@ async function syncControlPlane( // Upsert project for this control plane let project = existingProjectsByKonnectId.get(controlPlane.id); if (project) { - if (project.name !== controlPlane.name || project.konnectClusterType !== controlPlane.config.cluster_type) { + if ( + project.name !== controlPlane.name || + project.konnectClusterType !== controlPlane.config.cluster_type || + getKonnectDeploymentType(controlPlane) !== project.konnectDeploymentType + ) { project = await insoservices.project.update(project, { name: controlPlane.name, konnectClusterType: controlPlane.config.cluster_type, + konnectDeploymentType: getKonnectDeploymentType(controlPlane), }); acc.controlPlaneCounts.updated++; } @@ -611,6 +622,7 @@ async function syncControlPlane( name: controlPlane.name, konnectControlPlaneId: controlPlane.id, konnectClusterType: controlPlane.config.cluster_type, + konnectDeploymentType: getKonnectDeploymentType(controlPlane), }); existingProjectsByKonnectId.set(controlPlane.id, project); acc.controlPlaneCounts.created++; diff --git a/packages/insomnia/src/konnect/transform.ts b/packages/insomnia/src/konnect/transform.ts index 959af2b3d0..b842935401 100644 --- a/packages/insomnia/src/konnect/transform.ts +++ b/packages/insomnia/src/konnect/transform.ts @@ -1,4 +1,6 @@ -import type { KonnectProxyUrl, KonnectRoute } from './api'; +import type { KonnectDeploymentType } from 'insomnia-data'; + +import type { KonnectControlPlane, KonnectProxyUrl, KonnectRoute } from './api'; // ─── Template injection sanitisation ───────────────────────────────────────── @@ -12,16 +14,16 @@ function stripTemplateSyntax(value: string): string { let result = value; while (result !== prev) { prev = result; - result = result - .replace(/\{\{[\s\S]*?\}\}/g, '') - .replace(/\{%[\s\S]*?%\}/g, ''); + result = result.replace(/\{\{[\s\S]*?\}\}/g, '').replace(/\{%[\s\S]*?%\}/g, ''); } return result; } /** Strips template syntax from each item, filters empties, and returns null if nothing remains. */ function sanitizeStringArray(arr: string[] | null): string[] | null { - if (arr === null) { return null; } + if (arr === null) { + return null; + } const result = arr.map(stripTemplateSyntax).filter(s => s.trim() !== ''); return result.length > 0 ? result : null; } @@ -41,10 +43,10 @@ export function sanitizeRoute(route: KonnectRoute): KonnectRoute { hosts: sanitizeStringArray(route.hosts), headers: route.headers ? Object.fromEntries( - Object.entries(route.headers) - .map(([k, vs]): [string, string[]] => [stripTemplateSyntax(k), sanitizeStringArray(vs) ?? []]) - .filter(([k, vs]) => k.trim() !== '' && vs.length > 0), - ) + Object.entries(route.headers) + .map(([k, vs]): [string, string[]] => [stripTemplateSyntax(k), sanitizeStringArray(vs) ?? []]) + .filter(([k, vs]) => k.trim() !== '' && vs.length > 0), + ) : null, expression: route.expression !== null ? stripTemplateSyntax(route.expression) : null, }; @@ -79,7 +81,7 @@ export function extractRegionFromEndpoint(endpoint: string): string { /** * Names of the proxy environment variables Konnect sync manages. * On first sync, values are auto-filled from the control plane's `proxy_urls` - * when available; otherwise created as empty strings for manual entry. + * when available. * * - `proxy_host`: host (with port when non-standard), used in http/https/ws/wss URLs. * - `grpc_proxy_host`: host:port, used in grpc:// URLs. @@ -199,7 +201,9 @@ export function generatePathPlaceholder( // Validation: Check for leftover regex syntax const hasLeftoverRegex = /[()[\]*+?\\]/.test(path); if (hasLeftoverRegex) { - if (fallbackMode === 'keep') { return { path: regexString, pathParameters: [] }; } + if (fallbackMode === 'keep') { + return { path: regexString, pathParameters: [] }; + } return { path: '/:path', pathParameters: [{ name: 'path', value: '' }] }; } @@ -221,8 +225,12 @@ export function generatePathPlaceholder( * - regex path (Kong `~` prefix) → parsed via generatePathPlaceholder */ export function resolvePath(rawPath: string | null): ResolvedPath { - if (rawPath === null) { return { path: '', pathParameters: [] }; } - if (rawPath.startsWith('~')) { return generatePathPlaceholder(rawPath.slice(1)); } + if (rawPath === null) { + return { path: '', pathParameters: [] }; + } + if (rawPath.startsWith('~')) { + return generatePathPlaceholder(rawPath.slice(1)); + } return { path: rawPath, pathParameters: [] }; } @@ -230,15 +238,17 @@ export function routeDisplayName(route: { name: string | null; id: string }): st return route.name ?? `Route ${route.id}`; } -export function buildRequestName( - route: { name: string | null; paths: string[] | null; id: string }, -): string { +export function buildRequestName(route: { name: string | null; paths: string[] | null; id: string }): string { const rawPath = route.paths?.[0]; - if (rawPath === undefined) { return routeDisplayName(route); } + if (rawPath === undefined) { + return routeDisplayName(route); + } const resolved = resolvePath(rawPath).path; // If the regex was too complex to parse (fell back to '/:path'), use the raw // Kong path (including the '~' prefix) — it's more informative than '/:path'. - if (resolved === '/:path') { return rawPath; } + if (resolved === '/:path') { + return rawPath; + } return resolved || routeDisplayName(route); } @@ -282,7 +292,9 @@ export function pathParametersChanged( existing: { name: string; value: string }[], incoming: { name: string; value: string }[], ): boolean { - if (existing.length !== incoming.length) { return true; } + if (existing.length !== incoming.length) { + return true; + } return existing.some((p, i) => p.name !== incoming[i].name); } @@ -305,9 +317,112 @@ export function konnectHeadersChanged( for (const h of existing) { const expected = incomingByName.get(h.name); if (expected !== undefined) { - if (h.value !== expected) { return true; } + if (h.value !== expected) { + return true; + } matched++; } } return matched !== incoming.length; } + +export function getKonnectDeploymentType(controlPlane: KonnectControlPlane): KonnectDeploymentType | null { + const controlPlaneType = controlPlaneConfigToControlPlaneType({ + cluster_type: controlPlane.config.cluster_type as keyof typeof CLUSTER_TYPE_TO_CP_TYPE_MAP, + cloud_gateway: controlPlane.config.cloud_gateway, + }); + + switch (controlPlaneType) { + case ControlPlaneType.K8SIngressController: { + return 'k8sIngressController'; + } + case ControlPlaneType.Cloud: { + return 'dedicatedCloud'; + } + case ControlPlaneType.Serverless: { + return 'serverless'; + } + case ControlPlaneType.GroupWithCloudDataPlanes: + case ControlPlaneType.GroupWithOnPremDataPlanes: { + return 'group'; + } + case ControlPlaneType.ServerlessV1: { + return 'serverless'; + } + default: { + return 'selfManaged'; + } + } +} + +enum ControlPlaneType { + Hybrid = 'CONTROL_PLANE_TYPE_HYBRID', // self managed + Cloud = 'CONTROL_PLANE_TYPE_CLOUD', // Dedicated cloud + K8SIngressController = 'CONTROL_PLANE_TYPE_K8S_INGRESS_CONTROLLER', // KIC + /** + * Group of hybrid-type control planes, on-prem data planes can connect to this control plane group + */ + GroupWithOnPremDataPlanes = 'CONTROL_PLANE_TYPE_GROUP_WITH_ON_PREM_DATA_PLANES', + /** + * Group of hybrid-type control planes, cloud data planes can be created and managed by this control plane group + */ + GroupWithCloudDataPlanes = 'CONTROL_PLANE_TYPE_GROUP_WITH_CLOUD_DATA_PLANES', + Serverless = 'CONTROL_PLANE_TYPE_SERVERLESS', // Serverless.v0 deployed on fly.io + ServerlessV1 = 'CONTROL_PLANE_TYPE_SERVERLESS_V1', // Serverless.v1 (previously HVC) + // NativeEventProxy = 'CONTROL_PLANE_TYPE_KAFKA_NATIVE_EVENT_PROXY', // KNEP is deprecated in GM, DO NOT add it back +} + +const ControlPlaneClusterTypeEnum = { + ControlPlane: 'CLUSTER_TYPE_CONTROL_PLANE', + K8SIngressController: 'CLUSTER_TYPE_K8S_INGRESS_CONTROLLER', + ControlPlaneGroup: 'CLUSTER_TYPE_CONTROL_PLANE_GROUP', + Serverless: 'CLUSTER_TYPE_SERVERLESS', + HttpGateway: 'CLUSTER_TYPE_HTTP_GATEWAY', + EventGateway: 'CLUSTER_TYPE_EVENT_GATEWAY', + KafkaNativeEventProxy: 'CLUSTER_TYPE_KAFKA_NATIVE_EVENT_PROXY', + CloudApiGateway: 'CLUSTER_TYPE_CLOUD_API_GATEWAY', + ServerlessV1: 'CLUSTER_TYPE_SERVERLESS_V1', +}; + +const CLUSTER_TYPE_TO_CP_TYPE_MAP = { + [ControlPlaneClusterTypeEnum.ControlPlane]: { false: ControlPlaneType.Hybrid, true: ControlPlaneType.Cloud }, + [ControlPlaneClusterTypeEnum.K8SIngressController]: { false: ControlPlaneType.K8SIngressController }, + [ControlPlaneClusterTypeEnum.ControlPlaneGroup]: { + false: ControlPlaneType.GroupWithOnPremDataPlanes, + true: ControlPlaneType.GroupWithCloudDataPlanes, + }, + [ControlPlaneClusterTypeEnum.Serverless]: { false: ControlPlaneType.Serverless }, + [ControlPlaneClusterTypeEnum.ServerlessV1]: { true: ControlPlaneType.ServerlessV1 }, + // Placeholders for other cluster types + [ControlPlaneClusterTypeEnum.CloudApiGateway]: { true: ControlPlaneType.ServerlessV1 }, // TODO: remove this when CLUSTER_TYPE_SERVERLESS_V1 is accepted by the API (KHCP-19640) + [ControlPlaneClusterTypeEnum.HttpGateway]: { false: null }, + [ControlPlaneClusterTypeEnum.EventGateway]: { false: null }, + [ControlPlaneClusterTypeEnum.KafkaNativeEventProxy]: { false: null }, // KNEP is deprecated, DO NOT map it to any CP type +} as const; + +type ControlPlaneConfigToControlPlaneType< + T extends keyof typeof CLUSTER_TYPE_TO_CP_TYPE_MAP, + C extends boolean, +> = `${C}` extends keyof (typeof CLUSTER_TYPE_TO_CP_TYPE_MAP)[T] + ? (typeof CLUSTER_TYPE_TO_CP_TYPE_MAP)[T][`${C}`] + : never; + +type NeverToNull = T extends never ? null : T; + +function controlPlaneConfigToControlPlaneType< + T extends keyof typeof CLUSTER_TYPE_TO_CP_TYPE_MAP, + C extends boolean, +>(config: { cluster_type: T; cloud_gateway: C }): NeverToNull> { + type ReturnType = NeverToNull>; + const { cluster_type: clusterType, cloud_gateway: isCloudGateway } = config; + const subMap = CLUSTER_TYPE_TO_CP_TYPE_MAP[clusterType]; + if (subMap && Object.hasOwnProperty.call(subMap, `${isCloudGateway}`)) { + return subMap[`${isCloudGateway}` as keyof typeof subMap] as ReturnType; + } + // this should never happen, but just in case + console.error( + `ControlPlaneConfigToControlPlaneType: invalid clusterType ${clusterType} or cloud_gateway ${isCloudGateway}`, + ); + + return null as ReturnType; +} diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 27be1a8b40..78c887dd8c 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -1,7 +1,7 @@ import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core'; import type { GitRepository, Project, WorkspaceScope } from 'insomnia-data'; import { models } from 'insomnia-data'; -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { Button, GridList, @@ -252,16 +252,31 @@ const Component = () => { }, })); - const createNewCollection = (source: string) => - setNewWorkspaceModalState({ scope: 'collection', isOpen: true, source }); - const createNewDocument = (source: string) => setNewWorkspaceModalState({ scope: 'design', isOpen: true, source }); - const createNewMockServer = (source: string) => - canCreateMockServer && setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true, source }); - const createNewGlobalEnvironment = (source: string) => - setNewWorkspaceModalState({ scope: 'environment', isOpen: true, source }); - const createNewMcpClient = (source: string) => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true, source }); + const canCreateMockServer = activeProject?._id; - const createNewCollectionWithRequest = () => { + const createNewCollection = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'collection', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + const createNewDocument = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'design', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + const createNewMockServer = useCallback( + (source: string) => + canCreateMockServer && setNewWorkspaceModalState({ scope: 'mock-server', isOpen: true, source }), + [canCreateMockServer, setNewWorkspaceModalState], + ); + const createNewGlobalEnvironment = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'environment', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + const createNewMcpClient = useCallback( + (source: string) => setNewWorkspaceModalState({ scope: 'mcp', isOpen: true, source }), + [setNewWorkspaceModalState], + ); + + const createNewCollectionWithRequest = useCallback(() => { if (!activeProject) { return; } @@ -274,57 +289,67 @@ const Component = () => { withRequest: true, source: 'home-page', }); - }; + }, [activeProject, createNewWorkspaceFetcher, organizationId, projectId]); - const canCreateMockServer = activeProject?._id; - - const createInProjectActionList: { - id: string; - name: string; - icon: IconProp; - scope: WorkspaceScope; - action: () => void; - }[] = [ + const createInProjectActionList = useMemo< { - id: 'new-collection', - name: 'Collection', - icon: 'bars', - action: () => createNewCollection('navbar'), - scope: 'collection', - }, - { - id: 'new-document', - name: 'Document', - icon: 'file', - action: () => createNewDocument('navbar'), - scope: 'design', - }, - { - id: 'new-mcp-client', - name: 'MCP Client', - scope: 'mcp', - icon: ['fac', 'mcp'] as unknown as IconProp, - action: () => createNewMcpClient('navbar'), - }, - ...(canCreateMockServer - ? [ - { - id: 'new-mock-server', - name: 'Mock Server', - scope: 'mock-server' as WorkspaceScope, - icon: 'server' as IconName, - action: () => createNewMockServer('navbar'), - }, - ] - : []), - { - id: 'new-environment', - name: 'Environment', - icon: 'code', - action: () => createNewGlobalEnvironment('navbar'), - scope: 'environment', - }, - ]; + id: string; + name: string; + icon: IconProp; + scope: WorkspaceScope; + action: () => void; + }[] + >( + () => [ + { + id: 'new-collection', + name: 'Collection', + icon: 'bars', + action: () => createNewCollection('navbar'), + scope: 'collection', + }, + { + id: 'new-document', + name: 'Document', + icon: 'file', + action: () => createNewDocument('navbar'), + scope: 'design', + }, + { + id: 'new-mcp-client', + name: 'MCP Client', + scope: 'mcp', + icon: ['fac', 'mcp'] as unknown as IconProp, + action: () => createNewMcpClient('navbar'), + }, + ...(canCreateMockServer + ? [ + { + id: 'new-mock-server', + name: 'Mock Server', + scope: 'mock-server' as WorkspaceScope, + icon: 'server' as IconName, + action: () => createNewMockServer('navbar'), + }, + ] + : []), + { + id: 'new-environment', + name: 'Environment', + icon: 'code', + action: () => createNewGlobalEnvironment('navbar'), + scope: 'environment', + }, + ], + [ + canCreateMockServer, + createNewCollection, + createNewDocument, + createNewGlobalEnvironment, + createNewMcpClient, + createNewMockServer, + ], + ); const isRemoteProjectInconsistent = activeProject && models.project.isRemoteProject(activeProject) && !storageRules.enableCloudSync; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx index 0169365588..70136bd17e 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx @@ -48,6 +48,23 @@ export async function clientAction({ params }: Route.ClientActionArgs) { project.gitRepositoryId && reportGitProjectCount(organizationId, sessionId); + // If the deleted project is a Konnect project, navigate to another Konnect project + if (project.konnectControlPlaneId) { + const remainingKonnectProjects = (await services.project.list({ organizationId })).filter( + p => p.konnectControlPlaneId != null && p._id !== projectId, + ); + + if (remainingKonnectProjects.length > 0) { + const targetProject = remainingKonnectProjects[0]; + return redirect( + href('/organization/:organizationId/project/:projectId', { + organizationId, + projectId: targetProject._id, + }), + ); + } + } + // When redirect to `/organizations/:organizationId`, it sometimes doesn't reload the index loader, so manually redirect to the initial route for the organization const initialOrganizationRoute = await getInitialRouteForOrganization({ organizationId }); return redirect(initialOrganizationRoute); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index a2cd8c9ad8..ce4e9e1dc2 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -76,6 +76,28 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const project = await services.project.get(projectId); if (!project) { + // When a project is not found (e.g., after deletion), check if user was on Konnect tab + // and try to redirect to another Konnect project to avoid switching tabs + const storedTab = localStorage.getItem(`${organizationId}:sidebar-tab`); + if (storedTab) { + try { + const parsedTab = JSON.parse(storedTab); + if (parsedTab === 'konnect') { + const allProjects = await services.project.list({ organizationId }); + const konnectProjects = models.project.sortProjects(allProjects.filter(p => p.konnectControlPlaneId != null)); + if (konnectProjects.length > 0) { + return redirect( + href('/organization/:organizationId/project/:projectId', { + organizationId, + projectId: konnectProjects[0]._id, + }), + ); + } + } + } catch { + // ignore parse errors + } + } return redirect(href('/organization/:organizationId', { organizationId })); } diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx index 14d31c67c7..19e7ad1998 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-project-dropdown.tsx @@ -144,16 +144,22 @@ export const ProjectDropdown: FC = ({ name: 'Delete', icon: 'trash', action: (projectId: string, projectName: string) => { + let title = 'Delete Project'; let message = `You are deleting the project "${projectName}" that may have collaborators. As a result of this, the project will be permanently deleted for every collaborator of the organization. Do you really want to continue?`; + let yesText = 'Delete'; - if (models.project.isGitProject(project)) { + if (project.konnectControlPlaneId) { + title = 'Remove Project'; + message = `Do you wish to remove your local copy of the "${projectName}" project? This will not affect anything in Konnect, or any other users.`; + yesText = 'Remove'; + } else if (models.project.isGitProject(project)) { message = `You are deleting the Git project "${projectName}". Deleting this project will not delete the remote repository but all your local changes will be lost. Do you really want to continue?`; } showModal(AskModal, { - title: 'Delete Project', + title, message, - yesText: 'Delete', + yesText, noText: 'Cancel', color: 'danger', onDone: async (isYes: boolean) => { diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx index a795767e7b..61a98efcaa 100644 --- a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -440,7 +440,9 @@ export const SidebarWorkspaceDropdown = ({ {({ close }) => (
- Delete {getWorkspaceLabel(workspace).singular} + + {project.konnectControlPlaneId ? 'Remove' : 'Delete'} {getWorkspaceLabel(workspace).singular} +
diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index 015eef427a..95ad2e59b0 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -276,7 +276,9 @@ export const WorkspaceCardDropdown: FC = props => { {({ close }) => (
- Delete {getWorkspaceLabel(workspace).singular} + + {project.konnectControlPlaneId ? 'Remove' : 'Delete'} {getWorkspaceLabel(workspace).singular} +
diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx deleted file mode 100644 index 71f514b91b..0000000000 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx +++ /dev/null @@ -1,502 +0,0 @@ -import type { IconName } from '@fortawesome/fontawesome-svg-core'; -import { - exportGlobalEnvironmentToFile, - exportMcpClientToFile, - exportMockServerToFile, -} from 'insomnia/src/ui/components/settings/import-export'; -import type { Workspace } from 'insomnia-data'; -import { models } from 'insomnia-data'; -import type { PlatformKeyCombinations } from 'insomnia-data/common'; -import { invariant } from 'insomnia-data/common'; -import { type FC, type ReactNode, useCallback, useEffect, useState } from 'react'; -import { - Button, - Collection, - Dialog, - Header, - Heading, - Label, - Menu, - MenuItem, - MenuSection, - MenuTrigger, - Modal, - ModalOverlay, - Popover, - Radio, - RadioGroup, -} from 'react-aria-components'; -import { href, useNavigate, useParams } from 'react-router'; - -import { useWorkspaceDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.delete'; -import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update'; - -import { getProductName } from '../../../common/constants'; -import { database as db } from '../../../common/database'; -import { getWorkspaceLabel } from '../../../common/get-workspace-label'; -import type { SerializableActionMeta } from '../../../plugins/bridge-types'; -import { plugins } from '../../../plugins/renderer-bridge'; -import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -import { useMockServerGenerateRequestCollectionActionFetcher } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.mock-server.generate-request-collection'; -import { AnalyticsEvent } from '../../analytics'; -import { DropdownHint } from '../base/dropdown/dropdown-hint'; -import { Icon } from '../icon'; -import { useDocBodyKeyboardShortcuts } from '../keydown-binder'; -import { showError, showModal } from '../modals'; -import { ExportRequestsModal } from '../modals/export-requests-modal'; -import { ImportModal } from '../modals/import-modal/import-modal'; -import { PromptModal } from '../modals/prompt-modal'; -import { WorkspaceDuplicateModal } from '../modals/workspace-duplicate-modal'; -import { WorkspaceSettingsModal } from '../modals/workspace-settings-modal'; - -export const WorkspaceDropdown: FC<{}> = () => { - const { organizationId, projectId, workspaceId } = useParams() as { - organizationId: string; - projectId: string; - workspaceId: string; - }; - invariant(organizationId, 'Expected organizationId'); - const { activeWorkspace, activeWorkspaceMeta, activeProject, activeMockServer } = useWorkspaceLoaderData()!; - - const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); - const [isImportModalOpen, setIsImportModalOpen] = useState(false); - const [isExportModalOpen, setIsExportModalOpen] = useState(false); - const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); - - const updateWorkspaceFetcher = useWorkspaceUpdateActionFetcher(); - const [isDeleteRemoteWorkspaceModalOpen, setIsDeleteRemoteWorkspaceModalOpen] = useState(false); - const deleteWorkspaceFetcher = useWorkspaceDeleteActionFetcher(); - const [actionPlugins, setActionPlugins] = useState([]); - const [loadingActions, setLoadingActions] = useState>({}); - const navigate = useNavigate(); - const generateCollectionFetcher = useMockServerGenerateRequestCollectionActionFetcher(); - - // after duplicate workspace, close the modal - useEffect(() => { - setIsDuplicateModalOpen(false); - }, [workspaceId]); - - useDocBodyKeyboardShortcuts({ - workspace_showSettings: () => setIsSettingsModalOpen(true), - }); - - const handlePluginClick = useCallback( - async ({ pluginName, label }: SerializableActionMeta, workspace: Workspace) => { - setLoadingActions({ ...loadingActions, [label]: true }); - try { - const docs = await db.getWithDescendants(workspace, [models.request.type]); - const requests = docs.filter(models.request.isRequest).filter(doc => !doc.isPrivate); - const requestGroups = docs.filter(models.requestGroup.isRequestGroup); - await plugins.executeAction({ - type: 'workspace', - pluginName, - label, - projectId: activeProject._id, - domainData: { workspace, requests, requestGroups }, - }); - } catch (err) { - showError({ - title: 'Plugin Action Failed', - error: err, - }); - } - setLoadingActions({ ...loadingActions, [label]: false }); - }, - [activeProject._id, loadingActions], - ); - - const handleDropdownOpen = useCallback(async () => { - const actionPlugins = await plugins.getWorkspaceActions(); - setActionPlugins(actionPlugins); - }, []); - - const isScratchpadWorkspace = models.workspace.isScratchpad(activeWorkspace); - const scratchpadActionList: { - name: string; - id: string; - icon: IconName; - items: { - id: string; - name: string; - icon: ReactNode; - hint?: PlatformKeyCombinations; - action: () => void; - }[]; - }[] = [ - { - name: 'Actions', - id: 'Actions', - icon: 'cog', - items: [ - { - id: 'Import', - name: 'Import', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.importStarted, - properties: { - source: `scratchpad-${activeWorkspace.scope}-menu`, - }, - }); - - setIsImportModalOpen(true); - }, - }, - { - id: 'Export', - name: 'Export', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.exportStarted, - properties: { - source: `scratchpad-${activeWorkspace.scope}-menu`, - }, - }); - if (activeWorkspace.scope === 'mock-server') { - return exportMockServerToFile(activeWorkspace); - } - - if (activeWorkspace.scope === 'environment') { - return exportGlobalEnvironmentToFile(activeWorkspace); - } - - return setIsExportModalOpen(true); - }, - }, - ], - }, - ]; - - const workspaceActionsList: { - name: string; - id: string; - icon: IconName; - items: { - id: string; - name: string; - icon: ReactNode; - hint?: PlatformKeyCombinations; - action: () => void; - }[]; - }[] = [ - ...(models.workspace.isMcp(activeWorkspace) - ? [] - : [ - { - name: 'Import', - id: 'import', - icon: 'cog' as IconName, - items: [ - { - id: 'from-file', - name: 'From File', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.importStarted, - properties: { - source: `${activeWorkspace.scope}-menu`, - }, - }); - setIsImportModalOpen(true); - }, - }, - ], - }, - { - name: 'Runner', - id: 'runner', - icon: 'circle-play' as const, - items: [ - { - id: 'run', - name: 'Run Collection', - icon: , - action: () => { - navigate( - `/organization/${organizationId}/project/${activeWorkspace.parentId}/workspace/${activeWorkspace._id}/debug/runner?folder=`, - ); - }, - }, - ], - }, - ]), - { - name: 'Actions', - id: 'actions', - icon: 'cog', - items: [ - ...(models.workspace.isMcp(activeWorkspace) - ? [] - : [ - { - id: 'duplicate', - name: 'Duplicate', - icon: , - action: () => setIsDuplicateModalOpen(true), - }, - ]), - { - id: 'rename', - name: 'Rename', - icon: , - action: () => - showModal(PromptModal, { - title: `Rename ${getWorkspaceLabel(activeWorkspace).singular}`, - defaultValue: activeWorkspace.name, - submitName: 'Rename', - selectText: true, - label: 'Name', - onComplete: name => - updateWorkspaceFetcher.submit({ - organizationId, - projectId: activeWorkspace.parentId, - patch: { name, workspaceId: activeWorkspace._id }, - }), - }), - }, - { - id: 'export', - name: 'Export', - icon: , - action: () => { - window.main.trackAnalyticsEvent({ - event: AnalyticsEvent.exportStarted, - properties: { - source: `${activeWorkspace.scope}-menu`, - }, - }); - - if (activeWorkspace.scope === 'mock-server') { - return exportMockServerToFile(activeWorkspace); - } - - if (activeWorkspace.scope === 'environment') { - return exportGlobalEnvironmentToFile(activeWorkspace); - } - - if (activeWorkspace.scope === 'mcp') { - return exportMcpClientToFile(activeWorkspace); - } - - return setIsExportModalOpen(true); - }, - }, - ...(activeWorkspace.scope === 'mock-server' - ? [ - { - id: 'generate-collection', - name: 'Generate Collection', - icon: , - action: () => { - generateCollectionFetcher.submit({ - organizationId, - projectId: activeWorkspace.parentId, - workspaceId: activeWorkspace._id, - }); - }, - }, - ] - : []), - { - id: 'settings', - name: 'Settings', - icon: , - action: () => setIsSettingsModalOpen(true), - }, - { - id: 'delete', - name: 'Delete', - icon: , - action: () => setIsDeleteRemoteWorkspaceModalOpen(true), - }, - ], - }, - ...(actionPlugins.length > 0 - ? [ - { - name: 'Plugins', - id: 'plugins', - icon: 'plug' as IconName, - items: actionPlugins.map(plugin => ({ - id: plugin.label, - name: plugin.label, - icon: , - action: () => handlePluginClick(plugin, activeWorkspace), - })), - }, - ] - : []), - ]; - const actionlist = isScratchpadWorkspace ? scratchpadActionList : workspaceActionsList; - return ( - <> - isOpen && handleDropdownOpen()}> - - - - actionlist - .find(i => i.items.find(a => a.id === key)) - ?.items.find(a => a.id === key) - ?.action() - } - items={actionlist} - className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden" - > - {section => ( - -
- {section.name} -
- - {item => ( - - {item.icon} - {item.name} - {item.hint && } - - )} - -
- )} -
-
-
- {isDuplicateModalOpen && ( - setIsDuplicateModalOpen(false)} workspace={activeWorkspace} /> - )} - {isImportModalOpen && ( - setIsImportModalOpen(false)} - from={{ type: 'file' }} - projectName={activeProject.name ?? getProductName()} - workspaceName={activeWorkspace.name} - organizationId={organizationId} - defaultProjectId={projectId} - defaultWorkspaceId={workspaceId} - /> - )} - {isExportModalOpen && ( - setIsExportModalOpen(false)} /> - )} - {isSettingsModalOpen && ( - setIsSettingsModalOpen(false)} - /> - )} - {isDeleteRemoteWorkspaceModalOpen && ( - { - setIsDeleteRemoteWorkspaceModalOpen(false); - }} - isDismissable - className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" - > - { - setIsDeleteRemoteWorkspaceModalOpen(false); - }} - className="max-h-full w-full max-w-2xl rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" - > - - {({ close }) => ( -
-
- Delete {getWorkspaceLabel(activeWorkspace).singular} - -
- - -
-

- This will permanently delete the{' '} - {activeWorkspace?.name}{' '} - {getWorkspaceLabel(activeWorkspace).singular} -

- {models.project.isRemoteProject(activeProject) && ( - - -
- -
- Remove Local Copy -

The project will still exist on the Cloud.

-
-
- -
- Delete Permanently -

- The project will be deleted everywhere. You cannot undo this action. -

-
-
-
-
- )} -
- {deleteWorkspaceFetcher.data && deleteWorkspaceFetcher.data.error && ( -

{deleteWorkspaceFetcher.data.error}

- )} -
- -
-
-
- )} -
-
-
- )} - - ); -}; diff --git a/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx index 4cbb489fc2..69726c7397 100644 --- a/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/konnect-settings-modal.tsx @@ -13,9 +13,11 @@ import { Icon } from '../icon'; export const KonnectSettingsModal = ({ onClose, syncKonnectProjectsAndNotifyRef, + onDisconnect, }: { onClose: () => void; syncKonnectProjectsAndNotifyRef: React.MutableRefObject<() => Promise>; + onDisconnect?: () => void; }) => { const { settings } = useRootLoaderData()!; const patchSettings = useSettingsPatcher(); @@ -66,6 +68,7 @@ export const KonnectSettingsModal = ({ patchSettings({ hasKonnectPat: true }); window.main.trackAnalyticsEvent({ event: AnalyticsEvent.kongKonnectPatValidated }); syncKonnectProjectsAndNotifyRef.current(); + onClose(); } }; @@ -85,6 +88,7 @@ export const KonnectSettingsModal = ({ } await window.main.secretStorage.deleteSecret('konnectPat'); patchSettings({ hasKonnectPat: false }); + onDisconnect?.(); onClose(); } finally { setIsDisconnecting(false); diff --git a/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx deleted file mode 100644 index dae29982b5..0000000000 --- a/packages/insomnia/src/ui/components/modals/sync-delete-modal.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { strings } from 'insomnia-data/common'; -import React, { useEffect, useRef, useState } from 'react'; -import { OverlayContainer } from 'react-aria'; - -import { useWorkspaceLoaderData } from '../../../routes/organization.$organizationId.project.$projectId.workspace.$workspaceId'; -import { interceptAccessError } from '../../../sync/access-error'; -import { Button } from '../../components/themed-button'; -import { Modal, type ModalHandle, type ModalProps } from '../base/modal'; -import { ModalBody } from '../base/modal-body'; -import { ModalHeader } from '../base/modal-header'; - -interface SyncArchiveVCSLike { - archiveProject: () => Promise; -} - -type Props = ModalProps & { - vcs: SyncArchiveVCSLike; -}; - -interface State { - error?: string; - workspaceName: string; -} - -export const SyncDeleteModal = ({ vcs, onHide }: Props) => { - const modalRef = useRef(null); - const [state, setState] = useState({ - error: '', - workspaceName: '', - }); - const { activeWorkspace } = useWorkspaceLoaderData()!; - - useEffect(() => { - modalRef.current?.show(); - }, []); - const onSubmit = async (event: React.SyntheticEvent) => { - event.preventDefault(); - try { - await interceptAccessError({ - action: 'delete', - callback: async () => await vcs.archiveProject(), - resourceName: state.workspaceName, - resourceType: strings.collection.singular.toLowerCase(), - }); - modalRef.current?.hide(); - onHide?.(); - } catch (err) { - setState(state => ({ - ...state, - error: err.message, - })); - } - }; - const { error, workspaceName } = state; - - return ( - - - Delete {strings.collection.singular} - - {error &&

{error}

} -

- This will permanently delete the{' '} - {{activeWorkspace?.name}}{' '} - {strings.collection.singular.toLowerCase()} remotely. -

-

- Please type {{activeWorkspace?.name}} to confirm. -

-
-
- setState(state => ({ ...state, workspaceName: event.target.value }))} - value={workspaceName} - /> - -
-
-
-
-
- ); -}; diff --git a/packages/insomnia/src/ui/components/project/project-settings-form.tsx b/packages/insomnia/src/ui/components/project/project-settings-form.tsx index 25718675e4..02d96d520e 100644 --- a/packages/insomnia/src/ui/components/project/project-settings-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-settings-form.tsx @@ -248,11 +248,25 @@ export const ProjectSettingsForm: FC = ({ className="w-full rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) py-1 pr-7 pl-2 text-(--color-font) transition-colors placeholder:italic focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden" /> - setStorageType(v as 'local' | 'remote' | 'git')} - /> + {project?.konnectControlPlaneId ? ( +
+ +
+
+ + Synced from Konnect +
+
+
+ ) : ( + setStorageType(v as 'local' | 'remote' | 'git')} + /> + )} void; +} + +export const KonnectEnvOnboarding = ({ triggerElement, onDismiss }: KonnectEnvOnboardingProps) => { + const popoverRef = useRef(null); + + useEffect(() => { + if (!triggerElement) return; + + const updatePosition = () => { + if (!popoverRef.current) return; + const triggerRect = triggerElement.getBoundingClientRect(); + const popover = popoverRef.current; + popover.style.top = `${triggerRect.top}px`; + popover.style.left = `${triggerRect.right + 8}px`; + }; + + updatePosition(); + + const observer = new ResizeObserver(updatePosition); + observer.observe(triggerElement); + + return () => observer.disconnect(); + }, [triggerElement]); + + return ( +
+
+

+ Almost ready! Just set your proxy host for each control plane +

+ +
+

+ Your requests have been automatically set with a{' '} + proxy_host{' '} + environment variable for quick testing against different deployment stages. Enter it here before testing your + gateway routes. +

+ +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg new file mode 100644 index 0000000000..4f868a12ba --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/dedicated-cloud.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg new file mode 100644 index 0000000000..edd39aa676 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/group.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg new file mode 100644 index 0000000000..94beb267a9 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/k8s-ingress-controller.svg @@ -0,0 +1,13 @@ +My custom title + + + + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx new file mode 100644 index 0000000000..d7f50a0f67 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/konnect-project-icon.tsx @@ -0,0 +1,28 @@ +import type { KonnectDeploymentType } from 'insomnia-data'; + +import { Icon } from '../../../icon'; +import dedicatedCloudIcon from './dedicated-cloud.svg'; +import groupIcon from './group.svg'; +import k8sIngressControllerIcon from './k8s-ingress-controller.svg'; +import selfManagedIcon from './self-managed.svg'; +import serverlessIcon from './serverless.svg'; + +const konnectDeploymentTypeToIcon: Record = { + selfManaged: selfManagedIcon, + serverless: serverlessIcon, + dedicatedCloud: dedicatedCloudIcon, + group: groupIcon, + k8sIngressController: k8sIngressControllerIcon, +}; + +export const KonnectProjectIcon = ({ + konnectDeploymentType, +}: { + konnectDeploymentType?: KonnectDeploymentType | null; +}) => { + const icon = konnectDeploymentType ? konnectDeploymentTypeToIcon[konnectDeploymentType] : undefined; + if (!icon) { + return ; + } + return ; +}; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg new file mode 100644 index 0000000000..240821f764 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/self-managed.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg new file mode 100644 index 0000000000..13408cf372 --- /dev/null +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/konnect-project-icon/serverless.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 22c3459577..a62de1047f 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -14,7 +14,18 @@ import { useRef, useState, } from 'react'; -import { Button, GridList, GridListItem, Input, SearchField, Tab, TabList, Tabs } from 'react-aria-components'; +import { + Button, + GridList, + GridListItem, + Input, + SearchField, + Tab, + TabList, + Tabs, + Tooltip, + TooltipTrigger, +} from 'react-aria-components'; import { useNavigate, useParams, useSearchParams } from 'react-router'; import * as reactUse from 'react-use'; @@ -33,6 +44,7 @@ import { showModal } from '~/ui/components/modals'; import { AskModal } from '~/ui/components/modals/ask-modal'; import { KonnectSettingsModal } from '~/ui/components/modals/konnect-settings-modal'; import { EmptyNode } from '~/ui/components/sidebar/project-navigation-sidebar/empty-node'; +import { KonnectEnvOnboarding } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-env-onboarding'; import { KonnectSyncIntro } from '~/ui/components/sidebar/project-navigation-sidebar/konnect-sync-intro/konnect-sync-intro'; import { UnsyncedWorkspaceNode } from '~/ui/components/sidebar/project-navigation-sidebar/unsynced-workspace-node'; import { useInsomniaEventStreamContext } from '~/ui/context/app/insomnia-event-stream-context'; @@ -40,6 +52,7 @@ import uiEventBus, { CLOUD_SYNC_FILE_CHANGE } from '~/ui/event-bus'; import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab'; import { useKonnectSync } from '~/ui/hooks/use-konnect-sync'; import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; +import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import insomniaLogo from '~/ui/images/insomnia-logo.svg'; import { isPrimaryClickModifier } from '~/ui/utils'; @@ -73,6 +86,29 @@ export interface ProjectNavigationSidebarHandle { export type ProjectNavigationSidebarTabId = 'projects' | 'konnect'; +function LastSyncedLabel({ lastSyncedAt }: { lastSyncedAt: number | null }) { + return lastSyncedAt + ? `Last synced: ${getRelativeTimeString(lastSyncedAt, Date.now())}` + : 'Not yet synced'; +} + +function getRelativeTimeString(timestamp: number, now: number = Date.now()): string { + const seconds = Math.floor((now - timestamp) / 1000); + if (seconds < 60) { + return `${seconds}s ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ${seconds % 60}s ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ${minutes % 60}m ago`; + } + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h ago`; +} + const SidebarSearchField = ({ value, isDisabled, @@ -201,6 +237,10 @@ const ProjectNavigationSidebarInner = ( ); const isProjectTabActive = activeTab === 'projects'; const { syncing, progress, startSync, cancelSync } = useKonnectSync(); + const [lastSyncedAt, setLastSyncedAt] = reactUse.useLocalStorage( + `${organizationId}:konnect-last-synced-at`, + null, + ); const nonKonnectProjects = projects.filter(p => !p.konnectControlPlaneId); const konnectProjects = projects.filter(p => p.konnectControlPlaneId != null); @@ -312,10 +352,39 @@ const ProjectNavigationSidebarInner = ( }, [organizationId, cloudSyncProjectIdsKey]); const syncKonnectProjectsAndNotify = async () => { + const isFirstSync = lastSyncedAt == null; const result = await startSync(organizationId); setLastSyncResult(result ?? null); setShowSyncDetails(false); setCopiedReason(null); + if (result?.success) { + setLastSyncedAt(Date.now()); + // Navigate to and expand the first Konnect project after a successful sync + const allProjects = await services.project.list({ organizationId }); + const sortedKonnectProjects = models.project.sortProjects( + allProjects.filter(p => p.konnectControlPlaneId != null), + ); + const firstKonnectProject = sortedKonnectProjects[0]; + if (firstKonnectProject) { + const workspaces = await services.workspace.findByParentId(firstKonnectProject._id); + const envWorkspace = workspaces.find(w => w.scope === 'environment'); + if (envWorkspace) { + // Show environment onboarding after first successful sync + if (isFirstSync) { + setOnboardingEnvWorkspaceId(envWorkspace._id); + } + navigate( + `/organization/${organizationId}/project/${firstKonnectProject._id}/workspace/${envWorkspace._id}/environment`, + ); + } else { + navigate(`/organization/${organizationId}/project/${firstKonnectProject._id}`); + } + setExpandedProjectAndWorkspaceIds(prev => { + const ids = prev || []; + return ids.includes(firstKonnectProject._id) ? ids : [...ids, firstKonnectProject._id]; + }); + } + } }; syncKonnectProjectsAndNotifyRef.current = syncKonnectProjectsAndNotify; @@ -894,6 +963,13 @@ const ProjectNavigationSidebarInner = ( const [lastSyncResult, setLastSyncResult] = useState(null); const [showSyncDetails, setShowSyncDetails] = useState(false); const [copiedReason, setCopiedReason] = useState(null); + const [onboardingEnvWorkspaceId, setOnboardingEnvWorkspaceId] = useState(null); + const [envOnboardingNode, setEnvOnboardingNode] = useState(null); + + const dismissEnvOnboarding = useCallback(() => { + setOnboardingEnvWorkspaceId(null); + }, []); + const skippedRoutesByReason = useMemo(() => { const map = new Map(); for (const { routeName, reason, serviceName } of lastSyncResult?.skippedRoutes ?? []) { @@ -938,14 +1014,22 @@ const ProjectNavigationSidebarInner = ( ) : ( - + + + + + + )}
); }; @@ -1223,12 +1318,13 @@ export const ProjectNavigationSidebar = forwardRef void }) => { const { organizationId } = useParams() as { organizationId: string }; const isScratchPad = models.organization.isScratchpadOrganizationId(organizationId); + const { features } = useOrganizationPermissions(); return (
- + {doc.konnectControlPlaneId ? ( + + ) : ( + + )} {projectName}
{presence.length > 0 && } diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx index 6c72cfca11..203aea1e59 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/workspace-node.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { type Ref, useState } from 'react'; import { Button } from 'react-aria-components'; import type { SortOrder } from '~/common/constants'; @@ -21,9 +21,18 @@ interface WorkspaceNodeProps { sortOrder: SortOrder; onSortOrderChange: (newSortOrder: SortOrder) => void; + highlighted?: boolean; + nodeRef?: Ref | ((node: HTMLDivElement | null) => void); } -export const WorkspaceNode = ({ item, sortOrder, onToggle, onSortOrderChange }: WorkspaceNodeProps) => { +export const WorkspaceNode = ({ + item, + sortOrder, + onToggle, + onSortOrderChange, + highlighted, + nodeRef, +}: WorkspaceNodeProps) => { const { doc, collapsed, project, organizationId } = item; const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc; const [isContextMenuOpen, setIsContextMenuOpen] = useState(false); @@ -31,7 +40,8 @@ export const WorkspaceNode = ({ item, sortOrder, onToggle, onSortOrderChange }: return (