mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Report credentials created with partial package data separately (no-changelog) (#41156)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a32c652c09
commit
deed9a2f32
|
|
@ -54,7 +54,7 @@ describe('ImportResultDto', () => {
|
|||
workflows: { 'wf-1': 'wf-1' },
|
||||
credentials: { 'cred-1': 'cred-1' },
|
||||
},
|
||||
credentials: { matched: ['cred-1'], stubbed: [], seeded: [] },
|
||||
credentials: { matched: ['cred-1'], stubbed: [], seeded: [], incomplete: [] },
|
||||
dataTables: { matched: 1, created: 0, updated: 0 },
|
||||
variables: { matched: [], missing: [], created: [], stubbed: [], updated: [] },
|
||||
tags: { matched: [], created: [], renamed: [], reconciled: [], skipped: [] },
|
||||
|
|
@ -91,7 +91,7 @@ describe('ImportResultDto', () => {
|
|||
folders: [],
|
||||
projects: [],
|
||||
bindings: { workflows: {}, credentials: {} },
|
||||
credentials: { matched: [], stubbed: [], seeded: [] },
|
||||
credentials: { matched: [], stubbed: [], seeded: [], incomplete: [] },
|
||||
dataTables: { matched: 0, created: 0, updated: 0 },
|
||||
variables: { matched: [], missing: [], created: [], stubbed: [], updated: [] },
|
||||
tags: { matched: [], created: [], renamed: [], reconciled: [], skipped: [] },
|
||||
|
|
|
|||
|
|
@ -25,10 +25,11 @@ export const importPackageRequestFieldDocs = {
|
|||
'creates empty credential placeholders in the target project for missing references. ' +
|
||||
'`create-with-values` also creates them, seeded from the expression data bundled in the ' +
|
||||
'package (see the export option `credentialExportPolicy`); a reference the package ' +
|
||||
'carries no data for falls back to an empty stub. Seeded credentials are listed under ' +
|
||||
'`credentials.seeded` and only allow workflow publishing when the package confirms ' +
|
||||
'that all source data was retained. Partial or unverified data blocks publishing ' +
|
||||
'with `stub-credential`. ' +
|
||||
'carries no data for falls back to an empty stub. A credential created with package data ' +
|
||||
'is listed under `credentials.seeded` when the package confirms that all source data ' +
|
||||
'was retained. ' +
|
||||
'Otherwise, also when the package has no completeness flag, it is listed under ' +
|
||||
'`credentials.incomplete` and blocks workflow publishing with `incomplete-credential`. ' +
|
||||
'`must-preexist` requires every referenced credential to already exist.',
|
||||
},
|
||||
bindings: {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const workflowPublishingOutcomeSchema = z.object({
|
|||
state: z.enum(['published', 'unpublished', 'unchanged', 'blocked', 'failed']).openapi({
|
||||
description:
|
||||
'`blocked` means the imported version could not be published and no version is active ' +
|
||||
'(for example because the workflow uses an empty or partially seeded credential, or uses a node type this ' +
|
||||
'(for example because the workflow uses an empty or incomplete credential, or uses a node type this ' +
|
||||
'instance does not have). When a prior published version remains active, `state` is ' +
|
||||
'`unchanged` with `skippedPublishReason` instead. `failed` means publish or unpublish was ' +
|
||||
'attempted but did not succeed.',
|
||||
|
|
@ -25,7 +25,7 @@ const workflowPublishingOutcomeSchema = z.object({
|
|||
'Present when `state` is `failed`. Explains why publish or unpublish could not be applied.',
|
||||
}),
|
||||
blockedReason: z
|
||||
.enum(['stub-credential', 'missing-node-type'])
|
||||
.enum(['stub-credential', 'incomplete-credential', 'missing-node-type'])
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
|
|
@ -33,13 +33,13 @@ const workflowPublishingOutcomeSchema = z.object({
|
|||
'version is active.',
|
||||
}),
|
||||
skippedPublishReason: z
|
||||
.enum(['stub-credential', 'missing-node-type'])
|
||||
.enum(['stub-credential', 'incomplete-credential', 'missing-node-type'])
|
||||
.optional()
|
||||
.openapi({
|
||||
description:
|
||||
'Present when `state` is `unchanged` but the policy wanted to publish the imported ' +
|
||||
'version: a prior published version is still active (for example after an update with ' +
|
||||
'incomplete credentials).',
|
||||
'empty or incomplete credentials).',
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -146,14 +146,21 @@ const importCredentialSummarySchema = z
|
|||
}),
|
||||
seeded: z.array(z.string()).openapi({
|
||||
description:
|
||||
'Source credential ids for which credentials were created with bundled expression values ' +
|
||||
'in the target project. Partial or unverified bundled data still blocks workflow publishing.',
|
||||
'Source credential ids for which credentials were created in the target project with ' +
|
||||
'all their source data from the bundled expression values.',
|
||||
}),
|
||||
incomplete: z.array(z.string()).openapi({
|
||||
description:
|
||||
'Source credential ids for which credentials were created in the target project with ' +
|
||||
'bundled expression values, but the package does not confirm that it carries all source ' +
|
||||
'data. These credentials need input. The import does not publish the imported version ' +
|
||||
'of workflows that use them (`incomplete-credential`).',
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description:
|
||||
'Source credential ids grouped by whether they matched an existing credential or were ' +
|
||||
'created as empty stubs or seeded credentials. Full source→target id mapping is in `bindings.credentials`.',
|
||||
'created as empty stubs, seeded credentials, or incomplete credentials. Full source→target id mapping is in `bindings.credentials`.',
|
||||
});
|
||||
|
||||
const importDataTableSummarySchema = z.object({
|
||||
|
|
@ -239,8 +246,8 @@ const serializedBindingsSchema = z
|
|||
description:
|
||||
'Credential id from `requirements.credentials` in the package manifest → resolved ' +
|
||||
'target credential id on the target instance. Covers both a matched existing ' +
|
||||
'credential and a credential created as a stub under ' +
|
||||
'`credentialMissingMode=create-stub`.',
|
||||
'credential and a credential that the import created under ' +
|
||||
'`credentialMissingMode=create-stub` or `create-with-values`.',
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ n8n-cli package import --file=export.n8np --workflow-conflict-policy=fail --bind
|
|||
| `--folder-conflict-policy` | What to do when a package folder already exists in the target project. **Defaults to whatever `--project-conflict-policy` is**, so you state the intent once; for a workflow package, which defines no projects, it defaults to `merge`. `merge` reuses the existing folder and merges the package's children into it; `fail` rejects the import; `overwrite` additionally removes workflows the package does not contain, at the project root and in package-defined folders (see `--overwrite-deletion-policy`), and is rejected unless `--project-conflict-policy` is also `overwrite`. Folders the package does not define are removed too, but only once nothing is left inside them, so target-only content is never swept up. Requires a folders-enabled license when the package contains folders, and the `workflow:delete` and `folder:delete` scopes for `overwrite`. |
|
||||
| `--overwrite-deletion-policy` | How `--folder-conflict-policy=overwrite` removes a workflow the package does not contain: `archive` (instance default) archives it, keeping it and its execution history recoverable; `hard-delete` archives it — the step that unpublishes it — then deletes the workflow and its executions permanently. Each entry in `removedWorkflows` reports what actually happened in its `deletion` field, so a `hard-delete` whose row cannot be dropped yet (unpublishing defers trigger teardown) shows as `archived` rather than failing the import. Ignored unless the folder conflict policy is `overwrite`. |
|
||||
| `--credential-matching-mode` | How credential references are matched on the target instance: `id-only` (default, match by id), `name-and-type` (match by exact name and type), or `type-only` (match by type). For `name-and-type` and `type-only`, candidates are ranked by scope — owned by the target project, then shared into it, then global — and ties within a scope use the most recently updated credential. |
|
||||
| `--credential-missing-mode` | What to do when a referenced credential cannot be resolved. `create-stub` (instance default) creates empty placeholder credentials in the target project; `create-with-values` also creates them, seeded from the expression data bundled in the package (a reference the package carries no data for falls back to an empty stub); `must-preexist` requires every referenced credential to already exist. |
|
||||
| `--credential-missing-mode` | What to do when a referenced credential cannot be resolved. `create-stub` (instance default) creates empty placeholder credentials in the target project; `create-with-values` also creates them, seeded from the expression data bundled in the package (a reference the package carries no data for falls back to an empty stub). A credential created with package data is listed under `credentials.seeded` when the package confirms that it carries all source data, and under `credentials.incomplete` otherwise. The import does not publish the imported version of a workflow that uses an incomplete credential (`incomplete-credential`). An active earlier version stays active. `must-preexist` requires every referenced credential to already exist. |
|
||||
| `--data-table-matching-mode` | How data tables referenced by the package's workflows are matched on the target instance: `by-id` (default and only mode) matches the target-project table with the same id — imported tables keep their source id — and never falls back to name matching. |
|
||||
| `--data-table-missing-mode` | What to do when a referenced data table is absent in the target project. `create` (instance default) creates it from the package schema — keeping the source id, with no rows; `must-preexist` requires it to already exist; `do-nothing` skips creation. Under the `keep-existing` and `fail` schema conflict policies, matched tables are schema-validated (all package columns present with the same name and type), even under `do-nothing`. Under `overwrite-non-destructive`, a matched table blocks the import only when a change removes or retypes a column. The import never imports table rows. Matched tables change only under `--data-table-schema-conflict-policy=overwrite` or `overwrite-non-destructive`, which keep their rows. |
|
||||
| `--data-table-schema-conflict-policy` | How strictly a matched data table's schema is compared. Under `keep-existing` and `fail`, every package column must exist on the matched target table with the same name and type. A missing column or a type mismatch rejects the import. `keep-existing` (instance default) ignores additional columns the target table has of its own; `fail` is the strict drift-detection choice and rejects those too. `keep-existing` and `fail` never alter the matched target table. `overwrite` changes the matched target table to match the package. It can remove or retype columns. The values in those columns are removed. Rows and other columns are kept. When the package has column ids, a column that is only renamed keeps its values. The changes apply to all workflows that use the table. `overwrite-non-destructive` makes the same changes as `overwrite`, but rejects the import and writes nothing when a change removes or retypes a column. A target-only column counts as removed. |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
WIRE_VERSION_ID,
|
||||
type PackageWorkflow,
|
||||
} from './fixtures/package-fixtures';
|
||||
import { buildWorkflowReferencingCredential } from './utils/test-builders';
|
||||
import { streamToBuffer } from './utils/tar-support';
|
||||
|
||||
type ImportPackageParams = Pick<ImportPackageRequest, 'user' | 'packageBuffer'> &
|
||||
|
|
@ -1862,6 +1863,12 @@ describe('ImportPipeline credential matching modes', () => {
|
|||
});
|
||||
|
||||
expect(result.bindings.credentials).toEqual({ [SOURCE_CRED_ID]: target.id });
|
||||
expect(result.credentials).toEqual({
|
||||
matched: [SOURCE_CRED_ID],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not match when only the name is equal but the type differs', async () => {
|
||||
|
|
@ -2218,7 +2225,12 @@ describe('credential-missing-mode: create-stub', () => {
|
|||
),
|
||||
});
|
||||
|
||||
expect(result.credentials).toEqual({ matched: [], stubbed: ['missing-cred'], seeded: [] });
|
||||
expect(result.credentials).toEqual({
|
||||
matched: [],
|
||||
stubbed: ['missing-cred'],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
});
|
||||
expect(result.bindings.credentials['missing-cred']).toBe('missing-cred');
|
||||
|
||||
const workflow = await Container.get(WorkflowRepository).findOneOrFail({
|
||||
|
|
@ -2386,6 +2398,7 @@ describe('credential-missing-mode: create-with-values', () => {
|
|||
encryptedSettings: '{}',
|
||||
});
|
||||
});
|
||||
afterEach(() => licenseMocker.reset());
|
||||
|
||||
const scheduleTriggerNodes = () => [
|
||||
{
|
||||
|
|
@ -2409,7 +2422,7 @@ describe('credential-missing-mode: create-with-values', () => {
|
|||
},
|
||||
});
|
||||
|
||||
it('creates credentials seeded with the package expression data', async () => {
|
||||
it('lists a credential as incomplete when its package data has no completeness flag', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
const result = await importPackage({
|
||||
|
|
@ -2438,7 +2451,12 @@ describe('credential-missing-mode: create-with-values', () => {
|
|||
),
|
||||
});
|
||||
|
||||
expect(result.credentials).toEqual({ matched: [], stubbed: [], seeded: ['missing-cred'] });
|
||||
expect(result.credentials).toEqual({
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: ['missing-cred'],
|
||||
});
|
||||
|
||||
const targetId = result.bindings.credentials['missing-cred'];
|
||||
const stored = await Container.get(CredentialsRepository).findOneByOrFail({ id: targetId });
|
||||
|
|
@ -2522,8 +2540,12 @@ describe('credential-missing-mode: create-with-values', () => {
|
|||
),
|
||||
});
|
||||
|
||||
expect(result.credentials.seeded).toEqual(['seeded-cred']);
|
||||
expect(result.credentials.stubbed).toEqual(['bare-cred']);
|
||||
expect(result.credentials).toEqual({
|
||||
matched: [],
|
||||
stubbed: ['bare-cred'],
|
||||
seeded: ['seeded-cred'],
|
||||
incomplete: [],
|
||||
});
|
||||
|
||||
const seeded = result.workflows.find(
|
||||
({ sourceWorkflowId }) => sourceWorkflowId === 'wf-seeded',
|
||||
|
|
@ -2535,6 +2557,179 @@ describe('credential-missing-mode: create-with-values', () => {
|
|||
expect(stubbed?.publishing).toEqual({ state: 'blocked', blockedReason: 'stub-credential' });
|
||||
});
|
||||
|
||||
it('blocks publishing a workflow whose credential the import created from partial package data', async () => {
|
||||
licenseMocker.enable('feat:projectRole:admin');
|
||||
licenseMocker.setQuota('quota:maxTeamProjects', 100);
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Partial data', owner);
|
||||
const credential = await saveOwnedCredential(
|
||||
{
|
||||
name: 'Header Auth',
|
||||
type: 'httpHeaderAuth',
|
||||
data: { name: 'X-Auth', value: '={{ $secrets.github.token }}' },
|
||||
},
|
||||
{ project },
|
||||
);
|
||||
await buildWorkflowReferencingCredential({ name: 'Uses header auth', project, credential });
|
||||
const { stream } = await Container.get(N8nPackagesService).exportPackage({
|
||||
user: owner,
|
||||
projectIds: [project.id],
|
||||
});
|
||||
const packageBuffer = await streamToBuffer(stream);
|
||||
|
||||
await testDb.truncate([
|
||||
'Folder',
|
||||
'Project',
|
||||
'ProjectRelation',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
'SharedCredentials',
|
||||
'CredentialsEntity',
|
||||
]);
|
||||
const importer = await createOwner();
|
||||
|
||||
const result = await importPackage({
|
||||
user: importer,
|
||||
packageBuffer,
|
||||
credentialMissingMode: 'create-with-values',
|
||||
workflowPublishingPolicy: WorkflowPublishingPolicy.PublishAll,
|
||||
variableParentPolicy: undefined,
|
||||
});
|
||||
|
||||
expect(result.credentials).toEqual({
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [credential.id],
|
||||
});
|
||||
expect(result.workflows[0]?.publishing).toEqual({
|
||||
state: 'blocked',
|
||||
blockedReason: 'incomplete-credential',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports an empty stub or a missing node type before an incomplete credential', async () => {
|
||||
const owner = await createOwner();
|
||||
const incompleteNode = credentialNode('incomplete-cred', 'Incomplete GitHub');
|
||||
|
||||
const result = await importPackage({
|
||||
user: owner,
|
||||
credentialMissingMode: 'create-with-values',
|
||||
missingNodeTypeMode: 'import-anyway',
|
||||
workflowPublishingPolicy: WorkflowPublishingPolicy.PublishAll,
|
||||
packageBuffer: await buildImportPackageBuffer(
|
||||
[
|
||||
serializedWorkflow({
|
||||
id: 'wf-stub',
|
||||
name: 'Stub',
|
||||
nodes: [
|
||||
...scheduleTriggerNodes(),
|
||||
incompleteNode,
|
||||
{ ...credentialNode('bare-cred', 'Bare GitHub'), id: 'http-node-2', name: 'HTTP 2' },
|
||||
],
|
||||
}),
|
||||
serializedWorkflow({
|
||||
id: 'wf-unknown',
|
||||
name: 'Unknown node type',
|
||||
nodes: [
|
||||
...scheduleTriggerNodes(),
|
||||
incompleteNode,
|
||||
{
|
||||
id: 'unknown-node',
|
||||
name: 'Unknown Node',
|
||||
type: 'n8n-nodes-community.chatBot',
|
||||
typeVersion: 1,
|
||||
position: [600, 0] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
{
|
||||
sourceId,
|
||||
credentials: [
|
||||
{
|
||||
id: 'incomplete-cred',
|
||||
name: 'Incomplete GitHub',
|
||||
type: PACKAGE_GITHUB_CREDENTIAL_TYPE,
|
||||
data: { accessToken: '={{ $secrets.github.token }}' },
|
||||
dataIsComplete: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.credentials).toEqual({
|
||||
matched: [],
|
||||
stubbed: ['bare-cred'],
|
||||
seeded: [],
|
||||
incomplete: ['incomplete-cred'],
|
||||
});
|
||||
const publishingOf = (id: string) =>
|
||||
result.workflows.find(({ sourceWorkflowId }) => sourceWorkflowId === id)?.publishing;
|
||||
expect(publishingOf('wf-stub')).toEqual({ state: 'blocked', blockedReason: 'stub-credential' });
|
||||
expect(publishingOf('wf-unknown')).toEqual({
|
||||
state: 'blocked',
|
||||
blockedReason: 'missing-node-type',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the prior published version active when an incomplete credential blocks republishing an update', async () => {
|
||||
const owner = await createOwner();
|
||||
const personalProject = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(
|
||||
owner.id,
|
||||
);
|
||||
const active = await createActiveWorkflow({ name: 'Published workflow' }, personalProject);
|
||||
await Container.get(WorkflowRepository).update(active.id, {
|
||||
sourceWorkflowId: 'wf-incomplete-update',
|
||||
});
|
||||
const originalActiveVersionId = active.activeVersionId;
|
||||
|
||||
const result = await importPackage({
|
||||
user: owner,
|
||||
credentialMissingMode: 'create-with-values',
|
||||
workflowConflictPolicy: WorkflowConflictPolicy.NewVersion,
|
||||
workflowPublishingPolicy: WorkflowPublishingPolicy.PreservePublishedState,
|
||||
packageBuffer: await buildImportPackageBuffer(
|
||||
[
|
||||
{
|
||||
...serializedWorkflowWithCredential({
|
||||
id: 'wf-incomplete-update',
|
||||
name: 'Published workflow updated',
|
||||
credentialId: 'incomplete-cred',
|
||||
credentialName: 'Incomplete GitHub',
|
||||
}),
|
||||
publishedVersionId: WIRE_VERSION_ID,
|
||||
},
|
||||
],
|
||||
{
|
||||
sourceId,
|
||||
credentials: [
|
||||
{
|
||||
id: 'incomplete-cred',
|
||||
name: 'Incomplete GitHub',
|
||||
type: PACKAGE_GITHUB_CREDENTIAL_TYPE,
|
||||
data: { accessToken: '={{ $secrets.github.token }}' },
|
||||
dataIsComplete: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const summary = result.workflows.find(
|
||||
({ sourceWorkflowId }) => sourceWorkflowId === 'wf-incomplete-update',
|
||||
);
|
||||
|
||||
expect(summary?.status).toBe('updated');
|
||||
expect(summary?.activeVersionId).toBe(originalActiveVersionId);
|
||||
expect(summary?.publishing).toEqual({
|
||||
state: 'unchanged',
|
||||
skippedPublishReason: 'incomplete-credential',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a package whose bundled credential data carries literal values', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ describe('emitPackageImportedEvent', () => {
|
|||
matched: ['credA'],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
requirements: [requirement('credA')],
|
||||
dataTable: { matched: 1, created: 0, updated: 1, requirements: 2 },
|
||||
|
|
@ -226,12 +227,14 @@ describe('emitPackageImportedEvent', () => {
|
|||
bindings: new Map([
|
||||
['credB', 'stub-b'],
|
||||
['credC', 'seed-c'],
|
||||
['credD', 'incomplete-d'],
|
||||
]),
|
||||
matched: [],
|
||||
stubbed: ['credB'],
|
||||
seeded: ['credC'],
|
||||
incomplete: ['credD'],
|
||||
},
|
||||
requirements: [requirement('credB'), requirement('credC')],
|
||||
requirements: [requirement('credB'), requirement('credC'), requirement('credD')],
|
||||
dataTable: { matched: 0, created: 2, requirements: 2 },
|
||||
variables: {
|
||||
matched: 0,
|
||||
|
|
@ -260,16 +263,15 @@ describe('emitPackageImportedEvent', () => {
|
|||
// A multi-scope import has no single folder to attribute the event to.
|
||||
expect(payload.folderId).toBeNull();
|
||||
// Credential ids are resolved through each scope's binding map (source id -> target id).
|
||||
// Seeded credentials count as created alongside stubs.
|
||||
expect(payload.credentialIds).toEqual({
|
||||
matched: ['target-a'],
|
||||
created: ['stub-b', 'seed-c'],
|
||||
created: ['stub-b', 'seed-c', 'incomplete-d'],
|
||||
updated: [],
|
||||
});
|
||||
expect(payload.counts).toEqual({
|
||||
workflows: { created: 1, updated: 1, skipped: 1, archived: 0, deleted: 0 },
|
||||
folders: { removed: 0 },
|
||||
credentials: { matched: 1, created: 2, requirements: 3 },
|
||||
credentials: { matched: 1, created: 3, requirements: 4 },
|
||||
dataTables: { matched: 1, created: 2, updated: 1, requirements: 4 },
|
||||
// scope 2's two missing requirements were created, so post-apply missing is 0; its
|
||||
// overwritten name matched first but is counted as updated, not matched.
|
||||
|
|
@ -296,7 +298,13 @@ describe('emitPackageImportedEvent', () => {
|
|||
scope({
|
||||
projectId: 'P1',
|
||||
outcomes: [outcome('wf1', 'WF1', 'created')],
|
||||
credentialResult: { bindings: new Map(), matched: [], stubbed: [], seeded: [] },
|
||||
credentialResult: {
|
||||
bindings: new Map(),
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
// Of three missing requirements, one was created with a value and one already existed.
|
||||
variables: { matched: 0, missing: 3, requirements: 3, createdWithValue: 1, existing: 1 },
|
||||
}),
|
||||
|
|
@ -327,13 +335,25 @@ describe('emitPackageImportedEvent', () => {
|
|||
scope({
|
||||
projectId: 'P1',
|
||||
outcomes: [outcome('wf1', 'WF1', 'created')],
|
||||
credentialResult: { bindings: new Map(), matched: [], stubbed: [], seeded: [] },
|
||||
credentialResult: {
|
||||
bindings: new Map(),
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
variables: { matched: 0, missing: 1, requirements: 1, createdWithValue: 1 },
|
||||
}),
|
||||
scope({
|
||||
projectId: 'P2',
|
||||
outcomes: [outcome('wf2', 'WF2', 'created')],
|
||||
credentialResult: { bindings: new Map(), matched: [], stubbed: [], seeded: [] },
|
||||
credentialResult: {
|
||||
bindings: new Map(),
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
variables: { matched: 0, missing: 1, requirements: 1, existing: 1 },
|
||||
}),
|
||||
],
|
||||
|
|
@ -371,7 +391,13 @@ describe('emitPackageImportedEvent', () => {
|
|||
removedFolders: [
|
||||
{ folderId: 'F-stale', name: 'stale', projectId: 'P1', parentFolderId: null },
|
||||
],
|
||||
credentialResult: { bindings: new Map(), matched: [], stubbed: [], seeded: [] },
|
||||
credentialResult: {
|
||||
bindings: new Map(),
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
@ -400,7 +426,13 @@ describe('emitPackageImportedEvent', () => {
|
|||
projectId: 'P1',
|
||||
folderId: 'F1',
|
||||
outcomes: [outcome('wf1', 'WF1', 'created')],
|
||||
credentialResult: { bindings: new Map(), matched: [], stubbed: [], seeded: [] },
|
||||
credentialResult: {
|
||||
bindings: new Map(),
|
||||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -406,22 +406,21 @@ export class ImportOrchestrator {
|
|||
await this.dataTableImporter.apply(context, dataTablePlan);
|
||||
|
||||
// Which workflows the publish phase must leave inactive. Known only now, because it depends
|
||||
// on which credentials actually ended up stubbed.
|
||||
// on which credentials actually ended up stubbed or incomplete.
|
||||
const blockedFromPublish = new Map<string, WorkflowPublishingBlockedReason>();
|
||||
// Weakest reason first, so a later, stronger reason overwrites it.
|
||||
for (const sourceWorkflowId of workflowsBlockedFromPublish(
|
||||
credentialRequest.requirements,
|
||||
new Set([
|
||||
...credentialResult.stubbed,
|
||||
...credentialResult.seeded.filter(
|
||||
(id) =>
|
||||
!credentialRequest.requirements?.find((requirement) => requirement.id === id)
|
||||
?.packageDataIsComplete,
|
||||
),
|
||||
]),
|
||||
new Set(credentialResult.incomplete),
|
||||
)) {
|
||||
blockedFromPublish.set(sourceWorkflowId, 'incomplete-credential');
|
||||
}
|
||||
for (const sourceWorkflowId of workflowsBlockedFromPublish(
|
||||
credentialRequest.requirements,
|
||||
new Set(credentialResult.stubbed),
|
||||
)) {
|
||||
blockedFromPublish.set(sourceWorkflowId, 'stub-credential');
|
||||
}
|
||||
// A workflow blocked for both reasons reports missing-node-type: it physically can't run.
|
||||
for (const sourceWorkflowId of workflowsWithMissingNodeTypes(plan.missingNodeTypes)) {
|
||||
blockedFromPublish.set(sourceWorkflowId, 'missing-node-type');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,9 @@ export function emitPackageImportedEvent(
|
|||
const matchedCredentialIds = credentialResults.flatMap(({ matched, bindings }) =>
|
||||
matched.map((sourceId) => bindings.get(sourceId)!),
|
||||
);
|
||||
const createdCredentialIds = credentialResults.flatMap(({ stubbed, seeded, bindings }) =>
|
||||
[...stubbed, ...seeded].map((sourceId) => bindings.get(sourceId)!),
|
||||
const createdCredentialIds = credentialResults.flatMap(
|
||||
({ stubbed, seeded, incomplete, bindings }) =>
|
||||
[...stubbed, ...seeded, ...incomplete].map((sourceId) => bindings.get(sourceId)!),
|
||||
);
|
||||
|
||||
const dataTablePlans = scopes.map(({ imported }) => imported.dataTablePlan);
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@ export class ProjectPackageImporter {
|
|||
let dataTablesCreated = 0;
|
||||
let dataTablesUpdated = 0;
|
||||
const seeded: string[] = [];
|
||||
const incomplete: string[] = [];
|
||||
const variablesMatched: string[] = [];
|
||||
const variablesMissing: string[] = [];
|
||||
const variablesCreated: string[] = [];
|
||||
|
|
@ -249,6 +250,7 @@ export class ProjectPackageImporter {
|
|||
dataTablesCreated += content.dataTablePlan.creations.length;
|
||||
dataTablesUpdated += content.dataTablePlan.updates.length;
|
||||
seeded.push(...content.credentialResult.seeded);
|
||||
incomplete.push(...content.credentialResult.incomplete);
|
||||
variablesMatched.push(...content.variablePlan.matched);
|
||||
variablesMissing.push(...content.variablePlan.missing.map(({ name }) => name));
|
||||
variablesCreated.push(...content.variableResult.created);
|
||||
|
|
@ -274,7 +276,7 @@ export class ProjectPackageImporter {
|
|||
folders,
|
||||
projects: projectSummaries,
|
||||
bindings: mergeBindings(...scopedBindings),
|
||||
credentials: { matched, stubbed, seeded },
|
||||
credentials: { matched, stubbed, seeded, incomplete },
|
||||
dataTables: {
|
||||
matched: dataTablesMatched,
|
||||
created: dataTablesCreated,
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export class WorkflowPackageImporter {
|
|||
matched: content.credentialResult.matched,
|
||||
stubbed: content.credentialResult.stubbed,
|
||||
seeded: content.credentialResult.seeded,
|
||||
incomplete: content.credentialResult.incomplete,
|
||||
},
|
||||
dataTables: {
|
||||
matched: content.dataTablePlan.matchedCount,
|
||||
|
|
|
|||
|
|
@ -256,6 +256,7 @@ describe('CredentialImporter', () => {
|
|||
matched: [],
|
||||
stubbed: ['missing-cred'],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -277,6 +278,7 @@ describe('CredentialImporter', () => {
|
|||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -367,6 +369,7 @@ describe('CredentialImporter', () => {
|
|||
matched: ['cred-source'],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -631,6 +634,7 @@ describe('CredentialImporter', () => {
|
|||
const missingCredential = packageCredential({
|
||||
id: 'missing-cred',
|
||||
packageData: { token: '={{ $secrets.github.token }}' },
|
||||
packageDataIsComplete: true,
|
||||
});
|
||||
|
||||
const result = await importer.apply(
|
||||
|
|
@ -657,6 +661,7 @@ describe('CredentialImporter', () => {
|
|||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: ['missing-cred'],
|
||||
incomplete: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -716,6 +721,7 @@ describe('CredentialImporter', () => {
|
|||
);
|
||||
expect(result.stubbed).toEqual(['missing-cred']);
|
||||
expect(result.seeded).toEqual([]);
|
||||
expect(result.incomplete).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ export class CredentialImporter {
|
|||
const matched = [...resolution.successes.keys()];
|
||||
const stubbed: string[] = [];
|
||||
const seeded: string[] = [];
|
||||
const incomplete: string[] = [];
|
||||
|
||||
const packageData = credentialMissingModeUsesPackageData(request.missingMode)
|
||||
? packageDataBySourceId(request.requirements)
|
||||
|
|
@ -210,10 +211,17 @@ export class CredentialImporter {
|
|||
);
|
||||
|
||||
bindings.set(sourceId, stubCredential.id);
|
||||
(data !== undefined ? seeded : stubbed).push(sourceId);
|
||||
if (data === undefined) {
|
||||
stubbed.push(sourceId);
|
||||
} else {
|
||||
(request.requirements?.find(({ id }) => id === sourceId)?.packageDataIsComplete
|
||||
? seeded
|
||||
: incomplete
|
||||
).push(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
return { bindings, matched, stubbed, seeded };
|
||||
return { bindings, matched, stubbed, seeded, incomplete };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,15 +57,15 @@ export function credentialMissingModeUsesPackageData(mode: CredentialMissingMode
|
|||
return mode === 'create-with-values';
|
||||
}
|
||||
|
||||
/** Package workflow ids that should not be published because they use stubbed credentials. */
|
||||
/** Package workflow ids that should not be published because they use one of the given credentials. */
|
||||
export function workflowsBlockedFromPublish(
|
||||
requirements: PackageCredentialRequirement[] | undefined,
|
||||
stubbedSourceIds: ReadonlySet<string>,
|
||||
sourceIds: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const blocked = new Set<string>();
|
||||
|
||||
for (const requirement of requirements ?? []) {
|
||||
if (!stubbedSourceIds.has(requirement.id)) continue;
|
||||
if (!sourceIds.has(requirement.id)) continue;
|
||||
|
||||
for (const sourceWorkflowId of getWorkflowConsumerIds(requirement)) {
|
||||
blocked.add(sourceWorkflowId);
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export interface CredentialApplyResult {
|
|||
matched: string[];
|
||||
stubbed: string[];
|
||||
seeded: string[];
|
||||
incomplete: string[];
|
||||
}
|
||||
|
||||
export interface CredentialBindingRequest {
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export type PersistedWorkflowOutcome =
|
|||
item: PersistedWorkflowPlanItem;
|
||||
/**
|
||||
* Why this workflow must stay inactive even if the policy wants it published — it depends on
|
||||
* a credential that was stubbed, or a node type this instance does not have.
|
||||
* a credential that was stubbed or created incomplete, or a node type this instance does not have.
|
||||
*/
|
||||
blockedFromPublish?: WorkflowPublishingBlockedReason;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ export type WorkflowPublishingOutcomeState =
|
|||
| 'blocked'
|
||||
| 'failed';
|
||||
|
||||
export type WorkflowPublishingBlockedReason = 'stub-credential' | 'missing-node-type';
|
||||
export type WorkflowPublishingBlockedReason =
|
||||
| 'stub-credential'
|
||||
| 'incomplete-credential'
|
||||
| 'missing-node-type';
|
||||
|
||||
/** Result of applying a publishing policy to one imported workflow. */
|
||||
export interface WorkflowPublishingOutcome {
|
||||
|
|
|
|||
|
|
@ -736,7 +736,7 @@ function emptyImportResult(manifest: PackageManifest): ImportResult {
|
|||
folders: [],
|
||||
projects: [],
|
||||
bindings: createBindings(),
|
||||
credentials: { matched: [], stubbed: [], seeded: [] },
|
||||
credentials: { matched: [], stubbed: [], seeded: [], incomplete: [] },
|
||||
dataTables: { matched: 0, created: 0, updated: 0 },
|
||||
variables: { matched: [], created: [], stubbed: [], updated: [], missing: [] },
|
||||
tags: { matched: [], created: [], renamed: [], reconciled: [], skipped: [] },
|
||||
|
|
|
|||
|
|
@ -717,8 +717,10 @@ export interface ImportPackageSummary {
|
|||
export interface ImportCredentialSummary {
|
||||
matched: string[];
|
||||
stubbed: string[];
|
||||
/** Source ids of credentials created and seeded from the package's bundled expression data. */
|
||||
/** Source ids of credentials created with all their source data from the package. */
|
||||
seeded: string[];
|
||||
/** Source ids of credentials created from package data that is not confirmed as complete. */
|
||||
incomplete: string[];
|
||||
}
|
||||
|
||||
export interface ImportVariableSummary {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ requestBody:
|
|||
- create-stub
|
||||
- create-with-values
|
||||
default: create-stub
|
||||
description: What to do when a credential reference cannot be resolved. `create-stub` (default) creates empty credential placeholders in the target project for missing references. `create-with-values` also creates them, seeded from the expression data bundled in the package (see the export option `credentialExportPolicy`); a reference the package carries no data for falls back to an empty stub. Seeded credentials are listed under `credentials.seeded` and only allow workflow publishing when the package confirms that all source data was retained. Partial or unverified data blocks publishing with `stub-credential`. `must-preexist` requires every referenced credential to already exist.
|
||||
description: What to do when a credential reference cannot be resolved. `create-stub` (default) creates empty credential placeholders in the target project for missing references. `create-with-values` also creates them, seeded from the expression data bundled in the package (see the export option `credentialExportPolicy`); a reference the package carries no data for falls back to an empty stub. A credential created with package data is listed under `credentials.seeded` when the package confirms that all source data was retained. Otherwise, also when the package has no completeness flag, it is listed under `credentials.incomplete` and blocks workflow publishing with `incomplete-credential`. `must-preexist` requires every referenced credential to already exist.
|
||||
bindings:
|
||||
type: string
|
||||
description: 'Optional JSON object of explicit source→target id bindings, keyed by entity type. Only `credentials` is supported today: send `{"credentials":{"<packageCredentialId>":"<targetCredentialId>"}}` to map credential ids from the package to credential ids on the target instance. These explicit bindings are validated on type and applied before `credentialMatchingMode` resolution runs.'
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ properties:
|
|||
- unchanged
|
||||
- blocked
|
||||
- failed
|
||||
description: '`blocked` means the imported version could not be published and no version is active (for example because the workflow uses an empty or partially seeded credential, or uses a node type this instance does not have). When a prior published version remains active, `state` is `unchanged` with `skippedPublishReason` instead. `failed` means publish or unpublish was attempted but did not succeed.'
|
||||
description: '`blocked` means the imported version could not be published and no version is active (for example because the workflow uses an empty or incomplete credential, or uses a node type this instance does not have). When a prior published version remains active, `state` is `unchanged` with `skippedPublishReason` instead. `failed` means publish or unpublish was attempted but did not succeed.'
|
||||
error:
|
||||
type: string
|
||||
description: Present when `state` is `failed`. Explains why publish or unpublish could not be applied.
|
||||
|
|
@ -58,14 +58,16 @@ properties:
|
|||
type: string
|
||||
enum:
|
||||
- stub-credential
|
||||
- incomplete-credential
|
||||
- missing-node-type
|
||||
description: 'Present when `state` is `blocked`: the imported version could not be published and no version is active.'
|
||||
skippedPublishReason:
|
||||
type: string
|
||||
enum:
|
||||
- stub-credential
|
||||
- incomplete-credential
|
||||
- missing-node-type
|
||||
description: 'Present when `state` is `unchanged` but the policy wanted to publish the imported version: a prior published version is still active (for example after an update with incomplete credentials).'
|
||||
description: 'Present when `state` is `unchanged` but the policy wanted to publish the imported version: a prior published version is still active (for example after an update with empty or incomplete credentials).'
|
||||
required:
|
||||
- state
|
||||
description: Outcome of applying the selected publishing policy to this workflow.
|
||||
|
|
@ -205,7 +207,7 @@ properties:
|
|||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Credential id from `requirements.credentials` in the package manifest → resolved target credential id on the target instance. Covers both a matched existing credential and a credential created as a stub under `credentialMissingMode=create-stub`.
|
||||
description: Credential id from `requirements.credentials` in the package manifest → resolved target credential id on the target instance. Covers both a matched existing credential and a credential that the import created under `credentialMissingMode=create-stub` or `create-with-values`.
|
||||
required:
|
||||
- workflows
|
||||
- credentials
|
||||
|
|
@ -227,12 +229,18 @@ properties:
|
|||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Source credential ids for which credentials were created with bundled expression values in the target project. Partial or unverified bundled data still blocks workflow publishing.
|
||||
description: Source credential ids for which credentials were created in the target project with all their source data from the bundled expression values.
|
||||
incomplete:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Source credential ids for which credentials were created in the target project with bundled expression values, but the package does not confirm that it carries all source data. These credentials need input. The import does not publish the imported version of workflows that use them (`incomplete-credential`).
|
||||
required:
|
||||
- matched
|
||||
- stubbed
|
||||
- seeded
|
||||
description: Source credential ids grouped by whether they matched an existing credential or were created as empty stubs or seeded credentials. Full source→target id mapping is in `bindings.credentials`.
|
||||
- incomplete
|
||||
description: Source credential ids grouped by whether they matched an existing credential or were created as empty stubs, seeded credentials, or incomplete credentials. Full source→target id mapping is in `bindings.credentials`.
|
||||
dataTables:
|
||||
type: object
|
||||
properties:
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ describe('POST /n8n-packages/import', () => {
|
|||
matched: [],
|
||||
stubbed: [],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
},
|
||||
dataTables: {
|
||||
matched: 0,
|
||||
|
|
@ -530,6 +531,7 @@ describe('POST /n8n-packages/import', () => {
|
|||
matched: [],
|
||||
stubbed: ['missing-credential'],
|
||||
seeded: [],
|
||||
incomplete: [],
|
||||
});
|
||||
expect(response.body.bindings.credentials).toEqual({
|
||||
'missing-credential': expect.any(String),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user