From b213c369f60be4e644ae08c252cdc028177b555b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 08:36:47 +0000 Subject: [PATCH] fix(service-datasource): migrateCredential's drop-inline refusal names an empty stored credential as empty The refusal blamed a missing sys_secret row or a changed encryption key when the existing ref decrypted to ''. It now names the credential as empty in the connect path's wording; the missing-row / changed-key branch keeps its sentence. Same refused status, same outcome. Claude-Session: https://claude.ai/code/session_01ADHkmP66rDL21VeBrVWovN Co-authored-by: Claude --- .../29069-migrate-credential-empty-refusal.md | 10 ++++++ .../datasource-admin-service.test.ts | 32 ++++++++++++++++++- .../src/datasource-admin-service.ts | 17 +++++++++- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 .changeset/29069-migrate-credential-empty-refusal.md diff --git a/.changeset/29069-migrate-credential-empty-refusal.md b/.changeset/29069-migrate-credential-empty-refusal.md new file mode 100644 index 00000000000..3d62ccf9c4c --- /dev/null +++ b/.changeset/29069-migrate-credential-empty-refusal.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-datasource': patch +--- + +fix(service-datasource): the credential migration's drop-inline refusal names an empty stored credential as empty + +Clause-②: no +Metadata-protocol: none + +When a datasource already references a stored secret that decrypts to an empty value (a row sealed empty before the admin doors refused an empty `secret`), the credential migration still refuses to drop the inline copy, with the same `refused` status, but its reason now says the credential is empty, in the connect path's wording, instead of blaming a missing `sys_secret` row or a changed encryption key. A missing row or a decrypt failure keeps its sentence. diff --git a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts index e45dbdf9ad6..8f3b631ed60 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts @@ -631,11 +631,41 @@ describe('migrateCredential (#8155)', () => { const result = await h.service.migrateCredential('warehouse'); expect(result.status).toBe('refused'); - expect(result.reason).toContain('could not be resolved'); + expect(result.reason).toBe( + "Datasource 'warehouse' already references a stored secret, but it could not be resolved or " + + 'decrypted (a missing `sys_secret` row, or a changed encryption key). Removing the inline ' + + 'credential would leave this datasource with no working credential at all.', + ); + expect(result.remedy).toContain('replaces the unresolvable reference'); // The inline copy is the only working credential left — it stays. expect(h.store.get('warehouse')!.config?.password).toBe('hunter2'); }); + it('refuses to drop the inline copy when the existing ref decrypts to an empty value, and says it is empty', async () => { + // A row sealed empty before the admin doors refused an empty `secret` + // resolves to ''. The outcome is the same refusal; the cause is named as + // an empty credential, in the connect path's wording, not blamed on a + // missing row or a changed key. + const h = makeHarness({ + seed: [legacyRow({ external: { credentialsRef: 'sys_secret://sealed-empty' } })], + readBackOverride: async () => '', + }); + const result = await h.service.migrateCredential('warehouse'); + + expect(result.status).toBe('refused'); + expect(result.reason).toBe( + "Datasource 'warehouse' already references a stored secret, but credential " + + "'sys_secret://sealed-empty' is empty (its sys_secret row holds an empty value). Removing the " + + 'inline credential would leave this datasource with no working credential at all.', + ); + expect(result.reason).not.toContain('could not be resolved'); + expect(result.reason).not.toContain('encryption key'); + expect(result.remedy).toContain("set the datasource's secret again"); + // Same outcome as the missing-row branch: nothing written, inline copy kept. + expect(h.store.get('warehouse')!.config?.password).toBe('hunter2'); + expect(h.ops).toEqual(['readSecret']); + }); + it('refuses when the host has no readable secret store', async () => { const h = makeHarness({ seed: [legacyRow()], noReadSecret: true }); const result = await h.service.migrateCredential('warehouse'); diff --git a/packages/services/service-datasource/src/datasource-admin-service.ts b/packages/services/service-datasource/src/datasource-admin-service.ts index 1933a09ef47..144b0a89334 100644 --- a/packages/services/service-datasource/src/datasource-admin-service.ts +++ b/packages/services/service-datasource/src/datasource-admin-service.ts @@ -1176,7 +1176,22 @@ export class DatasourceAdminService implements IDatasourceAdminService { // inline copy is the last cleartext copy, so it is dropped only once the // existing ref is proved resolvable. const resolved = await this.tryReadSecret(plan.credentialsRef); - if (resolved == null || resolved === '') { + // [#29069] An empty credential is told apart from one that is gone, in + // the connect path's wording (`DatasourceConnectionService.connect`'s + // `secret === ''` branch): a row sealed empty before the admin doors + // refused an empty `secret` decrypts to ''. The outcome is the same + // refusal — dropping the inline copy would leave nothing that works. + if (resolved === '') { + return this.refuse( + name, + `Datasource '${name}' already references a stored secret, but credential ` + + `'${plan.credentialsRef}' is empty (its sys_secret row holds an empty value). Removing the ` + + 'inline credential would leave this datasource with no working credential at all.', + 'Re-enter the credential in the connection form\'s secret field to set the datasource\'s ' + + 'secret again — that rebinds it and replaces the empty one; the inline copy can then be removed.', + ); + } + if (resolved == null) { return this.refuse( name, `Datasource '${name}' already references a stored secret, but it could not be resolved or `