Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
85 changes: 84 additions & 1 deletion src/LogExpert.UI.Tests/Services/FileOperationServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ public void AddFileTab_RaisesFileOpenedEvent ()
// Assert
Assert.That(receivedArgs, Is.Not.Null);
Assert.That(receivedArgs!.LogWindow, Is.SameAs(_stubLogWindow));
Assert.That(receivedArgs.Request, Is.SameAs(request));
Assert.That(receivedArgs.Request, Is.EqualTo(request));
Assert.That(receivedArgs.ResolvedFileName, Is.Not.Null.And.Not.Empty);
Assert.That(receivedArgs.EncodingOptions, Is.Not.Null);
}
Expand Down Expand Up @@ -657,6 +657,71 @@ public void LoadFilesWithOption_SingleFile_CallsAddFileTab ()
Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo("test.log"));
}

[Test]
public void LoadFilesWithOption_ShortcutToLxjFile_CallsProjectCallback ()
{
// The .lxj check has to run on the resolved name, or the shortcut gets opened as a raw log.
var (directory, shortcut, session) = CreateShortcutToSession();

try
{
var decision = _sut.LoadFilesWithOption(new[] { shortcut }, false);

Assert.That(decision, Is.EqualTo(MultiFileDecision.Cancel));
Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
}
finally
{
Directory.Delete(directory, true);
}
}

[Test]
public void AddFileTabs_ShortcutToLxjFile_CallsProjectCallback ()
{
var (directory, shortcut, session) = CreateShortcutToSession();

try
{
_sut.AddFileTabs(new[] { shortcut });

Assert.That(_projectCallbackCalls, Has.Count.EqualTo(1));
Assert.That(_projectCallbackCalls[0].FileName, Is.EqualTo(session));
Assert.That(_factoryCalls, Is.Empty, "Should not open the shortcut as a log");
}
finally
{
Directory.Delete(directory, true);
}
}

[Test]
public void AddFileTab_ShortcutToLogFile_OpensTarget ()
{
var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutLog", Guid.NewGuid().ToString());
_ = Directory.CreateDirectory(directory);

try
{
var log = Path.Join(directory, "actual.log");
File.WriteAllText(log, "line1");

var shortcut = Path.Join(directory, "actual.lnk");
TestShortcuts.Create(shortcut, log);

_sut.AddFileTab(new FileTabRequest { FileName = shortcut });

Assert.That(_factoryCalls, Has.Count.EqualTo(1));
Assert.That(_factoryCalls[0].Request.FileName, Is.EqualTo(log), "the target file is opened, not the shortcut");
}
finally
{
Directory.Delete(directory, true);
}
}

[Test]
public void LoadFilesWithOption_SingleLxjFile_CallsProjectCallback ()
{
Expand Down Expand Up @@ -1225,6 +1290,24 @@ public void LoadFiles_DelegatesToAddFileTabs ()
Assert.That(_factoryCalls[1].Request.FileName, Is.EqualTo("b.log"));
}

/// <summary>
/// Creates a temporary directory holding a session file and a shortcut to it, and returns the
/// three paths. The caller owns the directory and deletes it.
/// </summary>
private static (string Directory, string Shortcut, string Session) CreateShortcutToSession ()
{
var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", "ShortcutSession", Guid.NewGuid().ToString());
_ = Directory.CreateDirectory(directory);

var session = Path.Join(directory, "project.lxj");
File.WriteAllText(session, "<Project></Project>");

var shortcut = Path.Join(directory, "session.lnk");
TestShortcuts.Create(shortcut, session);

return (directory, shortcut, session);
}

/// <summary>
/// Builds a second service on the shared config manager, standing in for another LogTabWindow
/// of the same session with its own tab controller.
Expand Down
178 changes: 178 additions & 0 deletions src/LogExpert.UI.Tests/Services/ShortcutResolverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using System.Runtime.Versioning;

using LogExpert.UI.Services.FileOperationService;

using NUnit.Framework;

namespace LogExpert.UI.Tests.Services;

[TestFixture]
[SupportedOSPlatform("windows")]
internal class ShortcutResolverTests
{
[Test]
public void Resolve_ShortcutToLocalTarget_ReturnsTarget ()
{
var directory = CreateTempDirectory("ShortcutLocal");

try
{
var log = Path.Join(directory, "actual.log");
File.WriteAllText(log, "line1");

var shortcut = Path.Join(directory, "actual.lnk");
TestShortcuts.Create(shortcut, log);

Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(log));
}
finally
{
Directory.Delete(directory, true);
}
}

[Test]
public void Resolve_ShortcutToUncTarget_ReturnsShortcutUnchanged ()
{
var directory = CreateTempDirectory("ShortcutUnc");

try
{
var shortcut = Path.Join(directory, "remote.lnk");
TestShortcuts.Create(shortcut, @"\\nonexistent-host\share\actual.log");

Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut),
"a UNC target must not be probed: File.Exists would authenticate against the remote host");
}
finally
{
Directory.Delete(directory, true);
}
}

[Test]
public void Resolve_OversizedShortcutFile_ReturnsPathUnchanged ()
{
var directory = CreateTempDirectory("ShortcutOversized");

try
{
var shortcut = Path.Join(directory, "oversized.lnk");
File.WriteAllBytes(shortcut, new byte[ShortcutResolver.MAX_SHORTCUT_SIZE + 1]);

Assert.That(ShortcutResolver.Resolve(shortcut), Is.EqualTo(shortcut));
}
finally
{
Directory.Delete(directory, true);
}
}

// The shell resolves .lnk targets when saving, so a real chain cannot be produced with
// WScript.Shell; the follower is exercised with injected hops instead.
[Test]
public void FollowChain_ShortcutToShortcut_ReturnsFinalTarget ()
{
var targets = new Dictionary<string, string>
{
[@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
[@"C:\logs\second.lnk"] = @"C:\logs\actual.log"
};

Assert.That(ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path)),
Is.EqualTo(@"C:\logs\actual.log"));
}

[Test]
public void FollowChain_ChainAtLimit_ReturnsFinalTarget ()
{
var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS);

Assert.That(ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path)),
Is.EqualTo(@"C:\logs\actual.log"));
}

[Test]
public void FollowChain_ChainBeyondLimit_StopsAfterMaxHops ()
{
var targets = CreateChain(ShortcutResolver.MAX_SHORTCUT_HOPS + 1);

Assert.That(ShortcutResolver.FollowChain(@"C:\logs\link1.lnk", path => targets.GetValueOrDefault(path)),
Is.EqualTo(@"C:\logs\link1.lnk"));
}

[Test]
public void FollowChain_CyclicChain_StopsAfterMaxHops ()
{
var targets = new Dictionary<string, string>
{
[@"C:\logs\first.lnk"] = @"C:\logs\second.lnk",
[@"C:\logs\second.lnk"] = @"C:\logs\first.lnk"
};

Assert.That(ShortcutResolver.FollowChain(@"C:\logs\first.lnk", path => targets.GetValueOrDefault(path)),
Is.EqualTo(@"C:\logs\first.lnk"));
}

[TestCase("//nonexistent-host/share/actual.log")]
[TestCase(@"\\?\UNC\nonexistent-host\share\actual.log")]
[TestCase(@"\\?\C:\Windows\system32\drivers\etc\hosts")]
[TestCase(@"\\.\C:\nonexistent\actual.log")]
[TestCase(@"\??\UNC\nonexistent-host\share\actual.log")]
public void SanitizeLocalTarget_DisguisedNonLocalTarget_IsRejected (string target)
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(target, @"C:\logs"), Is.Null,
$"'{target}' must not be followed: probing it could reach the network");
}

[Test]
public void SanitizeLocalTarget_PlainLocalTarget_ReturnsFullPath ()
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(@"C:\logs\actual.log", @"D:\other"),
Is.EqualTo(@"C:\logs\actual.log"));
}

[Test]
public void SanitizeLocalTarget_RelativeTarget_ResolvesAgainstShortcutDirectory ()
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(@"sub\actual.log", @"C:\logs"),
Is.EqualTo(@"C:\logs\sub\actual.log"));
}

[Test]
public void SanitizeLocalTarget_EnvironmentVariableTarget_IsExpanded ()
{
Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", @"C:\logs", EnvironmentVariableTarget.Process);

try
{
Assert.That(ShortcutResolver.SanitizeLocalTarget(@"%LOGEXPERT_TEST_VAR%\actual.log", @"D:\other"),
Is.EqualTo(@"C:\logs\actual.log"));
}
finally
{
Environment.SetEnvironmentVariable("LOGEXPERT_TEST_VAR", null, EnvironmentVariableTarget.Process);
}
}

private static Dictionary<string, string> CreateChain (int linkCount)
{
var targets = new Dictionary<string, string>();

for (var i = 1; i <= linkCount; i++)
{
targets[$@"C:\logs\link{i}.lnk"] = i == linkCount
? @"C:\logs\actual.log"
: $@"C:\logs\link{i + 1}.lnk";
}

return targets;
}

private static string CreateTempDirectory (string name)
{
var directory = Path.Join(Path.GetTempPath(), "LogExpertTests", name, Guid.NewGuid().ToString());
_ = Directory.CreateDirectory(directory);
return directory;
}
}
35 changes: 35 additions & 0 deletions src/LogExpert.UI.Tests/Services/TestShortcuts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

using NUnit.Framework;

namespace LogExpert.UI.Tests.Services;

[SupportedOSPlatform("windows")]
internal static class TestShortcuts
{
public static void Create (string shortcutPath, string targetPath)
{
var shellType = Type.GetTypeFromProgID("WScript.Shell");
Assert.That(shellType, Is.Not.Null, "WScript.Shell should be available on Windows test machines");

dynamic shell = Activator.CreateInstance(shellType)!;
object? shortcut = null;
try
{
dynamic link = shell.CreateShortcut(shortcutPath);
shortcut = link;
link.TargetPath = targetPath;
link.Save();
}
finally
{
if (shortcut != null)
{
Marshal.FinalReleaseComObject(shortcut);
}

Marshal.FinalReleaseComObject(shell);
}
}
}
Loading
Loading