Skip to content

Commit 73357b4

Browse files
committed
fix: label credential-gated template validation
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent 07b5d0a commit 73357b4

7 files changed

Lines changed: 141 additions & 2 deletions

File tree

backend/src/templates/__tests__/template-validation-ledger.service.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,35 @@ describe('TemplateValidationLedgerService', () => {
9999
rmSync(dir, { recursive: true, force: true });
100100
}
101101
});
102+
103+
it('returns requires-secrets metadata for credential-gated templates without a live ledger entry', () => {
104+
const dir = writeLedger({});
105+
106+
try {
107+
const result = new TemplateValidationLedgerService().getValidationForTemplate({
108+
name: 'Security Scan Discord Report',
109+
updatedAt: '2026-06-22T04:45:00.000Z',
110+
requiredSecrets: [
111+
{
112+
name: 'DISCORD_WEBHOOK_URL',
113+
type: 'string',
114+
description: 'Discord Incoming Webhook URL for scan notifications',
115+
},
116+
],
117+
});
118+
119+
expect(result).toEqual({
120+
status: 'requires-secrets',
121+
recommendation: 'review',
122+
terminalStatus: null,
123+
artifactsCount: null,
124+
verifiedAt: null,
125+
rationale:
126+
'Template is credential-gated and requires live secrets before execution: DISCORD_WEBHOOK_URL.',
127+
isCurrent: true,
128+
});
129+
} finally {
130+
rmSync(dir, { recursive: true, force: true });
131+
}
132+
});
102133
});

backend/src/templates/template-validation-ledger.service.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common';
22
import { existsSync, readFileSync } from 'node:fs';
33
import { join } from 'node:path';
44

5-
export type TemplateValidationStatus = 'live-verified' | 'needs-fix' | 'needs-review' | 'unknown';
5+
export type TemplateValidationStatus =
6+
| 'live-verified'
7+
| 'requires-secrets'
8+
| 'needs-fix'
9+
| 'needs-review'
10+
| 'unknown';
611

712
export interface TemplateValidationSummary {
813
status: TemplateValidationStatus;
@@ -27,9 +32,18 @@ interface TemplateValidationLedger {
2732
entries?: Record<string, TemplateValidationLedgerEntry>;
2833
}
2934

35+
interface TemplateRequiredSecretTarget {
36+
name?: string | null;
37+
type?: string | null;
38+
description?: string | null;
39+
placeholder?: string | null;
40+
}
41+
3042
interface TemplateValidationTarget {
3143
name: string;
3244
updatedAt?: Date | string | null;
45+
requiredSecrets?: TemplateRequiredSecretTarget[] | null;
46+
manifest?: { requiredSecrets?: TemplateRequiredSecretTarget[] | null } | null;
3347
}
3448

3549
const VALID_RECOMMENDATIONS = new Set(['keep', 'fix', 'consolidate', 'delete', 'review']);
@@ -40,6 +54,19 @@ export class TemplateValidationLedgerService {
4054
const entry = this.findLedgerEntry(template.name);
4155

4256
if (!entry) {
57+
const requiredSecretNames = this.getRequiredSecretNames(template);
58+
if (requiredSecretNames.length > 0) {
59+
return {
60+
status: 'requires-secrets',
61+
recommendation: 'review',
62+
terminalStatus: null,
63+
artifactsCount: null,
64+
verifiedAt: null,
65+
rationale: `Template is credential-gated and requires live secrets before execution: ${requiredSecretNames.join(', ')}.`,
66+
isCurrent: true,
67+
};
68+
}
69+
4370
return {
4471
status: 'unknown',
4572
recommendation: 'unknown',
@@ -103,6 +130,18 @@ export class TemplateValidationLedgerService {
103130
return null;
104131
}
105132

133+
private getRequiredSecretNames(template: TemplateValidationTarget): string[] {
134+
const requiredSecrets = Array.isArray(template.requiredSecrets)
135+
? template.requiredSecrets
136+
: Array.isArray(template.manifest?.requiredSecrets)
137+
? template.manifest.requiredSecrets
138+
: [];
139+
140+
return requiredSecrets
141+
.map((secret) => secret?.name?.trim())
142+
.filter((name): name is string => Boolean(name));
143+
}
144+
106145
private normalizeRecommendation(value: unknown): TemplateValidationSummary['recommendation'] {
107146
const recommendation = String(value || '').toLowerCase();
108147
return VALID_RECOMMENDATIONS.has(recommendation)

frontend/src/pages/TemplateLibraryPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ function matchesValidationFilter(template: Template, filter: TemplateValidationF
6464
const VALIDATION_FILTERS: TemplateValidationFilter[] = [
6565
'all',
6666
'live-verified',
67+
'requires-secrets',
6768
'stale',
6869
'needs-fix',
6970
'needs-review',

frontend/src/pages/__tests__/TemplateLibraryPage.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,57 @@ describe('TemplateLibraryPage', () => {
892892
expect(mockRevalidateMutate.mock.calls[0]?.[0]).toBe('tmpl-001');
893893
});
894894

895+
it('shows credential-gated templates separately from unknown validation', () => {
896+
setupStore({
897+
templates: [
898+
{
899+
...templateA,
900+
validation: {
901+
status: 'requires-secrets',
902+
recommendation: 'review',
903+
terminalStatus: null,
904+
artifactsCount: null,
905+
verifiedAt: null,
906+
rationale:
907+
'Template is credential-gated and requires live secrets before execution: DISCORD_WEBHOOK_URL.',
908+
isCurrent: true,
909+
},
910+
requiredSecrets: [
911+
{
912+
name: 'DISCORD_WEBHOOK_URL',
913+
type: 'string',
914+
description: 'Discord Incoming Webhook URL for scan notifications',
915+
},
916+
],
917+
} as Template,
918+
{
919+
...templateB,
920+
id: 'tmpl-unknown',
921+
name: 'unknown validation template',
922+
validation: {
923+
status: 'unknown',
924+
recommendation: 'unknown',
925+
artifactsCount: null,
926+
verifiedAt: null,
927+
rationale: 'No validation found.',
928+
isCurrent: false,
929+
},
930+
} as Template,
931+
],
932+
});
933+
934+
renderPage();
935+
936+
expect(screen.getByText('Requires secrets')).toBeInTheDocument();
937+
expect(screen.getByRole('option', { name: 'Requires secrets (1)' })).toBeInTheDocument();
938+
expect(screen.getByRole('option', { name: 'Unknown validation (1)' })).toBeInTheDocument();
939+
940+
fireEvent.click(screen.getByRole('option', { name: 'Requires secrets (1)' }));
941+
942+
expect(screen.getByText('Network Recon Scan')).toBeInTheDocument();
943+
expect(screen.queryByText('Unknown Validation Template')).not.toBeInTheDocument();
944+
});
945+
895946
it('does not render live-audit guidance for current live-verified templates', () => {
896947
setupStore({
897948
templates: [

frontend/src/pages/template-library/TemplateCard.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ function getValidationBadge(template: Template) {
5252
};
5353
}
5454

55+
if (validation.status === 'requires-secrets') {
56+
return {
57+
label: 'Requires secrets',
58+
Icon: KeyRound,
59+
className: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
60+
};
61+
}
62+
5563
if (validation.status === 'needs-fix' || validation.status === 'needs-review') {
5664
return {
5765
label: validation.status === 'needs-fix' ? 'Needs fix' : 'Needs review',
@@ -79,6 +87,7 @@ function getValidationAction(template: Template) {
7987
const shouldRevalidate =
8088
validation.status === 'unknown' ||
8189
!validation.isCurrent ||
90+
validation.status === 'requires-secrets' ||
8291
validation.status === 'needs-fix' ||
8392
validation.status === 'needs-review';
8493

frontend/src/pages/template-library/TemplateFilters.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ export function TemplateFilters({
111111
<SelectItem value="live-verified">
112112
Live verified ({validationCounts['live-verified']})
113113
</SelectItem>
114+
<SelectItem value="requires-secrets">
115+
Requires secrets ({validationCounts['requires-secrets']})
116+
</SelectItem>
114117
<SelectItem value="stale">Validation stale ({validationCounts.stale})</SelectItem>
115118
<SelectItem value="needs-fix">Needs fix ({validationCounts['needs-fix']})</SelectItem>
116119
<SelectItem value="needs-review">

frontend/src/types/templates.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export type TemplateValidationStatus = 'live-verified' | 'needs-fix' | 'needs-review' | 'unknown';
1+
export type TemplateValidationStatus =
2+
| 'live-verified'
3+
| 'requires-secrets'
4+
| 'needs-fix'
5+
| 'needs-review'
6+
| 'unknown';
27

38
export type TemplateValidationFilter = 'all' | TemplateValidationStatus | 'stale';
49

0 commit comments

Comments
 (0)