Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Node.Tests/Node/NodeLockDataTests.cs
2026-01-08 08:54:27 +02:00

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
}