mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-04 00:37:48 +08:00
fix(admin): address follow-up Copilot findings on the prior fixes
- loadAdminProviders now validates against a stored-shape schema where
secrets are plain strings, so a hand-edited ADMIN_PROVIDERS holding an
{isSet} marker is dropped instead of later crashing maskSecret().
- loadSettings guards against array values (typeof [] === 'object'),
which would otherwise overlay numeric keys onto process.env.
- Admin SecretInput uses the bare id so the shared component's
<Label htmlFor> stays associated (only one ProviderDetail mounts).
- Add tests: marker-secret rejection, array-values guard, bedrock
multi-secret round-trip.
This commit is contained in:
@@ -182,8 +182,10 @@ function ProviderDetail({
|
||||
disabled={disabled}
|
||||
onChange={(field, value) => onUpdate({ [field]: value })}
|
||||
renderSecret={({ field, id }) => (
|
||||
// Bare id keeps the shared component's <Label htmlFor={id}>
|
||||
// associated; only one ProviderDetail is mounted at a time.
|
||||
<SecretInput
|
||||
id={`${id}-${provider.id}`}
|
||||
id={id}
|
||||
keepOnEmpty
|
||||
value={provider[field]}
|
||||
disabled={disabled}
|
||||
|
||||
+11
-1
@@ -45,6 +45,16 @@ export const AdminProviderSchema = z.object({
|
||||
|
||||
export const AdminProvidersSchema = z.array(AdminProviderSchema)
|
||||
|
||||
// Stored shape: secrets are plain strings (never {isSet} markers, which
|
||||
// only exist in transit). Used to validate ADMIN_PROVIDERS on load so a
|
||||
// hand-edited/corrupted value can't slip a marker object past maskSecret.
|
||||
const StoredAdminProviderSchema = AdminProviderSchema.extend({
|
||||
apiKey: z.string().optional(),
|
||||
awsAccessKeyId: z.string().optional(),
|
||||
awsSecretAccessKey: z.string().optional(),
|
||||
vertexApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
export type AdminProviderInput = z.infer<typeof AdminProviderSchema>
|
||||
|
||||
// Stored form: secrets are plain strings
|
||||
@@ -95,7 +105,7 @@ export function loadAdminProviders(): StoredAdminProvider[] {
|
||||
// Validate each entry's shape — a malformed/hand-edited value must
|
||||
// not reach runtime code that assumes provider/models exist.
|
||||
return parsed.flatMap((entry) => {
|
||||
const result = AdminProviderSchema.safeParse(entry)
|
||||
const result = StoredAdminProviderSchema.safeParse(entry)
|
||||
return result.success ? [result.data as StoredAdminProvider] : []
|
||||
})
|
||||
} catch {
|
||||
|
||||
@@ -33,11 +33,14 @@ export function loadSettings(): Record<string, string> {
|
||||
// hold null/arrays/numbers that would otherwise be overlaid onto
|
||||
// process.env and coerce to junk like "[object Object]".
|
||||
const values: Record<string, string> = {}
|
||||
const raw_values =
|
||||
parsed && typeof parsed.values === "object" && parsed.values
|
||||
const rawValues =
|
||||
parsed &&
|
||||
typeof parsed.values === "object" &&
|
||||
parsed.values &&
|
||||
!Array.isArray(parsed.values)
|
||||
? parsed.values
|
||||
: {}
|
||||
for (const [key, value] of Object.entries(raw_values)) {
|
||||
for (const [key, value] of Object.entries(rawValues)) {
|
||||
if (typeof value === "string") values[key] = value
|
||||
}
|
||||
cachedSettings = values
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
adminProvidersToConfig,
|
||||
deriveEnvUpdates,
|
||||
loadAdminProviders,
|
||||
maskAdminProviders,
|
||||
mergeSecrets,
|
||||
type StoredAdminProvider,
|
||||
validateAdminProviders,
|
||||
@@ -341,6 +342,22 @@ describe("loadAdminProviders", () => {
|
||||
expect(loadAdminProviders()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("round-trips a bedrock provider with multiple string secrets", () => {
|
||||
const bedrock = provider({
|
||||
provider: "bedrock",
|
||||
apiKey: undefined,
|
||||
awsAccessKeyId: "AKIA123",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegion: "us-west-2",
|
||||
models: ["claude-x"],
|
||||
})
|
||||
saveSettings({ [ADMIN_PROVIDERS_KEY]: JSON.stringify([bedrock]) })
|
||||
const loaded = loadAdminProviders()
|
||||
expect(loaded).toHaveLength(1)
|
||||
expect(loaded[0].awsAccessKeyId).toBe("AKIA123")
|
||||
expect(loaded[0].awsSecretAccessKey).toBe("secret")
|
||||
})
|
||||
|
||||
it("drops malformed entries and keeps valid ones", () => {
|
||||
saveSettings({
|
||||
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
||||
@@ -364,4 +381,18 @@ describe("loadAdminProviders", () => {
|
||||
saveSettings({ [ADMIN_PROVIDERS_KEY]: "{ broken" })
|
||||
expect(loadAdminProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it("drops entries whose secret is an {isSet} marker, not a string", () => {
|
||||
// A hand-edited file could hold a transit-only marker object; if it
|
||||
// slipped through, maskSecret() would throw on a non-string value.
|
||||
saveSettings({
|
||||
[ADMIN_PROVIDERS_KEY]: JSON.stringify([
|
||||
{ ...provider(), apiKey: { isSet: true, hint: "…1234" } },
|
||||
]),
|
||||
})
|
||||
const loaded = loadAdminProviders()
|
||||
expect(loaded).toEqual([])
|
||||
// Masking the loaded list must not throw
|
||||
expect(() => maskAdminProviders(loaded)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,13 +57,22 @@ describe("loadSettings", () => {
|
||||
expect(loadSettings()).toEqual({ GOOD: "ok" })
|
||||
})
|
||||
|
||||
it("returns empty object when values is null or an array", () => {
|
||||
it("returns empty object when values is null", () => {
|
||||
fs.writeFileSync(
|
||||
process.env.SETTINGS_FILE!,
|
||||
JSON.stringify({ version: 1, values: null }),
|
||||
)
|
||||
expect(loadSettings()).toEqual({})
|
||||
})
|
||||
|
||||
it("returns empty object when values is an array (no numeric keys)", () => {
|
||||
fs.writeFileSync(
|
||||
process.env.SETTINGS_FILE!,
|
||||
JSON.stringify({ version: 1, values: ["a", "b"] }),
|
||||
)
|
||||
// Without the Array.isArray guard this would yield { "0": "a", ... }
|
||||
expect(loadSettings()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyToEnv / saveSettings", () => {
|
||||
|
||||
Reference in New Issue
Block a user