Skip to content

Commit b213c36

Browse files
committed
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 <noreply@anthropic.com>
1 parent fea2dbb commit b213c36

3 files changed

Lines changed: 57 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource): the credential migration's drop-inline refusal names an empty stored credential as empty
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎packages/services/service-datasource/src/__tests__/datasource-admin-service.test.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,11 +631,41 @@ describe('migrateCredential (#8155)', () => {
631631
const result = await h.service.migrateCredential('warehouse');
632632

633633
expect(result.status).toBe('refused');
634-
expect(result.reason).toContain('could not be resolved');
634+
expect(result.reason).toBe(
635+
"Datasource 'warehouse' already references a stored secret, but it could not be resolved or "
636+
+ 'decrypted (a missing `sys_secret` row, or a changed encryption key). Removing the inline '
637+
+ 'credential would leave this datasource with no working credential at all.',
638+
);
639+
expect(result.remedy).toContain('replaces the unresolvable reference');
635640
// The inline copy is the only working credential left — it stays.
636641
expect(h.store.get('warehouse')!.config?.password).toBe('hunter2');
637642
});
638643

644+
it('refuses to drop the inline copy when the existing ref decrypts to an empty value, and says it is empty', async () => {
645+
// A row sealed empty before the admin doors refused an empty `secret`
646+
// resolves to ''. The outcome is the same refusal; the cause is named as
647+
// an empty credential, in the connect path's wording, not blamed on a
648+
// missing row or a changed key.
649+
const h = makeHarness({
650+
seed: [legacyRow({ external: { credentialsRef: 'sys_secret://sealed-empty' } })],
651+
readBackOverride: async () => '',
652+
});
653+
const result = await h.service.migrateCredential('warehouse');
654+
655+
expect(result.status).toBe('refused');
656+
expect(result.reason).toBe(
657+
"Datasource 'warehouse' already references a stored secret, but credential "
658+
+ "'sys_secret://sealed-empty' is empty (its sys_secret row holds an empty value). Removing the "
659+
+ 'inline credential would leave this datasource with no working credential at all.',
660+
);
661+
expect(result.reason).not.toContain('could not be resolved');
662+
expect(result.reason).not.toContain('encryption key');
663+
expect(result.remedy).toContain("set the datasource's secret again");
664+
// Same outcome as the missing-row branch: nothing written, inline copy kept.
665+
expect(h.store.get('warehouse')!.config?.password).toBe('hunter2');
666+
expect(h.ops).toEqual(['readSecret']);
667+
});
668+
639669
it('refuses when the host has no readable secret store', async () => {
640670
const h = makeHarness({ seed: [legacyRow()], noReadSecret: true });
641671
const result = await h.service.migrateCredential('warehouse');

‎packages/services/service-datasource/src/datasource-admin-service.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1176,7 +1176,22 @@ export class DatasourceAdminService implements IDatasourceAdminService {
11761176
// inline copy is the last cleartext copy, so it is dropped only once the
11771177
// existing ref is proved resolvable.
11781178
const resolved = await this.tryReadSecret(plan.credentialsRef);
1179-
if (resolved == null || resolved === '') {
1179+
// [#29069] An empty credential is told apart from one that is gone, in
1180+
// the connect path's wording (`DatasourceConnectionService.connect`'s
1181+
// `secret === ''` branch): a row sealed empty before the admin doors
1182+
// refused an empty `secret` decrypts to ''. The outcome is the same
1183+
// refusal — dropping the inline copy would leave nothing that works.
1184+
if (resolved === '') {
1185+
return this.refuse(
1186+
name,
1187+
`Datasource '${name}' already references a stored secret, but credential `
1188+
+ `'${plan.credentialsRef}' is empty (its sys_secret row holds an empty value). Removing the `
1189+
+ 'inline credential would leave this datasource with no working credential at all.',
1190+
'Re-enter the credential in the connection form\'s secret field to set the datasource\'s '
1191+
+ 'secret again — that rebinds it and replaces the empty one; the inline copy can then be removed.',
1192+
);
1193+
}
1194+
if (resolved == null) {
11801195
return this.refuse(
11811196
name,
11821197
`Datasource '${name}' already references a stored secret, but it could not be resolved or `

0 commit comments

Comments
 (0)