1115 lines
38 KiB
C#
1115 lines
38 KiB
C#
using StellaOps.Scanner.Analyzers.Lang.Node.Internal;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.Scanner.Analyzers.Lang.Node.Tests.Node;
|
|
|
|
public sealed class NodeLockDataTests : IDisposable
|
|
{
|
|
private readonly string _tempDir;
|
|
|
|
public NodeLockDataTests()
|
|
{
|
|
_tempDir = Path.Combine(Path.GetTempPath(), "node-lock-tests-" + Guid.NewGuid().ToString("N")[..8]);
|
|
Directory.CreateDirectory(_tempDir);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_tempDir))
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(_tempDir, recursive: true);
|
|
}
|
|
catch
|
|
{
|
|
// Ignore cleanup failures in tests
|
|
}
|
|
}
|
|
}
|
|
|
|
#region LoadAsync Orchestration Tests
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_NoLockfiles_ReturnsEmpty()
|
|
{
|
|
// No lockfiles, no package.json
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Empty(result.DeclaredPackages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_EmptyRootPath_DoesNotThrow_WhenCurrentDirectoryHasPackageJson()
|
|
{
|
|
var originalDirectory = Environment.CurrentDirectory;
|
|
var tempDirectory = Path.Combine(Path.GetTempPath(), "node-lock-tests-cwd-" + Guid.NewGuid().ToString("N")[..8]);
|
|
Directory.CreateDirectory(tempDirectory);
|
|
|
|
try
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(tempDirectory, "package.json"), """
|
|
{
|
|
"name": "fixture",
|
|
"version": "0.0.0"
|
|
}
|
|
""");
|
|
|
|
Environment.CurrentDirectory = tempDirectory;
|
|
|
|
var result = await NodeLockData.LoadAsync(string.Empty, TestContext.Current.CancellationToken);
|
|
Assert.Empty(result.DeclaredPackages);
|
|
}
|
|
finally
|
|
{
|
|
Environment.CurrentDirectory = originalDirectory;
|
|
Directory.Delete(tempDirectory, recursive: true);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_OnlyPackageJson_CreatesDeclaredOnlyEntries()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), """
|
|
{
|
|
"name": "test",
|
|
"version": "1.0.0",
|
|
"dependencies": {
|
|
"lodash": "^4.17.21"
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.First();
|
|
Assert.Equal("lodash", entry.Name);
|
|
Assert.Equal("^4.17.21", entry.Version);
|
|
Assert.Equal("package.json", entry.Source);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_ResultsAreSortedDeterministically()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), """
|
|
{
|
|
"dependencies": {
|
|
"zeta": "^1.0.0",
|
|
"alpha": "^2.0.0",
|
|
"beta": "^1.0.0"
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
var names = result.DeclaredPackages.Select(x => x.Name).ToArray();
|
|
Assert.Equal(["alpha", "beta", "zeta"], names);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_PackageLockTakesPrecedence_OverDeclaredOnly()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), """
|
|
{
|
|
"dependencies": {
|
|
"lodash": "^4.17.0"
|
|
}
|
|
}
|
|
""");
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"name": "test",
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/lodash": {
|
|
"version": "4.17.21",
|
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.First();
|
|
Assert.Equal("lodash", entry.Name);
|
|
Assert.Equal("4.17.21", entry.Version);
|
|
Assert.Equal("package-lock.json", entry.Source);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_CancellationToken_IsRespected()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/test": { "version": "1.0.0" }
|
|
}
|
|
}
|
|
""");
|
|
|
|
var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
|
await NodeLockData.LoadAsync(_tempDir, cts.Token));
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region package-lock.json v3+ Parsing Tests
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_ParsesPackages()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/express": {
|
|
"version": "4.18.2",
|
|
"resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
|
"integrity": "sha512-abc123"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.First();
|
|
Assert.Equal("express", entry.Name);
|
|
Assert.Equal("4.18.2", entry.Version);
|
|
Assert.Equal("https://registry.npmjs.org/express/-/express-4.18.2.tgz", entry.Resolved);
|
|
Assert.Equal("sha512-abc123", entry.Integrity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_ExtractsNameFromPath()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/express": {
|
|
"version": "4.18.2"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("express", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_ScopedPackages()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/@angular/core": {
|
|
"version": "17.0.0"
|
|
},
|
|
"node_modules/@types/node": {
|
|
"version": "20.10.0"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@angular/core" && e.Version == "17.0.0");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@types/node" && e.Version == "20.10.0");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_SkipsEntriesWithNoVersionOrResolvedOrIntegrity()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"": {
|
|
"name": "test-project",
|
|
"license": "MIT"
|
|
},
|
|
"node_modules/valid": {
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("valid", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_NestedNodeModules()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/parent": {
|
|
"version": "1.0.0"
|
|
},
|
|
"node_modules/parent/node_modules/child": {
|
|
"version": "2.0.0"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "parent");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "child");
|
|
|
|
Assert.True(result.TryGet("node_modules/parent/node_modules/child", "child", "2.0.0", out var entry));
|
|
Assert.NotNull(entry);
|
|
Assert.Equal("2.0.0", entry!.Version);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_NestedNodeModules_ScopedChild()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/parent": {
|
|
"version": "1.0.0"
|
|
},
|
|
"node_modules/parent/node_modules/@types/node": {
|
|
"version": "20.10.0"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "parent");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@types/node");
|
|
|
|
Assert.True(result.TryGet("node_modules/parent/node_modules/@types/node", "@types/node", "20.10.0", out var entry));
|
|
Assert.NotNull(entry);
|
|
Assert.Equal("20.10.0", entry!.Version);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_V3Format_ExplicitNameOverridesPath()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/aliased": {
|
|
"name": "actual-package",
|
|
"version": "1.0.0"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("actual-package", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region package-lock.json Legacy Parsing Tests
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_LegacyFormat_ParsesDependencies()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 1,
|
|
"dependencies": {
|
|
"lodash": {
|
|
"version": "4.17.21",
|
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
"integrity": "sha512-xyz"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.First();
|
|
Assert.Equal("lodash", entry.Name);
|
|
Assert.Equal("4.17.21", entry.Version);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_LegacyFormat_NestedDependencies()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 1,
|
|
"dependencies": {
|
|
"parent": {
|
|
"version": "1.0.0",
|
|
"dependencies": {
|
|
"child": {
|
|
"version": "2.0.0"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "parent");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "child");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_LegacyFormat_ScopedPackages()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 1,
|
|
"dependencies": {
|
|
"@babel/core": {
|
|
"version": "7.23.0"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("@babel/core", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPackageLockJson_MalformedJson_ContinuesGracefully()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{ this is not valid json }
|
|
""");
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"), """
|
|
lodash@^4.17.21:
|
|
version "4.17.21"
|
|
""");
|
|
|
|
// Should continue with yarn.lock parsing
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("lodash", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region yarn.lock Parsing Tests
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_ParsesBasicEntry()
|
|
{
|
|
// Parser expects quoted values using ExtractQuotedValue
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
@"# yarn lockfile v1
|
|
|
|
lodash@^4.17.21:
|
|
version ""4.17.21""
|
|
resolved ""https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz""
|
|
integrity ""sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ""
|
|
");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.First();
|
|
Assert.Equal("lodash", entry.Name);
|
|
Assert.Equal("4.17.21", entry.Version);
|
|
Assert.StartsWith("https://registry.yarnpkg.com", entry.Resolved);
|
|
Assert.StartsWith("sha512-", entry.Integrity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_ScopedPackages()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
@"""@babel/core@^7.23.0"":
|
|
version ""7.23.0""
|
|
|
|
""@types/node@^20.0.0"":
|
|
version ""20.10.0""
|
|
");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@babel/core");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@types/node");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_MultipleVersionConstraints()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
@"""lodash@^4.0.0, lodash@^4.17.0, lodash@^4.17.21"":
|
|
version ""4.17.21""
|
|
");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("lodash", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_QuotedPackageKey()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
@"""express@^4.18.0"":
|
|
version ""4.18.2""
|
|
");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("express", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_FlushesAtEOF()
|
|
{
|
|
// No trailing newline - should still parse (integrity must be quoted)
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
"lodash@^4.17.21:\n version \"4.17.21\"\n resolved \"https://example.com/lodash.tgz\"\n integrity \"sha512-abc\"");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("lodash", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_MultiplePackages()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
@"express@^4.18.0:
|
|
version ""4.18.2""
|
|
|
|
lodash@^4.17.21:
|
|
version ""4.17.21""
|
|
|
|
axios@^1.6.0:
|
|
version ""1.6.2""
|
|
");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(3, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "express");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "lodash");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "axios");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_HandlesUnusualPackageKeys()
|
|
{
|
|
// Keys without @ separator are kept as-is as the package name
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
@"""@scope/package@^1.0.0"":
|
|
version ""1.0.0""
|
|
|
|
valid@^2.0.0:
|
|
version ""2.0.0""
|
|
");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@scope/package");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "valid");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadYarnLock_BerryFormat_ParsesResolutionChecksum_AndSkipsMetadata()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"), """
|
|
__metadata:
|
|
version: 6
|
|
|
|
"lodash@npm:^4.17.21":
|
|
version: 4.17.21
|
|
resolution: "lodash@npm:4.17.21"
|
|
checksum: 10c0deadbeef
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.DoesNotContain(result.DeclaredPackages, e => e.Name == "__metadata");
|
|
|
|
var entry = result.DeclaredPackages.Single();
|
|
Assert.Equal("lodash", entry.Name);
|
|
Assert.Equal("4.17.21", entry.Version);
|
|
Assert.Equal("lodash@npm:4.17.21", entry.Resolved);
|
|
Assert.Equal("checksum:10c0deadbeef", entry.Integrity);
|
|
|
|
Assert.True(result.TryGet("", "lodash", "4.17.21", out var byVersion));
|
|
Assert.NotNull(byVersion);
|
|
Assert.Equal("lodash@npm:^4.17.21", byVersion!.Locator);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region pnpm-lock.yaml Parsing Tests
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_ParsesBasicEntry()
|
|
{
|
|
// pnpm-lock.yaml format: package keys start with " /" and use /package/version format
|
|
// Version line is required for entry to be added to DeclaredPackages
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /lodash/4.17.21:\n" +
|
|
" version: 4.17.21\n" +
|
|
" resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.First();
|
|
Assert.Equal("lodash", entry.Name);
|
|
Assert.Equal("4.17.21", entry.Version);
|
|
Assert.StartsWith("sha512-", entry.Integrity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_ScopedPackages()
|
|
{
|
|
// Scoped packages use /@scope/package/version format
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /@angular/core/17.0.0:\n" +
|
|
" version: 17.0.0\n" +
|
|
" resolution: {integrity: sha512-abc123}\n" +
|
|
" /@types/node/20.10.0:\n" +
|
|
" version: 20.10.0\n" +
|
|
" resolution: {integrity: sha512-def456}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@angular/core");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "@types/node");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_ExtractsVersion()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /express/4.18.2:\n" +
|
|
" version: 4.18.2\n" +
|
|
" resolution: {integrity: sha512-xyz}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("4.18.2", result.DeclaredPackages.First().Version);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_WhenVersionLineMissing_UsesVersionFromKey()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /express/4.18.2:\n" +
|
|
" resolution: {integrity: sha512-xyz}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.Single();
|
|
Assert.Equal("express", entry.Name);
|
|
Assert.Equal("4.18.2", entry.Version);
|
|
Assert.Equal("sha512-xyz", entry.Integrity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_ExtractsTarball()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /lodash/4.17.21:\n" +
|
|
" version: 4.17.21\n" +
|
|
" resolution: {tarball: https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz, integrity: sha512-abc}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Contains("lodash-4.17.21.tgz", result.DeclaredPackages.First().Resolved);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_IntegrityMissingReason_File()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /local-file/1.0.0:\n" +
|
|
" resolution: {tarball: file:../local-file-1.0.0.tgz}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.Single();
|
|
Assert.Equal("local-file", entry.Name);
|
|
Assert.True(entry.IntegrityMissing);
|
|
Assert.Equal("file", entry.IntegrityMissingReason);
|
|
Assert.StartsWith("file:", entry.Resolved, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_SnapshotsSection_IsParsed()
|
|
{
|
|
var content = "lockfileVersion: '9.0'\n" +
|
|
"snapshots:\n" +
|
|
" /snap-only/1.0.0:\n" +
|
|
" resolution: {integrity: sha512-snap}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
var entry = result.DeclaredPackages.Single();
|
|
Assert.Equal("snap-only", entry.Name);
|
|
Assert.Equal("1.0.0", entry.Version);
|
|
Assert.Equal("sha512-snap", entry.Integrity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_SeparateIntegrityLine()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /express/4.18.2:\n" +
|
|
" version: 4.18.2\n" +
|
|
" integrity: sha512-separate-line-integrity\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("sha512-separate-line-integrity", result.DeclaredPackages.First().Integrity);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_PackagesWithoutIntegrity_AreKeptAndMarked()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /no-integrity/1.0.0:\n" +
|
|
" version: 1.0.0\n" +
|
|
" /has-integrity/2.0.0:\n" +
|
|
" version: 2.0.0\n" +
|
|
" resolution: {integrity: sha512-valid}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(2, result.DeclaredPackages.Count);
|
|
|
|
var noIntegrity = result.DeclaredPackages.Single(e => e.Name == "no-integrity");
|
|
Assert.Equal("1.0.0", noIntegrity.Version);
|
|
Assert.Null(noIntegrity.Integrity);
|
|
Assert.True(noIntegrity.IntegrityMissing);
|
|
Assert.Equal("missing", noIntegrity.IntegrityMissingReason);
|
|
|
|
var hasIntegrity = result.DeclaredPackages.Single(e => e.Name == "has-integrity");
|
|
Assert.Equal("2.0.0", hasIntegrity.Version);
|
|
Assert.Equal("sha512-valid", hasIntegrity.Integrity);
|
|
Assert.False(hasIntegrity.IntegrityMissing);
|
|
Assert.Null(hasIntegrity.IntegrityMissingReason);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadPnpmLock_MultiplePackages()
|
|
{
|
|
var content = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /express/4.18.2:\n" +
|
|
" version: 4.18.2\n" +
|
|
" resolution: {integrity: sha512-express}\n" +
|
|
" /lodash/4.17.21:\n" +
|
|
" version: 4.17.21\n" +
|
|
" resolution: {integrity: sha512-lodash}\n" +
|
|
" /axios/1.6.2:\n" +
|
|
" version: 1.6.2\n" +
|
|
" resolution: {integrity: sha512-axios}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), content);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(3, result.DeclaredPackages.Count);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region TryGet Tests
|
|
|
|
[Fact]
|
|
public async Task TryGet_ByPath_ReturnsEntry()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/lodash": {
|
|
"version": "4.17.21"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var lockData = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(lockData.TryGet("node_modules/lodash", "lodash", out var entry));
|
|
Assert.NotNull(entry);
|
|
Assert.Equal("lodash", entry!.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGet_ByName_ReturnsEntry()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"), """
|
|
lodash@^4.17.21:
|
|
version "4.17.21"
|
|
""");
|
|
|
|
var lockData = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(lockData.TryGet("", "lodash", out var entry));
|
|
Assert.NotNull(entry);
|
|
Assert.Equal("lodash", entry!.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGet_NotFound_ReturnsFalse()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/lodash": {
|
|
"version": "4.17.21"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var lockData = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.False(lockData.TryGet("node_modules/express", "express", out var entry));
|
|
Assert.Null(entry);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGet_NormalizesBackslashes()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/lodash": {
|
|
"version": "4.17.21"
|
|
}
|
|
}
|
|
}
|
|
""");
|
|
|
|
var lockData = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(lockData.TryGet("node_modules\\lodash", "lodash", out var entry));
|
|
Assert.NotNull(entry);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region DependencyIndex Integration Tests
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_SetsScope_FromPackageJson()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), """
|
|
{
|
|
"dependencies": {
|
|
"lodash": "^4.17.21"
|
|
},
|
|
"devDependencies": {
|
|
"jest": "^29.0.0"
|
|
},
|
|
"optionalDependencies": {
|
|
"fsevents": "^2.3.0"
|
|
}
|
|
}
|
|
""");
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/lodash": { "version": "4.17.21" },
|
|
"node_modules/jest": { "version": "29.7.0" },
|
|
"node_modules/fsevents": { "version": "2.3.3" }
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
var lodash = result.DeclaredPackages.First(e => e.Name == "lodash");
|
|
Assert.Equal(NodeDependencyScope.Production, lodash.Scope);
|
|
Assert.False(lodash.IsOptional);
|
|
|
|
var jest = result.DeclaredPackages.First(e => e.Name == "jest");
|
|
Assert.Equal(NodeDependencyScope.Development, jest.Scope);
|
|
Assert.False(jest.IsOptional);
|
|
|
|
var fsevents = result.DeclaredPackages.First(e => e.Name == "fsevents");
|
|
Assert.Equal(NodeDependencyScope.Optional, fsevents.Scope);
|
|
Assert.True(fsevents.IsOptional);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_DependencyIndex_IsAccessible()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package.json"), """
|
|
{
|
|
"dependencies": {
|
|
"express": "^4.18.0"
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(result.DependencyIndex.TryGetScope("express", out var scope));
|
|
Assert.Equal(NodeDependencyScope.Production, scope);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Edge Cases
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_EmptyPackageLock_ReturnsEmpty()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Empty(result.DeclaredPackages);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_AllThreeLockfiles_MergesCorrectly()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/from-npm": { "version": "1.0.0" }
|
|
}
|
|
}
|
|
""");
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"),
|
|
"from-yarn@^2.0.0:\n version \"2.0.0\"\n");
|
|
var pnpmContent = "lockfileVersion: '6.0'\n" +
|
|
"packages:\n" +
|
|
" /from-pnpm/3.0.0:\n" +
|
|
" version: 3.0.0\n" +
|
|
" resolution: {integrity: sha512-pnpm}\n";
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "pnpm-lock.yaml"), pnpmContent);
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Equal(3, result.DeclaredPackages.Count);
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "from-npm");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "from-yarn");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "from-pnpm");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_PathWithLeadingDotSlash_Normalized()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"./node_modules/lodash": { "version": "4.17.21" }
|
|
}
|
|
}
|
|
""");
|
|
|
|
var lockData = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.True(lockData.TryGet("node_modules/lodash", "lodash", out var entry));
|
|
Assert.NotNull(entry);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_DuplicatePackages_BothVersionsKeptSeparately()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/lodash": { "version": "4.17.21" }
|
|
}
|
|
}
|
|
""");
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "yarn.lock"), """
|
|
lodash@^4.0.0:
|
|
version "4.0.0"
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
// Both entries are kept in DeclaredPackages with different version keys
|
|
Assert.Equal(2, result.DeclaredPackages.Count(e => e.Name == "lodash"));
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "lodash" && e.Version == "4.17.21");
|
|
Assert.Contains(result.DeclaredPackages, e => e.Name == "lodash" && e.Version == "4.0.0");
|
|
|
|
// For TryGet lookups by name, yarn.lock overwrites the byName dictionary (loaded second)
|
|
Assert.True(result.TryGet("", "lodash", out var byNameEntry));
|
|
Assert.Equal("4.0.0", byNameEntry!.Version);
|
|
|
|
Assert.True(result.TryGet("", "lodash", "4.17.21", out var byVersionEntry));
|
|
Assert.Equal("4.17.21", byVersionEntry!.Version);
|
|
|
|
Assert.True(result.TryGet("", "lodash", "4.0.0", out var byVersionEntry2));
|
|
Assert.Equal("4.0.0", byVersionEntry2!.Version);
|
|
|
|
// For TryGet lookups by path, package-lock.json entry is found
|
|
Assert.True(result.TryGet("node_modules/lodash", "", out var byPathEntry));
|
|
Assert.Equal("4.17.21", byPathEntry!.Version);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoadAsync_UnicodePackageNames()
|
|
{
|
|
await File.WriteAllTextAsync(Path.Combine(_tempDir, "package-lock.json"), """
|
|
{
|
|
"lockfileVersion": 3,
|
|
"packages": {
|
|
"node_modules/日本語": { "version": "1.0.0" }
|
|
}
|
|
}
|
|
""");
|
|
|
|
var result = await NodeLockData.LoadAsync(_tempDir, TestContext.Current.CancellationToken);
|
|
|
|
Assert.Single(result.DeclaredPackages);
|
|
Assert.Equal("日本語", result.DeclaredPackages.First().Name);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Helper Method Tests
|
|
|
|
[Theory]
|
|
[InlineData(" version \"1.0.0\"", "1.0.0")]
|
|
[InlineData("version \"2.0.0\"", "2.0.0")]
|
|
[InlineData("resolved \"https://example.com/pkg.tgz\"", "https://example.com/pkg.tgz")]
|
|
[InlineData("no quotes here", null)]
|
|
[InlineData("\"single quote\"", "single quote")]
|
|
[InlineData("value \"\"", "")]
|
|
public void ExtractQuotedValue_Scenarios(string input, string? expected)
|
|
{
|
|
// ExtractQuotedValue is private, but we can test it indirectly through yarn.lock parsing
|
|
// For now, we'll just document the expected behavior in these theories
|
|
Assert.True(true); // Placeholder - behavior tested through LoadYarnLock tests
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("lodash@^4.17.21", "lodash")]
|
|
[InlineData("\"lodash@^4.17.21\"", "lodash")]
|
|
[InlineData("@babel/core@^7.23.0", "@babel/core")]
|
|
[InlineData("lodash@^4.0.0, lodash@^4.17.0", "lodash")]
|
|
public void ExtractPackageNameFromYarnKey_Scenarios(string key, string expectedName)
|
|
{
|
|
// Tested indirectly through LoadYarnLock tests
|
|
Assert.True(true); // Placeholder
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("/lodash@4.17.21", "lodash")]
|
|
[InlineData("@angular/core@17.0.0", "@angular/core")]
|
|
[InlineData("/@types/node@20.10.0", "@types/node")]
|
|
[InlineData("express@4.18.2", "express")]
|
|
public void ExtractNameFromPnpmKey_Scenarios(string key, string expectedName)
|
|
{
|
|
// Tested indirectly through LoadPnpmLock tests
|
|
Assert.True(true); // Placeholder
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("node_modules/lodash", "lodash")]
|
|
[InlineData("node_modules/@angular/core", "@angular/core")]
|
|
[InlineData("node_modules/parent/node_modules/child", "child")]
|
|
[InlineData("", "")]
|
|
[InlineData("./node_modules/express", "express")]
|
|
public void ExtractNameFromPath_Scenarios(string path, string expectedName)
|
|
{
|
|
// Tested indirectly through LoadPackageLockJson tests
|
|
Assert.True(true); // Placeholder
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("node_modules/lodash", "node_modules/lodash")]
|
|
[InlineData("node_modules\\lodash", "node_modules/lodash")]
|
|
[InlineData("./node_modules/lodash", "node_modules/lodash")]
|
|
[InlineData(".\\node_modules\\lodash", "node_modules/lodash")]
|
|
[InlineData("", "")]
|
|
public void NormalizeLockPath_Scenarios(string input, string expected)
|
|
{
|
|
// Tested indirectly through TryGet tests
|
|
Assert.True(true); // Placeholder
|
|
}
|
|
|
|
#endregion
|
|
}
|