Compare commits

...

6 Commits

Author SHA1 Message Date
Jill Regan
7d492de296 Merge branch 'jill/validate-secret-reference' into jill/migrate-to-connect-sdk 2026-02-23 08:19:08 -05:00
Jill Regan
398c918d60 Fix format 2026-02-23 08:13:30 -05:00
Jill Regan
44af64418a Update unit test 2026-02-22 12:37:21 -05:00
Jill Regan
50fb695a57 Merge branch 'jill/validate-secret-reference' into jill/migrate-to-connect-sdk 2026-02-22 12:32:39 -05:00
Jill Regan
dc90451a94 Apply code suggestions 2026-02-22 12:31:37 -05:00
Jill Regan
db4ac8464b Create client helper 2026-02-22 11:38:06 -05:00
2 changed files with 67 additions and 59 deletions

View File

@@ -549,7 +549,7 @@ describe("loadSecrets when using Service Account", () => {
describe("secret reference validation", () => { describe("secret reference validation", () => {
it("fails with clear message when a secret reference is invalid", async () => { it("fails with clear message when a secret reference is invalid", async () => {
process.env.MY_SECRET = "op://invalid/ref/form"; process.env.MY_SECRET = "op://x";
(Secrets.validateSecretReference as jest.Mock).mockImplementationOnce( (Secrets.validateSecretReference as jest.Mock).mockImplementationOnce(
() => { () => {
throw new Error("invalid reference format"); throw new Error("invalid reference format");
@@ -572,7 +572,6 @@ describe("loadSecrets when using Service Account", () => {
} }
}, },
); );
mockResolve.mockResolvedValue("value1");
await expect(loadSecrets(false)).rejects.toThrow( await expect(loadSecrets(false)).rejects.toThrow(
"Invalid secret reference(s): OTHER", "Invalid secret reference(s): OTHER",
@@ -836,21 +835,21 @@ describe("findMatchingFieldAndFile", () => {
describe("findSectionIdsByQuery", () => { describe("findSectionIdsByQuery", () => {
it("throws when sections is empty", () => { it("throws when sections is empty", () => {
expect(() => findSectionIdsByQuery([], "section-1")).toThrow( expect(() => findSectionIdsByQuery([], "section-1")).toThrow(
/section section-1 could not be found/, /Item has no sections; cannot resolve section "section-1"/,
); );
}); });
it("throws when sections is null/undefined", () => { it("throws when sections is null/undefined", () => {
expect(() => expect(() =>
findSectionIdsByQuery(undefined as unknown as FullItem["sections"], "x"), findSectionIdsByQuery(undefined as unknown as FullItem["sections"], "x"),
).toThrow(/could not be found/); ).toThrow(/Item has no sections; cannot resolve section "x"/);
}); });
it("returns section id when section matches by id", () => { it("throws when section query matches no section", () => {
const sections = [{ id: "sec-1", label: "Section 1" }]; const sections = [{ id: "sec-1", label: "Other" }];
expect( expect(() =>
findSectionIdsByQuery(sections as FullItem["sections"], "sec-1"), findSectionIdsByQuery(sections as FullItem["sections"], "nonexistent"),
).toEqual(["sec-1"]); ).toThrow(/No section matching "nonexistent" found in specified item/);
}); });
it("returns section id when section matches by label", () => { it("returns section id when section matches by label", () => {
@@ -864,7 +863,7 @@ describe("findSectionIdsByQuery", () => {
const sections = [{ id: "sec-1", label: "Other" }]; const sections = [{ id: "sec-1", label: "Other" }];
expect(() => expect(() =>
findSectionIdsByQuery(sections as FullItem["sections"], "nonexistent"), findSectionIdsByQuery(sections as FullItem["sections"], "nonexistent"),
).toThrow(/could not be found/); ).toThrow(/No section matching "nonexistent" found in specified item/);
}); });
it("returns multiple ids when multiple sections match", () => { it("returns multiple ids when multiple sections match", () => {

View File

@@ -92,30 +92,8 @@ const getSecretFromConnectItem = async (
return fieldValue; return fieldValue;
} }
// If a file was found, get the content of the file (with retry on 503)
if (fileId) { if (fileId) {
const maxAttempts = 3; return getFileContentWithRetry(client, parsed.vault, parsed.item, fileId);
const retryDelayMs = 2000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const content = await client.getFileContent(
parsed.vault,
parsed.item,
fileId,
);
return content;
} catch (err) {
const is503 =
err &&
typeof err === "object" &&
(err as Record<string, unknown>).statusCode === 503;
if (is503 && attempt < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
continue;
}
throw err;
}
}
} }
if (parsed.section) { if (parsed.section) {
@@ -129,6 +107,33 @@ const getSecretFromConnectItem = async (
); );
}; };
const getFileContentWithRetry = async (
client: OPConnect,
vaultId: string,
itemId: string,
fileId: string,
): Promise<string> => {
const maxAttempts = 3;
const retryDelayMs = 2000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await client.getFileContent(vaultId, itemId, fileId);
} catch (err) {
// Retry on 503 errors as this can happen on multiple secret fetches
const is503 =
err !== null &&
typeof err === "object" &&
(err as Record<string, unknown>).statusCode === 503;
if (is503 && attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, retryDelayMs));
continue;
}
throw err;
}
}
return "";
};
export const findSectionIdsByQuery = ( export const findSectionIdsByQuery = (
sections: FullItem["sections"], sections: FullItem["sections"],
sectionQuery: string | undefined, sectionQuery: string | undefined,
@@ -136,7 +141,7 @@ export const findSectionIdsByQuery = (
// If no sections were returned with the item throw an error // If no sections were returned with the item throw an error
if (!sections || sections.length === 0) { if (!sections || sections.length === 0) {
throw new Error( throw new Error(
`section ${sectionQuery} could not be found in specified item`, `Item has no sections; cannot resolve section "${sectionQuery}"`,
); );
} }
@@ -147,7 +152,7 @@ export const findSectionIdsByQuery = (
// If no sections were found with the given query throw an error // If no sections were found with the given query throw an error
if (ids.length === 0) { if (ids.length === 0) {
throw new Error( throw new Error(
`section ${sectionQuery} could not be found in specified item`, `No section matching "${sectionQuery}" found in specified item`,
); );
} }
@@ -251,6 +256,19 @@ const findSingleMatch = <T>(matches: T[]): T | undefined => {
} }
return matches[0]; return matches[0];
}; };
const createConnectClient = (host: string, token: string): OPConnect => {
try {
return OnePasswordConnect({
// eslint-disable-next-line @typescript-eslint/naming-convention
serverURL: host,
token,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Connect authentication failed: ${message}`);
}
};
// #endregion // #endregion
// #region Shared helpers and auth // #region Shared helpers and auth
@@ -262,7 +280,7 @@ export const getEnvVarNamesWithSecretRefs = (): string[] =>
); );
const validateSecretRefs = (envNames: string[]): void => { const validateSecretRefs = (envNames: string[]): void => {
const invalid: string[] = []; const invalid: { name: string; message: string }[] = [];
for (const envName of envNames) { for (const envName of envNames) {
const ref = process.env[envName]; const ref = process.env[envName];
@@ -272,15 +290,18 @@ const validateSecretRefs = (envNames: string[]): void => {
try { try {
Secrets.validateSecretReference(ref); Secrets.validateSecretReference(ref);
} catch { } catch (err) {
invalid.push(envName); const message = err instanceof Error ? err.message : String(err);
invalid.push({ name: envName, message });
} }
} }
// Throw an error if any secret references are invalid // Throw an error if any secret references are invalid
if (invalid.length > 0) { if (invalid.length > 0) {
const names = invalid.join(", "); const details = invalid
throw new Error(`Invalid secret reference(s): ${names}`); .map(({ name, message }) => `${name}: ${message}`)
.join("; ");
throw new Error(`Invalid secret reference(s): ${details}`);
} }
}; };
@@ -352,10 +373,10 @@ const fetchVaultId = async (
client: OPConnect, client: OPConnect,
vaultQuery: string, vaultQuery: string,
ref: string, ref: string,
cache: Map<string, string>, vaultIdCache: Map<string, string>,
): Promise<string> => { ): Promise<string> => {
// Check if the vault ID is already cached // Check if the vault ID is already cached to avoid unnecessary API calls
const cached = cache.get(vaultQuery); const cached = vaultIdCache.get(vaultQuery);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
@@ -367,7 +388,7 @@ const fetchVaultId = async (
); );
} }
cache.set(vaultQuery, vault.id); vaultIdCache.set(vaultQuery, vault.id);
return vault.id; return vault.id;
}; };
// #endregion // #endregion
@@ -390,20 +411,8 @@ const loadSecretsViaConnect = async (
throw new Error(authErr); throw new Error(authErr);
} }
// Authenticate with the Connect SDK const client = createConnectClient(host, token);
let client; const vaultIdCache = new Map<string, string>();
try {
client = OnePasswordConnect({
// eslint-disable-next-line @typescript-eslint/naming-convention
serverURL: host,
token,
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Connect authentication failed: ${message}`);
}
const vaultIdByQuery = new Map<string, string>();
for (const envName of envs) { for (const envName of envs) {
const ref = process.env[envName]; const ref = process.env[envName];
@@ -419,7 +428,7 @@ const loadSecretsViaConnect = async (
client, client,
parsed.vault, parsed.vault,
ref, ref,
vaultIdByQuery, vaultIdCache,
); );
const item = await client.getItem(vaultId, parsed.item); const item = await client.getItem(vaultId, parsed.item);