diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 0a2becfb9..68a17780b 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,2 +1,3 @@ +- Add additional error handling to `reclaim-mannequin` process - Removed ability to use `gh gei` to migrate from ADO -> GH. You must use `gh ado2gh` to do this now. This was long since obsolete, but was still available via hidden args - which have now been removed. - Add `bbs2gh inventory-report` command to write data available for migrations in CSV form diff --git a/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandArgs.cs b/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandArgs.cs index 72056b8d4..04e0559d7 100644 --- a/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandArgs.cs +++ b/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandArgs.cs @@ -10,6 +10,7 @@ public class ReclaimMannequinCommandArgs : CommandArgs public string MannequinId { get; set; } public string TargetUser { get; set; } public bool Force { get; set; } + public bool NoPrompt { get; set; } [Secret] public string GithubPat { get; set; } public bool SkipInvitation { get; set; } diff --git a/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandBase.cs b/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandBase.cs index 23fd77099..acaae470b 100644 --- a/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandBase.cs +++ b/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandBase.cs @@ -52,6 +52,11 @@ public ReclaimMannequinCommandBase() : base( Description = "Map the user even if it was previously mapped" }; + public virtual Option NoPrompt { get; } = new("--no-prompt") + { + Description = "Overrides all prompts and warnings with 'Y' value." + }; + public virtual Option GithubPat { get; } = new("--github-pat") { Description = "Personal access token of the GitHub target. Overrides GH_PAT environment variable." @@ -83,7 +88,7 @@ public override ReclaimMannequinCommandHandler BuildHandler(ReclaimMannequinComm var reclaimService = new ReclaimService(githubApi, log); var confirmationService = sp.GetRequiredService(); - return new ReclaimMannequinCommandHandler(log, reclaimService, confirmationService); + return new ReclaimMannequinCommandHandler(log, reclaimService, confirmationService, githubApi); } protected void AddOptions() @@ -94,6 +99,7 @@ protected void AddOptions() AddOption(MannequinId); AddOption(TargetUser); AddOption(Force); + AddOption(NoPrompt); AddOption(GithubPat); AddOption(SkipInvitation); AddOption(Verbose); diff --git a/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandler.cs b/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandler.cs index c60913c45..46276d5cd 100644 --- a/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandler.cs +++ b/src/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandler.cs @@ -11,15 +11,17 @@ public class ReclaimMannequinCommandHandler : ICommandHandler FileExists = path => File.Exists(path); internal Func GetFileContent = path => File.ReadLines(path).ToArray(); - public ReclaimMannequinCommandHandler(OctoLogger log, ReclaimService reclaimService, ConfirmationService confirmationService) + public ReclaimMannequinCommandHandler(OctoLogger log, ReclaimService reclaimService, ConfirmationService confirmationService, GithubApi githubApi) { _log = log; _reclaimService = reclaimService; _confirmationService = confirmationService; + _githubApi = githubApi; } public async Task Handle(ReclaimMannequinCommandArgs args) @@ -29,6 +31,24 @@ public async Task Handle(ReclaimMannequinCommandArgs args) throw new ArgumentNullException(nameof(args)); } + if (args.SkipInvitation) + { + // Check if user is admin to EMU org + var login = await _githubApi.GetLoginName(); + + var membership = await _githubApi.GetOrgMembershipForUser(args.GithubOrg, login); + + if (membership != "admin") + { + throw new OctoshiftCliException($"User {login} is not an org admin and is not eligible to reclaim mannequins with the --skip-invitation feature."); + } + + if (!args.NoPrompt) + { + _confirmationService.AskForConfirmation("Reclaiming mannequins with the --skip-invitation option is immediate and irreversible. Are you sure you wish to continue? [y/N]"); + } + } + if (!string.IsNullOrEmpty(args.Csv)) { _log.LogInformation("Reclaiming Mannequins with CSV..."); @@ -38,24 +58,14 @@ public async Task Handle(ReclaimMannequinCommandArgs args) throw new OctoshiftCliException($"File {args.Csv} does not exist."); } - //TODO: Get verbiage approved - if (args.SkipInvitation) - { - _ = _confirmationService.AskForConfirmation("Reclaiming mannequins with the --skip-invitation option is immediate and irreversible. Are you sure you wish to continue? (y/n)"); - } - await _reclaimService.ReclaimMannequins(GetFileContent(args.Csv), args.GithubOrg, args.Force, args.SkipInvitation); } else { - if (args.SkipInvitation) - { - throw new OctoshiftCliException($"--csv must be specified to skip reclaimation email"); - } _log.LogInformation("Reclaiming Mannequin..."); - await _reclaimService.ReclaimMannequin(args.MannequinUser, args.MannequinId, args.TargetUser, args.GithubOrg, args.Force); + await _reclaimService.ReclaimMannequin(args.MannequinUser, args.MannequinId, args.TargetUser, args.GithubOrg, args.Force, args.SkipInvitation); } } } diff --git a/src/Octoshift/Services/ConfirmationService.cs b/src/Octoshift/Services/ConfirmationService.cs index 19364749a..ab0c67e27 100644 --- a/src/Octoshift/Services/ConfirmationService.cs +++ b/src/Octoshift/Services/ConfirmationService.cs @@ -3,61 +3,64 @@ namespace OctoshiftCLI.Services { public class ConfirmationService { - # region Variables - - private readonly Action _writeToConsoleOut; + private readonly Action _writeToConsoleOut; private readonly Func _readConsoleKey; + private readonly Action _cancelCommand; - #endregion - - #region Constructors public ConfirmationService() { - _writeToConsoleOut = msg => Console.WriteLine(msg); + _writeToConsoleOut = (msg, outputColor) => + { + var currentColor = Console.ForegroundColor; + + Console.ForegroundColor = outputColor; + Console.WriteLine(msg); + + Console.ForegroundColor = currentColor; + }; _readConsoleKey = ReadKey; + _cancelCommand = code => Environment.Exit(code); } // Constructor designed to allow for testing console methods - public ConfirmationService(Action writeToConsoleOut, Func readConsoleKey) + public ConfirmationService(Action writeToConsoleOut, Func readConsoleKey, Action cancelCommand) { _writeToConsoleOut = writeToConsoleOut; _readConsoleKey = readConsoleKey; + _cancelCommand = cancelCommand; } - #endregion - - #region Functions - public bool AskForConfirmation(string confirmationPrompt, string cancellationErrorMessage = "") + public virtual bool AskForConfirmation(string confirmationPrompt, string cancellationErrorMessage = "") { ConsoleKey response; do { - _writeToConsoleOut(confirmationPrompt); + _writeToConsoleOut(confirmationPrompt, ConsoleColor.Yellow); response = _readConsoleKey(); if (response != ConsoleKey.Enter) { - _writeToConsoleOut(""); + _writeToConsoleOut("", ConsoleColor.White); } } while (response is not ConsoleKey.Y and not ConsoleKey.N); if (response == ConsoleKey.Y) { - _writeToConsoleOut("Confirmation Recorded. Proceeding..."); + _writeToConsoleOut("Confirmation Recorded. Proceeding...", ConsoleColor.White); return true; } else { - _writeToConsoleOut("Canceling Command..."); - throw new OctoshiftCliException($"Command Cancelled. {cancellationErrorMessage}"); + _writeToConsoleOut($"Command Cancelled. {cancellationErrorMessage}", ConsoleColor.White); + _cancelCommand(0); } + return false; } private ConsoleKey ReadKey() { return Console.ReadKey(false).Key; } - #endregion } } diff --git a/src/Octoshift/Services/GithubApi.cs b/src/Octoshift/Services/GithubApi.cs index 323d543fd..b5416e724 100644 --- a/src/Octoshift/Services/GithubApi.cs +++ b/src/Octoshift/Services/GithubApi.cs @@ -104,6 +104,48 @@ public virtual async Task RemoveTeamMember(string org, string teamSlug, string m await _retryPolicy.Retry(() => _client.DeleteAsync(url)); } + public virtual async Task GetLoginName() + { + var url = $"{_apiUrl}/graphql"; + + var payload = new + { + query = "query{viewer{login}}" + }; + + try + { + return await _retryPolicy.Retry(async () => + { + var data = await _client.PostGraphQLAsync(url, payload); + + return (string)data["data"]["viewer"]["login"]; + }); + } + catch (Exception ex) + { + throw new OctoshiftCliException($"Failed to lookup the login for current user", ex); + } + } + + public virtual async Task GetOrgMembershipForUser(string org, string member) + { + var url = $"{_apiUrl}/orgs/{org}/memberships/{member}"; + + try + { + var response = await _client.GetAsync(url); + + var data = JObject.Parse(response); + + return (string)data["role"]; + } + catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) // Not a member + { + return null; + } + } + public virtual async Task DoesRepoExist(string org, string repo) { var url = $"{_apiUrl}/repos/{org.EscapeDataString()}/{repo.EscapeDataString()}"; @@ -729,7 +771,7 @@ ... on User { return data.ToObject(); } - public virtual async Task ReclaimMannequinsSkipInvitation(string orgId, string mannequinId, string targetUserId) + public virtual async Task ReclaimMannequinSkipInvitation(string orgId, string mannequinId, string targetUserId) { var url = $"{_apiUrl}/graphql"; var mutation = "mutation($orgId: ID!,$sourceId: ID!,$targetId: ID!)"; @@ -758,10 +800,18 @@ ... on User { variables = new { orgId, sourceId = mannequinId, targetId = targetUserId } }; - var response = await _client.PostAsync(url, payload); - var data = JObject.Parse(response); - - return data.ToObject(); + try + { + return await _retryPolicy.Retry(async () => + { + var data = await _client.PostGraphQLAsync(url, payload); + return data.ToObject(); + }); + } + catch (OctoshiftCliException ex) when (ex.Message.Contains("Field 'reattributeMannequinToUser' doesn't exist on type 'Mutation'")) + { + throw new OctoshiftCliException($"Reclaiming mannequins with the--skip - invitation flag is not enabled for your GitHub organization.For more details, contact GitHub Support.", ex); + } } public virtual async Task> GetSecretScanningAlertsForRepository(string org, string repo) diff --git a/src/Octoshift/Services/ReclaimService.cs b/src/Octoshift/Services/ReclaimService.cs index 79dfd3048..9fadfe35e 100644 --- a/src/Octoshift/Services/ReclaimService.cs +++ b/src/Octoshift/Services/ReclaimService.cs @@ -80,7 +80,7 @@ public ReclaimService(GithubApi githubApi, OctoLogger logger) _log = logger; } - public virtual async Task ReclaimMannequin(string mannequinUser, string mannequinId, string targetUser, string githubOrg, bool force) + public virtual async Task ReclaimMannequin(string mannequinUser, string mannequinId, string targetUser, string githubOrg, bool force, bool skipInvitation) { var githubOrgId = await _githubApi.GetOrganizationId(githubOrg); @@ -101,18 +101,37 @@ public virtual async Task ReclaimMannequin(string mannequinUser, string mannequi var success = true; - // get all unique mannequins by login and id and map them all to the same target - foreach (var mannequin in mannequins.GetUniqueUsers()) + if (skipInvitation) { - var result = await _githubApi.CreateAttributionInvitation(githubOrgId, mannequin.Id, targetUserId); + foreach (var mannequin in mannequins.GetUniqueUsers()) + { + var result = await _githubApi.ReclaimMannequinSkipInvitation(githubOrgId, mannequin.Id, targetUserId); - success &= HandleInvitationResult(mannequinUser, targetUser, mannequin, targetUserId, result); - } + // If results return a fail-fast error, we should break out of the for-loop + if (!HandleReclaimationResult(mannequin.Login, targetUser, mannequin, targetUserId, result)) + { + throw new OctoshiftCliException("Failed to reclaim mannequin."); + } + } - if (!success) + } + else { - throw new OctoshiftCliException("Failed to send reclaim mannequin invitation(s)."); + // get all unique mannequins by login and id and map them all to the same target + foreach (var mannequin in mannequins.GetUniqueUsers()) + { + var result = await _githubApi.CreateAttributionInvitation(githubOrgId, mannequin.Id, targetUserId); + + success &= HandleInvitationResult(mannequinUser, targetUser, mannequin, targetUserId, result); + } + + if (!success) + { + throw new OctoshiftCliException("Failed to send reclaim mannequin invitation(s)."); + } } + + } public virtual async Task ReclaimMannequins(string[] lines, string githubTargetOrg, bool force, bool skipInvitation) @@ -136,30 +155,49 @@ public virtual async Task ReclaimMannequins(string[] lines, string githubTargetO var githubOrgId = await _githubApi.GetOrganizationId(githubTargetOrg); - // org.enterprise_managed_user_enabled? - var mannequins = await GetMannequins(githubOrgId); + // Parse CSV + var parsedMannequins = new List(); + foreach (var line in lines.Skip(1).Where(l => l != null && l.Trim().Length > 0)) { var (login, userid, claimantLogin) = ParseLine(line); - if (login == null) + parsedMannequins.Add(new Mannequin() + { + Login = login, + Id = userid, + MappedUser = new Claimant() + { + Login = claimantLogin + } + }); + } + + // Validate CSV and claim mannequins + foreach (var mannequin in parsedMannequins) + { + if (mannequin.Login == null) { continue; } - if (!force && mannequins.IsClaimed(login, userid)) + if (!force && mannequins.IsClaimed(mannequin.Login, mannequin.Id)) { - _log.LogError($"{login} is already claimed. Skipping (use force if you want to reclaim)"); + _log.LogWarning($"{mannequin.Login} is already claimed. Skipping (use force if you want to reclaim)"); continue; } - var mannequin = mannequins.FindFirst(login, userid); + if (mannequins.FindFirst(mannequin.Login, mannequin.Id) == null) + { + _log.LogWarning($"Mannequin {mannequin.Login} not found. Skipping."); + continue; + } - if (mannequin == null) + if (parsedMannequins.Where(x => x.Login == mannequin.Login && x.Id == mannequin.Id).Count() > 1) { - _log.LogError($"Mannequin {login} not found. Skipping."); + _log.LogWarning($"Mannequin {mannequin.Login} is a duplicate. Skipping."); continue; } @@ -167,24 +205,28 @@ public virtual async Task ReclaimMannequins(string[] lines, string githubTargetO try { - claimantId = await _githubApi.GetUserId(claimantLogin); + claimantId = await _githubApi.GetUserId(mannequin.MappedUser.Login); } catch (OctoshiftCliException ex) when (ex.Message.Contains("Could not resolve to a User with the login")) { - _log.LogError($"Claimant \"{claimantLogin}\" not found. Will ignore it."); + _log.LogWarning($"Claimant \"{mannequin.MappedUser.Login}\" not found. Will ignore it."); continue; } if (skipInvitation) { - //TODO: Check if org is emu before continuing, throw error if not - var result = await _githubApi.ReclaimMannequinsSkipInvitation(githubOrgId, userid, claimantId); - HandleReclaimationResult(login, claimantLogin, mannequin, claimantId, result); + var result = await _githubApi.ReclaimMannequinSkipInvitation(githubOrgId, mannequin.Id, claimantId); + + // If results return a fail-fast error, we should break out of the for-loop + if (!HandleReclaimationResult(mannequin.Login, mannequin.MappedUser.Login, mannequin, claimantId, result)) + { + return; + } } else { - var result = await _githubApi.CreateAttributionInvitation(githubOrgId, userid, claimantId); - HandleInvitationResult(login, claimantLogin, mannequin, claimantId, result); + var result = await _githubApi.CreateAttributionInvitation(githubOrgId, mannequin.Id, claimantId); + HandleInvitationResult(mannequin.Login, mannequin.MappedUser.Login, mannequin, claimantId, result); } } } @@ -221,8 +263,16 @@ private bool HandleReclaimationResult(string mannequinUser, string targetUser, M { if (result.Errors != null) { - _log.LogError($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId}): {result.Errors[0].Message}"); - return false; + // Writing as switch statement in anticipation of other errors that will need specific logic + switch (result.Errors[0].Message) + { + case string a when a.Contains("is not an Enterprise Managed Users (EMU) organization"): + _log.LogError("Failed to reclaim mannequins. The --skip-invitation flag is only available to EMU organizations."); + return false; // Indicates we should stop parsing through the CSV + default: + _log.LogWarning($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId}): {result.Errors[0].Message}"); + return true; + } } if (result.Data.ReattributeMannequinToUser is null || @@ -230,13 +280,13 @@ private bool HandleReclaimationResult(string mannequinUser, string targetUser, M result.Data.ReattributeMannequinToUser.Target.Id != targetUserId) { - _log.LogError($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId})"); - return false; + _log.LogWarning($"Failed to reclaim {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId})"); + return true; } _log.LogInformation($"Successfully reclaimed {mannequinUser} ({mannequin.Id}) to {targetUser} ({targetUserId})"); - return true; + return true; // Indiciates we should continue onto the next mannequin } private (string MannequinUser, string MannequinId, string TargetUser) ParseLine(string line) @@ -245,7 +295,7 @@ private bool HandleReclaimationResult(string mannequinUser, string targetUser, M if (components.Length != 3) { - _log.LogError($"Invalid line: \"{line}\". Will ignore it."); + _log.LogWarning($"Invalid line: \"{line}\". Will ignore it."); return (null, null, null); } @@ -255,19 +305,19 @@ private bool HandleReclaimationResult(string mannequinUser, string targetUser, M if (string.IsNullOrEmpty(login)) { - _log.LogError($"Invalid line: \"{line}\". Mannequin login is not defined. Will ignore it."); + _log.LogWarning($"Invalid line: \"{line}\". Mannequin login is not defined. Will ignore it."); return (null, null, null); } if (string.IsNullOrEmpty(userId)) { - _log.LogError($"Invalid line: \"{line}\". Mannequin Id is not defined. Will ignore it."); + _log.LogWarning($"Invalid line: \"{line}\". Mannequin Id is not defined. Will ignore it."); return (null, null, null); } if (string.IsNullOrEmpty(claimantLogin)) { - _log.LogError($"Invalid line: \"{line}\". Target User is not defined. Will ignore it."); + _log.LogWarning($"Invalid line: \"{line}\". Target User is not defined. Will ignore it."); return (null, null, null); } diff --git a/src/OctoshiftCLI.Tests/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandlerTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandlerTests.cs index 9e0cebe82..658d5d3ee 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandlerTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Commands/ReclaimMannequin/ReclaimMannequinCommandHandlerTests.cs @@ -12,19 +12,18 @@ public class ReclaimMannequinCommandHandlerTests { private readonly Mock _mockOctoLogger = TestHelpers.CreateMock(); private readonly Mock _mockReclaimService = TestHelpers.CreateMock(); - private readonly ConfirmationService _confirmationService; + private readonly Mock _mockConfirmationService = TestHelpers.CreateMock(); + private readonly Mock _mockGithubApi = TestHelpers.CreateMock(); private readonly ReclaimMannequinCommandHandler _handler; private const string GITHUB_ORG = "FooOrg"; private const string MANNEQUIN_USER = "mona"; private const string TARGET_USER = "mona_emu"; - - private string _consoleOutput; + private readonly string TARGET_USER_LOGIN = "mona_gh"; public ReclaimMannequinCommandHandlerTests() { - _confirmationService = new ConfirmationService(CaptureConsoleOutput, MockConsoleKeyPress); - _handler = new ReclaimMannequinCommandHandler(_mockOctoLogger.Object, _mockReclaimService.Object, _confirmationService) + _handler = new ReclaimMannequinCommandHandler(_mockOctoLogger.Object, _mockReclaimService.Object, _mockConfirmationService.Object, _mockGithubApi.Object) { FileExists = _ => true, GetFileContent = _ => Array.Empty() @@ -51,7 +50,7 @@ public async Task SingleReclaiming_Happy_Path() { string mannequinUserId = null; - _mockReclaimService.Setup(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false)).Returns(Task.FromResult(default(object))); + _mockReclaimService.Setup(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false, false)).Returns(Task.FromResult(default(object))); var args = new ReclaimMannequinCommandArgs { @@ -62,7 +61,7 @@ public async Task SingleReclaiming_Happy_Path() }; await _handler.Handle(args); - _mockReclaimService.Verify(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false), Times.Once); + _mockReclaimService.Verify(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false, false), Times.Once); } [Fact] @@ -70,7 +69,7 @@ public async Task SingleReclaiming_WithIdSpecifiedHappy_Path() { var mannequinUserId = "monaid"; - _mockReclaimService.Setup(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false)).Returns(Task.FromResult(default(object))); + _mockReclaimService.Setup(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false, false)).Returns(Task.FromResult(default(object))); var args = new ReclaimMannequinCommandArgs { @@ -81,7 +80,7 @@ public async Task SingleReclaiming_WithIdSpecifiedHappy_Path() }; await _handler.Handle(args); - _mockReclaimService.Verify(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false), Times.Once); + _mockReclaimService.Verify(x => x.ReclaimMannequin(MANNEQUIN_USER, mannequinUserId, TARGET_USER, GITHUB_ORG, false, false), Times.Once); } [Fact] @@ -116,7 +115,7 @@ public async Task CSV_CSV_TakesPrecedence() public async Task Skip_Invitation_Happy_Path() { // Arrange - var expectedResult = "Reclaiming mannequins with the --skip-invitation option is immediate and irreversible. Are you sure you wish to continue? (y/n)Confirmation Recorded. Proceeding..."; + var role = "admin"; var args = new ReclaimMannequinCommandArgs { @@ -125,33 +124,68 @@ public async Task Skip_Invitation_Happy_Path() Csv = "file.csv", }; + _mockConfirmationService.Setup(x => x.AskForConfirmation(It.IsAny(), It.IsAny())).Returns(true); + _mockGithubApi.Setup(x => x.GetLoginName().Result).Returns(TARGET_USER_LOGIN); + _mockGithubApi.Setup(x => x.GetOrgMembershipForUser(GITHUB_ORG, TARGET_USER_LOGIN).Result).Returns(role); + // Act await _handler.Handle(args); // Assert _mockReclaimService.Verify(x => x.ReclaimMannequins(Array.Empty(), GITHUB_ORG, false, true), Times.Once); - _consoleOutput.Trim().Should().BeEquivalentTo(expectedResult); } [Fact] - public async Task Skip_Invitation_Without_CSV_Throws_Error() + public async Task Skip_Invitation_No_Confirmation_With_NoPrompt_Arg() { // Arrange + var role = "admin"; + var args = new ReclaimMannequinCommandArgs { GithubOrg = GITHUB_ORG, SkipInvitation = true, - MannequinUser = MANNEQUIN_USER, - TargetUser = TARGET_USER, + Csv = "file.csv", + NoPrompt = true }; - // Act/ Assert - await FluentActions - .Invoking(async () => await _handler.Handle(args)) - .Should().ThrowAsync(); + _mockGithubApi.Setup(x => x.GetLoginName().Result).Returns(TARGET_USER_LOGIN); + _mockGithubApi.Setup(x => x.GetOrgMembershipForUser(GITHUB_ORG, TARGET_USER_LOGIN).Result).Returns(role); + + // Act + await _handler.Handle(args); + + // Assert + _mockReclaimService.Verify(x => x.ReclaimMannequins(Array.Empty(), GITHUB_ORG, false, true), Times.Once); + _mockConfirmationService.Verify(x => x.AskForConfirmation(It.IsAny(), It.IsAny()), Times.Never); } - private void CaptureConsoleOutput(string msg) => _consoleOutput += msg; + [Fact] + public async Task ReclaimMannequinsSkipInvitation_No_Admin_Throws_Error() + { + // Arrange + var role = "member"; + + var args = new ReclaimMannequinCommandArgs + { + GithubOrg = GITHUB_ORG, + SkipInvitation = true, + Csv = "file.csv", + }; + + _mockConfirmationService.Setup(x => x.AskForConfirmation(It.IsAny(), It.IsAny())).Returns(true); + _mockGithubApi.Setup(x => x.GetLoginName().Result).Returns(TARGET_USER_LOGIN); + _mockGithubApi.Setup(x => x.GetOrgMembershipForUser(GITHUB_ORG, TARGET_USER_LOGIN).Result).Returns(role); + + // Act + var exception = await FluentActions + .Invoking(async () => await _handler.Handle(args)) + .Should().ThrowAsync(); + exception.WithMessage($"User {TARGET_USER_LOGIN} is not an org admin and is not eligible to reclaim mannequins with the --skip-invitation feature."); - private ConsoleKey MockConsoleKeyPress() => ConsoleKey.Y; + // Assert + _mockGithubApi.Verify(m => m.GetLoginName(), Times.Once); + _mockGithubApi.Verify(x => x.GetOrgMembershipForUser(GITHUB_ORG, TARGET_USER_LOGIN), Times.Once); + _mockGithubApi.VerifyNoOtherCalls(); + } } diff --git a/src/OctoshiftCLI.Tests/Octoshift/Services/ConfirmationServiceTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Services/ConfirmationServiceTests.cs index e977c17a7..cd2924c5e 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Services/ConfirmationServiceTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Services/ConfirmationServiceTests.cs @@ -8,27 +8,23 @@ namespace OctoshiftCLI.Tests.Octoshift.Services { public class ConfirmationServiceTests { - #region Variables private readonly ConfirmationService _confirmationService; private readonly string confirmationPrompt; private readonly string cancelationOutput; private readonly string confirmationOutput; private string _consoleOutput; + private int _exitOutput; private int numOfCalls; private ConsoleKey passedKey; - #endregion - #region Constructor public ConfirmationServiceTests() { - _confirmationService = new ConfirmationService(CaptureConsoleOutput, MockConsoleKeyPress); - confirmationPrompt = "Are you sure you wish to continue? Y/N?"; - cancelationOutput = "Canceling Command..."; + _confirmationService = new ConfirmationService(CaptureConsoleOutput, MockConsoleKeyPress, CancelCommand); + confirmationPrompt = "Are you sure you wish to continue? [y/N]"; + cancelationOutput = "Command Cancelled."; confirmationOutput = "Confirmation Recorded. Proceeding..."; } - #endregion - #region Tests [Fact] public void AskForConfirmation_Happy_Path() { @@ -53,10 +49,11 @@ public void AskForConfirmation_Should_Exit_With_N_Keypress() var expectedResult = confirmationPrompt + cancelationOutput; // Act - Assert.Throws(() => _confirmationService.AskForConfirmation(confirmationPrompt)); + _confirmationService.AskForConfirmation(confirmationPrompt); // Assert _consoleOutput.Trim().Should().BeEquivalentTo(expectedResult); + _exitOutput.Should().Be(0); } [Fact] @@ -66,14 +63,14 @@ public void AskForConfirmation_Should_Exit_With_Provided_ErrorMessage() passedKey = ConsoleKey.N; numOfCalls = 3; var failureReason = "You made me fail."; - var expectedResult = confirmationPrompt + cancelationOutput; + var expectedResult = confirmationPrompt + cancelationOutput + " " + failureReason; // Act - var exception = Assert.Throws(() => _confirmationService.AskForConfirmation(confirmationPrompt, failureReason)); + _confirmationService.AskForConfirmation(confirmationPrompt, failureReason); // Assert _consoleOutput.Trim().Should().BeEquivalentTo(expectedResult); - Assert.Equal($"Command Cancelled. {failureReason}", exception.Message); + _exitOutput.Should().Be(0); } [Fact] @@ -86,15 +83,14 @@ public void AskForConfirmation_Should_Ask_Again_On_Wrong_KeyPress() // Act - Assert.Throws(() => _confirmationService.AskForConfirmation(confirmationPrompt)); + _confirmationService.AskForConfirmation(confirmationPrompt); // Assert _consoleOutput.Trim().Should().BeEquivalentTo(expectedResult); + _exitOutput.Should().Be(0); } - #endregion - #region Private functions - private void CaptureConsoleOutput(string msg) => _consoleOutput += msg; + private void CaptureConsoleOutput(string msg, ConsoleColor outputColor) => _consoleOutput += msg; private ConsoleKey MockConsoleKeyPress() { @@ -106,8 +102,8 @@ private ConsoleKey MockConsoleKeyPress() numOfCalls--; return passedKey; } - #endregion + private void CancelCommand(int exitCode) => _exitOutput = exitCode; } } diff --git a/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs index 76665b1ce..25644b040 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Services/GithubApiTests.cs @@ -436,6 +436,48 @@ public async Task RemoveTeamMember_Retries_On_Exception() _githubClientMock.Verify(m => m.DeleteAsync(url, null), Times.Exactly(2)); } + [Fact] + public async Task GetOrgMembershipForUser_Returns_User_Role() + { + // Arrange + var member = "USER"; + var url = $"https://api.github.com/orgs/{GITHUB_ORG}/memberships/{member}"; + var role = "admin"; + var response = $@" + {{ + ""role"": ""{role}"" + }}"; + + _githubClientMock + .Setup(m => m.GetAsync(url, null)) + .ReturnsAsync(response); + + // Act + var result = await _githubApi.GetOrgMembershipForUser(GITHUB_ORG, member); + + // Assert + result.Should().Match(role); + } + + [Fact] + public async Task GetOrgMembershipForUser_Returns_Empty_On_HTTP_Exception() + { + // Arrange + var member = "USER"; + var url = $"https://api.github.com/orgs/{GITHUB_ORG}/memberships/{member}"; + + _githubClientMock + .SetupSequence(m => m.GetAsync(url, null)) + .ThrowsAsync(new HttpRequestException(null, null, HttpStatusCode.NotFound)) + .ReturnsAsync(string.Empty); + + // Act + var result = await _githubApi.GetOrgMembershipForUser(GITHUB_ORG, member); + + // Assert + result.Should().BeNull(); + } + [Fact] public async Task AddTeamSync_Calls_The_Right_Endpoint_With_Payload() { diff --git a/src/OctoshiftCLI.Tests/Octoshift/Services/ReclaimServiceTests.cs b/src/OctoshiftCLI.Tests/Octoshift/Services/ReclaimServiceTests.cs index ce79a5d42..09b03c6fc 100644 --- a/src/OctoshiftCLI.Tests/Octoshift/Services/ReclaimServiceTests.cs +++ b/src/OctoshiftCLI.Tests/Octoshift/Services/ReclaimServiceTests.cs @@ -74,6 +74,105 @@ public async Task ReclaimMannequins_AlreadyMapped_Force_Reclaim() _mockGithubApi.VerifyNoOtherCalls(); } + [Fact] + public async Task ReclaimMannequins_Duplicates_Same_Claimant_Throws_Error() + { + var mannequinsResponse = new[] + { + new Mannequin + { + Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN, MappedUser = new Claimant { Id = TARGET_USER_ID, Login = TARGET_USER_LOGIN } + } + }; + + var reclaimMannequinResponse = new CreateAttributionInvitationResult() + { + Data = new CreateAttributionInvitationData() + { + CreateAttributionInvitation = new CreateAttributionInvitation() + { + Source = new UserInfo() { Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN }, + Target = new UserInfo() { Id = TARGET_USER_ID, Login = TARGET_USER_LOGIN } + } + } + }; + + _mockGithubApi.Setup(x => x.GetOrganizationId(TARGET_ORG).Result).Returns(ORG_ID); + _mockGithubApi.Setup(x => x.GetMannequins(ORG_ID).Result).Returns(mannequinsResponse); + _mockGithubApi.Setup(x => x.GetUserId(TARGET_USER_LOGIN).Result).Returns(TARGET_USER_ID); + _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); + + var csvContent = new string[] { + HEADER, + $"{MANNEQUIN_LOGIN},{MANNEQUIN_ID},{TARGET_USER_LOGIN}", + $"{MANNEQUIN_LOGIN},{MANNEQUIN_ID},{TARGET_USER_LOGIN}" + }; + + // Act + await _service.ReclaimMannequins(csvContent, TARGET_ORG, true, false); + + // Assert + _mockGithubApi.Verify(m => m.GetOrganizationId(TARGET_ORG), Times.Once); + _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); + _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); + _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Never); + _mockGithubApi.VerifyNoOtherCalls(); + _mockOctoLogger.Verify(x => x.LogWarning($"Mannequin {MANNEQUIN_LOGIN} is a duplicate. Skipping."), Times.Exactly(2)); + } + + [Fact] + public async Task ReclaimMannequins_Duplicates_Different_Claimants_Throws_Error() + { + var TARGET_USER_ID_2 = Guid.NewGuid().ToString(); + var TARGET_USER_LOGIN_2 = "mona_gh_2"; + + var mannequinsResponse = new[] + { + new Mannequin + { + Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN, MappedUser = new Claimant { Id = TARGET_USER_ID, Login = TARGET_USER_LOGIN } + }, + new Mannequin + { + Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN, MappedUser = new Claimant { Id = TARGET_USER_ID_2, Login = TARGET_USER_LOGIN_2 } + } + }; + + var reclaimMannequinResponse = new CreateAttributionInvitationResult() + { + Data = new CreateAttributionInvitationData() + { + CreateAttributionInvitation = new CreateAttributionInvitation() + { + Source = new UserInfo() { Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN }, + Target = new UserInfo() { Id = TARGET_USER_ID, Login = TARGET_USER_LOGIN } + } + } + }; + + _mockGithubApi.Setup(x => x.GetOrganizationId(TARGET_ORG).Result).Returns(ORG_ID); + _mockGithubApi.Setup(x => x.GetMannequins(ORG_ID).Result).Returns(mannequinsResponse); + _mockGithubApi.Setup(x => x.GetUserId(TARGET_USER_LOGIN).Result).Returns(TARGET_USER_ID); + _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); + + var csvContent = new string[] { + HEADER, + $"{MANNEQUIN_LOGIN},{MANNEQUIN_ID},{TARGET_USER_LOGIN}", + $"{MANNEQUIN_LOGIN},{MANNEQUIN_ID},ADiffClaimant" + }; + + // Act + await _service.ReclaimMannequins(csvContent, TARGET_ORG, true, false); + + // Assert + _mockGithubApi.Verify(m => m.GetOrganizationId(TARGET_ORG), Times.Once); + _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); + _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); + _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Never); + _mockGithubApi.VerifyNoOtherCalls(); + _mockOctoLogger.Verify(x => x.LogWarning($"Mannequin {MANNEQUIN_LOGIN} is a duplicate. Skipping."), Times.Exactly(2)); + } + [Fact] public async Task ReclaimMannequins_EmptyCSV_NoReclaims_IssuesWarning() { @@ -107,7 +206,7 @@ public async Task ReclaimMannequins_InvalidCSVContent_IssuesError() _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(It.IsAny()), Times.Never); - _mockOctoLogger.Verify(m => m.LogError($"Invalid line: \"login\". Will ignore it."), Times.Once); + _mockOctoLogger.Verify(m => m.LogWarning($"Invalid line: \"login\". Will ignore it."), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -142,7 +241,7 @@ public async Task ReclaimMannequins_InvalidCSVContentLoginNotSpecified_IssuesErr _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(It.IsAny()), Times.Never); - _mockOctoLogger.Verify(m => m.LogError($"Invalid line: \",,mona_gh\". Mannequin login is not defined. Will ignore it."), Times.Once); + _mockOctoLogger.Verify(m => m.LogWarning($"Invalid line: \",,mona_gh\". Mannequin login is not defined. Will ignore it."), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -164,7 +263,7 @@ public async Task ReclaimMannequins_InvalidCSVContentTargetLoginNotSpecified_Iss _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(It.IsAny()), Times.Never); - _mockOctoLogger.Verify(m => m.LogError("Invalid line: \"xx,id,\". Target User is not defined. Will ignore it."), Times.Once); + _mockOctoLogger.Verify(m => m.LogWarning("Invalid line: \"xx,id,\". Target User is not defined. Will ignore it."), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -186,7 +285,7 @@ public async Task ReclaimMannequins_InvalidCSVContentClaimantLoginNotSpecified_I _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(It.IsAny()), Times.Never); - _mockOctoLogger.Verify(m => m.LogError($"Invalid line: \"mona,,\". Mannequin Id is not defined. Will ignore it."), Times.Once); + _mockOctoLogger.Verify(m => m.LogWarning($"Invalid line: \"mona,,\". Mannequin Id is not defined. Will ignore it."), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -399,7 +498,7 @@ public async Task ReclaimMannequins_AlreadyMapped_No_Reclaim_ShowsError() _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Never); - _mockOctoLogger.Verify(x => x.LogError($"{MANNEQUIN_LOGIN} is already claimed. Skipping (use force if you want to reclaim)"), Times.Once); + _mockOctoLogger.Verify(x => x.LogWarning($"{MANNEQUIN_LOGIN} is already claimed. Skipping (use force if you want to reclaim)"), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -424,7 +523,7 @@ public async Task ReclaimMannequins_NoExistantMannequin_No_Reclaim_IssuesError() _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Never); - _mockOctoLogger.Verify(x => x.LogError($"Mannequin {MANNEQUIN_LOGIN} not found. Skipping."), Times.Once); + _mockOctoLogger.Verify(x => x.LogWarning($"Mannequin {MANNEQUIN_LOGIN} not found. Skipping."), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -452,7 +551,7 @@ public async Task ReclaimMannequins_NoTarget_No_Reclaim_IssuesError() _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Once); - _mockOctoLogger.Verify(x => x.LogError($"Claimant \"{TARGET_USER_LOGIN}\" not found. Will ignore it."), Times.Once); + _mockOctoLogger.Verify(x => x.LogWarning($"Claimant \"{TARGET_USER_LOGIN}\" not found. Will ignore it."), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } @@ -478,7 +577,7 @@ public async Task ReclaimMannequinsSkipInvitation_Happy_Path() _mockGithubApi.Setup(x => x.GetOrganizationId(TARGET_ORG).Result).Returns(ORG_ID); _mockGithubApi.Setup(x => x.GetMannequins(ORG_ID).Result).Returns(mannequinsResponse); _mockGithubApi.Setup(x => x.GetUserId(TARGET_USER_LOGIN).Result).Returns(TARGET_USER_ID); - _mockGithubApi.Setup(x => x.ReclaimMannequinsSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); + _mockGithubApi.Setup(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); var csvContent = new string[] { HEADER, @@ -492,21 +591,53 @@ public async Task ReclaimMannequinsSkipInvitation_Happy_Path() _mockGithubApi.Verify(m => m.GetOrganizationId(TARGET_ORG), Times.Once); _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); - _mockGithubApi.Verify(x => x.ReclaimMannequinsSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); + _mockGithubApi.Verify(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Once); _mockGithubApi.VerifyNoOtherCalls(); } - //[Fact] - //public async Task ReclaimMannequinsSkipInvitation_Fails_When_Org_Not_EMU() - //{ - // // Arrange + [Fact] + public async Task ReclaimMannequinsSkipInvitation_No_EMU_Throws_Error_Fails_Fast() + { + var mannequinsResponse = new Mannequin[] { + new Mannequin { Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN} + }; - // // Act + var reclaimMannequinResponse = new ReattributeMannequinToUserResult() + { + Errors = new Collection() + { + new ErrorData() + { + Message = "is not an Enterprise Managed Users (EMU) organization" + } + } + }; + _mockGithubApi.Setup(x => x.GetOrganizationId(TARGET_ORG).Result).Returns(ORG_ID); + _mockGithubApi.Setup(x => x.GetMannequins(ORG_ID).Result).Returns(mannequinsResponse); + _mockGithubApi.Setup(x => x.GetUserId(TARGET_USER_LOGIN).Result).Returns(TARGET_USER_ID); + _mockGithubApi.Setup(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); - // // Assert - //} + var csvContent = new string[] { + HEADER, + $"{MANNEQUIN_LOGIN},{MANNEQUIN_ID},{TARGET_USER_LOGIN}", + "SecondLogin,SecondMannId,SecondTargetUserLogin", + "ThirdLogin,ThirdMannId,ThirdTargetUserLogin" + }; + + // Act + await _service.ReclaimMannequins(csvContent, TARGET_ORG, false, true); + + // Assert + _mockGithubApi.Verify(m => m.GetOrganizationId(TARGET_ORG), Times.Once); + _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); + _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); + _mockGithubApi.Verify(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); + _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Once); + _mockGithubApi.VerifyNoOtherCalls(); + _mockOctoLogger.Verify(x => x.LogError("Failed to reclaim mannequins. The --skip-invitation flag is only available to EMU organizations."), Times.Once); + } [Fact] public async Task ReclaimMannequin_TwoUsersSameLogin_AllReclaimed() @@ -549,7 +680,7 @@ public async Task ReclaimMannequin_TwoUsersSameLogin_AllReclaimed() _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, mannequinUserId2, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse2); // Act - await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false); + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false); // Assert _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); @@ -582,7 +713,7 @@ public async Task ReclaimMannequin_Happy_Path() _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); // Act - await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false); + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); } @@ -613,7 +744,7 @@ public async Task ReclaimMannequin_Duplicates_ReclaimOnlyOnce() _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); // Act - await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false); + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); } @@ -658,7 +789,7 @@ public async Task ReclaimMannequin_Mannequins_ReclaimOnlySpecifiedId() _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, mannequinUserId2, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse2); // Act - await _service.ReclaimMannequin(MANNEQUIN_LOGIN, MANNEQUIN_ID, TARGET_USER_LOGIN, TARGET_ORG, false); + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, MANNEQUIN_ID, TARGET_USER_LOGIN, TARGET_ORG, false, false); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, mannequinUserId2, TARGET_USER_ID), Times.Never); @@ -692,7 +823,7 @@ public async Task ReclaimMannequin_Duplicates_ForceReclaimOnlyOnce() _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); // Act - await _service.ReclaimMannequin(MANNEQUIN_LOGIN, MANNEQUIN_ID, TARGET_USER_LOGIN, TARGET_ORG, true); + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, MANNEQUIN_ID, TARGET_USER_LOGIN, TARGET_ORG, true, false); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); } @@ -726,7 +857,7 @@ public async Task ReclaimMannequin_Duplicates_NoReclaimOneAlreadyReclaimed_Octos _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, null, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); var exception = await FluentActions - .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false)) + .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false)) .Should().ThrowAsync(); exception.WithMessage("User mona is already mapped to a user. Use the force option if you want to reclaim the mannequin again."); @@ -749,7 +880,7 @@ public async Task ReclaimMannequin_AlreadyMapped_No_Reclaim_Throws_OctoshiftCliE // Act var exception = await FluentActions - .Invoking(async () => await reclaimService.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false)) + .Invoking(async () => await reclaimService.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false)) .Should().ThrowAsync(); _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Never()); @@ -791,7 +922,7 @@ public async Task ReclaimMannequin_AlreadyMapped_Force_Reclaim() _mockGithubApi.Setup(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); // Act - await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, true); + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, true, false); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID)); } @@ -827,7 +958,7 @@ public async Task ReclaimMannequin_FailedToReclaim_LogsError_Throws_OctoshiftCli // Act var exception = await FluentActions - .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false)) + .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false)) .Should().ThrowAsync(); exception.WithMessage("Failed to send reclaim mannequin invitation(s)."); @@ -881,7 +1012,7 @@ public async Task ReclaimMannequin_TwoMannequins_FailedToReclaimOne_LogsError_Th // Act var exception = await FluentActions - .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false)) + .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false)) .Should().ThrowAsync(); exception.WithMessage("Failed to send reclaim mannequin invitation(s)."); @@ -903,7 +1034,7 @@ public async Task ReclaimMannequin_NoExistantMannequin_No_Reclaim_Throws_Octoshi // Act await FluentActions - .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false)) + .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false)) .Should().ThrowAsync(); _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never()); @@ -923,11 +1054,85 @@ public async Task ReclaimMannequin_NoClaimantUser_No_Reclaim_Throws_OctoshiftCli // Act var exception = await FluentActions - .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false)) + .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, false)) .Should().ThrowAsync(); exception.WithMessage($"Could not resolve to a User with the login of 'idonotexist'."); _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Once()); } + + [Fact] + public async Task ReclaimMannequinSkipInvitation_Happy_Path() + { + var mannequinsResponse = new Mannequin[] { + new Mannequin { Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN} + }; + + var reclaimMannequinResponse = new ReattributeMannequinToUserResult() + { + Data = new ReattributeMannequinToUserData() + { + ReattributeMannequinToUser = new ReattributeMannequinToUser() + { + Source = new UserInfo() { Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN }, + Target = new UserInfo() { Id = TARGET_USER_ID, Login = TARGET_USER_LOGIN } + } + } + }; + + _mockGithubApi.Setup(x => x.GetOrganizationId(TARGET_ORG).Result).Returns(ORG_ID); + _mockGithubApi.Setup(x => x.GetMannequins(ORG_ID).Result).Returns(mannequinsResponse); + _mockGithubApi.Setup(x => x.GetUserId(TARGET_USER_LOGIN).Result).Returns(TARGET_USER_ID); + _mockGithubApi.Setup(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); + + // Act + await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, true); + + // Assert + _mockGithubApi.Verify(m => m.GetOrganizationId(TARGET_ORG), Times.Once); + _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); + _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); + _mockGithubApi.Verify(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); + _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Once); + _mockGithubApi.VerifyNoOtherCalls(); + } + + [Fact] + public async Task ReclaimMannequinSkipInvitation_No_EMU_Throws_Error_Fails_Fast() + { + var mannequinsResponse = new Mannequin[] { + new Mannequin { Id = MANNEQUIN_ID, Login = MANNEQUIN_LOGIN} + }; + + var reclaimMannequinResponse = new ReattributeMannequinToUserResult() + { + Errors = new Collection() + { + new ErrorData() + { + Message = "is not an Enterprise Managed Users (EMU) organization" + } + } + }; + + _mockGithubApi.Setup(x => x.GetOrganizationId(TARGET_ORG).Result).Returns(ORG_ID); + _mockGithubApi.Setup(x => x.GetMannequins(ORG_ID).Result).Returns(mannequinsResponse); + _mockGithubApi.Setup(x => x.GetUserId(TARGET_USER_LOGIN).Result).Returns(TARGET_USER_ID); + _mockGithubApi.Setup(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID).Result).Returns(reclaimMannequinResponse); + + // Act + var exception = await FluentActions + .Invoking(async () => await _service.ReclaimMannequin(MANNEQUIN_LOGIN, null, TARGET_USER_LOGIN, TARGET_ORG, false, true)) + .Should().ThrowAsync(); + exception.WithMessage("Failed to reclaim mannequin."); + + // Assert + _mockGithubApi.Verify(m => m.GetOrganizationId(TARGET_ORG), Times.Once); + _mockGithubApi.Verify(m => m.GetMannequins(ORG_ID), Times.Once); + _mockGithubApi.Verify(x => x.CreateAttributionInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Never); + _mockGithubApi.Verify(x => x.ReclaimMannequinSkipInvitation(ORG_ID, MANNEQUIN_ID, TARGET_USER_ID), Times.Once); + _mockGithubApi.Verify(x => x.GetUserId(TARGET_USER_LOGIN), Times.Once); + _mockGithubApi.VerifyNoOtherCalls(); + } } diff --git a/src/OctoshiftCLI.Tests/ado2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs b/src/OctoshiftCLI.Tests/ado2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs index 4918e3580..ff9e2f223 100644 --- a/src/OctoshiftCLI.Tests/ado2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs +++ b/src/OctoshiftCLI.Tests/ado2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs @@ -11,7 +11,7 @@ public void Should_Have_Options() var command = new ReclaimMannequinCommand(); Assert.NotNull(command); Assert.Equal("reclaim-mannequin", command.Name); - Assert.Equal(9, command.Options.Count); + Assert.Equal(10, command.Options.Count); TestHelpers.VerifyCommandOption(command.Options, "github-org", true); TestHelpers.VerifyCommandOption(command.Options, "csv", false); @@ -19,6 +19,7 @@ public void Should_Have_Options() TestHelpers.VerifyCommandOption(command.Options, "mannequin-id", false); TestHelpers.VerifyCommandOption(command.Options, "target-user", false); TestHelpers.VerifyCommandOption(command.Options, "force", false); + TestHelpers.VerifyCommandOption(command.Options, "no-prompt", false); TestHelpers.VerifyCommandOption(command.Options, "github-pat", false); TestHelpers.VerifyCommandOption(command.Options, "skip-invitation", false, true); TestHelpers.VerifyCommandOption(command.Options, "verbose", false); diff --git a/src/OctoshiftCLI.Tests/bbs2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs b/src/OctoshiftCLI.Tests/bbs2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs index 658feb259..1372ad6b4 100644 --- a/src/OctoshiftCLI.Tests/bbs2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs +++ b/src/OctoshiftCLI.Tests/bbs2gh/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs @@ -11,7 +11,7 @@ public void Should_Have_Options() var command = new ReclaimMannequinCommand(); Assert.NotNull(command); Assert.Equal("reclaim-mannequin", command.Name); - Assert.Equal(9, command.Options.Count); + Assert.Equal(10, command.Options.Count); TestHelpers.VerifyCommandOption(command.Options, "github-org", true); TestHelpers.VerifyCommandOption(command.Options, "csv", false); @@ -19,6 +19,7 @@ public void Should_Have_Options() TestHelpers.VerifyCommandOption(command.Options, "mannequin-id", false); TestHelpers.VerifyCommandOption(command.Options, "target-user", false); TestHelpers.VerifyCommandOption(command.Options, "force", false); + TestHelpers.VerifyCommandOption(command.Options, "no-prompt", false); TestHelpers.VerifyCommandOption(command.Options, "github-pat", false); TestHelpers.VerifyCommandOption(command.Options, "skip-invitation", false, true); TestHelpers.VerifyCommandOption(command.Options, "verbose", false); diff --git a/src/OctoshiftCLI.Tests/gei/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs b/src/OctoshiftCLI.Tests/gei/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs index 7e345deda..49a37be39 100644 --- a/src/OctoshiftCLI.Tests/gei/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs +++ b/src/OctoshiftCLI.Tests/gei/Commands/ReclaimMannequin/ReclaimMannequinCommandTests.cs @@ -11,7 +11,7 @@ public void Should_Have_Options() var command = new ReclaimMannequinCommand(); Assert.NotNull(command); Assert.Equal("reclaim-mannequin", command.Name); - Assert.Equal(9, command.Options.Count); + Assert.Equal(10, command.Options.Count); TestHelpers.VerifyCommandOption(command.Options, "github-target-org", true); TestHelpers.VerifyCommandOption(command.Options, "csv", false); @@ -19,6 +19,7 @@ public void Should_Have_Options() TestHelpers.VerifyCommandOption(command.Options, "mannequin-id", false); TestHelpers.VerifyCommandOption(command.Options, "target-user", false); TestHelpers.VerifyCommandOption(command.Options, "force", false); + TestHelpers.VerifyCommandOption(command.Options, "no-prompt", false); TestHelpers.VerifyCommandOption(command.Options, "github-target-pat", false); TestHelpers.VerifyCommandOption(command.Options, "skip-invitation", false, true); TestHelpers.VerifyCommandOption(command.Options, "verbose", false);