mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
fix(ql3): bind evolved reconciliation target
This commit is contained in:
+18
-5
@@ -223,12 +223,25 @@ function normalizeOptions(
|
||||
fail('authentication or Secret material must be below deploymentRoot');
|
||||
}
|
||||
}
|
||||
const targetRelative = path.relative(
|
||||
normalized.deploymentRoot,
|
||||
normalized.targetDatabasePath,
|
||||
);
|
||||
if (
|
||||
roots.some(
|
||||
(root) =>
|
||||
overlaps(root, normalized.targetDatabasePath) ||
|
||||
overlaps(normalized.targetDatabasePath, root),
|
||||
)
|
||||
!targetRelative ||
|
||||
targetRelative.startsWith('..') ||
|
||||
path.isAbsolute(targetRelative)
|
||||
) {
|
||||
fail('targetDatabasePath must be below deploymentRoot');
|
||||
}
|
||||
if (
|
||||
roots
|
||||
.slice(1)
|
||||
.some(
|
||||
(root) =>
|
||||
overlaps(root, normalized.targetDatabasePath) ||
|
||||
overlaps(normalized.targetDatabasePath, root),
|
||||
)
|
||||
) {
|
||||
fail('targetDatabasePath overlaps an authority root');
|
||||
}
|
||||
|
||||
+23
-3
@@ -338,6 +338,14 @@ export async function applyLocalReconciliationSecretConfig(
|
||||
) {
|
||||
fail('apply command is detached from a ready signed decision');
|
||||
}
|
||||
const targetSnapshotSha256 = terminal.context.planHeader.targetSnapshotSha256;
|
||||
if (terminal.receipt.applyBindingCount > 0 && targetSnapshotSha256 === null) {
|
||||
fail('active binding plan is missing evolved target authority');
|
||||
}
|
||||
const stoppedProofOptions =
|
||||
targetSnapshotSha256 === null
|
||||
? {}
|
||||
: { expectedEvolvedTargetSha256: targetSnapshotSha256 };
|
||||
const planTerminal = terminal.context.planTerminal;
|
||||
const capture = readLocalReconciliationCaptureIntent(
|
||||
planTerminal.intent.command.options.captureRoot,
|
||||
@@ -375,7 +383,11 @@ export async function applyLocalReconciliationSecretConfig(
|
||||
fail('apply lost reviewed head compare-and-swap');
|
||||
}
|
||||
discardUnpreparedLocalReconciliationSecretConfigMaterials(selected);
|
||||
const before = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||
const before = proveLocalReconciliationStoppedState(
|
||||
capture.command,
|
||||
uid,
|
||||
stoppedProofOptions,
|
||||
);
|
||||
const materials: Readonly<PreparedReconciliationSecretConfigMaterial>[] =
|
||||
[];
|
||||
const openRequirements =
|
||||
@@ -441,7 +453,11 @@ export async function applyLocalReconciliationSecretConfig(
|
||||
? {}
|
||||
: { busyTimeoutMs: command.options.busyTimeoutMs }),
|
||||
});
|
||||
const after = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||
const after = proveLocalReconciliationStoppedState(
|
||||
capture.command,
|
||||
uid,
|
||||
stoppedProofOptions,
|
||||
);
|
||||
if (after.proofDigest !== before.proofDigest) {
|
||||
fail('stopped target drifted across preparation');
|
||||
}
|
||||
@@ -522,7 +538,11 @@ export async function applyLocalReconciliationSecretConfig(
|
||||
fail('apply lost prepared head compare-and-swap');
|
||||
}
|
||||
if (!recoveringPreparedIntent) {
|
||||
const stopped = proveLocalReconciliationStoppedState(capture.command, uid);
|
||||
const stopped = proveLocalReconciliationStoppedState(
|
||||
capture.command,
|
||||
uid,
|
||||
stoppedProofOptions,
|
||||
);
|
||||
if (stopped.proofDigest !== intent.stoppedProofDigest) {
|
||||
fail('stopped proof drifted before write');
|
||||
}
|
||||
|
||||
+3
@@ -614,6 +614,7 @@ function publishPlan(
|
||||
authority: Readonly<SecretConfigReviewAuthority>,
|
||||
dependencies: LocalReconciliationSecretConfigPlanDependencies,
|
||||
uid: number,
|
||||
targetSnapshotSha256: string | null,
|
||||
automationTarget?: DatabaseSync,
|
||||
): Readonly<LocalReconciliationSecretConfigPlanReceipt> {
|
||||
let descriptor: number | undefined;
|
||||
@@ -649,6 +650,7 @@ function publishPlan(
|
||||
projectId: command.request.projectId,
|
||||
tableDisposition: authority.tableDisposition,
|
||||
unadaptedLegacyConfigCount: authority.unadaptedLegacyConfigCount,
|
||||
targetSnapshotSha256,
|
||||
preparedHeadDigest: head.headDigest,
|
||||
preparedAtMs: command.request.preparedAtMs,
|
||||
});
|
||||
@@ -987,6 +989,7 @@ export async function planLocalReconciliationSecretConfig(
|
||||
authority,
|
||||
dependencies,
|
||||
identity.uid,
|
||||
automationTarget?.snapshotSha256 ?? null,
|
||||
target,
|
||||
);
|
||||
const receipt =
|
||||
|
||||
+4
@@ -226,6 +226,7 @@ function header(
|
||||
'schemaVersion',
|
||||
'secretConfigId',
|
||||
'tableDisposition',
|
||||
'targetSnapshotSha256',
|
||||
'unadaptedLegacyConfigCount',
|
||||
],
|
||||
'header',
|
||||
@@ -244,6 +245,9 @@ function header(
|
||||
record.projectId.length < 1 ||
|
||||
(record.tableDisposition !== 'absent' &&
|
||||
record.tableDisposition !== 'manual_external') ||
|
||||
(record.targetSnapshotSha256 !== null &&
|
||||
(typeof record.targetSnapshotSha256 !== 'string' ||
|
||||
!DIGEST_PATTERN.test(record.targetSnapshotSha256))) ||
|
||||
!Number.isSafeInteger(record.unadaptedLegacyConfigCount) ||
|
||||
(record.unadaptedLegacyConfigCount as number) < 0 ||
|
||||
![
|
||||
|
||||
+4
-12
@@ -54,6 +54,7 @@ export interface LocalReconciliationSecretConfigPlanHeader {
|
||||
readonly projectId: string;
|
||||
readonly tableDisposition: 'absent' | 'manual_external';
|
||||
readonly unadaptedLegacyConfigCount: number;
|
||||
readonly targetSnapshotSha256: string | null;
|
||||
readonly preparedHeadDigest: string;
|
||||
readonly preparedAtMs: number;
|
||||
readonly headerDigest: string;
|
||||
@@ -350,11 +351,7 @@ function targetAutomationAdoptionProjection(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
),
|
||||
planDigest: adoptionText(row, 'planDigest', DIGEST_PATTERN),
|
||||
inventoryDigest: adoptionText(
|
||||
row,
|
||||
'inventoryDigest',
|
||||
DIGEST_PATTERN,
|
||||
),
|
||||
inventoryDigest: adoptionText(row, 'inventoryDigest', DIGEST_PATTERN),
|
||||
decisionDigest: adoptionText(row, 'decisionDigest', DIGEST_PATTERN),
|
||||
receiptDigest: adoptionText(row, 'receiptDigest', DIGEST_PATTERN),
|
||||
authorizationFileDigest: adoptionText(
|
||||
@@ -372,11 +369,7 @@ function targetAutomationAdoptionProjection(
|
||||
adoptedTriggerCount: selectedAdoptedTriggerCount,
|
||||
skippedCount,
|
||||
auditEventId: adoptionText(row, 'auditEventId', UUID_V4_PATTERN),
|
||||
createdAtMs: adoptionCount(
|
||||
row,
|
||||
'createdAtMs',
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
),
|
||||
createdAtMs: adoptionCount(row, 'createdAtMs', Number.MAX_SAFE_INTEGER),
|
||||
});
|
||||
if (payload.auditEventId !== payload.mutationId) {
|
||||
fail('target Automation adoption audit binding drifted');
|
||||
@@ -1048,8 +1041,7 @@ export function buildLocalReconciliationSecretConfigPlanReceipt(
|
||||
adoptedLegacyTriggerCount: footer.adoptedLegacyTriggerCount,
|
||||
adoptionProvenanceTaskCount: footer.adoptionProvenanceTaskCount,
|
||||
adoptionProvenanceTriggerCount: footer.adoptionProvenanceTriggerCount,
|
||||
automationAdoptionProvenanceState:
|
||||
footer.automationAdoptionProvenanceState,
|
||||
automationAdoptionProvenanceState: footer.automationAdoptionProvenanceState,
|
||||
unadaptedLegacyConfigCount: footer.unadaptedLegacyConfigCount,
|
||||
outcome: footer.outcome,
|
||||
preparedAtMs: header.preparedAtMs,
|
||||
|
||||
@@ -342,14 +342,25 @@ function normalizeOptions(
|
||||
}
|
||||
if (
|
||||
secretConfig !== null &&
|
||||
roots.some(
|
||||
(root) =>
|
||||
overlaps(root, secretConfig.targetDatabasePath) ||
|
||||
overlaps(secretConfig.targetDatabasePath, root),
|
||||
)
|
||||
roots
|
||||
.slice(1)
|
||||
.some(
|
||||
(root) =>
|
||||
overlaps(root, secretConfig.targetDatabasePath) ||
|
||||
overlaps(secretConfig.targetDatabasePath, root),
|
||||
)
|
||||
) {
|
||||
fail('targetDatabasePath overlaps an authority root');
|
||||
}
|
||||
if (secretConfig !== null) {
|
||||
const relative = path.relative(
|
||||
normalized.deploymentRoot,
|
||||
secretConfig.targetDatabasePath,
|
||||
);
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
fail('Secret/Config targetDatabasePath must be below deploymentRoot');
|
||||
}
|
||||
}
|
||||
if (
|
||||
automation !== null &&
|
||||
secretConfig !== null &&
|
||||
|
||||
@@ -28,9 +28,14 @@ const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
||||
export interface LocalReconciliationStoppedProof {
|
||||
readonly stoppedRecordDigest: string;
|
||||
readonly reconciliationEvidenceDigest: string;
|
||||
readonly evolvedTargetSha256?: string;
|
||||
readonly proofDigest: string;
|
||||
}
|
||||
|
||||
export interface LocalReconciliationStoppedProofOptions {
|
||||
readonly expectedEvolvedTargetSha256?: string;
|
||||
}
|
||||
|
||||
function configurationError(message: string): never {
|
||||
throw new LocalDeploymentConfigurationError(message);
|
||||
}
|
||||
@@ -63,6 +68,61 @@ function exact(
|
||||
}
|
||||
}
|
||||
|
||||
function sameFileStat(left: fs.BigIntStats, right: fs.BigIntStats): boolean {
|
||||
return (
|
||||
left.dev === right.dev &&
|
||||
left.ino === right.ino &&
|
||||
left.mode === right.mode &&
|
||||
left.nlink === right.nlink &&
|
||||
left.uid === right.uid &&
|
||||
left.size === right.size &&
|
||||
left.mtimeNs === right.mtimeNs &&
|
||||
left.ctimeNs === right.ctimeNs
|
||||
);
|
||||
}
|
||||
|
||||
function stableTargetSha256(filePath: string, uid: number): string {
|
||||
let descriptor: number | undefined;
|
||||
const buffer = Buffer.allocUnsafe(64 * 1024);
|
||||
try {
|
||||
const pathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
const before = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (
|
||||
!pathStat.isFile() ||
|
||||
pathStat.isSymbolicLink() ||
|
||||
!sameFileStat(pathStat, before) ||
|
||||
before.uid !== BigInt(uid) ||
|
||||
before.nlink !== 1n ||
|
||||
(before.mode & 0o077n) !== 0n ||
|
||||
fs.realpathSync(filePath) !== filePath ||
|
||||
before.size < 1n
|
||||
) {
|
||||
configurationError('evolved target database identity is invalid');
|
||||
}
|
||||
const hash = crypto.createHash('sha256');
|
||||
for (;;) {
|
||||
const count = fs.readSync(descriptor, buffer, 0, buffer.byteLength, null);
|
||||
if (count === 0) break;
|
||||
hash.update(buffer.subarray(0, count));
|
||||
}
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (!sameFileStat(before, after)) {
|
||||
configurationError('evolved target database changed while hashing');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
} catch (error) {
|
||||
if (error instanceof LocalDeploymentConfigurationError) throw error;
|
||||
return configurationError('evolved target database is unavailable');
|
||||
} finally {
|
||||
buffer.fill(0);
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function serviceManagerStoppedPath(
|
||||
command: Readonly<LocalReconciliationCapturePrepareCommand>,
|
||||
): string {
|
||||
@@ -177,7 +237,15 @@ function serviceManagerStoppedRecord(
|
||||
export function proveLocalReconciliationStoppedState(
|
||||
command: Readonly<LocalReconciliationCapturePrepareCommand>,
|
||||
uid: number,
|
||||
options: Readonly<LocalReconciliationStoppedProofOptions> = {},
|
||||
): Readonly<LocalReconciliationStoppedProof> {
|
||||
const expectedEvolvedTargetSha256 = options.expectedEvolvedTargetSha256;
|
||||
if (
|
||||
expectedEvolvedTargetSha256 !== undefined &&
|
||||
!DIGEST_PATTERN.test(expectedEvolvedTargetSha256)
|
||||
) {
|
||||
configurationError('evolved target snapshot digest is invalid');
|
||||
}
|
||||
const persisted =
|
||||
command.request.stoppedAuthority === 'docker'
|
||||
? dockerStoppedEvidence(command)
|
||||
@@ -198,11 +266,42 @@ export function proveLocalReconciliationStoppedState(
|
||||
},
|
||||
uid,
|
||||
);
|
||||
if (
|
||||
current.disposition !== 'reconciliation_required' ||
|
||||
(persisted !== undefined &&
|
||||
persisted.evidenceDigest !== current.evidenceDigest)
|
||||
) {
|
||||
const exactStoppedData =
|
||||
expectedEvolvedTargetSha256 === undefined &&
|
||||
current.disposition === 'reconciliation_required' &&
|
||||
(persisted === undefined ||
|
||||
persisted.evidenceDigest === current.evidenceDigest);
|
||||
let evolvedStoppedData = false;
|
||||
if (expectedEvolvedTargetSha256 !== undefined) {
|
||||
const currentSha256 = stableTargetSha256(
|
||||
command.request.targetDatabasePath,
|
||||
uid,
|
||||
);
|
||||
const confirmed = readTargetDataReconciliationEvidenceForPaths(
|
||||
{
|
||||
profile: command.request.profile,
|
||||
activationPath: command.request.activationPath,
|
||||
legacySourcePath: command.request.legacySourcePath,
|
||||
targetDatabasePath: command.request.targetDatabasePath,
|
||||
expectedActivationDigest: command.request.expectedActivationDigest,
|
||||
...(adoptedTargetBaseline === undefined
|
||||
? {}
|
||||
: { adoptedTargetBaseline }),
|
||||
},
|
||||
uid,
|
||||
);
|
||||
evolvedStoppedData =
|
||||
current.disposition === 'reconciliation_required' &&
|
||||
current.sourceMatchesActivation === true &&
|
||||
current.sourceSidecarsClear === true &&
|
||||
current.targetSidecarsClear === true &&
|
||||
current.targetMatchesActivation === false &&
|
||||
(current.baselineKind !== 'adopted_target' ||
|
||||
current.targetMatchesBaseline === false) &&
|
||||
currentSha256 === expectedEvolvedTargetSha256 &&
|
||||
confirmed.evidenceDigest === current.evidenceDigest;
|
||||
}
|
||||
if (!exactStoppedData && !evolvedStoppedData) {
|
||||
configurationError(
|
||||
'stopped data does not have exact reconciliation-required evidence',
|
||||
);
|
||||
@@ -211,6 +310,9 @@ export function proveLocalReconciliationStoppedState(
|
||||
stoppedAuthority: command.request.stoppedAuthority,
|
||||
stoppedRecordDigest: command.request.expectedStoppedRecordDigest,
|
||||
reconciliationEvidenceDigest: current.evidenceDigest,
|
||||
...(expectedEvolvedTargetSha256 === undefined
|
||||
? {}
|
||||
: { evolvedTargetSha256: expectedEvolvedTargetSha256 }),
|
||||
});
|
||||
return Object.freeze({ ...payload, proofDigest: cutoverDigest(payload) });
|
||||
}
|
||||
|
||||
@@ -1444,6 +1444,7 @@ async function secretConfigPlanFixture(t, options = {}) {
|
||||
options.applicationId ?? '00000000-0000-4000-8000-000000000423',
|
||||
reviewSuffix: `secret-config-${suffix}`,
|
||||
createDefaultSidecars: false,
|
||||
targetInsideDeploymentRoot: true,
|
||||
initializeDatabases: secretConfigDatabaseInitializer({
|
||||
active: options.active === true,
|
||||
configs: options.configs === true,
|
||||
@@ -4278,6 +4279,129 @@ test('Secret/Config plan follows applied Automation and preserved Run History on
|
||||
},
|
||||
});
|
||||
assert.equal(verified.status, 'verified');
|
||||
|
||||
const secretConfigDecisionRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'cross-domain-secret-config-decision',
|
||||
);
|
||||
fs.mkdirSync(secretConfigDecisionRoot, { mode: 0o700 });
|
||||
const candidates = fs
|
||||
.readFileSync(
|
||||
path.join(secretConfigRoot, secretConfigId, 'plan.ndjson'),
|
||||
'utf8',
|
||||
)
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.map((line) => JSON.parse(line))
|
||||
.filter(
|
||||
(record) =>
|
||||
record.kind ===
|
||||
'qinglong3-local-reconciliation-secret-config-plan-candidate',
|
||||
);
|
||||
const decisionState = {
|
||||
...state,
|
||||
planned,
|
||||
secretConfigRoot,
|
||||
secretConfigId,
|
||||
secretConfigCommand,
|
||||
secretConfigDecisionRoot,
|
||||
candidates,
|
||||
};
|
||||
const secretConfigDecisionId = '019b0000-0000-7000-8000-000000000432';
|
||||
const decisionPrepareCommand = secretConfigDecisionPrepareCommand(
|
||||
decisionState,
|
||||
secretConfigDecisionId,
|
||||
);
|
||||
const decisionPrepared = await prepareLocalReconciliationSecretConfigDecision(
|
||||
decisionPrepareCommand,
|
||||
);
|
||||
const decisionFile = secretConfigDecisionFile(
|
||||
decisionState,
|
||||
{ result: decisionPrepared },
|
||||
[
|
||||
{
|
||||
disposition: 'apply_active_binding',
|
||||
reason: 'reviewed_active_binding',
|
||||
},
|
||||
],
|
||||
'cross-domain-active-binding',
|
||||
);
|
||||
const decisionCommit = secretConfigDecisionCommitFixture(
|
||||
decisionState,
|
||||
{
|
||||
result: decisionPrepared,
|
||||
commandOptions: decisionPrepareCommand.options,
|
||||
},
|
||||
decisionFile.filePath,
|
||||
);
|
||||
const decision = await commitLocalReconciliationSecretConfigDecision(
|
||||
decisionCommit.command,
|
||||
decisionCommit.dependencies,
|
||||
);
|
||||
assert.equal(decision.outcome, 'ready');
|
||||
assert.equal(decision.applyBindingCount, 1);
|
||||
|
||||
const secretKeyringPath = path.join(
|
||||
state.deploymentRoot,
|
||||
'cross-domain-local-secret-keyring.json',
|
||||
);
|
||||
await provisionLocalSecretKeyring(secretKeyringPath);
|
||||
const secretConfigApplyRoot = path.join(
|
||||
path.dirname(state.captureRoot),
|
||||
'cross-domain-secret-config-apply',
|
||||
);
|
||||
fs.mkdirSync(secretConfigApplyRoot, { mode: 0o700 });
|
||||
const appliedAtMs = decisionCommit.command.request.committedAtMs + 1;
|
||||
const applied = await applyLocalReconciliationSecretConfig(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
operation: 'local.deployment.reconciliation.secret-config.apply',
|
||||
options: {
|
||||
...decisionPrepareCommand.options,
|
||||
secretConfigApplyRoot,
|
||||
targetDatabasePath: state.targetDatabasePath,
|
||||
secretKeyringPath,
|
||||
ownerPepperKeyringDirectory:
|
||||
state.command.options.ownerPepperKeyringDirectory,
|
||||
credentialFilePath: state.command.options.credentialFilePath,
|
||||
},
|
||||
request: {
|
||||
decisionId: secretConfigDecisionId,
|
||||
secretConfigId,
|
||||
expectedDecisionDigest: decision.decisionDigest,
|
||||
expectedHeadDigest: decision.instanceHeadDigest,
|
||||
mutationId: '00000000-0000-4000-8000-000000000433',
|
||||
requestId: 'cross-domain-secret-config-apply',
|
||||
appliedAtMs,
|
||||
},
|
||||
},
|
||||
{
|
||||
async openAuthenticationDatabase() {
|
||||
return { async close() {} };
|
||||
},
|
||||
async authenticate(_database, authenticationOptions) {
|
||||
const authenticatedAtMs = authenticationOptions.now();
|
||||
return {
|
||||
principal: {
|
||||
subject: { type: 'user', id: 'review-owner' },
|
||||
authenticationId: 'reconcile_secret_config_apply:test',
|
||||
authenticatedAtMs,
|
||||
expiresAtMs: authenticatedAtMs + 60 * 60 * 1_000,
|
||||
assurance: 'local_console',
|
||||
},
|
||||
databaseFence: {
|
||||
credentialId: 'review-owner',
|
||||
credentialVersion: 1,
|
||||
pepperKeyId: 'review-owner-v1',
|
||||
pepperVersion: 1,
|
||||
},
|
||||
async confirm() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(applied.state, 'reconciliation_secret_config_applied');
|
||||
assert.equal(applied.activeBindingCount, 1);
|
||||
});
|
||||
|
||||
test('Secret/Config decision reauthenticates the same reviewer, seals exact candidates and verifies content-free', async (t) => {
|
||||
@@ -4515,10 +4639,7 @@ test('Secret/Config apply publishes encrypted material atomically and recovers e
|
||||
},
|
||||
async authenticate(_database, options) {
|
||||
authentications += 1;
|
||||
assert.match(
|
||||
options.authenticationNamespace,
|
||||
/^[a-z][a-z0-9_]{0,31}$/,
|
||||
);
|
||||
assert.match(options.authenticationNamespace, /^[a-z][a-z0-9_]{0,31}$/);
|
||||
assert.equal(
|
||||
options.authenticationNamespace,
|
||||
'reconcile_secret_config_apply',
|
||||
@@ -4561,6 +4682,22 @@ test('Secret/Config apply publishes encrypted material atomically and recovers e
|
||||
),
|
||||
/authentication or Secret material must be below deploymentRoot/,
|
||||
);
|
||||
await assert.rejects(
|
||||
applyLocalReconciliationSecretConfig(
|
||||
{
|
||||
...applyCommand,
|
||||
options: {
|
||||
...applyOptions,
|
||||
targetDatabasePath: path.join(
|
||||
path.dirname(state.deploymentRoot),
|
||||
'outside-target.sqlite',
|
||||
),
|
||||
},
|
||||
},
|
||||
applyDependencies,
|
||||
),
|
||||
/targetDatabasePath must be below deploymentRoot/,
|
||||
);
|
||||
for (const boundary of ['afterMaterialPublished']) {
|
||||
await assert.rejects(
|
||||
applyLocalReconciliationSecretConfig(applyCommand, {
|
||||
|
||||
Reference in New Issue
Block a user