diff --git a/packages/insomnia-data/src/models/utils/ignore-keys.ts b/packages/insomnia-data/src/models/utils/ignore-keys.ts index a08af280f1..2c2a6f7a97 100644 --- a/packages/insomnia-data/src/models/utils/ignore-keys.ts +++ b/packages/insomnia-data/src/models/utils/ignore-keys.ts @@ -2,7 +2,7 @@ import type { BaseModel, ProjectLintRuleset, Workspace } from 'insomnia-data'; import { models } from 'insomnia-data'; // Key for VCS to delete before computing changes -const DELETE_KEY: keyof BaseModel = 'modified'; +export const DELETE_KEY: keyof BaseModel = 'modified'; type ResetModelKeys = { [K in keyof T]?: T[K] | null; diff --git a/packages/insomnia-data/src/models/workspace.ts b/packages/insomnia-data/src/models/workspace.ts index 5c8fd2a2f0..9c535d01bf 100644 --- a/packages/insomnia-data/src/models/workspace.ts +++ b/packages/insomnia-data/src/models/workspace.ts @@ -1,6 +1,7 @@ import { strings } from 'insomnia-data/common'; +import { z } from 'zod/v4'; -import type { BaseModel } from './base-types'; +import { type BaseModel, createModelSchema } from './base-types'; export const name = 'Workspace'; export const type = 'Workspace'; @@ -10,14 +11,6 @@ export const canSync = true; export const SCRATCHPAD_WORKSPACE_ID = 'wrk_scratchpad'; -export interface BaseWorkspace { - name: string; - description: string; - certificates?: any; // deprecated - scope: 'design' | 'collection' | 'mock-server' | 'environment' | 'mcp'; - konnectServiceId?: string | null; -} - export type WorkspaceScope = BaseWorkspace['scope']; export const WorkspaceScopeKeys = { @@ -28,11 +21,20 @@ export const WorkspaceScopeKeys = { mcp: 'mcp', } as const; -export type Workspace = BaseModel & BaseWorkspace; +export const baseWorkspaceSchema = z.object({ + name: z.string().default(`New ${strings.collection.singular}`), + description: z.string().default(''), + certificates: z.any().optional(), // deprecated + scope: z.enum(Object.values(WorkspaceScopeKeys)), + konnectServiceId: z.string().optional().nullable(), +}); +export type BaseWorkspace = z.infer; + +export const schema = createModelSchema(type, prefix).extend(baseWorkspaceSchema.shape); +export type Workspace = z.infer; export const isWorkspace = (model: Pick): model is Workspace => model.type === type; -export const optionalKeys = ['konnectServiceId']; export const isWorkspaceId = (id?: string | null) => id?.startsWith(prefix + '_'); export const isDesign = (workspace: Pick) => workspace.scope === WorkspaceScopeKeys.design; @@ -46,6 +48,8 @@ export const isEnvironment = (workspace: Pick) => export const isMcp = (workspace: Pick) => workspace.scope === WorkspaceScopeKeys.mcp; +export const optionalKeys: (keyof BaseWorkspace)[] = ['konnectServiceId']; + export const init = (): BaseWorkspace => ({ name: `New ${strings.collection.singular}`, description: '', diff --git a/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts b/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts index 9a79fb543f..ce882eaaa3 100644 --- a/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/external-vault-integration.test.ts @@ -64,12 +64,7 @@ test('Setup external vault and used in request', async ({ app, page, insomnia }) await page.locator('#close-add-cloud-credential-modal').click(); // close the settings - await page.locator('.app').press('Escape'); - // dismiss any remaining modal overlay before interacting with the sidebar - await page - .locator('[data-close-modal="true"]') - .click() - .catch(() => {}); + await page.getByRole('button', { name: 'Modal Close Button' }).click(); // used in request await insomnia.navigationSidebar.clickRequestOrFolder('External Vault Tag'); @@ -91,18 +86,21 @@ test('Setup external vault and used in request', async ({ app, page, insomnia }) // test aws vault tag await page.locator(`[data-template^="${externalVaultTestCases.aws.tagPrefix}"]`).click(); await page.getByLabel('Credential For Vault Service').selectOption(awsCredentialName); + await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click(); const previewText = page.getByRole('dialog').getByLabel('Live Preview'); await expect.soft(previewText).toHaveText(externalVaultTestCases.aws.expectedResult); await page.getByRole('button', { name: 'Done' }).click(); // test gcp vault tag await page.locator(`[data-template^="${externalVaultTestCases.gcp.tagPrefix}"]`).click(); await page.getByLabel('Credential For Vault Service').selectOption(gcpCredentialName); + await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click(); const gcpPreviewText = page.getByRole('dialog').getByLabel('Live Preview'); await expect.soft(gcpPreviewText).toHaveText(externalVaultTestCases.gcp.expectedResult); await page.getByRole('button', { name: 'Done' }).click(); // test hashicorp vault tag await page.locator(`[data-template^="${externalVaultTestCases.hashicorp.tagPrefix}"]`).click(); await page.getByLabel('Credential For Vault Service').selectOption(hashicorpCredentialName); + await page.getByRole('dialog').getByRole('button', { name: 'Reveal value' }).click(); const hashicorpPreviewText = page.getByRole('dialog').getByLabel('Live Preview'); await expect.soft(hashicorpPreviewText).toHaveText(externalVaultTestCases.hashicorp.expectedResult); await page.getByRole('button', { name: 'Done' }).click(); diff --git a/packages/insomnia/src/common/__tests__/doc-unknown-fields.test.ts b/packages/insomnia/src/common/__tests__/doc-unknown-fields.test.ts new file mode 100644 index 0000000000..f8318b5142 --- /dev/null +++ b/packages/insomnia/src/common/__tests__/doc-unknown-fields.test.ts @@ -0,0 +1,169 @@ +import { models } from 'insomnia-data'; +import { describe, expect, it } from 'vitest'; +import { stringify } from 'yaml'; + +import { checkDocCompatibilityFromSchema, findDocUnknownFieldRanges } from '../zod/check-doc-compatibility'; + +// Like a real synced blob, there is no `modified`: hashDocument deletes it before the blob is stored. +const makeEnvironment = (extra: Record = {}) => ({ + ...models.environment.init(), + _id: 'env_1', + type: models.environment.type, + parentId: 'wrk_1', + created: 1, + ...extra, +}); + +// hashDocument also resets a workspace's `parentId` to null (see resetKeys in insomnia-data). +const makeWorkspace = (extra: Record = {}) => ({ + ...models.workspace.init(), + _id: 'wrk_1', + type: models.workspace.type, + parentId: null, + created: 1, + ...extra, +}); + +const toBlob = (doc: object) => JSON.stringify(doc); + +// A request blob as it is stored by cloud sync. +const requestBlob = { + _id: 'req_cc3d9e822ceb4eefbb7f74114b6e4860', + authentication: {}, + body: {}, + created: 1_767_691_001_603, + description: '', + headers: [{ description: '', disabled: false, name: 'User-Agent', value: 'insomnia/12.2.0' }], + isPrivate: false, + metaSortKey: -1_767_335_591_505.5, + method: 'GET', + name: 'Request Tow', + parameters: [], + parentId: 'wrk_ad2095fbf3b14d76b638696336aef369', + pathParameters: [], + settingDisableRenderRequestBody: false, + settingEncodeUrl: true, + settingFollowRedirects: 'global', + settingRebuildPath: true, + settingSendCookies: true, + settingStoreCookies: true, + type: 'Request', + url: 'https://httpbin.org/get', +}; + +describe('checkDocCompatibilityFromSchema on a stored request blob', () => { + it('is ok for a blob that matches the Request schema', () => { + expect(checkDocCompatibilityFromSchema(toBlob(requestBlob))).toBe('strict-match'); + }); + + it('reports unknown-fields when the blob has a field this version does not know', () => { + expect(checkDocCompatibilityFromSchema(toBlob({ ...requestBlob, addedByNewerVersion: 1 }))).toBe('match-with-unknown-fields'); + }); + + it('reports unsupported when a known field has a value or format this version does not accept', () => { + expect(checkDocCompatibilityFromSchema(toBlob({ ...requestBlob, method: 42 }))).toBe('mismatch'); + expect(checkDocCompatibilityFromSchema(toBlob({ ...requestBlob, headers: 'a-new-format' }))).toBe('mismatch'); + }); + + it('reports unsupported when a required field is missing (e.g. renamed by a newer version)', () => { + const { created: _renamed, ...withoutCreated } = requestBlob; + expect(checkDocCompatibilityFromSchema(toBlob({ ...withoutCreated, createdAt: requestBlob.created }))).toBe( + 'mismatch', + ); + }); + + it('only reports unknown-fields when the renamed field has a default, since the old name is then optional', () => { + const { url: _renamed, ...withoutUrl } = requestBlob; + expect(checkDocCompatibilityFromSchema(toBlob({ ...withoutUrl, uri: requestBlob.url }))).toBe('match-with-unknown-fields'); + }); + + it('highlights the unknown field in the diff text', () => { + const blob = toBlob({ ...requestBlob, addedByNewerVersion: 1 }); + const yamlText = stringify(JSON.parse(blob)); + const line = yamlText.split('\n').findIndex(l => l.startsWith('addedByNewerVersion:')) + 1; + + expect(findDocUnknownFieldRanges(blob, yamlText)).toEqual([ + expect.objectContaining({ startLine: line, endLine: line, label: 'addedByNewerVersion' }), + ]); + }); +}); + +describe('checkDocCompatibilityFromSchema on a stored workspace blob', () => { + it('is ok even though hashDocument reset its parentId to null', () => { + expect(checkDocCompatibilityFromSchema(toBlob(makeWorkspace()))).toBe('strict-match'); + }); + + it('reports unknown-fields for a field this version does not know', () => { + expect(checkDocCompatibilityFromSchema(toBlob(makeWorkspace({ addedByNewerVersion: true })))).toBe( + 'match-with-unknown-fields', + ); + }); + + it('reports unsupported for a scope added by a newer version', () => { + expect(checkDocCompatibilityFromSchema(toBlob(makeWorkspace({ scope: 'a-scope-from-the-future' })))).toBe( + 'mismatch', + ); + }); +}); + +describe('checkDocCompatibilityFromSchema', () => { + it('is ok for a doc that matches its model schema', () => { + expect(checkDocCompatibilityFromSchema(toBlob(makeEnvironment()))).toBe('strict-match'); + }); + + it('reports unknown-fields for a field its model schema does not know', () => { + expect(checkDocCompatibilityFromSchema(toBlob(makeEnvironment({ addedByNewerVersion: true })))).toBe( + 'match-with-unknown-fields', + ); + }); + + it('reports unknown-fields for an unknown field nested inside a known one', () => { + const doc = makeEnvironment({ + kvPairData: [{ id: 'a', name: 'k', value: 'v', type: 'str', enabled: true, future: 1 }], + }); + expect(checkDocCompatibilityFromSchema(toBlob(doc))).toBe('match-with-unknown-fields'); + }); + + it('prefers unsupported when a doc has both a mismatch and an unknown field', () => { + expect(checkDocCompatibilityFromSchema(toBlob(makeEnvironment({ parentId: 123, extra: 1 })))).toBe('mismatch'); + }); + + it('is ok for empty, invalid and non-object blobs', () => { + expect(checkDocCompatibilityFromSchema()).toBe('strict-match'); + expect(checkDocCompatibilityFromSchema('')).toBe('strict-match'); + expect(checkDocCompatibilityFromSchema('null')).toBe('strict-match'); + expect(checkDocCompatibilityFromSchema('not json')).toBe('strict-match'); + expect(checkDocCompatibilityFromSchema('[]')).toBe('strict-match'); + }); + + it('is ok for unknown model types, which have no schema to check against', () => { + expect(checkDocCompatibilityFromSchema(toBlob({ _id: 'x_1', type: 'NotAModel', extra: 1 }))).toBe('strict-match'); + }); + + it('returns the same result for repeated calls', () => { + const blob = toBlob(makeEnvironment({ addedByNewerVersion: true })); + expect(checkDocCompatibilityFromSchema(blob)).toBe('match-with-unknown-fields'); + expect(checkDocCompatibilityFromSchema(blob)).toBe('match-with-unknown-fields'); + }); +}); + +describe('findDocUnknownFieldRanges', () => { + it('highlights the unknown field in the YAML rendering of the doc', () => { + const blob = toBlob(makeEnvironment({ addedByNewerVersion: { a: 1, b: 2 } })); + const yamlText = stringify(JSON.parse(blob)); + const line = yamlText.split('\n').findIndex(l => l.startsWith('addedByNewerVersion:')) + 1; + + expect(findDocUnknownFieldRanges(blob, yamlText)).toEqual([ + expect.objectContaining({ startLine: line, endLine: line + 2, label: 'addedByNewerVersion' }), + ]); + }); + + it('returns nothing for a clean doc, a missing blob or an unsupported doc', () => { + const blob = toBlob(makeEnvironment()); + expect(findDocUnknownFieldRanges(blob, stringify(JSON.parse(blob)))).toEqual([]); + expect(findDocUnknownFieldRanges(undefined, '{}')).toEqual([]); + + const unsupported = toBlob(makeEnvironment({ parentId: 123, extra: 1 })); + expect(findDocUnknownFieldRanges(unsupported, stringify(JSON.parse(unsupported)))).toEqual([]); + }); +}); diff --git a/packages/insomnia/src/common/__tests__/har.test.ts b/packages/insomnia/src/common/__tests__/har.test.ts index 22ea08687e..2cf258df1f 100644 --- a/packages/insomnia/src/common/__tests__/har.test.ts +++ b/packages/insomnia/src/common/__tests__/har.test.ts @@ -27,6 +27,7 @@ vi.mock('~/common/runtime', () => ({ import type { Cookie, Request, Response } from 'insomnia-data'; import { models, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType } from 'insomnia-data'; import { database as db } from '../../common/database'; import { getRenderedRequestAndContext } from '../../common/render'; @@ -441,6 +442,71 @@ describe('export', () => { }); }); + describe('Generate Code vs explicit export separation', () => { + it('exportHarWithRequest with purpose=preview masks confidential env values when setting is ON (default)', async () => { + const workspace = await services.workspace.create(); + const baseEnv = await services.environment.getOrCreateForParentId(workspace._id); + await services.environment.update(baseEnv, { + data: { token: 'real-secret' }, + kvPairData: [ + { + id: 'pair_token', + name: 'token', + value: 'real-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ], + }); + const request: Request = { + ...models.request.init(), + _id: 'req_codegen', + modified: 1, + created: 1, + parentId: workspace._id, + type: models.request.type, + headers: [{ name: 'Authorization', value: 'Bearer {{ _.token }}' }], + method: 'GET', + url: 'https://api.example.com/', + }; + const { context } = await getRenderedRequestAndContext({ request, environment: baseEnv._id, purpose: 'preview' }); + expect((context as any).token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + + it('exportHar (explicit export) uses undefined purpose — confidential env values are masked (same as SECRET, not affected by setting)', async () => { + const workspace = await services.workspace.create({ _id: 'wrk_export_sep' }); + const baseEnv = await services.environment.getOrCreateForParentId(workspace._id); + await services.environment.update(baseEnv, { + data: { token: 'real-secret' }, + kvPairData: [ + { + id: 'pair_token', + name: 'token', + value: 'real-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ], + }); + const request: Request = { + ...models.request.init(), + _id: 'req_export_sep', + modified: 1, + created: 1, + parentId: workspace._id, + type: models.request.type, + method: 'GET', + url: 'https://api.example.com/', + headers: [], + }; + // explicit export passes no purpose → fail-closed 'mask' → same behaviour as SECRET + const { context } = await getRenderedRequestAndContext({ request, environment: baseEnv._id }); + expect((context as any).token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + }); + describe('exportHarWithRequest()', () => { it('renders does it correctly', async () => { const workspace = await services.workspace.create(); diff --git a/packages/insomnia/src/common/__tests__/render.test.ts b/packages/insomnia/src/common/__tests__/render.test.ts index 94de012655..090064e30e 100644 --- a/packages/insomnia/src/common/__tests__/render.test.ts +++ b/packages/insomnia/src/common/__tests__/render.test.ts @@ -1,7 +1,7 @@ // @ts-nocheck import { createBuilder } from '@develohpanda/fluent-builder'; import type { Environment, Workspace } from 'insomnia-data'; -import { models, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType, models, services } from 'insomnia-data'; import { beforeEach, describe, expect, it } from 'vitest'; import { environmentModelSchema, requestGroupModelSchema } from '../../sync/__schemas__/model-schemas'; @@ -30,6 +30,16 @@ const stubContextGetters = { getGlobalEnvironmentId: expect.any(Function), getProjectId: expect.any(Function), }; +const makeBaseContext = ( + purpose: 'preview' | 'send' | 'no-render' | 'script', + hideSecretValuesInPreviewAndConsole = true, + forceReveal?: boolean, +) => + ({ + getMeta: () => ({}), + getPurpose: () => purpose, + getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole, forceReveal }), + }) as any; describe('render tests', () => { beforeEach(async () => { @@ -103,6 +113,273 @@ describe('render tests', () => { }); describe('buildRenderContext()', () => { + it('preserves legacy vault masking for no-render', async () => { + const rootEnvironment = envBuilder + .data({ [models.environment.vaultEnvironmentPath]: { token: 'encrypted-value' } }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'encrypted-value', + type: EnvironmentKvPairDataType.SECRET, + enabled: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('no-render'), + }); + + expect(context.vault.token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + + it('masks confidential values in preview while preserving JSON shape and derived variables', async () => { + const rootEnvironment = envBuilder + .data({ + token: 'top-secret', + config: { token: 'nested-secret', enabled: true, retries: 3, empty: null, values: ['secret'] }, + authorization: 'Bearer {{ token }}', + }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + { + id: 'envPair_config', + name: 'config', + value: '{}', + type: EnvironmentKvPairDataType.JSON, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe(models.environment.vaultEnvironmentMaskValue); + expect(context.config).toEqual({ + token: models.environment.vaultEnvironmentMaskValue, + enabled: models.environment.vaultEnvironmentMaskValue, + retries: models.environment.vaultEnvironmentMaskValue, + empty: models.environment.vaultEnvironmentMaskValue, + values: [models.environment.vaultEnvironmentMaskValue], + }); + expect(context.authorization).toBe(`Bearer ${models.environment.vaultEnvironmentMaskValue}`); + }); + + it('uses the last enabled KV row and higher-priority public layers to clear confidentiality', async () => { + const rootEnvironment = envBuilder + .data({ token: 'root-secret' }) + .kvPairData([ + { + id: 'envPair_confidential', + name: 'token', + value: 'root-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + { + id: 'envPair_public', + name: 'token', + value: 'root-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: false, + }, + ]) + .build(); + const subEnvironment = envBuilder.data({ token: 'sub-public' }).build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + subEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe('sub-public'); + }); + + it('masks a higher-priority confidential value that overrides a public value', async () => { + const rootEnvironment = envBuilder.data({ token: 'root-public' }).build(); + const subEnvironment = envBuilder + .data({ token: 'sub-secret' }) + .kvPairData([ + { + id: 'envPair_sub', + name: 'token', + value: 'sub-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + subEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe(models.environment.vaultEnvironmentMaskValue); + }); + + it('reveals confidential values in preview when the local setting is disabled', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview', false), + }); + + expect(context.token).toBe('top-secret'); + }); + + it('ignores disabled confidential rows', async () => { + const rootEnvironment = envBuilder + .data({ token: 'visible' }) + .kvPairData([ + { + id: 'envPair_disabled', + name: 'token', + value: 'visible', + type: EnvironmentKvPairDataType.STRING, + enabled: false, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(context.token).toBe('visible'); + }); + + it('script purpose reveals confidential values', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('script'), + }); + + expect(context.token).toBe('top-secret'); + }); + + it('forceReveal in preview bypasses hideSecretValuesInPreviewAndConsole', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const context = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview', true, true), + }); + + expect(context.token).toBe('top-secret'); + }); + + it('does not mutate vault data object when preview masking runs', async () => { + const rootEnvironment = envBuilder + .data({ [models.environment.vaultEnvironmentPath]: { token: 'encrypted-value' } }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'encrypted-value', + type: EnvironmentKvPairDataType.SECRET, + enabled: true, + }, + ]) + .build(); + + await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + + expect(rootEnvironment.data[models.environment.vaultEnvironmentPath].token).toBe('encrypted-value'); + }); + + it('does not mutate environment data when preview masking precedes a send render', async () => { + const rootEnvironment = envBuilder + .data({ token: 'top-secret' }) + .kvPairData([ + { + id: 'envPair_token', + name: 'token', + value: 'top-secret', + type: EnvironmentKvPairDataType.STRING, + enabled: true, + isConfidential: true, + }, + ]) + .build(); + + const previewContext = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('preview'), + }); + const sendContext = await renderUtils.buildRenderContext({ + rootEnvironment, + baseContext: makeBaseContext('send'), + }); + + expect(previewContext.token).toBe(models.environment.vaultEnvironmentMaskValue); + expect(sendContext.token).toBe('top-secret'); + expect(rootEnvironment.data.token).toBe('top-secret'); + expect(rootEnvironment.kvPairData?.[0].value).toBe('top-secret'); + }); + it('cascades properly', async () => { const ancestors = [ reqGroupBuilder.environment({ foo: 'parent', ancestor: true }).build(), @@ -129,6 +406,25 @@ describe('render tests', () => { }); }); + it('does not mutate the ancestors array passed by the caller', async () => { + const ancestors = [ + reqGroupBuilder.environment({ foo: 'parent', ancestor: true }).build(), + reqGroupBuilder.environment({ foo: 'grandparent', ancestor: true }).build(), + ]; + const originalOrder = [...ancestors]; + + // Calling buildRenderContext twice with the same array (e.g. a prefill + // pass followed by the real render pass) must yield the same result + // both times. A previous bug reversed `ancestors` in place, so the + // second call would see an already-reversed array and flip precedence. + const firstContext = await renderUtils.buildRenderContext({ baseContext: stubBaseContext, ancestors }); + const secondContext = await renderUtils.buildRenderContext({ baseContext: stubBaseContext, ancestors }); + + expect(ancestors).toEqual(originalOrder); + expect(firstContext.foo).toBe('parent'); + expect(secondContext.foo).toBe('parent'); + }); + it('rendered recursive should not infinite loop', async () => { const ancestors = [reqGroupBuilder.environment({ recursive: '{{ recursive }}/hello' }).build()]; diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index e649e9b11f..40fb9613ca 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -17,8 +17,8 @@ import { version } from '../../package.json'; // In the inso CLI and main process, fall back to process.env. const ENV = 'env'; -// eslint-disable-next-line no-restricted-globals -- isomorphic: guarded by `typeof window`. Renderer reads env from the preload (`window.env`); main process, UtilityProcess and the inso CLI fall back to process.env. -const env = typeof window !== 'undefined' && window.env ? window.env : process[ENV]; +// eslint-disable-next-line no-restricted-globals -- isomorphic: guarded by `typeof window`/`typeof process`. Renderer reads env from the preload (`window.env`); main process, UtilityProcess and the inso CLI fall back to process.env; dedicated Web Workers (e.g. the templating worker) have neither, so fall back to `{}` there instead of throwing. +const env = typeof window !== 'undefined' && window.env ? window.env : typeof process !== 'undefined' ? process[ENV] : {}; export const INSOMNIA_GITLAB_REDIRECT_URI = env.INSOMNIA_GITLAB_REDIRECT_URI; export const INSOMNIA_GITLAB_CLIENT_ID = env.INSOMNIA_GITLAB_CLIENT_ID; diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index 2495d134dd..d0a8ca26a0 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -1,6 +1,7 @@ import clone from 'clone'; import type { Environment, + EnvironmentKvPairData, GrpcRequest, GrpcRequestBody, McpRequest, @@ -11,9 +12,10 @@ import type { WebSocketRequest, Workspace, } from 'insomnia-data'; -import { models, services } from 'insomnia-data'; +import { EnvironmentKvPairDataType, models, services } from 'insomnia-data'; import orderedJSON from 'json-order'; +import { type ConfidentialValueKind, getConfidentialValuePolicy } from '~/common/templating/confidential-value-policy'; import { NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME } from '~/common/templating/constants'; import { maskOrDecryptVaultDataIfNecessary } from '~/common/templating/mask-or-decrypt-vault-data'; import { RenderError } from '~/common/templating/render-error'; @@ -74,6 +76,204 @@ function getKeySource( } } +interface EnvironmentRenderLayer { + data: Record; + kvPairData?: EnvironmentKvPairData[]; + source: 'global' | 'collection' | 'folder' | 'iteration' | 'transient'; + // Per-layer source attribution for the "Template tags show variable source + // and value" setting; layers that define no variables of their own carry none. + meta?: VariableSourceMeta; +} + +// Builds a name → ConfidentialValueKind lookup for one environment layer. +// SECRET rows are intentionally excluded: they are handled separately by +// maskOrDecryptVaultDataIfNecessary and must not be double-processed here. +// Disabled rows are excluded to match getDataFromKVPair semantics — they +// do not appear in `data` either. +// If the same name appears more than once, the last enabled non-SECRET row +// wins (consistent with getDataFromKVPair last-wins behaviour). +function getConfidentialityByKey(kvPairData?: EnvironmentKvPairData[]) { + const confidentialityByKey = new Map(); + + for (const pair of kvPairData || []) { + if (!pair.enabled || pair.type === EnvironmentKvPairDataType.SECRET) { + continue; + } + confidentialityByKey.set(pair.name, pair.isConfidential ? 'normal' : undefined); + } + + return confidentialityByKey; +} + +function maskConfidentialValue(value: any): any { + if (Array.isArray(value)) { + return value.map(maskConfidentialValue); + } + + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, maskConfidentialValue(child)])); + } + + return models.environment.vaultEnvironmentMaskValue; +} + +export function buildEnvironmentRenderLayers({ + ancestors, + rootEnvironment, + subEnvironment, + rootGlobalEnvironment, + subGlobalEnvironment, + userUploadEnvironment, + transientVariables, + workspaceId, +}: { + ancestors?: RenderContextAncestor[]; + rootEnvironment?: Environment; + subEnvironment?: Environment; + rootGlobalEnvironment?: Environment | null; + subGlobalEnvironment?: Environment | null; + userUploadEnvironment?: UserUploadEnvironment; + transientVariables?: Environment; + // Collection and folder layers attribute their source to this workspace. + workspaceId?: string; +}): EnvironmentRenderLayer[] { + const environmentLayers: EnvironmentRenderLayer[] = []; + + if (rootGlobalEnvironment) { + const ordered = orderedJSON.order( + rootGlobalEnvironment.data, + rootGlobalEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: rootGlobalEnvironment.kvPairData, + source: 'global', + meta: { + label: formatSourceLabel('Project Environment', rootGlobalEnvironment.name), + workspaceId: rootGlobalEnvironment.parentId, + environmentId: rootGlobalEnvironment._id, + }, + }); + } + + if (subGlobalEnvironment) { + const ordered = orderedJSON.order( + subGlobalEnvironment.data, + subGlobalEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: subGlobalEnvironment.kvPairData, + source: 'global', + meta: { + label: formatSourceLabel('Project Environment', subGlobalEnvironment.name), + workspaceId: rootGlobalEnvironment?.parentId, + environmentId: subGlobalEnvironment._id, + }, + }); + } + + // Get root environment keys in correct order + // Then get sub environment keys in correct order + // Then get ancestor (folder) environment keys in correct order + if (rootEnvironment) { + const ordered = orderedJSON.order( + rootEnvironment.data, + rootEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: rootEnvironment.kvPairData, + source: 'collection', + meta: { + label: formatSourceLabel('Collection Environment', rootEnvironment.name), + workspaceId: workspaceId, + environmentId: rootEnvironment._id, + }, + }); + } + + if (subEnvironment) { + const ordered = orderedJSON.order( + subEnvironment.data, + subEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + kvPairData: subEnvironment.kvPairData, + source: 'collection', + // The active sub environment can be the base environment itself; only a + // distinct sub environment carries its own source attribution. + meta: + subEnvironment._id !== rootEnvironment?._id + ? { + label: formatSourceLabel('Collection Environment', subEnvironment.name), + workspaceId: workspaceId, + environmentId: subEnvironment._id, + } + : undefined, + }); + } + + for (const doc of [...(ancestors || [])].reverse()) { + const ancestor: any = doc; + const { environment, environmentPropertyOrder, kvPairData } = ancestor; + + if (typeof environment === 'object' && environment !== null) { + const ordered = orderedJSON.order(environment, environmentPropertyOrder ?? null, JSON_ORDER_SEPARATOR); + environmentLayers.push({ + data: ordered, + kvPairData, + source: 'folder', + // Only folder environments carry source metadata; the request and workspace + // ancestors define no variables of their own. + meta: + isRequestGroup(ancestor) && 'name' in ancestor + ? { + label: formatSourceLabel('Folder Environment', ancestor.name), + workspaceId: workspaceId, + requestGroupId: ancestor._id, + } + : undefined, + }); + } + } + + // user upload env in collection runner has highest priority except local variables + if (userUploadEnvironment) { + const ordered = orderedJSON.order( + userUploadEnvironment.data, + userUploadEnvironment.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + source: 'iteration', + meta: { label: formatSourceLabel('Upload Data', userUploadEnvironment.name) }, + }); + } + + // script local variables (insomnia.variable.set) has highest priority + if (transientVariables) { + const ordered = orderedJSON.order( + transientVariables.data, + transientVariables.dataPropertyOrder ?? null, + JSON_ORDER_SEPARATOR, + ); + environmentLayers.push({ + data: ordered, + source: 'transient', + meta: { label: formatSourceLabel('Script Variables', transientVariables.name) }, + }); + } + + return environmentLayers; +} + export async function buildRenderContext({ ancestors, rootEnvironment, @@ -97,122 +297,16 @@ export async function buildRenderContext({ // follows the same merge order and winners as the values. const keySource: Record = {}; const keySourceMeta: Record = {}; - const workspaceId = baseContext.getMeta().workspaceId; - const envObjects: { data: Record; meta?: VariableSourceMeta }[] = []; - - if (rootGlobalEnvironment) { - const ordered = orderedJSON.order( - rootGlobalEnvironment.data, - rootGlobalEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - meta: { - label: formatSourceLabel('Project Environment', rootGlobalEnvironment.name), - workspaceId: rootGlobalEnvironment.parentId, - environmentId: rootGlobalEnvironment._id, - }, - }); - } - - if (subGlobalEnvironment) { - const ordered = orderedJSON.order( - subGlobalEnvironment.data, - subGlobalEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - meta: { - label: formatSourceLabel('Project Environment', subGlobalEnvironment.name), - workspaceId: rootGlobalEnvironment?.parentId, - environmentId: subGlobalEnvironment._id, - }, - }); - } - - // Get root environment keys in correct order - // Then get sub environment keys in correct order - // Then get ancestor (folder) environment keys in correct order - if (rootEnvironment) { - const ordered = orderedJSON.order( - rootEnvironment.data, - rootEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - meta: { - label: formatSourceLabel('Collection Environment', rootEnvironment.name), - workspaceId: workspaceId, - environmentId: rootEnvironment._id, - }, - }); - } - - if (subEnvironment) { - const ordered = orderedJSON.order( - subEnvironment.data, - subEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ - data: ordered, - // The active sub environment can be the base environment itself; only a - // distinct sub environment carries its own source attribution. - meta: - subEnvironment._id !== rootEnvironment?._id - ? { - label: formatSourceLabel('Collection Environment', subEnvironment.name), - workspaceId: workspaceId, - environmentId: subEnvironment._id, - } - : undefined, - }); - } - - for (const doc of (ancestors || []).reverse()) { - const ancestor: any = doc; - const { environment, environmentPropertyOrder } = ancestor; - - if (typeof environment === 'object' && environment !== null) { - const ordered = orderedJSON.order(environment, environmentPropertyOrder ?? null, JSON_ORDER_SEPARATOR); - // Only folder environments carry source metadata; the request and workspace - // ancestors define no variables of their own. - envObjects.push({ - data: ordered, - meta: - isRequestGroup(ancestor) && 'name' in ancestor - ? { - label: formatSourceLabel('Folder Environment', ancestor.name), - workspaceId: workspaceId, - requestGroupId: ancestor._id, - } - : undefined, - }); - } - } - - // user upload env in collection runner has highest priority except local variables - if (userUploadEnvironment) { - const ordered = orderedJSON.order( - userUploadEnvironment.data, - userUploadEnvironment.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ data: ordered, meta: { label: formatSourceLabel('Upload Data', userUploadEnvironment.name) } }); - } - - // script local variables (insomnia.variable.set) has highest priority - if (transientVariables) { - const ordered = orderedJSON.order( - transientVariables.data, - transientVariables.dataPropertyOrder ?? null, - JSON_ORDER_SEPARATOR, - ); - envObjects.push({ data: ordered, meta: { label: formatSourceLabel('Script Variables', transientVariables.name) } }); - } + const environmentLayers = buildEnvironmentRenderLayers({ + ancestors, + rootEnvironment, + subEnvironment, + rootGlobalEnvironment, + subGlobalEnvironment, + userUploadEnvironment, + transientVariables, + workspaceId: baseContext.getMeta().workspaceId, + }); // At this point, environments is a list of environments ordered // from top-most parent to bottom-most child, and they keys in each environment @@ -271,9 +365,43 @@ export async function buildRenderContext({ } let finalRenderContext = { ...renderContext }; - for (const { data } of envObjects) { - // For every environment render the Objects - finalRenderContext = await renderSubContext(data, finalRenderContext); + // Cross-layer confidentiality accumulator. Updated on every key as layers are + // merged in priority order (low → high). When a higher-priority layer defines + // the same key, its confidentiality verdict overwrites the previous one — including + // setting it to undefined when the higher-priority layer has no kvPairData entry + // for that key (e.g. iteration/transient layers), which intentionally clears the + // confidential flag so a public override is not masked. + const confidentialityByKey = new Map(); + const hideSecretValues = renderContext?.getSettings?.().hideSecretValuesInPreviewAndConsole; + const forceReveal = renderContext?.getSettings?.().forceReveal; + // Hoisted above both loops: purpose/hideSecretValues/forceReveal are invariant for the + // entire buildRenderContext call. undefined purpose hits the fail-closed 'mask' branch, + // so file-export paths (explicit HAR, etc.) always mask confidential values — consistent + // with SECRET behaviour and independent of the hideSecretValuesInPreviewAndConsole setting. + const purpose = renderContext?.getPurpose?.(); + const confidentialPolicy = getConfidentialValuePolicy({ purpose, hideSecretValues, forceReveal }); + + for (const environmentLayer of environmentLayers) { + // Per-layer lookup built from kvPairData metadata (not from data values). + const layerConfidentialityByKey = getConfidentialityByKey(environmentLayer.kvPairData); + // Build a masked copy of this layer's data before merging into the context. + // Masking must happen here — after this point the values enter renderSubContext + // where they may be rendered into other variables, and we must never let a + // masked value be subsequently over-written with the real one from a cache or + // re-render of the same context. + const renderableData: Record = {}; + + for (const [key, value] of Object.entries(environmentLayer.data)) { + // Overwrite the accumulated confidentiality with this layer's verdict. + // layerConfidentialityByKey.get(key) returns undefined for keys that have + // no kvPairData (e.g. raw-JSON layers, iteration data), which is the + // correct "public" signal and clears any confidentiality from lower layers. + confidentialityByKey.set(key, layerConfidentialityByKey.get(key)); + const confidentiality = confidentialityByKey.get(key); + renderableData[key] = confidentiality && confidentialPolicy === 'mask' ? maskConfidentialValue(value) : value; + } + + finalRenderContext = await renderSubContext(renderableData, finalRenderContext); } // Key source attribution, registered in the same order the runtime merges the @@ -285,7 +413,7 @@ export async function buildRenderContext({ // there for `_.vault.key` chips; the reserved key itself is never exposed. const inKey = NUNJUCKS_TEMPLATE_GLOBAL_PROPERTY_NAME; - for (const { data, meta } of envObjects) { + for (const { data, meta } of environmentLayers) { if (!meta) { continue; } @@ -301,7 +429,7 @@ export async function buildRenderContext({ } } - for (const { data, meta } of envObjects) { + for (const { data, meta } of environmentLayers) { if (!meta) { continue; } @@ -311,7 +439,9 @@ export async function buildRenderContext({ const vaultEnvironmentData = await maskOrDecryptVaultDataIfNecessary( finalRenderContext[models.environment.vaultEnvironmentPath], - renderContext.getPurpose(), + purpose, + hideSecretValues, + forceReveal, ); if (vaultEnvironmentData) { // avoid add undefined data to render context @@ -498,6 +628,7 @@ export async function getRenderContext({ ancestors: _ancestors, purpose, extraInfo, + forceReveal, }: RenderContextOptions): Promise { const ancestors = _ancestors || (await getRenderContextAncestors(request)); @@ -553,7 +684,11 @@ export async function getRenderContext({ getGlobalEnvironmentId: () => subGlobalEnvironment?._id || rootGlobalEnvironment?._id, // It is possible for a project to not exist because this code path can be reached via Inso which has no concept of a project. getProjectId: () => project?._id, - getSettings: () => ({ dataFolders: settings.dataFolders }), + getSettings: () => ({ + dataFolders: settings.dataFolders, + hideSecretValuesInPreviewAndConsole: settings.hideSecretValuesInPreviewAndConsole, + forceReveal: forceReveal, + }), }; // Generate the context we need to render diff --git a/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts b/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts index 348dc9dbdf..2c8f4e7d4d 100644 --- a/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts +++ b/packages/insomnia/src/common/templating/__tests__/render-context-serialization.test.ts @@ -15,7 +15,7 @@ const makeContext = () => ({ getKeysContext: () => ({ keyContext: {} }), getProjectId: () => 'proj_1', getPurpose: () => 'send', - getSettings: () => ({ dataFolders: [] }), + getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true }), }); describe('serializeRenderContext', () => { @@ -41,7 +41,7 @@ describe('serializeRenderContext', () => { keysContext: { keyContext: {} }, projectId: 'proj_1', purpose: 'send', - settings: { dataFolders: [] }, + settings: { dataFolders: [], hideSecretValuesInPreviewAndConsole: true }, }); }); }); @@ -54,8 +54,24 @@ describe('deserializeRenderContext', () => { expect(context.getMeta()).toEqual({ requestId: 'req_1', workspaceId: 'wrk_1' }); expect(context.getProjectId()).toBe('proj_1'); expect(context.getEnvironmentId()).toBe('env_1'); - expect(context.getSettings()).toEqual({ dataFolders: [] }); + expect(context.getSettings()).toEqual({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true }); // environment data survives the round-trip expect(context.foo).toBe('bar'); }); + + it('preserves forceReveal in settings across the worker boundary', () => { + const ctx = { + ...makeContext(), + getSettings: () => ({ dataFolders: [], hideSecretValuesInPreviewAndConsole: true, forceReveal: true }), + }; + const roundTripped = structuredClone(serializeRenderContext(ctx)); + const context = deserializeRenderContext(roundTripped); + + expect(context.getSettings()).toEqual({ + dataFolders: [], + hideSecretValuesInPreviewAndConsole: true, + forceReveal: true, + }); + }); + }); diff --git a/packages/insomnia/src/common/templating/confidential-value-policy.test.ts b/packages/insomnia/src/common/templating/confidential-value-policy.test.ts new file mode 100644 index 0000000000..e07dd4689c --- /dev/null +++ b/packages/insomnia/src/common/templating/confidential-value-policy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { type ConfidentialValuePolicy, getConfidentialValuePolicy, shouldMaskExternalVaultTag } from './confidential-value-policy'; +import type { RenderPurpose } from './types'; + +const purposes: (RenderPurpose | undefined)[] = [ + undefined, + 'general', + 'send', + 'preview', + 'script', + 'no-render', +]; +const settings = [true, false, undefined]; + +function expectedPolicy( + purpose: RenderPurpose | undefined, + hideSecretValues: boolean | undefined, +): ConfidentialValuePolicy { + if (purpose === 'send' || purpose === 'script') { + return 'reveal'; + } + + if (purpose === 'preview') { + return hideSecretValues === false ? 'reveal' : 'mask'; + } + + return 'mask'; +} + +describe('getConfidentialValuePolicy', () => { + it.each(purposes.flatMap(purpose => settings.map(hideSecretValues => ({ purpose, hideSecretValues }))))( + 'purpose=$purpose hideSecretValues=$hideSecretValues follows the policy matrix', + ({ purpose, hideSecretValues }) => { + expect(getConfidentialValuePolicy({ purpose, hideSecretValues })).toBe( + expectedPolicy(purpose, hideSecretValues), + ); + }, + ); + + it('forceReveal only applies to preview — fallback surfaces ignore it', () => { + expect(getConfidentialValuePolicy({ purpose: 'preview', forceReveal: true })).toBe('reveal'); + expect(getConfidentialValuePolicy({ purpose: undefined, forceReveal: true })).toBe('mask'); + expect(getConfidentialValuePolicy({ purpose: 'general', forceReveal: true })).toBe('mask'); + }); +}); + +describe('shouldMaskExternalVaultTag', () => { + const PLUGIN = '@kong/insomnia-plugin-external-vault'; + const TAG = 'vault'; + + it('returns false for non-vault plugin', () => { + expect(shouldMaskExternalVaultTag({ pluginName: 'other-plugin', tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + }); + + it('returns false for non-vault tag name', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: 'other-tag', renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + }); + + it('returns false for non-preview purposes — plugin run() handles its own placeholder', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'send', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'script', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'general', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(false); + }); + + it('returns true for preview when setting is ON (default-closed)', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true } })).toBe(true); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: {} })).toBe(true); + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview' })).toBe(true); + }); + + it('returns false for preview when setting is OFF — reveal real value', () => { + expect(shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: false } })).toBe(false); + }); + + it('returns false for preview when forceReveal is true — eye-button reveal bypasses setting', () => { + expect( + shouldMaskExternalVaultTag({ pluginName: PLUGIN, tagName: TAG, renderPurpose: 'preview', settings: { hideSecretValuesInPreviewAndConsole: true, forceReveal: true } }), + ).toBe(false); + }); +}); diff --git a/packages/insomnia/src/common/templating/confidential-value-policy.ts b/packages/insomnia/src/common/templating/confidential-value-policy.ts new file mode 100644 index 0000000000..fad6ba8eca --- /dev/null +++ b/packages/insomnia/src/common/templating/confidential-value-policy.ts @@ -0,0 +1,75 @@ +import { EXTERNAL_VAULT_PLUGIN_NAME } from '~/common/constants'; + +import type { RenderPurpose } from './types'; + +export type ConfidentialValueKind = 'normal' | 'secret' | 'external-vault'; +export type ConfidentialValuePolicy = + // Use the real plaintext value (send, script, or preview with setting OFF). + | 'reveal' + // Replace the value with •••••• before it reaches any display surface or log (preview with setting ON). + | 'mask'; + +// The string displayed in place of a masked confidential value across all surfaces. +export const CONFIDENTIAL_MASK_VALUE = '••••••'; + +// Tag name for the bundled external vault plugin, used to intercept tag execution +// at the adapter boundary before any provider fetch occurs. +const BUNDLED_EXTERNAL_VAULT_TAG = 'vault'; + +export function getConfidentialValuePolicy({ + purpose, + hideSecretValues, + forceReveal, +}: { + purpose?: RenderPurpose; + hideSecretValues?: boolean; + forceReveal?: boolean; +}): ConfidentialValuePolicy { + if (purpose === 'send' || purpose === 'script') { + return 'reveal'; + } + + if (purpose === 'preview') { + // forceReveal is set by the VariableEditor eye-button; it only applies to the preview + // surface so that codegen and fallback surfaces remain unaffected. + return forceReveal || hideSecretValues === false ? 'reveal' : 'mask'; + } + + // Fail-closed: any surface that does not explicitly declare its purpose is masked. + // All known display surfaces (preview, send, script) are explicit above. + return 'mask'; +} + +/** + * Returns true when the external vault tag's run() should be skipped and CONFIDENTIAL_MASK_VALUE + * returned instead, avoiding any provider fetch. + * + * Only intercepts the 'preview' purpose. Generate Code and Copy as cURL callers use + * purpose:'preview' so they are covered here. For 'general', undefined, and other purposes + * the plugin's own run() handles its behavior. + */ +export function shouldMaskExternalVaultTag({ + pluginName, + tagName, + renderPurpose, + settings, +}: { + pluginName: string; + tagName?: string; + renderPurpose?: RenderPurpose; + settings?: { hideSecretValuesInPreviewAndConsole?: boolean; forceReveal?: boolean }; +}): boolean { + if (pluginName !== EXTERNAL_VAULT_PLUGIN_NAME || tagName !== BUNDLED_EXTERNAL_VAULT_TAG) { + return false; + } + if (renderPurpose !== 'preview') { + return false; + } + return ( + getConfidentialValuePolicy({ + purpose: renderPurpose, + hideSecretValues: settings?.hideSecretValuesInPreviewAndConsole, + forceReveal: settings?.forceReveal, + }) === 'mask' + ); +} diff --git a/packages/insomnia/src/common/templating/liquid-extension-worker.ts b/packages/insomnia/src/common/templating/liquid-extension-worker.ts index 614542051c..ec279cc002 100644 --- a/packages/insomnia/src/common/templating/liquid-extension-worker.ts +++ b/packages/insomnia/src/common/templating/liquid-extension-worker.ts @@ -5,6 +5,7 @@ import { Tag } from 'liquidjs'; import type { Plugin } from '~/common/plugins/types'; import packageJson from '../../../package.json'; +import { CONFIDENTIAL_MASK_VALUE, shouldMaskExternalVaultTag } from './confidential-value-policy'; import { resolveArg } from './resolve-arg'; import { tokenizeArgs } from './tokenize-args'; import type { @@ -182,6 +183,13 @@ export function createLiquidTagWorker( }, }; + // Short-circuit external vault before any provider fetch when the policy says mask. + const renderSettings = renderContext.getSettings?.(); + if (shouldMaskExternalVaultTag({ pluginName: plugin.name, tagName: ext.name, renderPurpose, settings: renderSettings })) { + emitter.write(CONFIDENTIAL_MASK_VALUE); + return; + } + const result = await Promise.resolve(ext.run(helperContext, ...args)); emitter.write(result == null ? '' : String(result)); } diff --git a/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts b/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts index 4c7d2e2bad..bb5aeb24b5 100644 --- a/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts +++ b/packages/insomnia/src/common/templating/mask-or-decrypt-vault-data.ts @@ -1,5 +1,6 @@ import { models, services } from 'insomnia-data'; +import { getConfidentialValuePolicy } from '~/common/templating/confidential-value-policy'; import type { RenderPurpose } from '~/common/templating/types'; import { decryptVaultKeyFromSession } from '~/common/utils/vault'; import { getRuntime } from '~/runtimes'; @@ -11,14 +12,15 @@ import { getRuntime } from '~/runtimes'; * * Given an object the result is always a fresh copy; a non-object input is returned as is. */ -export async function maskOrDecryptVaultDataIfNecessary(vaultEnvironmentData: any, renderPurpose?: RenderPurpose) { - /** - * Decrypt secrets when renderPurpose is one of the following: - * - preview: render the template in variable editor to do the live preview - * - send: render the template when sending requests - * - script: render the template in pre-request or after-response script - */ - const shouldDecrypt = renderPurpose === 'preview' || renderPurpose === 'send' || renderPurpose === 'script'; +export async function maskOrDecryptVaultDataIfNecessary( + vaultEnvironmentData: any, + renderPurpose?: RenderPurpose, + hideSecretValues?: boolean, + forceReveal?: boolean, +) { + // getConfidentialValuePolicy is the single decision point for reveal/mask across all render surfaces + const shouldDecrypt = + getConfidentialValuePolicy({ purpose: renderPurpose, hideSecretValues, forceReveal }) === 'reveal'; if (typeof vaultEnvironmentData !== 'object' || vaultEnvironmentData === null) { return vaultEnvironmentData; } diff --git a/packages/insomnia/src/common/templating/types.ts b/packages/insomnia/src/common/templating/types.ts index 0b5acd1a39..e41cb85488 100644 --- a/packages/insomnia/src/common/templating/types.ts +++ b/packages/insomnia/src/common/templating/types.ts @@ -15,6 +15,7 @@ import type { Response, ResponseHeader, Services, + Settings, SocketIORequest, UserUploadEnvironment, WebSocketRequest, @@ -63,6 +64,7 @@ export interface SerializedCookie { hostOnly?: boolean; } +// TODO: check if we still need no-render, since we do not have any entry points that require it export type RenderPurpose = 'send' | 'general' | 'preview' | 'script' | 'no-render'; export type PluginToMainAPIPaths = | 'readFile' @@ -139,6 +141,9 @@ export interface BaseRenderContextOptions { purpose?: RenderPurpose; extraInfo?: { requestChain: string[] }; ignoreUndefinedEnvVariable?: boolean; + // When true the user has explicitly clicked reveal in the VariableEditor; + // this bypasses hideSecretValuesInPreviewAndConsole for the current render only. + forceReveal?: boolean; } export type RenderContextAncestor = | Request @@ -282,6 +287,7 @@ export interface BaseRenderContext { keyContextMeta?: Record; }; getPurpose: () => RenderPurpose | undefined; + getSettings: () => Pick & { forceReveal?: boolean }; getExtraInfo: () => { requestChain: string[] } | undefined; getEnvironmentId: () => string | undefined; getGlobalEnvironmentId: () => string | undefined; diff --git a/packages/insomnia/src/common/zod/check-doc-compatibility.ts b/packages/insomnia/src/common/zod/check-doc-compatibility.ts new file mode 100644 index 0000000000..d27b376451 --- /dev/null +++ b/packages/insomnia/src/common/zod/check-doc-compatibility.ts @@ -0,0 +1,100 @@ +import { models } from 'insomnia-data'; +import { z } from 'zod/v4'; + +import { getStrictSchema } from './check-schema-mismatch'; +import { findUnknownFieldRanges, type UnknownFieldRange } from './find-unknown-field-ranges'; + +export type SchemaCompatibility = + // Passes the schema. + | 'match' + // Passes the strict schema. + | 'strict-match' + // Passes the schema but fails the strict schema (contains unknown fields). + | 'match-with-unknown-fields' + // Fails even the schema: a field is missing, renamed or has a value/format this schema doesn't accept. + | 'mismatch'; + +// Memoized so getStrictSchema's WeakMap cache keeps hitting instead of rebuilding the strict schema on every call. +const modelSchemaCache = new Map(); + +function getModelSchema(type: unknown): z.ZodTypeAny | undefined { + if (typeof type !== 'string') { + return undefined; + } + const cachedSchema = modelSchemaCache.get(type); + if (cachedSchema) { + return cachedSchema; + } + const model = models.getModel(type); + // Models that haven't been migrated to zod don't export a schema and are never checked. + if (!model || !('schema' in model)) { + return undefined; + } + // Cloud sync deletes DELETE_KEY (`modified`) from a doc before storing its blob. Make it optional to avoid a false mismatch. + const schema = model.schema as unknown as z.ZodObject; + let docSchema = schema.partial({ [models.DELETE_KEY]: true }); + // hashDocument also resets a Workspace's `parentId` to null (resetKeys in insomnia-data), the schema expects a string. + if (type === models.workspace.type) { + docSchema = docSchema.extend({ parentId: z.string().nullable() }); + } + modelSchemaCache.set(type, docSchema); + return docSchema; +} + +function parseDoc(json?: string): Record | null { + if (!json) { + return null; + } + try { + const doc = JSON.parse(json); + return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : null; + } catch { + return null; + } +} + +export function getDocSchemaCompatibility(schema: z.ZodTypeAny, doc: unknown): SchemaCompatibility { + if (schema.safeParse(doc).success) { + if (getStrictSchema(schema).safeParse(doc).success) { + return 'strict-match'; + } + return 'match-with-unknown-fields'; + } + return 'mismatch'; +} +// Blobs are content-addressed and status is recomputed on every DB change, so remember results per blob content. +const MAX_CACHED_RESULTS = 200; +// A Map iterates in insertion order, so the first key is always the least recently used one. +const docBlobStatusCache = new Map(); + +export function checkDocCompatibilityFromSchema(blobContent?: string): SchemaCompatibility { + if (!blobContent) { + return 'strict-match'; + } + const cached = docBlobStatusCache.get(blobContent); + if (cached !== undefined) { + // Re-insert to mark it as most recently used + docBlobStatusCache.delete(blobContent); + docBlobStatusCache.set(blobContent, cached); + return cached; + } + + const doc = parseDoc(blobContent); + const schema = doc && getModelSchema(doc.type); + const result: SchemaCompatibility = schema ? getDocSchemaCompatibility(schema, doc) : 'strict-match'; + + if (docBlobStatusCache.size >= MAX_CACHED_RESULTS) { + docBlobStatusCache.delete(docBlobStatusCache.keys().next().value!); + } + docBlobStatusCache.set(blobContent, result); + return result; +} + +// Lines of the YAML rendering of a synced doc that hold unknown fields. +export function findDocUnknownFieldRanges(blobContent: string | undefined, yamlText: string): UnknownFieldRange[] { + if (checkDocCompatibilityFromSchema(blobContent) !== 'match-with-unknown-fields') { + return []; + } + const schema = getModelSchema(parseDoc(blobContent)?.type); + return schema ? findUnknownFieldRanges(yamlText, schema) : []; +} diff --git a/packages/insomnia/src/common/zod/check-schema-mismatch.ts b/packages/insomnia/src/common/zod/check-schema-mismatch.ts index 0582d094e7..13b80994fc 100644 --- a/packages/insomnia/src/common/zod/check-schema-mismatch.ts +++ b/packages/insomnia/src/common/zod/check-schema-mismatch.ts @@ -100,7 +100,7 @@ function collectIssues(issues: readonly z.core.$ZodIssue[], basePath: PropertyKe // cache the strict schema to avoid rebuilding it multiple times const strictSchemaCache = new WeakMap(); -function getStrictSchema(schema: z.ZodTypeAny): z.ZodTypeAny { +export function getStrictSchema(schema: z.ZodTypeAny): z.ZodTypeAny { let strictSchema = strictSchemaCache.get(schema); if (!strictSchema) { strictSchema = deepStrict(schema); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index a732c25f23..e852e85321 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -10,7 +10,7 @@ import type { RunTemplateTagActionArgs, } from '~/common/plugins/bridge-types'; import type { GenerateMcpSamplingResponseFunction } from '~/common/plugins/types'; -import type { RenderedRequest } from '~/common/templating/types'; +import type { RenderedRequest, RenderPurpose } from '~/common/templating/types'; import { invariant } from '~/common/utils/invariant'; import { invokeWithNormalizedError } from '~/main/ipc/invoke'; import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service'; @@ -425,9 +425,9 @@ const main: Window['main'] = { generateCodeSnippet: (options: { har: object; target: string; client: string }) => invokeWithNormalizedError('generateCodeSnippet', options), getCodeSnippetTargets: () => invokeWithNormalizedError('getCodeSnippetTargets'), - exportHarWithRequest: (options: { requestId: string; environmentId?: string; addContentLength?: boolean }) => + exportHarWithRequest: (options: { requestId: string; environmentId?: string; addContentLength?: boolean; purpose?: RenderPurpose }) => invokeWithNormalizedError('exportHarWithRequest', options), - exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean }) => + exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; purpose?: RenderPurpose }) => invokeWithNormalizedError('exportHarRequest', options), exportHarCurrentRequest: (options: { requestId: string; responseId: string }) => invokeWithNormalizedError('exportHarCurrentRequest', options), diff --git a/packages/insomnia/src/main/har.ts b/packages/insomnia/src/main/har.ts index c99c7ad57c..72e51a2a68 100644 --- a/packages/insomnia/src/main/har.ts +++ b/packages/insomnia/src/main/har.ts @@ -4,7 +4,7 @@ import { models, services } from 'insomnia-data'; import { Cookie as ToughCookie } from 'tough-cookie'; import { RenderError } from '~/common/templating/render-error'; -import type { RenderedRequest } from '~/common/templating/types'; +import type { RenderedRequest, RenderPurpose } from '~/common/templating/types'; import { parseGraphQLReqeustBody } from '~/common/utils/graph-ql'; import { smartEncodeUrl } from '~/common/utils/url/querystring'; import { getAuthHeader } from '~/main/network/get-auth-header'; @@ -218,7 +218,7 @@ export async function exportHarResponse(response?: Response) { return harResponse; } -export async function exportHarRequest(requestId: string, environmentOrWorkspaceId: string, addContentLength = false) { +export async function exportHarRequest(requestId: string, environmentOrWorkspaceId: string, addContentLength = false, purpose?: RenderPurpose) { const request = await services.request.getById(requestId); let environmentId: string; if (models.environment.isEnvironmentId(environmentOrWorkspaceId)) { @@ -239,12 +239,12 @@ export async function exportHarRequest(requestId: string, environmentOrWorkspace return null; } - return exportHarWithRequest(request, environmentId, addContentLength); + return exportHarWithRequest(request, environmentId, addContentLength, purpose); } -export async function exportHarWithRequest(request: Request, environmentId?: string, addContentLength = false) { +export async function exportHarWithRequest(request: Request, environmentId?: string, addContentLength = false, purpose?: RenderPurpose) { try { - const renderResult = await getRenderedRequestAndContext({ request, environment: environmentId }); + const renderResult = await getRenderedRequestAndContext({ request, environment: environmentId, purpose }); const renderedRequest = await getRuntime().network.applyRequestHooks(renderResult.request, renderResult.context); parseGraphQLReqeustBody(renderedRequest); return exportHarWithRenderedRequest(renderedRequest, addContentLength); diff --git a/packages/insomnia/src/main/ipc/main.ts b/packages/insomnia/src/main/ipc/main.ts index 5a3169d34e..411fcf047b 100644 --- a/packages/insomnia/src/main/ipc/main.ts +++ b/packages/insomnia/src/main/ipc/main.ts @@ -32,7 +32,7 @@ import type { MockRouteData, ModelConfig, } from '~/common/plugins/types'; -import type { RenderedRequest } from '~/common/templating/types'; +import type { RenderedRequest, RenderPurpose } from '~/common/templating/types'; import { bundleSpectralRuleset } from '~/main/bundle-spectral-ruleset'; import { initializeWorkspaceBackendProject, syncNewWorkspaceIfNeeded } from '~/main/cloud-sync/initialization'; import type { SyncBridgeAPI } from '~/main/cloud-sync/ipc'; @@ -320,11 +320,13 @@ export interface RendererToMainBridgeAPI { requestId: string; environmentId?: string; addContentLength?: boolean; + purpose?: RenderPurpose; }) => Promise; exportHarRequest: (options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; + purpose?: RenderPurpose; }) => Promise; exportHarCurrentRequest: (options: { requestId: string; responseId: string }) => Promise; exportRequestsHAR: (options: { requests: any[]; includePrivateDocs?: boolean }) => Promise; @@ -567,19 +569,19 @@ export function registerMainHandlers() { ipcMainHandle( 'exportHarWithRequest', - async (_, options: { requestId: string; environmentId?: string; addContentLength?: boolean }) => { + async (_, options: { requestId: string; environmentId?: string; addContentLength?: boolean; purpose?: RenderPurpose }) => { const request = await services.request.getById(options.requestId); if (!request) { throw new Error(`Request ${options.requestId} not found`); } - return exportHarWithRequest(request, options.environmentId, options.addContentLength); + return exportHarWithRequest(request, options.environmentId, options.addContentLength, options.purpose); }, ); ipcMainHandle( 'exportHarRequest', - async (_, options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean }) => { - return exportHarRequest(options.requestId, options.environmentOrWorkspaceId, options.addContentLength); + async (_, options: { requestId: string; environmentOrWorkspaceId: string; addContentLength?: boolean; purpose?: RenderPurpose }) => { + return exportHarRequest(options.requestId, options.environmentOrWorkspaceId, options.addContentLength, options.purpose); }, ); diff --git a/packages/insomnia/src/templating/liquid-extension.ts b/packages/insomnia/src/templating/liquid-extension.ts index 982fa48fcb..91f1b0fc0d 100644 --- a/packages/insomnia/src/templating/liquid-extension.ts +++ b/packages/insomnia/src/templating/liquid-extension.ts @@ -10,6 +10,7 @@ import { Tag } from 'liquidjs'; import { jarFromCookies } from '~/common/cookies'; import type { Plugin } from '~/common/plugins/types'; +import { CONFIDENTIAL_MASK_VALUE, shouldMaskExternalVaultTag } from '~/common/templating/confidential-value-policy'; import { resolveArg } from '~/common/templating/resolve-arg'; import type { BaseRenderContext, PluginTemplateTag, PluginTemplateTagContext } from '~/common/templating/types'; import { decodeEncoding, tokenizeArgs } from '~/common/templating/utils'; @@ -104,6 +105,13 @@ export function createLiquidTag( }, }; + // Short-circuit external vault before any provider fetch when the policy says mask. + const renderSettings = renderContext.getSettings?.(); + if (shouldMaskExternalVaultTag({ pluginName: plugin.name, tagName: ext.name, renderPurpose, settings: renderSettings })) { + emitter.write(CONFIDENTIAL_MASK_VALUE); + return; + } + const result = await Promise.resolve(ext.run(helperContext, ...args)); emitter.write(result == null ? '' : String(result)); } diff --git a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx index 0aa84f800e..9d80726480 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/code-editor.tsx @@ -36,7 +36,7 @@ import { showModal } from '~/ui/components/modals/index'; import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal'; import { UpgradeModal } from '~/ui/components/modals/upgrade-modal'; import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts'; -import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks'; +import { useNunjucks, type UseNunjucksOptions } from '~/ui/context/nunjucks/use-nunjucks'; import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh'; import { useOpenVariableSource } from '~/ui/hooks/use-open-variable-source'; import { usePlanData } from '~/ui/hooks/use-plan'; @@ -49,6 +49,10 @@ import { queryXPath } from '~/ui/utils/xpath/query'; import { getCachedEditorState, setCachedEditorState } from './editor-state-cache'; import { hideVariableSourceTooltip, isVariableSourceTooltipInside } from './extensions/variable-source-tooltip'; import { normalizeIrregularWhitespace } from './normalize-irregular-whitespace'; + +// Stable options object so handleRender reference stays constant across re-renders. +// A new object per render would cause initEditor to re-run on every render. +const PREVIEW_NUNJUCKS_OPTIONS: UseNunjucksOptions = { renderContext: { purpose: 'preview' } }; const TAB_SIZE = 4; const MAX_SIZE_FOR_LINTING = 1_000_000; // Around 1MB const LONG_LINE_THRESHOLD = 10_000; // Collapse lines longer than 10,000 characters @@ -293,7 +297,7 @@ export const CodeEditor = memo( }), [indentChars], ); - const { handleRender, handleGetRenderContext } = useNunjucks(); + const { handleRender, handleGetRenderContext } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); const openVariableSource = useOpenVariableSource(); const isNunjucksEnabled = enableNunjucks && handleRender; const shouldTruncateLongLines = !!readOnly && !!truncateLongLines; diff --git a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx index 77989c55a8..9bb240c513 100644 --- a/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/.client/codemirror/one-line-editor.tsx @@ -31,7 +31,7 @@ import { NunjucksModal } from '~/ui/components/modals/nunjucks-modal'; import { UpgradeModal } from '~/ui/components/modals/upgrade-modal'; import { isKeyCombinationInRegistry } from '~/ui/components/settings/shortcuts'; import { Tooltip } from '~/ui/components/tooltip'; -import { useNunjucks } from '~/ui/context/nunjucks/use-nunjucks'; +import { useNunjucks, type UseNunjucksOptions } from '~/ui/context/nunjucks/use-nunjucks'; import { useEditorRefresh } from '~/ui/hooks/use-editor-refresh'; import { useOpenVariableSource } from '~/ui/hooks/use-open-variable-source'; import { usePlanData } from '~/ui/hooks/use-plan'; @@ -42,6 +42,10 @@ import { getTagDefinitions } from '~/ui/templating/renderer-safe'; import { getCachedEditorState, setCachedEditorState } from './editor-state-cache'; import { hideVariableSourceTooltip, isVariableSourceTooltipInside } from './extensions/variable-source-tooltip'; +// Stable options object so handleRender reference stays constant across re-renders. +// A new object per render would cause initEditor to re-run on every render. +const PREVIEW_NUNJUCKS_OPTIONS: UseNunjucksOptions = { renderContext: { purpose: 'preview' } }; + // Replace the editor's entire value while PRESERVING undo/redo history and the // cursor. Unlike cm.setValue(), which clears history, replaceRange records the // change as a normal, undoable edit. No-ops when the value is unchanged so we @@ -114,7 +118,7 @@ export const OneLineEditor = forwardRef ); const { settings } = useRootLoaderData()!; const { isOwner, isEnterprisePlan } = usePlanData(); - const { handleRender, handleGetRenderContext } = useNunjucks(); + const { handleRender, handleGetRenderContext } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); const openVariableSource = useOpenVariableSource(); const isPasswordEditor = type?.toLowerCase() === 'password'; diff --git a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx index 1c2a6df9b0..bb60a95ee3 100644 --- a/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/request-actions-dropdown.tsx @@ -161,6 +161,7 @@ export const RequestActionsDropdown = ({ const har = await window.main.exportHarRequest({ requestId: request._id, environmentOrWorkspaceId: workspaceId, + purpose: 'preview', }); if (!har) { return; diff --git a/packages/insomnia/src/ui/components/git/unknown-fields-warning.tsx b/packages/insomnia/src/ui/components/git/unknown-fields-warning.tsx index a29d38b8bf..cff02e911c 100644 --- a/packages/insomnia/src/ui/components/git/unknown-fields-warning.tsx +++ b/packages/insomnia/src/ui/components/git/unknown-fields-warning.tsx @@ -21,3 +21,24 @@ export const UnknownFieldsWarningIcon: FC = () => ( ); + +export const UNSUPPORTED_DOC_MESSAGE = + 'The remote version of this file is not supported by this version of Insomnia. It has been converted to an older format and committing changes will break functionality for your collaborators.'; + +export const UnsupportedDocErrorIcon: FC = () => ( + + + + {UNSUPPORTED_DOC_MESSAGE} + + +); diff --git a/packages/insomnia/src/ui/components/modals/confirm-unknown-fields-commit-modal.tsx b/packages/insomnia/src/ui/components/modals/confirm-unknown-fields-commit-modal.tsx new file mode 100644 index 0000000000..0aeb85eda3 --- /dev/null +++ b/packages/insomnia/src/ui/components/modals/confirm-unknown-fields-commit-modal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; + +import { Icon } from '../icon'; + +interface Props { + onConfirm?: () => void; + onClose?: () => void; +} + +// Warns before committing changes to files whose committed version has fields this +// version of Insomnia doesn't recognize, since re-saving them here would drop those fields. +export const ConfirmUnknownFieldsCommitModal = ({ onConfirm, onClose }: Props) => { + return ( + { + !isOpen && onClose?.(); + }} + isDismissable + className="fixed top-[50%] left-0 z-10 flex h-(--visual-viewport-height) w-full translate-y-[-50%] items-center justify-center bg-black/30" + > + { + !isOpen && onClose?.(); + }} + className="flex w-full max-w-2xl flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" + > + + {({ close }) => ( +
+
+ + + Unrecognized Fields + + + +
+
+ The commit will modify files with unknown fields. Committing any changes to them from here may break or + remove functionality for your collaborators. Are you sure you want to continue? +
+
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/modals/cookies-modal.tsx b/packages/insomnia/src/ui/components/modals/cookies-modal.tsx index d52ecf986b..8db977e7aa 100644 --- a/packages/insomnia/src/ui/components/modals/cookies-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/cookies-modal.tsx @@ -48,8 +48,10 @@ interface Props { setIsOpen: (isOpen: boolean) => void; } +const PREVIEW_NUNJUCKS_OPTIONS = { renderContext: { purpose: 'preview' as const } }; + export const CookiesModal = ({ setIsOpen }: Props) => { - const { handleRender } = useNunjucks(); + const { handleRender } = useNunjucks(PREVIEW_NUNJUCKS_OPTIONS); const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; diff --git a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx index 901c7da8c4..cb843d35dd 100644 --- a/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/generate-code-modal.tsx @@ -92,6 +92,7 @@ export const GenerateCodeModal = forwardRef((pro requestId: request._id, environmentId: props.environmentId, addContentLength, + purpose: 'preview', }); if (har) { const cmd = await window.main.generateCodeSnippet({ diff --git a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index 0eaa35be41..6538a5017c 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx @@ -58,6 +58,7 @@ import { AnalyticsEvent } from '~/ui/analytics'; import { Badge } from '~/ui/components/base/badge'; import { GitOauthAuthBanner } from '~/ui/components/git/git-oauth-auth-banner'; import { isGitRepoLoadAuthHttp40Error } from '~/ui/components/git/git-oauth-auth-utils'; +import { ConfirmUnknownFieldsCommitModal } from '~/ui/components/modals/confirm-unknown-fields-commit-modal'; import { showSettingsModal } from '~/ui/components/modals/settings-modal'; import { SvgIcon } from '~/ui/components/svg-icon'; import { WarningBanner } from '~/ui/components/warning-banner'; @@ -2144,72 +2145,6 @@ const CreateBranchAndPushModal = ({ ); }; -// Warns before committing changes to files whose committed version has fields this -// version of Insomnia doesn't recognize, since re-saving them here would drop those fields. -const ConfirmUnknownFieldsCommitModal = ({ onConfirm, onClose }: Omit) => { - return ( - { - !isOpen && onClose?.(); - }} - isDismissable - className="fixed top-[50%] left-0 z-10 flex h-(--visual-viewport-height) w-full translate-y-[-50%] items-center justify-center bg-black/30" - > - { - !isOpen && onClose?.(); - }} - className="flex w-full max-w-2xl flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" - > - - {({ close }) => ( -
-
- - - Unrecognized Fields - - - -
-
- The commit will modify files with unknown fields. Committing any changes to them from here may break or - remove functionality for your collaborators. Are you sure you want to continue? -
-
- - -
-
- )} -
-
-
- ); -}; - // Asks the user whether to stage all changes and commit them directly when // nothing is staged (mirrors VSCode, without the "Always"/"Never" options). const ConfirmCommitAllModal = ({ onConfirm, onClose }: Omit) => { diff --git a/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx index d1fc46c94c..5d825e53d5 100644 --- a/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/sync-staging-modal.tsx @@ -1,6 +1,6 @@ import { models } from 'insomnia-data'; import type { StageEntry, Status, StatusCandidate } from 'insomnia-vcs'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Button, Dialog, @@ -18,11 +18,19 @@ import { import { useParams } from 'react-router'; import { stringify } from 'yaml'; +import { checkDocCompatibilityFromSchema, findDocUnknownFieldRanges } from '~/common/zod/check-doc-compatibility'; import { useInsomniaSyncCreateSnapshotActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.create-snapshot'; import { useInsomniaSyncStageActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.stage'; import { useInsomniaSyncUnstageActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.insomnia-sync.unstage'; +import { ConfirmUnknownFieldsCommitModal } from '~/ui/components/modals/confirm-unknown-fields-commit-modal'; +import { ErrorBanner, WarningBanner } from '~/ui/components/warning-banner'; import { DiffEditor } from '../diff-view-editor'; +import { + UnknownFieldsWarningIcon, + UNSUPPORTED_DOC_MESSAGE, + UnsupportedDocErrorIcon, +} from '../git/unknown-fields-warning'; import { Icon } from '../icon'; interface Props { @@ -32,16 +40,24 @@ interface Props { onClose: () => void; } -function getDiff(previewDiffItem?: StageEntry) { +function getPreviousBlobContent(item?: StageEntry) { + return item && 'previousBlobContent' in item ? item.previousBlobContent : undefined; +} + +function getBlobContent(item?: StageEntry) { + return item && 'blobContent' in item ? item.blobContent : undefined; +} + +function getDiff(previousBlobContent?: string, blobContent?: string) { let before = '{}'; let after = '{}'; - if (previewDiffItem && 'previousBlobContent' in previewDiffItem && previewDiffItem.previousBlobContent) { - before = previewDiffItem.previousBlobContent === 'null' ? '' : previewDiffItem.previousBlobContent || '{}'; + if (previousBlobContent) { + before = previousBlobContent === 'null' ? '' : previousBlobContent; } - if (previewDiffItem && 'blobContent' in previewDiffItem && previewDiffItem.blobContent) { - after = previewDiffItem.blobContent === 'null' ? '' : previewDiffItem.blobContent || '{}'; + if (blobContent) { + after = blobContent === 'null' ? '' : blobContent; } try { @@ -56,9 +72,18 @@ function getDiff(previewDiffItem?: StageEntry) { console.warn('Failed to parse after JSON', e); } + // Check the compatibility of the original document against the schema only when there is blob content (not being deleted). + const originDocCompatibility = !!blobContent && checkDocCompatibilityFromSchema(previousBlobContent); + const unknownFieldRanges = + originDocCompatibility === 'match-with-unknown-fields' + ? findDocUnknownFieldRanges(previousBlobContent, before) + : []; + return { before, after, + unknownFieldRanges, + originStatus: originDocCompatibility, }; } @@ -98,6 +123,9 @@ export const SyncStagingModal = ({ onClose, status, syncItems }: Props) => { syncItems.find(item => item.key === key)?.document || 'deleted' in entry ? { type: getModelTypeById(key) } : undefined, + originStatus: getBlobContent(entry) + ? checkDocCompatibilityFromSchema(getPreviousBlobContent(entry)) + : 'strict-match', id: `staged-${key}`, })); const unstagedChanges = Object.entries(status.unstaged).map(([key, entry]) => ({ @@ -106,6 +134,9 @@ export const SyncStagingModal = ({ onClose, status, syncItems }: Props) => { syncItems.find(item => item.key === key)?.document || 'deleted' in entry ? { type: getModelTypeById(key) } : undefined, + originStatus: getBlobContent(entry) + ? checkDocCompatibilityFromSchema(getPreviousBlobContent(entry)) + : 'strict-match', id: `unstaged-${key}`, })); @@ -142,275 +173,332 @@ export const SyncStagingModal = ({ onClose, status, syncItems }: Props) => { }, [allChangesLength, onClose, createSnapshotFetcher.data?.error]); const [selectedItemId, setSelectedItemId] = useState(''); + const [pendingCommit, setPendingCommit] = useState<{ message: string; push: boolean } | null>(null); + + const hasStagedUnknownFields = stagedChanges.some(item => item.originStatus === 'match-with-unknown-fields'); + + const createSnapshot = (message: string, push: boolean) => { + createSnapshotFetcher.submit({ + organizationId, + projectId, + workspaceId, + message, + push, + }); + }; const previewDiffItem = allChanges.find(item => item.id === selectedItemId); const previewDiffItemName = getPreviewItemName(previewDiffItem); - const diff = getDiff(previewDiffItem); + const previewPreviousBlobContent = getPreviousBlobContent(previewDiffItem); + const previewBlobContent = getBlobContent(previewDiffItem); + + // DiffEditor re-creates its models whenever its props change, so keep `diff` (and its ranges) referentially stable. + const diff = useMemo( + () => getDiff(previewPreviousBlobContent, previewBlobContent), + [previewPreviousBlobContent, previewBlobContent], + ); return ( - { - !isOpen && onClose(); - }} - isDismissable - className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" - > - + { !isOpen && onClose(); }} - className="flex h-[calc(100%-var(--padding-xl))] w-[calc(100%-var(--padding-xl))] flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" + isDismissable + className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30" > - - {({ close }) => ( -
-
- - Commit changes - - -
-
-
-
{ - e.preventDefault(); - const submitter = e.nativeEvent instanceof SubmitEvent ? e.nativeEvent.submitter : null; - const formData = new FormData(e.currentTarget, submitter); - const message = formData.get('message')?.toString().trim() || ''; - const push = Boolean(formData.get('push') === 'true'); - if (message) { - createSnapshotFetcher.submit({ - organizationId, - projectId, - workspaceId, - message, - push, - }); - } - }} - className="flex flex-col gap-2" + { + !isOpen && onClose(); + }} + className="flex h-[calc(100%-var(--padding-xl))] w-[calc(100%-var(--padding-xl))] flex-col rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)" + > + + {({ close }) => ( +
+
+ + Commit changes + +