From fc868c786c131631b9d319b6c377d015f3a378ee Mon Sep 17 00:00:00 2001 From: BobSilent Date: Fri, 6 Oct 2023 15:28:37 +0200 Subject: [PATCH 1/5] configure email as reviewers or assignees --- extension/task/IDependabotConfig.ts | 7 +- extension/task/index.ts | 7 +- extension/task/utils/parseConfigFile.ts | 4 +- .../utils/resolveAzureDevOpsIdentities.ts | 159 ++++++++++++++++++ updater/bin/update_script.rb | 14 +- 5 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 extension/task/utils/resolveAzureDevOpsIdentities.ts diff --git a/extension/task/IDependabotConfig.ts b/extension/task/IDependabotConfig.ts index d4573617..eafa50e1 100644 --- a/extension/task/IDependabotConfig.ts +++ b/extension/task/IDependabotConfig.ts @@ -43,11 +43,14 @@ export interface IDependabotUpdate { /** * Reviewers. */ - reviewers?: string; + reviewers?: string[]; /** * Assignees. */ - assignees?: string; + assignees?: string[]; + /** + * Commit Message. + */ commitMessage?: string; /** * The milestone to associate pull requests with. diff --git a/extension/task/index.ts b/extension/task/index.ts index 2f837fe5..79e6a32e 100644 --- a/extension/task/index.ts +++ b/extension/task/index.ts @@ -3,6 +3,7 @@ import { ToolRunner } from "azure-pipelines-task-lib/toolrunner" import { IDependabotConfig, IDependabotRegistry, IDependabotUpdate } from "./IDependabotConfig"; import getSharedVariables from "./utils/getSharedVariables"; import { parseConfigFile } from "./utils/parseConfigFile"; +import { resolveAzureDevOpsIdentities } from "./utils/resolveAzureDevOpsIdentities"; async function run() { try { @@ -128,12 +129,14 @@ async function run() { // Set the reviewers if (update.reviewers) { - dockerRunner.arg(["-e", `DEPENDABOT_REVIEWERS=${update.reviewers}`]); + const reviewers = await resolveAzureDevOpsIdentities(variables.organizationUrl, update.reviewers) + dockerRunner.arg(["-e", `DEPENDABOT_REVIEWERS=${JSON.stringify(reviewers)}`]); } // Set the assignees if (update.assignees) { - dockerRunner.arg(["-e", `DEPENDABOT_ASSIGNEES=${update.assignees}`]); + const assignees = await resolveAzureDevOpsIdentities(variables.organizationUrl, update.assignees) + dockerRunner.arg(["-e", `DEPENDABOT_ASSIGNEES=${JSON.stringify(assignees)}`]); } // Set the updater options, if provided diff --git a/extension/task/utils/parseConfigFile.ts b/extension/task/utils/parseConfigFile.ts index 8848cf45..8fca4117 100644 --- a/extension/task/utils/parseConfigFile.ts +++ b/extension/task/utils/parseConfigFile.ts @@ -189,10 +189,10 @@ function parseUpdates(config: any): IDependabotUpdate[] { ignore: update["ignore"] ? JSON.stringify(update["ignore"]) : undefined, labels: update["labels"] ? JSON.stringify(update["labels"]) : undefined, reviewers: update["reviewers"] - ? JSON.stringify(update["reviewers"]) + ? update["reviewers"] : undefined, assignees: update["assignees"] - ? JSON.stringify(update["assignees"]) + ? update["assignees"] : undefined, commitMessage: update["commit-message"] ? JSON.stringify(update["commit-message"]) diff --git a/extension/task/utils/resolveAzureDevOpsIdentities.ts b/extension/task/utils/resolveAzureDevOpsIdentities.ts new file mode 100644 index 00000000..f4e769dc --- /dev/null +++ b/extension/task/utils/resolveAzureDevOpsIdentities.ts @@ -0,0 +1,159 @@ +import * as tl from "azure-pipelines-task-lib/task"; +import axios from "axios"; +import extractOrganization from "./extractOrganization"; + +export interface IIdentity { + /** + * The identity id to use for PR reviewer or assignee Id. + */ + id: string, + /** + * Human readable Username. + */ + displayName?: string, + /** + * The provided input to use for searching an identity. + */ + input: string, +} + +/** + * Resolves the given input email addresses to an array of IIdentity information. + * It also handles non email input, which is assumed to be already an identity id + * to pass as reviewer id to an PR. + * + * @param organizationUrl + * @param inputs + * @returns + */ +export async function resolveAzureDevOpsIdentities(organizationUrl: URL, inputs: string[]): Promise { + const result: IIdentity[] = []; + + tl.debug(`Attempting to fetch configuration file via REST API ...`); + for (const input of inputs) { + if (input.indexOf("@") > 0 ) { + // input is email to look-up + const identityInfo = await querySubject(organizationUrl, input); + if (identityInfo) { + result.push(identityInfo); + } + } else { + // input is already identity id + result.push({id: input, input: input}); + } + } + return result; +} + + +const DEV_AZURE = "dev.azure.com"; +const VISUALSTUDIO = "visualstudio.com"; + +function isHostedAzureDevOps(uri: URL): boolean { + const hostname = uri.hostname.toLowerCase(); + return hostname.startsWith(DEV_AZURE) || hostname.endsWith(VISUALSTUDIO); +} + +function decodeBase64(input: string):string { + return Buffer.from(input, 'base64').toString('utf8'); +} + +function encodeBase64(input: string):string { + return Buffer.from(input, 'utf8').toString('base64'); +} + +function isSuccessStatusCode(statusCode?: number) : boolean { + return (statusCode >= 200) && (statusCode <= 299); +} + +async function querySubject(organizationUrl: URL, email: string): Promise { + + if (isHostedAzureDevOps(organizationUrl)) { + const organization: string = extractOrganization(organizationUrl.toString()); + return await querySubjectHosted(organization, email); + } else { + return await querySubjectOnPrem(organizationUrl, email); + } +} + +/** + * Make the HTTP Request for an OnPrem Azure DevOps Server to resolve an email to an IIdentity + * @param organizationUrl + * @param email + * @returns + */ +async function querySubjectOnPrem(organizationUrl: URL, email: string): Promise { + var url = `${organizationUrl}_apis/identities?searchFilter=MailAddress&queryMembership=None&filterValue=${email}`; + tl.debug(`GET ${url}`); + try { + var response = await axios.get(url, { + headers: { + Authorization: `Basic ${encodeBase64("PAT:" + tl.getVariable("System.AccessToken"))}`, + Accept: "application/json;api-version=5.0", + }, + }); + + if (isSuccessStatusCode(response.status)) { + return { + id: response.data.value[0]?.id, + displayName: response.data.value[0]?.providerDisplayName, + input: email} + } + } catch (error) { + var responseStatusCode = error?.response?.status; + tl.debug(`HTTP Response Status: ${responseStatusCode}`) + if (responseStatusCode > 400 && responseStatusCode < 500) { + tl.debug(`Access token is ${tl.getVariable("System.AccessToken")?.length > 0 ? "not" : ""} null or empty.`); + throw new Error( + `The access token provided is empty or does not have permissions to access '${url}'` + ); + } else { + throw error; + } + } +} + +/** + * * Make the HTTP Request for a hosted Azure DevOps Service, to resolve an email to an IIdentity + * @param organization + * @param email + * @returns + */ +async function querySubjectHosted(organization: string, email: string): Promise { + // make HTTP request + var url = `https://vssps.dev.azure.com/${organization}/_apis/graph/subjectquery`; + tl.debug(`GET ${url}`); + try { + var response = await axios.post(url, { + headers: { + Authorization: `Basic ${encodeBase64("PAT:" + tl.getVariable("System.AccessToken"))}`, + Accept: "application/json;api-version=6.0-preview.1", + "Content-Type": "application/json", + }, + data: { + "query": email, + "subjectKind": [ "User" ] + } + }); + + if (isSuccessStatusCode(response.status)) { + var descriptor: string = response.data.value[0]?.descriptor || ""; + var id = decodeBase64(descriptor.substring(descriptor.indexOf(".") + 1)) + return { + id: id, + displayName: response.data.value[0]?.displayName, + input: email} + } + } catch (error) { + var responseStatusCode = error?.response?.status; + tl.debug(`HTTP Response Status: ${responseStatusCode}`) + if (responseStatusCode > 400 && responseStatusCode < 500) { + tl.debug(`Access token is ${tl.getVariable("System.AccessToken")?.length > 0 ? "not" : ""} null or empty.`); + throw new Error( + `The access token provided is empty or does not have permissions to access '${url}'` + ); + } else { + throw error; + } + } +} diff --git a/updater/bin/update_script.rb b/updater/bin/update_script.rb index 919caec0..d770f225 100644 --- a/updater/bin/update_script.rb +++ b/updater/bin/update_script.rb @@ -278,18 +278,24 @@ def allow_conditions_for(dep) ######################################################################### # Setup Reviewers # -# DEPENDABOT_REVIEWERS Example: ["be9321e2-f404-4ffa-8d6b-44efddb04865"] +# DEPENDABOT_REVIEWERS Example: "[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]" ######################################################################### unless ENV["DEPENDABOT_REVIEWERS"].to_s.strip.empty? - $options[:reviewers] = JSON.parse(ENV.fetch("DEPENDABOT_REVIEWERS", nil)) + reviewers = JSON.parse(ENV["DEPENDABOT_REVIEWERS"]) + $options[:reviewers] = reviewers&.map { |entry| entry['id'] } if reviewers + + puts "Pull Requests shall be assigned to required reviewers #{reviewers&.select{ |entry| entry['displayName'] }.map { |entry| "#{entry['displayName']} <#{entry['input']}>" }&.join(', ')}" if reviewers end ######################################################################### # Setup Assignees # -# DEPENDABOT_ASSIGNEES Example: ["be9321e2-f404-4ffa-8d6b-44efddb04865"] +# DEPENDABOT_ASSIGNEES Example: "[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]" ######################################################################### unless ENV["DEPENDABOT_ASSIGNEES"].to_s.strip.empty? - $options[:assignees] = JSON.parse(ENV.fetch("DEPENDABOT_ASSIGNEES", nil)) + assignees = JSON.parse(ENV["DEPENDABOT_ASSIGNEES"]) + $options[:assignees] = assignees&.map { |entry| entry['id'] } if assignees + + puts "Pull Requests shall be assigned to optional reviewers #{assignees&.select{ |entry| entry['displayName'] }.map { |entry| "#{entry['displayName']} <#{entry['input']}>" }&.join(', ')}" if assignees end # Get ignore versions for a dependency From a355c67af28fdc5c7d905685a3a92bc8026ecfb4 Mon Sep 17 00:00:00 2001 From: BobSilent Date: Fri, 6 Oct 2023 15:34:54 +0200 Subject: [PATCH 2/5] update readme --- docs/updater.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/updater.md b/docs/updater.md index f3ccfad5..6ad0ac9e 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -113,8 +113,8 @@ To run the script, some environment variables are required. |DEPENDABOT_IGNORE_CONDITIONS|**_Optional_**. The dependencies to be ignored, in JSON format. This can be used to control which packages can be updated. For example: `[{\"dependency-name\":\"express\",\"versions\":[\"4.x\",\"5.x\"]}]`. See [official docs](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates#ignore) for more.| |DEPENDABOT_COMMIT_MESSAGE_OPTIONS|**_Optional_**. The commit message options, in JSON format. For example: `{\"prefix\":\"(dependabot)\"}`. See [official docs](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#commit-message) for more.| |DEPENDABOT_LABELS|**_Optional_**. The custom labels to be used, in JSON format. This can be used to override the default values. For example: `[\"npm dependencies\",\"triage-board\"]`. See [official docs](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/customizing-dependency-updates#setting-custom-labels) for more.| -|DEPENDABOT_REVIEWERS|**_Optional_**. The identifiers of the users to review the pull requests, in JSON format. These shall be added as optional approvers. For example: `[\"23d9f23d-981e-4a0c-a975-8e5c665914ec\",\"62b67ef1-58e9-4be9-83d3-690a6fc67d6b\"]`. -|DEPENDABOT_ASSIGNEES|**_Optional_**. The identifiers of the users to be assigned to the pull requests, in JSON format. These shall be added as required approvers. For example: `[\"be9321e2-f404-4ffa-8d6b-44efddb04865\"]`. | +|DEPENDABOT_REVIEWERS|**_Optional_**. The identifiers of the users to review the pull requests, in JSON format. These will be added as optional approvers. For example: `"[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]"`. +|DEPENDABOT_ASSIGNEES|**_Optional_**. The identifiers of the users to be assigned to the pull requests, in JSON format. These will be added as required approvers. For example: `[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]`. | |DEPENDABOT_BRANCH_NAME_SEPARATOR|**_Optional_**. The separator to use in created branches. For example: `-`. See [official docs](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#pull-request-branch-nameseparator) for more.| |DEPENDABOT_REJECT_EXTERNAL_CODE|**_Optional_**. Determines if the execution external code is allowed. Defaults to `false`.| |DEPENDABOT_FAIL_ON_EXCEPTION|**_Optional_**. Determines if the execution should fail when an exception occurs. Defaults to `true`.| From 85aec5c3e5591686a666e1383c5776cb805d3ecb Mon Sep 17 00:00:00 2001 From: BobSilent Date: Fri, 6 Oct 2023 18:06:29 +0200 Subject: [PATCH 3/5] improve isHostedAzureDevOps --- extension/task/utils/resolveAzureDevOpsIdentities.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/extension/task/utils/resolveAzureDevOpsIdentities.ts b/extension/task/utils/resolveAzureDevOpsIdentities.ts index f4e769dc..45fd560e 100644 --- a/extension/task/utils/resolveAzureDevOpsIdentities.ts +++ b/extension/task/utils/resolveAzureDevOpsIdentities.ts @@ -45,13 +45,14 @@ export async function resolveAzureDevOpsIdentities(organizationUrl: URL, inputs: return result; } - -const DEV_AZURE = "dev.azure.com"; -const VISUALSTUDIO = "visualstudio.com"; - +/** + * Returns whether the extension is run in a hosted environment (as opposed to an on-premise environment). + * In Azure DevOps terms, hosted environment is also known as "Azure DevOps Services" and on-premise environment is known as + * "Team Foundation Server" or "Azure DevOps Server". + */ function isHostedAzureDevOps(uri: URL): boolean { const hostname = uri.hostname.toLowerCase(); - return hostname.startsWith(DEV_AZURE) || hostname.endsWith(VISUALSTUDIO); + return hostname === 'dev.azure.com' || hostname.endsWith('.visualstudio.com'); } function decodeBase64(input: string):string { From 1af334840610344db970e7b2b8d928887000f15c Mon Sep 17 00:00:00 2001 From: BobSilent Date: Mon, 9 Oct 2023 10:43:19 +0200 Subject: [PATCH 4/5] revert update_script.rb changes --- docs/updater.md | 4 ++-- extension/task/index.ts | 4 ++-- updater/bin/update_script.rb | 14 ++++---------- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/docs/updater.md b/docs/updater.md index 6ad0ac9e..f3ccfad5 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -113,8 +113,8 @@ To run the script, some environment variables are required. |DEPENDABOT_IGNORE_CONDITIONS|**_Optional_**. The dependencies to be ignored, in JSON format. This can be used to control which packages can be updated. For example: `[{\"dependency-name\":\"express\",\"versions\":[\"4.x\",\"5.x\"]}]`. See [official docs](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates#ignore) for more.| |DEPENDABOT_COMMIT_MESSAGE_OPTIONS|**_Optional_**. The commit message options, in JSON format. For example: `{\"prefix\":\"(dependabot)\"}`. See [official docs](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#commit-message) for more.| |DEPENDABOT_LABELS|**_Optional_**. The custom labels to be used, in JSON format. This can be used to override the default values. For example: `[\"npm dependencies\",\"triage-board\"]`. See [official docs](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/customizing-dependency-updates#setting-custom-labels) for more.| -|DEPENDABOT_REVIEWERS|**_Optional_**. The identifiers of the users to review the pull requests, in JSON format. These will be added as optional approvers. For example: `"[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]"`. -|DEPENDABOT_ASSIGNEES|**_Optional_**. The identifiers of the users to be assigned to the pull requests, in JSON format. These will be added as required approvers. For example: `[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]`. | +|DEPENDABOT_REVIEWERS|**_Optional_**. The identifiers of the users to review the pull requests, in JSON format. These shall be added as optional approvers. For example: `[\"23d9f23d-981e-4a0c-a975-8e5c665914ec\",\"62b67ef1-58e9-4be9-83d3-690a6fc67d6b\"]`. +|DEPENDABOT_ASSIGNEES|**_Optional_**. The identifiers of the users to be assigned to the pull requests, in JSON format. These shall be added as required approvers. For example: `[\"be9321e2-f404-4ffa-8d6b-44efddb04865\"]`. | |DEPENDABOT_BRANCH_NAME_SEPARATOR|**_Optional_**. The separator to use in created branches. For example: `-`. See [official docs](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#pull-request-branch-nameseparator) for more.| |DEPENDABOT_REJECT_EXTERNAL_CODE|**_Optional_**. Determines if the execution external code is allowed. Defaults to `false`.| |DEPENDABOT_FAIL_ON_EXCEPTION|**_Optional_**. Determines if the execution should fail when an exception occurs. Defaults to `true`.| diff --git a/extension/task/index.ts b/extension/task/index.ts index 79e6a32e..eed439ff 100644 --- a/extension/task/index.ts +++ b/extension/task/index.ts @@ -130,13 +130,13 @@ async function run() { // Set the reviewers if (update.reviewers) { const reviewers = await resolveAzureDevOpsIdentities(variables.organizationUrl, update.reviewers) - dockerRunner.arg(["-e", `DEPENDABOT_REVIEWERS=${JSON.stringify(reviewers)}`]); + dockerRunner.arg(["-e", `DEPENDABOT_REVIEWERS=${JSON.stringify(reviewers.map(identity => identity.id))}`]); } // Set the assignees if (update.assignees) { const assignees = await resolveAzureDevOpsIdentities(variables.organizationUrl, update.assignees) - dockerRunner.arg(["-e", `DEPENDABOT_ASSIGNEES=${JSON.stringify(assignees)}`]); + dockerRunner.arg(["-e", `DEPENDABOT_ASSIGNEES=${JSON.stringify(assignees.map(identity => identity.id))}`]); } // Set the updater options, if provided diff --git a/updater/bin/update_script.rb b/updater/bin/update_script.rb index d770f225..919caec0 100644 --- a/updater/bin/update_script.rb +++ b/updater/bin/update_script.rb @@ -278,24 +278,18 @@ def allow_conditions_for(dep) ######################################################################### # Setup Reviewers # -# DEPENDABOT_REVIEWERS Example: "[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]" +# DEPENDABOT_REVIEWERS Example: ["be9321e2-f404-4ffa-8d6b-44efddb04865"] ######################################################################### unless ENV["DEPENDABOT_REVIEWERS"].to_s.strip.empty? - reviewers = JSON.parse(ENV["DEPENDABOT_REVIEWERS"]) - $options[:reviewers] = reviewers&.map { |entry| entry['id'] } if reviewers - - puts "Pull Requests shall be assigned to required reviewers #{reviewers&.select{ |entry| entry['displayName'] }.map { |entry| "#{entry['displayName']} <#{entry['input']}>" }&.join(', ')}" if reviewers + $options[:reviewers] = JSON.parse(ENV.fetch("DEPENDABOT_REVIEWERS", nil)) end ######################################################################### # Setup Assignees # -# DEPENDABOT_ASSIGNEES Example: "[{"id":"be9321e2-f404-4ffa-8d6b-44efddb04865","displayName":"Alice","input":"alice@example.com"}]" +# DEPENDABOT_ASSIGNEES Example: ["be9321e2-f404-4ffa-8d6b-44efddb04865"] ######################################################################### unless ENV["DEPENDABOT_ASSIGNEES"].to_s.strip.empty? - assignees = JSON.parse(ENV["DEPENDABOT_ASSIGNEES"]) - $options[:assignees] = assignees&.map { |entry| entry['id'] } if assignees - - puts "Pull Requests shall be assigned to optional reviewers #{assignees&.select{ |entry| entry['displayName'] }.map { |entry| "#{entry['displayName']} <#{entry['input']}>" }&.join(', ')}" if assignees + $options[:assignees] = JSON.parse(ENV.fetch("DEPENDABOT_ASSIGNEES", nil)) end # Get ignore versions for a dependency From 8e2ed873d78008a5b21d0a8b93ed0e5aa9f7dfdc Mon Sep 17 00:00:00 2001 From: BobSilent Date: Mon, 9 Oct 2023 15:23:26 +0200 Subject: [PATCH 5/5] Add tests --- .../utils/resolveAzureDevOpsIdentities.ts | 28 +++--- .../resolveAzureDevOpsIdentities.test.ts | 93 +++++++++++++++++++ 2 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 extension/tests/utils/resolveAzureDevOpsIdentities.test.ts diff --git a/extension/task/utils/resolveAzureDevOpsIdentities.ts b/extension/task/utils/resolveAzureDevOpsIdentities.ts index 45fd560e..c77755db 100644 --- a/extension/task/utils/resolveAzureDevOpsIdentities.ts +++ b/extension/task/utils/resolveAzureDevOpsIdentities.ts @@ -50,7 +50,7 @@ export async function resolveAzureDevOpsIdentities(organizationUrl: URL, inputs: * In Azure DevOps terms, hosted environment is also known as "Azure DevOps Services" and on-premise environment is known as * "Team Foundation Server" or "Azure DevOps Server". */ -function isHostedAzureDevOps(uri: URL): boolean { +export function isHostedAzureDevOps(uri: URL): boolean { const hostname = uri.hostname.toLowerCase(); return hostname === 'dev.azure.com' || hostname.endsWith('.visualstudio.com'); } @@ -84,10 +84,10 @@ async function querySubject(organizationUrl: URL, email: string): Promise { - var url = `${organizationUrl}_apis/identities?searchFilter=MailAddress&queryMembership=None&filterValue=${email}`; - tl.debug(`GET ${url}`); + const url = `${organizationUrl}_apis/identities?searchFilter=MailAddress&queryMembership=None&filterValue=${email}`; + tl.debug(`GET ${url}`); try { - var response = await axios.get(url, { + const response = await axios.get(url, { headers: { Authorization: `Basic ${encodeBase64("PAT:" + tl.getVariable("System.AccessToken"))}`, Accept: "application/json;api-version=5.0", @@ -101,7 +101,7 @@ async function querySubjectOnPrem(organizationUrl: URL, email: string): Promise< input: email} } } catch (error) { - var responseStatusCode = error?.response?.status; + const responseStatusCode = error?.response?.status; tl.debug(`HTTP Response Status: ${responseStatusCode}`) if (responseStatusCode > 400 && responseStatusCode < 500) { tl.debug(`Access token is ${tl.getVariable("System.AccessToken")?.length > 0 ? "not" : ""} null or empty.`); @@ -116,16 +116,16 @@ async function querySubjectOnPrem(organizationUrl: URL, email: string): Promise< /** * * Make the HTTP Request for a hosted Azure DevOps Service, to resolve an email to an IIdentity - * @param organization - * @param email - * @returns + * @param organization + * @param email + * @returns */ async function querySubjectHosted(organization: string, email: string): Promise { // make HTTP request - var url = `https://vssps.dev.azure.com/${organization}/_apis/graph/subjectquery`; - tl.debug(`GET ${url}`); + const url = `https://vssps.dev.azure.com/${organization}/_apis/graph/subjectquery`; + tl.debug(`GET ${url}`); try { - var response = await axios.post(url, { + const response = await axios.post(url, { headers: { Authorization: `Basic ${encodeBase64("PAT:" + tl.getVariable("System.AccessToken"))}`, Accept: "application/json;api-version=6.0-preview.1", @@ -138,15 +138,15 @@ async function querySubjectHosted(organization: string, email: string): Promise< }); if (isSuccessStatusCode(response.status)) { - var descriptor: string = response.data.value[0]?.descriptor || ""; - var id = decodeBase64(descriptor.substring(descriptor.indexOf(".") + 1)) + const descriptor: string = response.data.value[0]?.descriptor || ""; + const id = decodeBase64(descriptor.substring(descriptor.indexOf(".") + 1)) return { id: id, displayName: response.data.value[0]?.displayName, input: email} } } catch (error) { - var responseStatusCode = error?.response?.status; + const responseStatusCode = error?.response?.status; tl.debug(`HTTP Response Status: ${responseStatusCode}`) if (responseStatusCode > 400 && responseStatusCode < 500) { tl.debug(`Access token is ${tl.getVariable("System.AccessToken")?.length > 0 ? "not" : ""} null or empty.`); diff --git a/extension/tests/utils/resolveAzureDevOpsIdentities.test.ts b/extension/tests/utils/resolveAzureDevOpsIdentities.test.ts new file mode 100644 index 00000000..9b39605b --- /dev/null +++ b/extension/tests/utils/resolveAzureDevOpsIdentities.test.ts @@ -0,0 +1,93 @@ +import { isHostedAzureDevOps, resolveAzureDevOpsIdentities } from "../../task/utils/resolveAzureDevOpsIdentities"; +import { describe } from "node:test"; +import axios from "axios"; + +describe("isHostedAzureDevOps", () => { + it("Old visualstudio url is hosted.", () => { + const url = new URL("https://example.visualstudio.com/abc") + const result = isHostedAzureDevOps(url); + + expect(result).toBeTruthy(); + }); + it("Dev Azure url is hosted.", () => { + const url = new URL("https://dev.azure.com/example") + const result = isHostedAzureDevOps(url); + + expect(result).toBeTruthy(); + }); + it("private url is not hosted.", () => { + const url = new URL("https://tfs.example.com/tfs/Collection") + const result = isHostedAzureDevOps(url); + + expect(result).toBeFalsy(); + }); +}); + + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +const aliceOnPrem = { + id: "any id", + email: "alice@example.com", + providerDisplayName: "Alice" +} + +const aliceHostedId = "any Id" +const aliceHosted = { + descriptor: "aad." + Buffer.from(aliceHostedId, 'utf8').toString('base64'), + email: "alice@example.com", + providerDisplayName: "Alice" +} + +describe("resolveAzureDevOpsIdentities", () => { + it("No email input, is directly returned.", async () => { + const url = new URL("https://example.visualstudio.com/abc") + + const input = ["be9321e2-f404-4ffa-8d6b-44efddb04865"]; + const results = await resolveAzureDevOpsIdentities(url, input); + + const outputs = results.map(identity => identity.id); + expect(outputs).toHaveLength(1); + expect(outputs).toContain(input[0]); + }); + it("successfully resolve id for azure devops server", async () => { + const url = new URL("https://example.onprem.com/abc") + + // Provide the data object to be returned + mockedAxios.get.mockResolvedValue({ + data: { + count: 1, + value: [aliceOnPrem], + }, + status: 200, + }); + + const input = [aliceOnPrem.email]; + const results = await resolveAzureDevOpsIdentities(url, input); + + const outputs = results.map(identity => identity.id); + expect(outputs).toHaveLength(1); + expect(outputs).toContain(aliceOnPrem.id); + }); + it("successfully resolve id for hosted azure devops", async () => { + const url = new URL("https://dev.azure.com/exampleorganization") + + + // Provide the data object to be returned + mockedAxios.post.mockResolvedValue({ + data: { + count: 1, + value: [aliceHosted], + }, + status: 200, + }); + + const input = [aliceHosted.email]; + const results = await resolveAzureDevOpsIdentities(url, input); + + const outputs = results.map(identity => identity.id); + expect(outputs).toHaveLength(1); + expect(outputs).toContain(aliceHostedId); + }); +}); \ No newline at end of file