Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ec6c520
Adding refactored approach to npm
AlexVTor Mar 3, 2026
7142b26
remove dependency on packaging-common
AlexVTor Mar 4, 2026
988f724
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 4, 2026
a1c2d72
Fix test failures + add utility tests
AlexVTor Mar 5, 2026
80abaeb
Naming + test improvements
AlexVTor Mar 9, 2026
2a3ebbd
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 9, 2026
68aad79
capitalization bugfix + utility test
AlexVTor Mar 10, 2026
baa6106
npmv1
AlexVTor Mar 10, 2026
40d836f
Revert "npmv1"
AlexVTor Mar 10, 2026
3c55948
task version bump
AlexVTor Mar 11, 2026
59787dc
Adding utility unit tests
AlexVTor Mar 12, 2026
96fb0ec
remove deprecated url Parse usage
AlexVTor Mar 13, 2026
92497c1
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 13, 2026
b8ed486
try catch on url parsing
AlexVTor Mar 13, 2026
c715ca8
Merge branch 'users/alextorres/NpmAuthenticateV0Refactor' of https://…
AlexVTor Mar 13, 2026
70db543
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 16, 2026
597c937
apply code review suggestions
AlexVTor Mar 17, 2026
e14ddea
wip
AlexVTor Mar 25, 2026
e8df270
code review
AlexVTor Mar 25, 2026
02ee5bf
User helper function in main auth loop + adjust import/export
AlexVTor Mar 26, 2026
19d395b
Code Review: Loc strings, timeouts, better jsonindexing
AlexVTor Mar 26, 2026
33b81bf
adding generated files
AlexVTor Mar 26, 2026
8f56cf9
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 26, 2026
828d059
move loc strings from _generated to base task
AlexVTor Mar 26, 2026
6bbcd45
Merge branch 'users/alextorres/NpmAuthenticateV0Refactor' of https://…
AlexVTor Mar 26, 2026
ebce4e5
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 26, 2026
7581ec7
Merge branch 'master' into users/alextorres/NpmAuthenticateV0Refactor
AlexVTor Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"loc.messages.FailedToGetServiceConnectionAuth": "Unable to get federated credentials from service connection: %s.",
"loc.messages.MissingFeedUrlOrServiceConnection": "If feed url is provided, the 'Azure DevOps' service connection must be provided and cannot be empty.",
"loc.messages.SkippingParsingNpmrc": "Skipping parsing npmrc",
"loc.messages.InvalidRegistryUrl": "Skipping registry '%s' because it is not a valid URL.",
"loc.messages.DuplicateCredentials": "Auth for the registry '%s' was previously set. Overwriting with new configuration.",
"loc.messages.FoundEndpointCredentials": "Found set credentials for the '%s' service connection."
"loc.messages.FoundEndpointCredentials": "Found set credentials for the '%s' service connection.",
"loc.messages.Error_UnableToGetPackagingUris": "Unable to retrieve packaging service URLs.",
"loc.messages.Info_ApplyingWifToAllRegistries": "No feedUrl specified. Applying WIF credentials to all %d registries in .npmrc.",
"loc.messages.Error_UnsupportedAuthScheme": "Unsupported auth scheme '%s' for the service connection. Supported schemes are 'Token' and 'UsernamePassword'.",
"loc.messages.Warning_NpmrcFileNotFound": "npmrc file not found: %s"
}
329 changes: 207 additions & 122 deletions Tasks/NpmAuthenticateV0/Tests/L0.Authentication.ts

Large diffs are not rendered by default.

84 changes: 40 additions & 44 deletions Tasks/NpmAuthenticateV0/Tests/L0.Cleanup.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import * as path from 'path';
import * as fs from 'fs';
import * as ttm from 'azure-pipelines-task-lib/mock-test';
import { TestEnvVars } from './TestConstants';
import { TestHelpers } from './TestHelpers';

// ── Helpers ───────────────────────────────────────────────────────────────────

/**
* Create a SAVE_NPMRC_PATH directory containing an index.json whose entry for
* `npmrcPath` maps to a placeholder original content string.
* The directory is tracked via TestHelpers so afterEach() cleans it up.
* Create a SAVE_NPMRC_PATH directory containing an index.json and a backup file
* in the format NpmrcBackupManager expects: { "nextId": 1, "entries": { "<npmrcPath>": 0 } }
* with a file named "0" holding the original .npmrc content.
*/
function createSaveDir(npmrcPath: string): string {
function createSaveDir(npmrcPath: string, originalContent: string = 'original=https://registry.npmjs.org/\n'): string {
const saveDir = TestHelpers.createTempDir('npm-auth-save-');
const indexContent: { [key: string]: string } = {};
indexContent[npmrcPath] = 'original=https://registry.npmjs.org/\n';
fs.writeFileSync(path.join(saveDir, 'index.json'), JSON.stringify(indexContent), 'utf8');
const index = { nextId: 1, entries: { [npmrcPath]: 0 } };
fs.writeFileSync(path.join(saveDir, 'index.json'), JSON.stringify(index), 'utf8');
// Create the backup file that restoreBackedUpFile() will copy back
fs.writeFileSync(path.join(saveDir, '0'), originalContent, 'utf8');
return saveDir;
}

Expand All @@ -34,82 +34,78 @@ describe('NpmAuthenticate L0 - Cleanup', function () {

it('restores the .npmrc when index.json and working file both exist', async () => {
// Arrange
const originalContent = 'registry=https://registry.npmjs.org/\n';
const npmrcPath = TestHelpers.createTempNpmrc('modified-by-task');
const saveDir = createSaveDir(npmrcPath);
const tp = path.join(__dirname, 'TestSetupCleanup.js');
const tr = new ttm.MockTestRunner(tp);

process.env[TestEnvVars.cleanupNpmrcPath] = npmrcPath;
process.env[TestEnvVars.cleanupSaveNpmrcPath] = saveDir;
const saveDir = createSaveDir(npmrcPath, originalContent);

// Act
await tr.runAsync();
const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.cleanupNpmrcPath]: npmrcPath,
[TestEnvVars.cleanupSaveNpmrcPath]: saveDir
}, 'TestSetupCleanup.js');

// Assert
TestHelpers.assertSuccess(tr);
TestHelpers.assertOutputContains(tr, 'RESTORE_FILE_CALLED');
TestHelpers.assertOutputContains(tr, 'loc_mock_RevertedChangesToNpmrc');
// Verify the file was physically restored
const restoredContent = fs.readFileSync(npmrcPath, 'utf8');
TestHelpers.assertNpmrcContains(npmrcPath, 'registry=https://registry.npmjs.org/');
});

it('logs NoIndexJsonFile when index.json is missing from SAVE_NPMRC_PATH', async () => {
// Arrange — save dir exists but index.json is absent
const npmrcPath = TestHelpers.createTempNpmrc();
const saveDir = TestHelpers.createTempDir('npm-auth-save-'); // no index.json written
const tp = path.join(__dirname, 'TestSetupCleanup.js');
const tr = new ttm.MockTestRunner(tp);

process.env[TestEnvVars.cleanupNpmrcPath] = npmrcPath;
process.env[TestEnvVars.cleanupSaveNpmrcPath] = saveDir;
process.env[TestEnvVars.cleanupIndexShouldExist] = 'false';

// Act
await tr.runAsync();
const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.cleanupNpmrcPath]: npmrcPath,
[TestEnvVars.cleanupSaveNpmrcPath]: saveDir,
[TestEnvVars.cleanupIndexShouldExist]: 'false'
}, 'TestSetupCleanup.js');

// Assert
TestHelpers.assertSuccess(tr);
TestHelpers.assertOutputNotContains(tr, 'RESTORE_FILE_CALLED');
TestHelpers.assertOutputNotContains(tr, 'loc_mock_RevertedChangesToNpmrc');
TestHelpers.assertOutputContains(tr, 'loc_mock_NoIndexJsonFile');
});

it('logs NoIndexJsonFile when the working .npmrc file does not exist', async () => {
// Arrange — index.json is present but the .npmrc it references is gone
const npmrcPath = TestHelpers.createTempNpmrc();
const saveDir = createSaveDir(npmrcPath);
const tp = path.join(__dirname, 'TestSetupCleanup.js');
const tr = new ttm.MockTestRunner(tp);

process.env[TestEnvVars.cleanupNpmrcPath] = npmrcPath;
process.env[TestEnvVars.cleanupSaveNpmrcPath] = saveDir;
process.env[TestEnvVars.cleanupNpmrcShouldExist] = 'false'; // simulate deleted .npmrc

// Act
await tr.runAsync();
const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.cleanupNpmrcPath]: npmrcPath,
[TestEnvVars.cleanupSaveNpmrcPath]: saveDir,
[TestEnvVars.cleanupNpmrcShouldExist]: 'false'
}, 'TestSetupCleanup.js');

// Assert
TestHelpers.assertSuccess(tr);
TestHelpers.assertOutputNotContains(tr, 'RESTORE_FILE_CALLED');
TestHelpers.assertOutputNotContains(tr, 'loc_mock_RevertedChangesToNpmrc');
TestHelpers.assertOutputContains(tr, 'loc_mock_NoIndexJsonFile');
});

it('removes the temp directory when SAVE_NPMRC_PATH contains only the index file', async () => {
// Arrange — save dir has exactly 1 file (index.json) so the rmRF branch is reached
// Arrange — after restore, only index.json remains so the rmRF branch triggers.
// createSaveDir adds both index.json and the backup file "0".
// After restoreBackedUpFile runs, it deletes "0", leaving only index.json.
const npmrcPath = TestHelpers.createTempNpmrc('modified-by-task');
const saveDir = createSaveDir(npmrcPath); // 1 file: index.json
const saveDir = createSaveDir(npmrcPath);
const tempDir = TestHelpers.createTempDir('npm-auth-temp-');
const tp = path.join(__dirname, 'TestSetupCleanup.js');
const tr = new ttm.MockTestRunner(tp);

process.env[TestEnvVars.cleanupNpmrcPath] = npmrcPath;
process.env[TestEnvVars.cleanupSaveNpmrcPath] = saveDir;
process.env[TestEnvVars.cleanupTempDirectory] = tempDir;
process.env[TestEnvVars.cleanupTempDirExists] = 'true';

// Act
await tr.runAsync();
const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.cleanupNpmrcPath]: npmrcPath,
[TestEnvVars.cleanupSaveNpmrcPath]: saveDir,
[TestEnvVars.cleanupTempDirectory]: tempDir,
[TestEnvVars.cleanupTempDirExists]: 'true'
}, 'TestSetupCleanup.js');

// Assert — task reaches the rmRF branch without throwing
// Assert
TestHelpers.assertSuccess(tr);
TestHelpers.assertOutputContains(tr, 'RESTORE_FILE_CALLED');
TestHelpers.assertOutputContains(tr, 'loc_mock_RevertedChangesToNpmrc');
});
});
88 changes: 88 additions & 0 deletions Tasks/NpmAuthenticateV0/Tests/L0.EndpointCredential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Tests that exercise the real resolveServiceEndpointCredential logic
// (npmrcCredential.ts) via TestSetupEndpointCredential, which provides
// endpoint auth through ENDPOINT_AUTH_* env vars instead of mocking.

import { TestEnvVars, TestData } from './TestConstants';
import { TestHelpers } from './TestHelpers';

describe('NpmAuthenticate L0 - Endpoint Credential Resolution', function () {
this.timeout(20000);

beforeEach(function () { TestHelpers.beforeEach(); });
afterEach(function () { TestHelpers.afterEach(); });

describe('Token auth on external registry', function () {
it('writes bearer _authToken for an external Token endpoint', async () => {
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.externalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.customEndpoint]: TestData.externalEndpointId,
[TestEnvVars.externalRegistryUrl]: TestData.externalRegistryUrl,
[TestEnvVars.externalRegistryToken]: TestData.externalRegistryToken,
[TestEnvVars.endpointAuthScheme]: 'Token'
}, 'TestSetupEndpointCredential.js');

TestHelpers.assertSuccess(tr);
TestHelpers.assertNpmrcContains(npmrcPath, `_authToken=${TestData.externalRegistryToken}`);
TestHelpers.assertNpmrcContains(npmrcPath, 'always-auth=true');
});
});

describe('Token auth on internal (Azure DevOps) registry', function () {
it('writes basic auth for an internal Token endpoint', async () => {
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.externalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.customEndpoint]: TestData.externalEndpointId,
[TestEnvVars.externalRegistryUrl]: TestData.externalRegistryUrl,
[TestEnvVars.externalRegistryToken]: TestData.externalRegistryToken,
[TestEnvVars.endpointAuthScheme]: 'Token',
[TestEnvVars.isInternalEndpoint]: 'true'
}, 'TestSetupEndpointCredential.js');

TestHelpers.assertSuccess(tr);
TestHelpers.assertNpmrcContains(npmrcPath, 'username=VssToken');
TestHelpers.assertNpmrcContains(npmrcPath, '_password=');
TestHelpers.assertNpmrcNotContains(npmrcPath, '_authToken=');
});
});

describe('UsernamePassword auth', function () {
it('writes basic auth with the provided username', async () => {
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.externalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.customEndpoint]: TestData.externalEndpointId,
[TestEnvVars.externalRegistryUrl]: TestData.externalRegistryUrl,
[TestEnvVars.endpointAuthScheme]: 'UsernamePassword',
[TestEnvVars.endpointUsername]: 'myuser',
[TestEnvVars.endpointPassword]: 'mypassword'
}, 'TestSetupEndpointCredential.js');

TestHelpers.assertSuccess(tr);
TestHelpers.assertNpmrcContains(npmrcPath, 'username=myuser');
TestHelpers.assertNpmrcContains(npmrcPath, '_password=');
TestHelpers.assertNpmrcContains(npmrcPath, 'email=myuser');
});
});

describe('Telemetry with real credential resolution', function () {
it('records ExternalFeedAuthCount', async () => {
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.externalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.customEndpoint]: TestData.externalEndpointId,
[TestEnvVars.externalRegistryUrl]: TestData.externalRegistryUrl,
[TestEnvVars.externalRegistryToken]: TestData.externalRegistryToken,
[TestEnvVars.endpointAuthScheme]: 'Token'
}, 'TestSetupEndpointCredential.js');

TestHelpers.assertSuccess(tr);
TestHelpers.assertOutputContains(tr, '"ExternalFeedAuthCount":1');
});
});
});
55 changes: 55 additions & 0 deletions Tasks/NpmAuthenticateV0/Tests/L0.ErrorPaths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { TestEnvVars, TestData } from './TestConstants';
import { TestHelpers } from './TestHelpers';

describe('NpmAuthenticate L0 - Error Paths', function () {
this.timeout(20000);

beforeEach(function () { TestHelpers.beforeEach(); });
afterEach(function () { TestHelpers.afterEach(); });

it('fails when packaging location cannot be resolved', async () => {
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.internalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.packagingLocationShouldFail]: 'true'
});

TestHelpers.assertFailure(tr, 'Task should fail when packaging URIs cannot be resolved');
// Telemetry should still be emitted (finally block)
TestHelpers.assertOutputContains(tr, `${TestData.telemetryPrefix}Packaging.NpmAuthenticateV0`);
});

it('falls back to bearer auth when HTTP probe fails', async () => {
// When isEndpointInternal throws a network error, the endpoint should
// be treated as external (bearer _authToken, not basic VssToken)
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.externalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.customEndpoint]: TestData.externalEndpointId,
[TestEnvVars.externalRegistryUrl]: TestData.externalRegistryUrl,
[TestEnvVars.externalRegistryToken]: TestData.externalRegistryToken,
[TestEnvVars.endpointAuthScheme]: 'Token',
[TestEnvVars.httpProbeShouldFail]: 'true'
}, 'TestSetupEndpointCredential.js');

TestHelpers.assertSuccess(tr);
// Should use bearer auth (external fallback), not basic auth
TestHelpers.assertNpmrcContains(npmrcPath, `_authToken=${TestData.externalRegistryToken}`);
TestHelpers.assertNpmrcNotContains(npmrcPath, 'username=VssToken');
});

it('fails when service endpoint is not configured', async () => {
// customEndpoint references a non-existent endpoint — no ENDPOINT_URL/AUTH env vars
const npmrcPath = TestHelpers.createTempNpmrc(`registry=${TestData.externalRegistryUrl}`);

const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: npmrcPath,
[TestEnvVars.customEndpoint]: 'NonExistentEndpoint'
// Deliberately not setting ENDPOINT_URL or ENDPOINT_AUTH for this endpoint
}, 'TestSetupEndpointCredential.js');

TestHelpers.assertFailure(tr, 'Task should fail when endpoint is not configured');
});
});
19 changes: 8 additions & 11 deletions Tasks/NpmAuthenticateV0/Tests/L0.InputValidation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as path from 'path';
import * as os from 'os';
import * as ttm from 'azure-pipelines-task-lib/mock-test';
import * as path from 'path';
import { TestEnvVars } from './TestConstants';
import { TestHelpers } from './TestHelpers';

Expand All @@ -17,13 +16,12 @@ describe('NpmAuthenticate L0 - Input Validation', function () {

it('fails when workingFile does not have an .npmrc extension', async () => {
// Arrange: point workingFile at a non-.npmrc file path
const tp = path.join(__dirname, 'TestSetup.js');
const tr = new ttm.MockTestRunner(tp);
const notNpmrc = path.join(os.tmpdir(), 'package.json');
process.env[TestEnvVars.npmrcPath] = notNpmrc;

// Act
await tr.runAsync();
const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: notNpmrc
});

// Assert: task must fail because the extension check fires before any file I/O
TestHelpers.assertFailure(tr, 'Task should fail when workingFile does not end in .npmrc');
Expand All @@ -32,14 +30,13 @@ describe('NpmAuthenticate L0 - Input Validation', function () {

it('fails when the .npmrc file does not exist on disk', async () => {
// Arrange: valid .npmrc extension but file is not present
const tp = path.join(__dirname, 'TestSetup.js');
const tr = new ttm.MockTestRunner(tp);
const missingFile = path.join(os.tmpdir(), 'does-not-exist.npmrc');
process.env[TestEnvVars.npmrcPath] = missingFile;
process.env[TestEnvVars.npmrcShouldExist] = 'false'; // answer tl.exist() as false

// Act
await tr.runAsync();
const tr = await TestHelpers.runTestWithEnv({
[TestEnvVars.npmrcPath]: missingFile,
[TestEnvVars.npmrcShouldExist]: 'false'
});

// Assert
TestHelpers.assertFailure(tr, 'Task should fail when the .npmrc file is missing');
Expand Down
Loading