|
| 1 | +using System.Diagnostics; |
| 2 | +using ManagedCode.CodexSharpSDK.Client; |
| 3 | +using ManagedCode.CodexSharpSDK.Configuration; |
| 4 | +using ManagedCode.CodexSharpSDK.Models; |
| 5 | + |
| 6 | +namespace ManagedCode.CodexSharpSDK.Tests.Integration; |
| 7 | + |
| 8 | +[NotInParallel(CliProcessConstraint)] |
| 9 | +public class CliLoginTests |
| 10 | +{ |
| 11 | + private const string CliProcessConstraint = "CliProcess"; |
| 12 | + private const string RequiresCodexAuthProperty = "RequiresCodexAuth"; |
| 13 | + private const string GuidFormat = "N"; |
| 14 | + private const string SolutionFile = "ManagedCode.CodexSharpSDK.slnx"; |
| 15 | + private const string TestsDirectory = "tests"; |
| 16 | + private const string SandboxDirectory = ".sandbox"; |
| 17 | + private const string SandboxPrefix = "CliLogin-"; |
| 18 | + private const string PathVariable = "PATH"; |
| 19 | + private const string HomeVariable = "HOME"; |
| 20 | + private const string UserProfileVariable = "USERPROFILE"; |
| 21 | + private const string ProviderHomeVariable = "CODEX_HOME"; |
| 22 | + private const string ConfigHomeVariable = "XDG_CONFIG_HOME"; |
| 23 | + private const string AppDataVariable = "APPDATA"; |
| 24 | + private const string LocalAppDataVariable = "LOCALAPPDATA"; |
| 25 | + private const string SystemRootVariable = "SystemRoot"; |
| 26 | + private const string CiVariable = "CI"; |
| 27 | + private const string BrowserVariable = "BROWSER"; |
| 28 | + private const string NoBrowserCommand = "false"; |
| 29 | + private const string TrueValue = "true"; |
| 30 | + private const string HttpsScheme = "https"; |
| 31 | + private const string HelpFlag = "--help"; |
| 32 | + private const string CredentialFile = "auth.json"; |
| 33 | + private const string PlainCode = "invalid-code"; |
| 34 | + private static readonly TimeSpan LoginTimeout = TimeSpan.FromSeconds(60); |
| 35 | + |
| 36 | + [Test] |
| 37 | + public async Task BeginLogin_AlreadyCancelled_DoesNotWriteCredentials() |
| 38 | + { |
| 39 | + using var profile = new LoginProfile(); |
| 40 | + using var client = new CodexClient(profile.CreateOptions()); |
| 41 | + using var cancellation = new CancellationTokenSource(); |
| 42 | + await cancellation.CancelAsync(); |
| 43 | + await Assert.ThrowsAsync<OperationCanceledException>(async () => await client.BeginLoginAsync(cancellation.Token)); |
| 44 | + await Assert.That(profile.HasCredentials).IsFalse(); |
| 45 | + } |
| 46 | + |
| 47 | + [Test] |
| 48 | + public async Task BeginLogin_NativeHelpExit_IsNotAuthenticationSuccess() |
| 49 | + { |
| 50 | + using var profile = new LoginProfile(); |
| 51 | + var options = profile.CreateOptions(); |
| 52 | + var launch = options.GetCliLaunchCommand(); |
| 53 | + using var client = new CodexClient(options with |
| 54 | + { |
| 55 | + LaunchCommand = new CliLaunchCommand(launch.ExecutablePath, launch.PrefixArguments.Add(HelpFlag)), |
| 56 | + }); |
| 57 | + using var deadline = new CancellationTokenSource(LoginTimeout); |
| 58 | + await Assert.ThrowsAsync<InvalidOperationException>(async () => await client.BeginLoginAsync(deadline.Token)); |
| 59 | + await Assert.That(profile.HasCredentials).IsFalse(); |
| 60 | + } |
| 61 | + |
| 62 | + [Property(RequiresCodexAuthProperty, TrueValue)] |
| 63 | + [Test] |
| 64 | + public async Task BeginLogin_NativeInstructions_WaitCancellationAndSessionCancellationHaveDifferentEffects() |
| 65 | + { |
| 66 | + using var profile = new LoginProfile(); |
| 67 | + using var client = new CodexClient(profile.CreateOptions()); |
| 68 | + using var deadline = new CancellationTokenSource(LoginTimeout); |
| 69 | + await using var login = await client.BeginLoginAsync(deadline.Token); |
| 70 | + var processId = login.ProcessId; |
| 71 | + var instructions = login.Instructions; |
| 72 | + await Assert.That(instructions).IsNotNull(); |
| 73 | + await Assert.That(instructions!.LoginUrl.Scheme).IsEqualTo(HttpsScheme); |
| 74 | + await Assert.That(instructions.RequiresAuthorizationCode).IsFalse(); |
| 75 | + await Assert.That(string.IsNullOrWhiteSpace(instructions.UserCode)).IsFalse(); |
| 76 | + using var observation = new CancellationTokenSource(); |
| 77 | + await observation.CancelAsync(); |
| 78 | + await Assert.ThrowsAsync<OperationCanceledException>(async () => await login.WaitForCompletionAsync(observation.Token)); |
| 79 | + using (var native = Process.GetProcessById(processId)) |
| 80 | + { |
| 81 | + await Assert.That(native.HasExited).IsFalse(); |
| 82 | + } |
| 83 | + await Assert.ThrowsAsync<NotSupportedException>(async () => await login.SubmitAuthorizationCodeAsync(PlainCode)); |
| 84 | + await login.CancelAsync(deadline.Token); |
| 85 | + await Assert.ThrowsAsync<OperationCanceledException>(async () => await login.WaitForCompletionAsync()); |
| 86 | + await Assert.That(IsRunning(processId)).IsFalse(); |
| 87 | + await Assert.That(profile.HasCredentials).IsFalse(); |
| 88 | + } |
| 89 | + |
| 90 | + private static bool IsRunning(int processId) |
| 91 | + { |
| 92 | + try |
| 93 | + { |
| 94 | + using var process = Process.GetProcessById(processId); |
| 95 | + return !process.HasExited; |
| 96 | + } |
| 97 | + catch (ArgumentException) |
| 98 | + { |
| 99 | + return false; |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + private sealed class LoginProfile : IDisposable |
| 104 | + { |
| 105 | + private readonly string _root; |
| 106 | + private readonly CliLaunchCommand _launch; |
| 107 | + |
| 108 | + public LoginProfile() |
| 109 | + { |
| 110 | + var current = new DirectoryInfo(AppContext.BaseDirectory); |
| 111 | + while (current is not null && !File.Exists(Path.Combine(current.FullName, SolutionFile))) |
| 112 | + { |
| 113 | + current = current.Parent; |
| 114 | + } |
| 115 | + _root = Path.Combine(current!.FullName, TestsDirectory, SandboxDirectory, |
| 116 | + string.Concat(SandboxPrefix, Guid.NewGuid().ToString(GuidFormat))); |
| 117 | + Directory.CreateDirectory(_root); |
| 118 | + _launch = new CodexOptions().GetCliLaunchCommand(); |
| 119 | + } |
| 120 | + |
| 121 | + public bool HasCredentials => Directory.GetFiles(_root, CredentialFile, SearchOption.AllDirectories).Length != 0; |
| 122 | + |
| 123 | + public CodexOptions CreateOptions() |
| 124 | + { |
| 125 | + var variables = new Dictionary<string, string> |
| 126 | + { |
| 127 | + [PathVariable] = Environment.GetEnvironmentVariable(PathVariable)!, |
| 128 | + [HomeVariable] = _root, |
| 129 | + [UserProfileVariable] = _root, |
| 130 | + [ProviderHomeVariable] = _root, |
| 131 | + [ConfigHomeVariable] = _root, |
| 132 | + [AppDataVariable] = _root, |
| 133 | + [LocalAppDataVariable] = _root, |
| 134 | + [CiVariable] = TrueValue, |
| 135 | + [BrowserVariable] = NoBrowserCommand, |
| 136 | + }; |
| 137 | + if (Environment.GetEnvironmentVariable(SystemRootVariable) is { } systemRoot) |
| 138 | + { |
| 139 | + variables[SystemRootVariable] = systemRoot; |
| 140 | + } |
| 141 | + return new CodexOptions { LaunchCommand = _launch, EnvironmentVariables = variables, InheritEnvironmentVariables = false }; |
| 142 | + } |
| 143 | + |
| 144 | + public void Dispose() => Directory.Delete(_root, recursive: true); |
| 145 | + } |
| 146 | +} |
0 commit comments