diff --git a/backend/src/CodeSpace.Api/Controllers/ProviderInstancesController.cs b/backend/src/CodeSpace.Api/Controllers/ProviderInstancesController.cs index fbb732b18..9eb08e527 100644 --- a/backend/src/CodeSpace.Api/Controllers/ProviderInstancesController.cs +++ b/backend/src/CodeSpace.Api/Controllers/ProviderInstancesController.cs @@ -1,4 +1,5 @@ using CodeSpace.Messages.Commands.ProviderInstances; +using CodeSpace.Messages.Queries.Credentials; using CodeSpace.Messages.Enums; using CodeSpace.Messages.Queries.ProviderInstances; using CodeSpace.Messages.Queries.Providers; @@ -24,6 +25,18 @@ public async Task List(CancellationToken cancellationToken) return Ok(result); } + /// + /// The credentials a repository on this connection could be re-linked to, best first — the whole list the Re-link dialog shows: the + /// caller's own live connections and the team's service tokens, on this connection or another one to the same host. Route's id is + /// authoritative; fromCredentialId (the credential being left) is the only query-string field. + /// + [HttpGet("{providerInstanceId:guid}/relink-candidates")] + public async Task RelinkCandidates([FromRoute] Guid providerInstanceId, [FromQuery] GetRelinkCandidatesQuery query, CancellationToken cancellationToken) + { + var result = await _mediator.Send(query with { ProviderInstanceId = providerInstanceId }, cancellationToken).ConfigureAwait(false); + return Ok(result); + } + [HttpPost] public async Task Add([FromBody] AddProviderInstanceCommand command, CancellationToken cancellationToken) { diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkCandidatesQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkCandidatesQueryHandler.cs new file mode 100644 index 000000000..222d824e3 --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkCandidatesQueryHandler.cs @@ -0,0 +1,16 @@ +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Queries.Credentials; +using MediatR; + +namespace CodeSpace.Core.Handlers.QueryHandlers.Credentials; + +public sealed class GetRelinkCandidatesQueryHandler : IRequestHandler> +{ + private readonly ICredentialRelinkService _service; + + public GetRelinkCandidatesQueryHandler(ICredentialRelinkService service) { _service = service; } + + public async Task> Handle(GetRelinkCandidatesQuery request, CancellationToken cancellationToken) => + await _service.GetCandidatesAsync(request.ProviderInstanceId, request.FromCredentialId, cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Candidates.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Candidates.cs new file mode 100644 index 000000000..a0c8ee46d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Candidates.cs @@ -0,0 +1,74 @@ +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.ProviderInstances; +using CodeSpace.Core.Services.Providers.Identity; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Dtos.Providers; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials; + +/// The dialog's read side: what a repository could be pointed at, across every connection of the team to the host it lives on. +public sealed partial class CredentialRelinkService +{ + public async Task> GetCandidatesAsync(Guid providerInstanceId, Guid? leavingCredentialId, CancellationToken cancellationToken) + { + var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName); + var callerId = _currentUser.Id ?? throw TenantAccessDeniedException.NotAuthenticated(teamId); + + var instance = await LoadTeamInstanceAsync(providerInstanceId, teamId, cancellationToken).ConfigureAwait(false); + var leaving = leavingCredentialId is Guid id ? await LoadTeamCredentialAsync(id, cancellationToken).ConfigureAwait(false) : null; + + var reach = await ProviderInstanceHost.InterchangeableIdsAsync(_db, instance, cancellationToken).ConfigureAwait(false); + var bindable = await LoadBindableAsync(teamId, reach, callerId, cancellationToken).ConfigureAwait(false); + + var ranked = RelinkSuggestionRanking.RankForRepository(instance.Id, leaving, bindable, callerId, _clock.GetUtcNow()); + + return await WithStrandedActAsAsync(instance.Id, ranked, cancellationToken).ConfigureAwait(false); + } + + /// + /// What a pick on another connection takes from the people who act as themselves: an identity belongs to a connection, so a repository that moves + /// leaves behind everyone whose live identity is on the connection it leaves and not on the one it goes to. Named on each candidate that would, by the + /// act-as reader's own predicate — the people the author picker offers today and would no longer. Nothing is read when no candidate is on another + /// connection, so a team with one connection pays for nothing. + /// + private async Task> WithStrandedActAsAsync(Guid repositoryInstanceId, IReadOnlyList ranked, CancellationToken cancellationToken) + { + var elsewhere = ranked.Select(r => r.ProviderInstanceId).Where(id => id != repositoryInstanceId).Distinct().ToList(); + + if (elsewhere.Count == 0) return ranked; + + var here = await _actors.ListCandidatesOnInstanceAsync(repositoryInstanceId, cancellationToken).ConfigureAwait(false); + + if (here.Count == 0) return ranked; + + var stranded = new Dictionary>(); + + foreach (var instanceId in elsewhere) stranded[instanceId] = await NamesNotOnAsync(here, instanceId, cancellationToken).ConfigureAwait(false); + + return ranked.Select(r => stranded.TryGetValue(r.ProviderInstanceId, out var names) ? r with { StrandedActAs = names } : r).ToList(); + } + + /// The names, among , of those with no live identity on . + private async Task> NamesNotOnAsync(IReadOnlyList people, Guid instanceId, CancellationToken cancellationToken) + { + var there = (await _actors.ListCandidatesOnInstanceAsync(instanceId, cancellationToken).ConfigureAwait(false)).Select(c => c.UserId).ToHashSet(); + + return people.Where(p => !there.Contains(p.UserId)).Select(p => p.Name).ToList(); + } + + /// The team's connected credentials on those connections that the caller may bind with: their own personal ones, and the team's service tokens. The ranking keeps the ones that can still authenticate (). + private async Task> LoadBindableAsync(Guid teamId, IReadOnlyList instanceIds, Guid callerId, CancellationToken cancellationToken) => + await _db.Credential.AsNoTracking().Include(c => c.ProviderInstance) + .Where(c => c.TeamId == teamId && instanceIds.Contains(c.ProviderInstanceId) && c.Status == CredentialStatus.Active && c.DeletedDate == null) + .Where(c => (c.Ownership == CredentialOwnership.Personal && c.OwnerUserId == callerId) || c.Ownership == CredentialOwnership.TeamService) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + /// The team's provider connection by id — not found for a missing id and for another team's alike. + private async Task LoadTeamInstanceAsync(Guid id, Guid teamId, CancellationToken cancellationToken) => + await _db.ProviderInstance.AsNoTracking().SingleOrDefaultAsync(p => p.Id == id && p.TeamId == teamId && p.DeletedDate == null, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException($"provider instance {id} not found"); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs index c5e4fdeb2..3ae333b45 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs @@ -1,9 +1,4 @@ -using CodeSpace.Core.Services.OAuth; -using CodeSpace.Core.Services.Providers; -using CodeSpace.Core.Services.Providers.Capabilities; -using CodeSpace.Messages.Exceptions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; namespace CodeSpace.Core.Services.Credentials; @@ -37,7 +32,11 @@ private async Task LoadBoundRepositoriesAsync(CancellationToken cancellationToke /// disconnected credential), so the whole move is refused and the unreadable ones are named: unbind them, or pick a /// credential that reaches them. Sequential and read-only, and bounded (); with /// nothing bound there is nothing to ask. Only a provider that ANSWERED says anything about the target - /// () — one that did not ends the check at once with a retryable refusal. + /// () — one that did not ends the check at once with a retryable refusal. + /// + /// A target on another connection to the same host takes the repository to that connection, so it must also read the repository where CodeSpace + /// has it: the same id at another path is another repository, or the same one renamed since CodeSpace last read it, and either way not something to + /// move a binding across on a guess. /// private async Task EnsureTargetReadsEveryRepositoryAsync(CancellationToken cancellationToken) { @@ -45,18 +44,21 @@ private async Task EnsureTargetReadsEveryRepositoryAsync(CancellationToken cance EnsureFewEnoughToVerify(); - var catalog = _registry.Require(_ctx.To.ProviderInstance.Provider); - var context = new ProviderContext(_ctx.To.ProviderInstance, _ctx.To); + var changesConnection = _ctx.To.ProviderInstanceId != _ctx.From.ProviderInstanceId; var unreadable = new List(); + var elsewhere = new List(); foreach (var repository in _ctx.Repositories) { - if (!await CanReadAsync(catalog, context, repository, cancellationToken).ConfigureAwait(false)) unreadable.Add(repository.FullPath); + var read = await _probe.ReadAsync(_ctx.To, repository.ExternalId, _ctx.From.DisplayName, cancellationToken).ConfigureAwait(false); + + if (!read.Readable) unreadable.Add(repository.FullPath); + else if (changesConnection && !read.IsAt(repository.FullPath)) elsewhere.Add($"{repository.FullPath} (the provider names it {read.FullPath})"); } - if (unreadable.Count == 0) return; + if (unreadable.Count + elsewhere.Count == 0) return; - throw new InvalidOperationException(UnreadableMessage(unreadable)); + throw new InvalidOperationException(elsewhere.Count == 0 ? UnreadableMessage(unreadable) : RefusedMessage(unreadable, elsewhere)); } private void EnsureFewEnoughToVerify() @@ -66,33 +68,6 @@ private void EnsureFewEnoughToVerify() throw new InvalidOperationException($"{_ctx.Repositories.Count} repositories are bound through '{_ctx.From.DisplayName}', and one relink asks the target to read each of them first — at most {MaxRepositoriesVerified} in one action. Nothing was moved. Unbind the repositories that are no longer wanted, or re-link the rest one at a time."); } - private async Task CanReadAsync(IRepositoryCatalogCapability catalog, ProviderContext context, BoundRepository repository, CancellationToken cancellationToken) - { - try - { - await catalog.GetByExternalIdAsync(context, repository.ExternalId, cancellationToken).ConfigureAwait(false); - return true; - } - catch (Exception ex) when (TargetIsRefused(ex)) - { - _logger.LogWarning(ex, "Credential {CredentialId} could not read repository {RepositoryPath}; the relink from {FromCredentialId} is refused", context.Credential.Id, repository.FullPath, _ctx.From.Id); - return false; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Could not check whether credential {CredentialId} reads repository {RepositoryPath}, because the provider did not answer; the relink from {FromCredentialId} is refused, to be tried again", context.Credential.Id, repository.FullPath, _ctx.From.Id); - - throw new RelinkVerificationUnavailableException(_ctx.To.DisplayName, _ctx.From.DisplayName, ex); - } - } - - /// - /// The provider answered, and the answer is about the target: this token is not let in (401, 403 or an insufficient - /// scope), cannot see that project (404), or — for an OAuth connection — the grant itself was refused when its token was - /// refreshed (the provider's answer, or the credential already marked as needing a reconnect for it). Anything else (a 5xx, a 429, a dropped connection, a local rate limit) says nothing about the target. - /// - private static bool TargetIsRefused(Exception ex) => ex is ProviderInsufficientScopeException or OAuthExchangeException or CredentialNeedsReconnectException or ProviderApiException { StatusCode: 401 or 403 or 404 }; - private string UnreadableMessage(List unreadable) { var named = string.Join(", ", unreadable.Take(NamedUnreadableLimit)); @@ -100,4 +75,16 @@ private string UnreadableMessage(List unreadable) return $"Credential '{_ctx.To.DisplayName}' cannot read {unreadable.Count} of the {_ctx.Repositories.Count} repositories bound through '{_ctx.From.DisplayName}': {named}{more}. Nothing was moved. Pick a credential that reaches them, or unbind those repositories first."; } + + /// A move onto another connection that some repositories cannot make: the ones the target cannot read, and the ones it reads somewhere else. + private string RefusedMessage(List unreadable, List elsewhere) + { + var refused = unreadable.Count + elsewhere.Count; + var parts = new List(); + + if (unreadable.Count > 0) parts.Add($"cannot read {string.Join(", ", unreadable.Take(NamedUnreadableLimit))}"); + if (elsewhere.Count > 0) parts.Add($"does not find {string.Join(", ", elsewhere.Take(NamedUnreadableLimit))} where CodeSpace has it"); + + return $"Credential '{_ctx.To.DisplayName}' cannot take {refused} of the {_ctx.Repositories.Count} repositories bound through '{_ctx.From.DisplayName}' over to its connection: it {string.Join(", and ", parts)}. Nothing was moved. Pick a credential that reaches them, or unbind those repositories first."; + } } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs index 21215ac5f..a18871769 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs @@ -1,5 +1,6 @@ using CodeSpace.Core.Authorization; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.ProviderInstances; using CodeSpace.Messages.Dtos.Credentials; using CodeSpace.Messages.Enums; using Microsoft.EntityFrameworkCore; @@ -13,26 +14,29 @@ public async Task> GetSuggestionsAsync(Guid cred var from = await LoadTeamCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false); var callerId = _currentUser.Id ?? throw TenantAccessDeniedException.NotAuthenticated(from.TeamId); - var candidates = await LoadCandidatesAsync(from, callerId, cancellationToken).ConfigureAwait(false); - var accepted = await KeepWhatTheMoveAcceptsAsync(from, candidates, callerId, cancellationToken).ConfigureAwait(false); + var reach = await ProviderInstanceHost.InterchangeableIdsAsync(_db, from.ProviderInstance, cancellationToken).ConfigureAwait(false); + var reachSet = reach.ToHashSet(); - return RelinkSuggestionRanking.Rank(from, accepted, callerId); + var candidates = await LoadCandidatesAsync(from, reach, callerId, cancellationToken).ConfigureAwait(false); + var accepted = await KeepWhatTheMoveAcceptsAsync(from, candidates, callerId, reachSet, cancellationToken).ConfigureAwait(false); + + return RelinkSuggestionRanking.Rank(from, accepted, callerId, reachSet); } - /// The live credentials of the team and instance that could be on either list: the caller's own, the team's, and the credential's owner's — the last being whom a Disconnect of a live credential may hand its bindings to. - private async Task> LoadCandidatesAsync(Credential from, Guid callerId, CancellationToken cancellationToken) => - await _db.Credential.AsNoTracking() - .Where(c => c.TeamId == from.TeamId && c.ProviderInstanceId == from.ProviderInstanceId && c.Id != from.Id) + /// The live credentials of the team, on the credential's instance or another connection to the same host, that could be on either list: the caller's own, the team's, and the credential's owner's — the last being whom a Disconnect of a live credential may hand its bindings to. + private async Task> LoadCandidatesAsync(Credential from, IReadOnlyList reach, Guid callerId, CancellationToken cancellationToken) => + await _db.Credential.AsNoTracking().Include(c => c.ProviderInstance) + .Where(c => c.TeamId == from.TeamId && reach.Contains(c.ProviderInstanceId) && c.Id != from.Id) .Where(c => c.Status == CredentialStatus.Active && c.DeletedDate == null) .Where(c => c.OwnerUserId == callerId || c.OwnerUserId == from.OwnerUserId || c.Ownership == CredentialOwnership.TeamService) .ToListAsync(cancellationToken).ConfigureAwait(false); /// Only the candidates the rules in let through, and of those the ones the move itself would not refuse — the holder of a person's credential must still stand in the team. - private async Task> KeepWhatTheMoveAcceptsAsync(Credential from, List candidates, Guid callerId, CancellationToken cancellationToken) + private async Task> KeepWhatTheMoveAcceptsAsync(Credential from, List candidates, Guid callerId, IReadOnlySet reach, CancellationToken cancellationToken) { var accepted = new List(); - foreach (var candidate in candidates.Where(c => RelinkSuggestionRanking.Qualifies(from, c, callerId))) + foreach (var candidate in candidates.Where(c => RelinkSuggestionRanking.Qualifies(from, c, callerId, reach))) { if (await _succession.RefusalAsync(from, candidate, cancellationToken).ConfigureAwait(false) is null) accepted.Add(candidate); } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs index e2cda21d3..4692fc7f2 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs @@ -3,7 +3,7 @@ using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Identity; -using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.Core.Services.Providers.Identity; using CodeSpace.Messages.Authorization; using CodeSpace.Messages.Commands.Credentials; using CodeSpace.Messages.Constants; @@ -25,19 +25,23 @@ public sealed partial class CredentialRelinkService : ICredentialRelinkService, private readonly ICurrentUser _currentUser; private readonly TeamMembershipResolver _membership; private readonly ICredentialSuccessionService _succession; - private readonly IProviderRegistry _registry; + private readonly IRepositoryReadProbe _probe; + private readonly IActorIdentityResolver _actors; + private readonly TimeProvider _clock; private readonly ILogger _logger; private RelinkContext _ctx = new(); - public CredentialRelinkService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver membership, ICredentialSuccessionService succession, IProviderRegistry registry, ILogger logger) + public CredentialRelinkService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver membership, ICredentialSuccessionService succession, IRepositoryReadProbe probe, IActorIdentityResolver actors, TimeProvider clock, ILogger logger) { _db = db; _currentTeam = currentTeam; _currentUser = currentUser; _membership = membership; _succession = succession; - _registry = registry; + _probe = probe; + _actors = actors; + _clock = clock; _logger = logger; } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs index 0b2fc02e2..8ca78df57 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs @@ -2,6 +2,7 @@ using CodeSpace.Messages.Dtos.Credentials; using CodeSpace.Messages.Enums; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.ProviderInstances; using Microsoft.EntityFrameworkCore; namespace CodeSpace.Core.Services.Credentials; @@ -9,7 +10,9 @@ namespace CodeSpace.Core.Services.Credentials; /// /// The third door onto the same move: a person (or an Admin for them) names where a credential's bindings go, instead of /// waiting for a connect or the repair sweep to find a successor. The floor every move obeys lives here; who may ask for -/// which target is each caller's own rule. +/// which target is each caller's own rule. The target may belong to another connection to the same host +/// (), and then a repository that moves onto it moves to that connection too +/// (). /// public sealed partial class CredentialSuccessionService { @@ -19,13 +22,42 @@ public sealed partial class CredentialSuccessionService if (to.TeamId != from.TeamId || to.DeletedDate != null) return $"Credential {to.Id} not found or no longer active."; - if (to.ProviderInstanceId != from.ProviderInstanceId) return $"Credential '{to.DisplayName}' is for a different provider instance. Pick a credential connected to the same provider as '{from.DisplayName}'."; + if (!await ReachesTheSameHostAsync(from, to, cancellationToken).ConfigureAwait(false)) return $"Credential '{to.DisplayName}' is for a different provider instance. Pick a credential connected to the same provider as '{from.DisplayName}'."; if (to.Status != CredentialStatus.Active) return $"Credential '{to.DisplayName}' is {to.Status} — pick an Active credential."; - return await LackOfStandingAsync(to, cancellationToken).ConfigureAwait(false); + return await LackOfStandingAsync(to, cancellationToken).ConfigureAwait(false) ?? await RehomingRefusalAsync(from, to, cancellationToken).ConfigureAwait(false); } + /// Most refusals worded in one message — enough to act on, short enough to read. + private const int NamedRefusalLimit = 3; + + /// + /// Whether the repositories bound through may move onto a credential of another connection (): one + /// that would not is a reason to refuse the whole move, since a person who named the target asked for everything to go there. Nothing to ask when the credentials + /// share a connection. + /// + private async Task RehomingRefusalAsync(Credential from, Credential to, CancellationToken cancellationToken) + { + if (to.ProviderInstanceId == from.ProviderInstanceId) return null; + + var crossing = await _db.Repository.AsNoTracking() + .Where(r => r.CredentialId == from.Id && r.TeamId == from.TeamId && r.ProviderInstanceId == from.ProviderInstanceId && r.DeletedDate == null) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + if (crossing.Count == 0) return null; + + var refused = await _rehoming.Value.RefusalsAsync(crossing, to, cancellationToken).ConfigureAwait(false); + + if (refused.Count == 0) return null; + + return $"{refused.Count} of the {crossing.Count} repositories bound through '{from.DisplayName}' cannot move onto '{to.DisplayName}'. {string.Join(" ", refused.Values.Take(NamedRefusalLimit))}"; + } + + /// The same connection, or another live connection of the team to the same host — the one definition, . + private async Task ReachesTheSameHostAsync(Credential from, Credential to, CancellationToken cancellationToken) => + await ProviderInstanceHost.AreInterchangeableAsync(_db, from.TeamId, from.ProviderInstanceId, to.ProviderInstanceId, cancellationToken).ConfigureAwait(false); + public async Task FindOnlyOtherConnectionAsync(Credential from, Guid callerId, CancellationToken cancellationToken) { if (!IsTheCallersOwnLiveConnection(from, callerId)) return null; @@ -50,11 +82,24 @@ public async Task MoveBindingsAsync(Credential from, Credential t var adopted = await AdoptBindingsAsync(to, new List { from.Id }, cancellationToken).ConfigureAwait(false); - await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); + EnsureNothingWasLeftBehind(from, to, adopted); + + await DispatchAfterCommitAsync(adopted, cancellationToken).ConfigureAwait(false); return new BindingsMoved { RepositoryCount = adopted.RepositoryCount, ConnectionWebhookCount = adopted.ConnectionWebhookCount }; } + /// + /// A move a person asked for moves everything or nothing: has already refused what it can know, so a repository left behind here is + /// one that changed since — it is thrown, and the caller's unit of work rolls the rest of the move back. + /// + private static void EnsureNothingWasLeftBehind(Credential from, Credential to, Adopted adopted) + { + if (adopted.Refusals.Count == 0) return; + + throw new InvalidOperationException($"{adopted.Refusals.Count} repositories bound through '{from.DisplayName}' cannot move onto '{to.DisplayName}'. Nothing was moved. {string.Join(" ", adopted.Refusals.Take(NamedRefusalLimit))}"); + } + /// /// A person's credential held by someone with no standing in the team () is one the departed- /// token sweep retires: whatever it took in would be stranded again within the tick. A team-service credential belongs diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Revive.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Revive.cs index 4b11ea925..dc52c17f7 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Revive.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Revive.cs @@ -11,7 +11,7 @@ public async Task ReviveBindingsAsync(Credential credential, Ca { var own = new List { credential.Id }; - var repositories = await LoadBoundRepositoriesAsync(credential, own, cancellationToken).ConfigureAwait(false); + var repositories = await LoadBoundRepositoriesAsync(credential.TeamId, new[] { credential.ProviderInstanceId }, own, cancellationToken).ConfigureAwait(false); var hooks = await LoadBoundConnectionWebhooksAsync(credential, own, cancellationToken).ConfigureAwait(false); var revived = new BindingsRevived { RepositoryCount = repositories.Count(r => r.Status == RepositoryStatus.Error), ConnectionWebhookCount = hooks.Count(h => Revivable.Contains(h.RegistrationStatus)) }; diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs index 362b6d039..2d187efde 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs @@ -3,6 +3,8 @@ using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.OAuth; +using CodeSpace.Core.Services.ProviderInstances; +using CodeSpace.Core.Services.RepositoryBinding; using CodeSpace.Core.Services.Webhooks.Registration; using CodeSpace.Messages.Enums; using Microsoft.EntityFrameworkCore; @@ -13,7 +15,10 @@ namespace CodeSpace.Core.Services.Credentials; /// /// One rule, applied at connect time and by the recurring repair: what is bound through a DEAD credential — its /// repositories and the group hooks it registered — follows that credential's owner to the credential they connected -/// after it on the same provider instance in the same team. Dead is a revoked credential, and one its provider refused for +/// after it on the same provider instance in the same team — or on another connection of the team to the same host +/// (), where a repository moves to that connection with it () and the +/// owner's credential on the dead one's own connection is preferred. A group hook is keyed to its connection, so it follows only to a +/// credential of that connection: a dead credential whose only live binding is such a hook has no successor on a sibling. Dead is a revoked credential, and one its provider refused for /// good (Error, with its payload kept): the failure for either says to reconnect, and a person who does has no /// Disconnect to click first — the Providers list shows only live credentials, so a refused one is already gone from it. /// Ownership is the whole key — a teammate connecting the same @@ -46,15 +51,19 @@ public sealed partial class CredentialSuccessionService : ICredentialSuccessionS private readonly IPostCommitActions _postCommit; private readonly ISweepFailureBackoff _backoff; private readonly ICrossProcessTryLock _tryLock; + + /// Lazy because the service that moves a repository between connections reads Webhooks:BaseUrl when it is built, which is required outside Development: connecting, disconnecting and listing credentials must not need it, and only a move between connections does. + private readonly Lazy _rehoming; private readonly ILogger _logger; - public CredentialSuccessionService(CodeSpaceDbContext db, IConnectionWebhookRegistrationDispatcher hookDispatcher, IPostCommitActions postCommit, ISweepFailureBackoff backoff, ICrossProcessTryLock tryLock, ILogger logger) + public CredentialSuccessionService(CodeSpaceDbContext db, IConnectionWebhookRegistrationDispatcher hookDispatcher, IPostCommitActions postCommit, ISweepFailureBackoff backoff, ICrossProcessTryLock tryLock, Lazy rehoming, ILogger logger) { _db = db; _hookDispatcher = hookDispatcher; _postCommit = postCommit; _backoff = backoff; _tryLock = tryLock; + _rehoming = rehoming; _logger = logger; } @@ -68,16 +77,16 @@ public async Task CarryForwardAsync(Credential successor, CancellationToken canc var adopted = await AdoptBindingsAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); - await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); + await DispatchAfterCommitAsync(adopted, cancellationToken).ConfigureAwait(false); } public async Task RepairStrandedAsync(int batchSize, CancellationToken cancellationToken) => await new SweepPass(SweepName, _backoff, _logger).RunAsync(batchSize, LoadStrandedAsync, RepairAsync, cancellationToken).ConfigureAwait(false); - public async Task CountAsync(CancellationToken cancellationToken) => await StrandedWithSuccessor().CountAsync(cancellationToken).ConfigureAwait(false); + public async Task CountAsync(CancellationToken cancellationToken) => await (await StrandedWithSuccessorAsync(cancellationToken: cancellationToken).ConfigureAwait(false)).CountAsync(cancellationToken).ConfigureAwait(false); /// - /// The owner's dead credentials on the successor's instance and team — revoked, or refused by their provider. Filtered on + /// The owner's dead credentials on the successor's instance — or another connection to the same host — and team: revoked, or refused by their provider. Filtered on /// the tracked entities, not in SQL: a token link revokes the credential it replaces in the same unit of work, so that row /// is Revoked in memory before it is Revoked in the database. No date is asked of a refused one here: the successor is /// being created now, after whatever the owner's credentials last did. Held as read before it is moved @@ -85,8 +94,10 @@ public async Task RepairStrandedAsync(int batchSize, CancellationToken canc /// private async Task> LoadDeadPredecessorIdsAsync(Credential successor, Guid ownerId, CancellationToken cancellationToken) { + var reach = await ProviderInstanceHost.InterchangeableIdsAsync(_db, successor.ProviderInstanceId, cancellationToken).ConfigureAwait(false); + var owned = await _db.Credential - .Where(c => c.TeamId == successor.TeamId && c.ProviderInstanceId == successor.ProviderInstanceId && c.OwnerUserId == ownerId && c.Id != successor.Id) + .Where(c => c.TeamId == successor.TeamId && reach.Contains(c.ProviderInstanceId) && c.OwnerUserId == ownerId && c.Id != successor.Id) .ToListAsync(cancellationToken).ConfigureAwait(false); var dead = owned.Where(c => c.Status is CredentialStatus.Revoked or CredentialStatus.Error).ToList(); @@ -129,7 +140,8 @@ private bool IsStoredAsRead(Credential credential, List stored) /// /// Dead owned credentials that something live is still bound to, each paired with its successor: the owner's - /// newest Active credential on the same instance and team connected at or after the moment the dead one stopped working. A revoked row is not modified again, so its last-modified date is when it was revoked (a token link revokes + /// newest Active credential on the same instance and team — or on another connection to the same host, the dead one's own + /// connection preferred — connected at or after the moment the dead one stopped working. A revoked row is not modified again, so its last-modified date is when it was revoked (a token link revokes /// the credential it replaces in the same save that creates the new one, hence at-or-after). A refused row IS written again, /// every hour its verdict is confirmed, so its last-modified date says nothing about when it died: it was last known good /// at its last validation, or at its creation if it never had one, and a credential connected since is the owner's @@ -140,7 +152,7 @@ private bool IsStoredAsRead(Credential credential, List stored) /// private async Task> LoadStrandedAsync(Guid? after, int take, CancellationToken cancellationToken) { - var rows = await StrandedWithSuccessor(after) + var rows = await (await StrandedWithSuccessorAsync(after, cancellationToken: cancellationToken).ConfigureAwait(false)) .OrderBy(candidate => candidate.DeadId) .Take(take) .ToListAsync(cancellationToken).ConfigureAwait(false); @@ -153,8 +165,28 @@ private async Task> LoadStrandedAsync(Guid? after, int /// pages through and counts, so the two cannot disagree about what a candidate is. Narrowed to /// it is the verdict on that one credential, which asks for again under /// the credential's lock — the one definition of "dead and stranded", asked twice. + /// + /// One query per set of connections that reach each other (), and one for + /// every other connection, joined by a union: the rule that two connections are the same host is C# (CredentialUrl.SameRoot), so + /// it reaches SQL as the ids of each group, and a dead credential belongs to exactly one of the queries. + /// + private async Task> StrandedWithSuccessorAsync(Guid? after = null, Guid? only = null, CancellationToken cancellationToken = default) + { + var groups = await ProviderInstanceHost.SiblingGroupsAsync(_db, cancellationToken).ConfigureAwait(false); + var grouped = groups.SelectMany(group => group).ToArray(); + + var everyone = StrandedAmong(deadScope: null, grouped, after, only); + + return groups.Aggregate(everyone, (all, group) => all.Union(StrandedAmong(group.ToArray(), grouped, after, only))); + } + + /// + /// The candidates whose dead credential is on one of (a group of connections to one host), whose successor may be on any of + /// them — or, with no scope, the ones on no group's connection, whose successor is on their own. A dead credential with live repositories is a candidate + /// for any successor; one with only group hooks needs a successor on its own connection, because a hook is keyed to its connection and cannot follow it to a + /// sibling — without that, such a credential would be "repaired" every tick and never leave. /// - private IQueryable StrandedWithSuccessor(Guid? after = null, Guid? only = null) + private IQueryable StrandedAmong(Guid[]? deadScope, Guid[] grouped, Guid? after, Guid? only) { var deadCredentials = _db.Credential.AsNoTracking().Where(c => (c.Status == CredentialStatus.Revoked || c.Status == CredentialStatus.Error) && c.OwnerUserId != null); @@ -162,19 +194,21 @@ private IQueryable StrandedWithSuccessor(Guid? after = null, Guid? if (only is Guid single) deadCredentials = deadCredentials.Where(c => c.Id == single); - return deadCredentials - .Where(dead => _db.Repository.Any(r => r.CredentialId == dead.Id && r.TeamId == dead.TeamId && r.ProviderInstanceId == dead.ProviderInstanceId && r.DeletedDate == null) - || _db.ConnectionWebhook.Any(w => w.CredentialId == dead.Id && w.ProviderInstanceId == dead.ProviderInstanceId && WebhookRegistrationLifecycle.InService.Contains(w.RegistrationStatus))) - .Select(dead => new StrandedRow - { - DeadId = dead.Id, - SuccessorId = _db.Credential - .Where(c => c.TeamId == dead.TeamId && c.ProviderInstanceId == dead.ProviderInstanceId && c.OwnerUserId == dead.OwnerUserId && c.Status == CredentialStatus.Active && c.DeletedDate == null && c.CreatedDate >= (dead.Status == CredentialStatus.Revoked ? dead.LastModifiedDate : (dead.LastValidatedDate ?? dead.CreatedDate))) - .OrderByDescending(c => c.CreatedDate).ThenByDescending(c => c.Id) - .Select(c => (Guid?)c.Id) - .FirstOrDefault() - }) - .Where(candidate => candidate.SuccessorId != null); + var siblings = deadScope ?? Array.Empty(); + + deadCredentials = deadScope is null ? deadCredentials.Where(c => !grouped.Contains(c.ProviderInstanceId)) : deadCredentials.Where(c => siblings.Contains(c.ProviderInstanceId)); + + return ( + from dead in deadCredentials + let hasRepositories = _db.Repository.Any(r => r.CredentialId == dead.Id && r.TeamId == dead.TeamId && r.ProviderInstanceId == dead.ProviderInstanceId && r.DeletedDate == null) + where hasRepositories || _db.ConnectionWebhook.Any(w => w.CredentialId == dead.Id && w.ProviderInstanceId == dead.ProviderInstanceId && WebhookRegistrationLifecycle.InService.Contains(w.RegistrationStatus)) + let successor = _db.Credential + .Where(c => c.TeamId == dead.TeamId && (c.ProviderInstanceId == dead.ProviderInstanceId || siblings.Contains(c.ProviderInstanceId)) && c.OwnerUserId == dead.OwnerUserId && c.Status == CredentialStatus.Active && c.DeletedDate == null && c.CreatedDate >= (dead.Status == CredentialStatus.Revoked ? dead.LastModifiedDate : (dead.LastValidatedDate ?? dead.CreatedDate))) + .OrderByDescending(c => c.ProviderInstanceId == dead.ProviderInstanceId).ThenByDescending(c => c.CreatedDate).ThenByDescending(c => c.Id) + .Select(c => new { c.Id, c.ProviderInstanceId }) + .FirstOrDefault() + where successor != null && (hasRepositories || successor.ProviderInstanceId == dead.ProviderInstanceId) + select new StrandedRow { DeadId = dead.Id, DeadInstanceId = dead.ProviderInstanceId, HasRepositories = hasRepositories, SuccessorId = successor.Id, SuccessorInstanceId = successor.ProviderInstanceId }); } /// @@ -209,7 +243,7 @@ private async Task RepairHeldAsync(StrandedCredential candidate, Cancellat if (adopted is null) return false; - await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); + await DispatchAfterCommitAsync(adopted, cancellationToken).ConfigureAwait(false); return true; } @@ -234,37 +268,76 @@ private async Task RepairHeldAsync(StrandedCredential candidate, Cancellat await HoldRowsAsync(new[] { candidate.DeadId }, cancellationToken).ConfigureAwait(false); - var verdict = await StrandedWithSuccessor(only: candidate.DeadId).SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); + var verdict = await (await StrandedWithSuccessorAsync(only: candidate.DeadId, cancellationToken: cancellationToken).ConfigureAwait(false)).SingleOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (verdict is null) return null; - var successor = await _db.Credential.SingleAsync(c => c.Id == verdict.SuccessorId!.Value, cancellationToken).ConfigureAwait(false); + var successor = await _db.Credential.SingleAsync(c => c.Id == verdict.SuccessorId, cancellationToken).ConfigureAwait(false); var adopted = await AdoptBindingsAsync(successor, new List { candidate.DeadId }, cancellationToken).ConfigureAwait(false); + EnsureSomethingMoved(candidate, successor, adopted); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); return adopted; } - /// Re-point every live binding of at as tracked changes. Returns what moved, and the group hooks now waiting for a registrar. + /// A successor that took nothing because everything was refused is a failure of the candidate, not a repair (). Thrown before the commit, so nothing is written. + private static void EnsureSomethingMoved(StrandedCredential candidate, Credential successor, Adopted adopted) + { + if (adopted.RepositoryCount + adopted.ConnectionWebhookCount > 0 || adopted.Refusals.Count == 0) return; + + throw new StrandedBindingsRefusedException(candidate.DeadId, successor.Id, adopted.Refusals); + } + + /// + /// Re-point every live binding of at as tracked changes. Returns what moved, the group + /// hooks now waiting for a registrar, and what could not move. A repository on another connection to the same host moves to the successor's connection with it + /// () — unless the move would be refused (), in which case it stays where it was, bound + /// through the credential it had, and is reported. Group hooks only follow to a credential of their own connection. + /// private async Task AdoptBindingsAsync(Credential successor, List predecessorIds, CancellationToken cancellationToken) { - var repositories = await LoadBoundRepositoriesAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); + var reach = await ProviderInstanceHost.InterchangeableIdsAsync(_db, successor.ProviderInstanceId, cancellationToken).ConfigureAwait(false); + var repositories = await LoadBoundRepositoriesAsync(successor.TeamId, reach, predecessorIds, cancellationToken).ConfigureAwait(false); var hooks = await LoadBoundConnectionWebhooksAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); - foreach (var repository in repositories) Adopt(repository, successor); + var crossing = repositories.Where(r => r.ProviderInstanceId != successor.ProviderInstanceId).ToList(); + var refused = crossing.Count == 0 ? new Dictionary() : await _rehoming.Value.RefusalsAsync(crossing, successor, cancellationToken).ConfigureAwait(false); + var moving = repositories.Where(r => !refused.ContainsKey(r.Id)).ToList(); + var rehomed = new List(); + + foreach (var repository in moving) await AdoptAsync(repository, successor, rehomed, cancellationToken).ConfigureAwait(false); foreach (var hook in hooks) Adopt(hook, successor); - if (repositories.Count + hooks.Count > 0) - _logger.LogInformation("Moved {RepositoryCount} repositories and {ConnectionWebhookCount} group hooks from {OwnerUserId}'s disconnected credentials on provider instance {ProviderInstanceId} to credential {CredentialId}", repositories.Count, hooks.Count, successor.OwnerUserId, successor.ProviderInstanceId, successor.Id); + LogMoved(successor, moving.Count, hooks.Count, refused); + + return new Adopted(moving.Count, hooks.Count, hooks.Where(h => h.RegistrationStatus == RepositoryWebhookRegistrationStatus.Pending).Select(h => h.Id).ToList(), rehomed, refused.Values.ToList()); + } + + /// Bind the live credential — taking the repository to the credential's connection first when it lives under another one — and lift the Error the dead one's disconnect set. + private async Task AdoptAsync(Repository repository, Credential successor, List rehomed, CancellationToken cancellationToken) + { + if (repository.ProviderInstanceId != successor.ProviderInstanceId) + rehomed.Add(await _rehoming.Value.RehomeAsync(repository, successor, cancellationToken).ConfigureAwait(false)); - return new Adopted(repositories.Count, hooks.Count, hooks.Where(h => h.RegistrationStatus == RepositoryWebhookRegistrationStatus.Pending).Select(h => h.Id).ToList()); + Adopt(repository, successor); } - private async Task> LoadBoundRepositoriesAsync(Credential successor, List predecessorIds, CancellationToken cancellationToken) => + private void LogMoved(Credential successor, int repositories, int hooks, IReadOnlyDictionary refused) + { + if (repositories + hooks > 0) + _logger.LogInformation("Moved {RepositoryCount} repositories and {ConnectionWebhookCount} group hooks from {OwnerUserId}'s disconnected credentials to credential {CredentialId} on provider instance {ProviderInstanceId}", repositories, hooks, successor.OwnerUserId, successor.Id, successor.ProviderInstanceId); + + if (refused.Count > 0) + _logger.LogWarning("{RefusedCount} repositories stay bound through {OwnerUserId}'s disconnected credentials, because credential {CredentialId} on provider instance {ProviderInstanceId} cannot take them over: {Reasons}", refused.Count, successor.OwnerUserId, successor.Id, successor.ProviderInstanceId, string.Join(" ", refused.Values)); + } + + /// The live repositories of the team bound through a predecessor, under any of — a connection and its siblings. + private async Task> LoadBoundRepositoriesAsync(Guid teamId, IReadOnlyList instanceIds, List predecessorIds, CancellationToken cancellationToken) => await _db.Repository - .Where(r => r.TeamId == successor.TeamId && r.ProviderInstanceId == successor.ProviderInstanceId && r.DeletedDate == null && r.CredentialId != null && predecessorIds.Contains(r.CredentialId.Value)) + .Where(r => r.TeamId == teamId && instanceIds.Contains(r.ProviderInstanceId) && r.DeletedDate == null && r.CredentialId != null && predecessorIds.Contains(r.CredentialId.Value)) .ToListAsync(cancellationToken).ConfigureAwait(false); /// The in-service group hooks registered through a predecessor — a hook records the credential it registers, retries and deletes through, so it is as stranded as a repository. A Cancelled one is retired and stays. @@ -319,6 +392,14 @@ private async Task DispatchAfterCommitAsync(List hookIds, CancellationToke await _postCommit.RunAfterCommitAsync(ct => _hookDispatcher.DispatchAsync(hookId, ct), cancellationToken).ConfigureAwait(false); } + /// Everything an adoption left for after the commit: the revived group hooks, and what the repositories that changed connection staged — their new hooks, and the old ones to delete at the provider. + private async Task DispatchAfterCommitAsync(Adopted adopted, CancellationToken cancellationToken) + { + await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); + + foreach (var staged in adopted.Rehomed) await _rehoming.Value.DispatchAfterCommitAsync(staged, cancellationToken).ConfigureAwait(false); + } + private sealed record StrandedCredential(Guid DeadId) : ISweepCandidate { public Guid Id => DeadId; @@ -327,12 +408,16 @@ private sealed record StrandedCredential(Guid DeadId) : ISweepCandidate /// What a credential's row holds that a re-authorization changes: compared with what the entity was read with. private sealed record StoredState(Guid Id, CredentialStatus Status, string EncryptedPayload); - private sealed record Adopted(int RepositoryCount, int ConnectionWebhookCount, List RevivedHookIds); + /// What an adoption did: how much moved, the hooks to hand to a registrar, what the moves that changed connection staged, and the reasons the repositories left behind could not move. + private sealed record Adopted(int RepositoryCount, int ConnectionWebhookCount, List RevivedHookIds, List Rehomed, List Refusals); - /// The query's row: a member-initialised class because an anonymous type cannot be returned from . + /// The query's row: a member-initialised class because an anonymous type cannot be returned from . private sealed class StrandedRow { public Guid DeadId { get; init; } - public Guid? SuccessorId { get; init; } + public Guid DeadInstanceId { get; init; } + public bool HasRepositories { get; init; } + public Guid SuccessorId { get; init; } + public Guid SuccessorInstanceId { get; init; } } } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs index 40b885b0f..cbddf4d29 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs @@ -14,12 +14,16 @@ public interface ICredentialRelinkService /// Every repository (with its projects), group hook, act-as identity and workflow trigger that leans on the credential. A credential outside the caller's team reads as not found. Task GetDependentsAsync(Guid credentialId, CancellationToken cancellationToken); - /// The credentials the caller could move this one's bindings onto — their own live connections first, then the team's service tokens — each a candidate would accept. + /// The credentials the caller could move this one's bindings onto — their own live connections first, then the team's service tokens, the same connection's before another connection's to the same host — each a candidate would accept. Task> GetSuggestionsAsync(Guid credentialId, CancellationToken cancellationToken); + /// The credentials a Re-link dialog offers for a repository on a provider connection — see . The connection reads as not found outside the caller's team. + Task> GetCandidatesAsync(Guid providerInstanceId, Guid? leavingCredentialId, CancellationToken cancellationToken); + /// /// Move every live repository and in-service group hook bound through a dead credential onto another credential, after - /// the target has been asked to read each repository. Needs repos.manage (the command's marker) and + /// the target has been asked to read each repository. A target on another connection to the same host takes the repositories to + /// its connection (), and they must be found at the path CodeSpace has; a group hook stays with its connection. Needs repos.manage (the command's marker) and /// credentials.manage (asserted here). Tracked changes only — the caller's unit of work saves them. /// Task RelinkAllAsync(Guid fromCredentialId, Guid toCredentialId, CancellationToken cancellationToken); diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs index ece26c5b6..9b3b8948e 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs @@ -13,9 +13,11 @@ public interface ICredentialSuccessionService { /// /// Re-point every live repository and in-service group hook bound through one of 's - /// owner's dead credentials (revoked, or refused by their provider) on the same provider instance and team at - /// , lifting the "needs a new credential" error a disconnect set and reviving the hooks its - /// failures buried. A credential with no + /// owner's dead credentials (revoked, or refused by their provider) on the same provider instance — or another connection to the same + /// host — and team at , lifting the "needs a new credential" error a disconnect set and reviving the hooks its + /// failures buried. A repository on another connection moves to the successor's connection with it, unless that is refused + /// (), when it stays where it was and the connect goes on; a group hook only follows to a credential of + /// its own connection. A credential with no /// owner (team-service) succeeds nobody. Tracked changes only — the caller's unit of work saves them with the /// successor, and the revived hooks are dispatched after it commits. A dead credential that a re-authorization has /// revived since it was read keeps its bindings, and one revived after that waits for the unit of work to commit: the @@ -33,7 +35,8 @@ public interface ICredentialSuccessionService /// repairs (). Each credential is repaired under its refresh lock and judged dead /// again once it is held: one a re-authorization has revived since the page was read is no longer a candidate and keeps /// its bindings, and one a refresh or a re-authorization is writing is skipped, not waited for, and met on the next pass. The move is made - /// holding the credential's row, so a verdict the health probe records meanwhile waits for it. + /// holding the credential's row, so a verdict the health probe records meanwhile waits for it. The owner's newest credential on the dead one's own connection + /// is its successor before one on another connection to the same host, and a credential holding only a group hook has no successor on another connection. A successor that takes nothing because every repository was refused () is a failure of that credential: logged with the reasons and set aside after a few passes, so it never holds a place a repairable one could have. /// Task RepairStrandedAsync(int batchSize, CancellationToken cancellationToken); @@ -41,7 +44,8 @@ public interface ICredentialSuccessionService /// Where a person's own act of ending a connection sends what is bound through it when they name no destination — the /// answer reconnecting would have given: when is still connected, is a person's own credential, /// is that person, and they hold exactly one other live Personal credential on the same - /// provider instance, that one — unless the move would refuse it (). Null in every other case: + /// provider instance, that one — unless the move would refuse it (). Never a credential of another + /// connection to the same host: a repository changing connection is a bigger step than holding a credential elsewhere asks for, so it takes a named target. Null in every other case: /// someone else acting for them (an Admin cutting off a teammate's token must not have the repository carried onto the /// teammate's other grant, with no way to say otherwise), a credential already disconnected (a retry is not a new /// choice), a team-service token (two team tokens are rarely interchangeable), none to follow, or two or more to choose @@ -53,8 +57,9 @@ public interface ICredentialSuccessionService /// /// Why may not take over 's bindings, worded for the person who asked — or /// null when it may. Every move obeys the same floor, whoever asks and whatever else they hold: another credential, of - /// the same team and provider instance, live, and — when it is a person's — held by someone who still stands in the - /// team, because the departed-token sweep would retire it and strand what it just took. Who is ALLOWED to ask for + /// the same team and provider instance — or of another connection to the same host (ProviderInstanceHost), where + /// each repository must also be one the new connection can take (not already bound there, its scopes covering the hook) — live, and — when it is a + /// person's — held by someone who still stands in the team, because the departed-token sweep would retire it and strand what it just took. Who is ALLOWED to ask for /// which target is the caller's rule, not this one. /// Task RefusalAsync(Credential from, Credential to, CancellationToken cancellationToken); @@ -62,8 +67,9 @@ public interface ICredentialSuccessionService /// /// Re-point every live repository and in-service group hook bound through at /// , as the carry-forward does: a repository's disconnect Error is lifted, a hook its failures buried - /// is revived and handed to the registrar once the caller's unit of work commits. Throws - /// with the reason when the move is not allowed. + /// is revived and handed to the registrar once the caller's unit of work commits. A repository that changes connection stages the + /// hooks the new connection needs and the old ones are retired, once that unit of work commits. Throws + /// with the reason when the move is not allowed — everything moves or nothing does. /// Tracked changes only. Returns what moved. /// Task MoveBindingsAsync(Credential from, Credential to, CancellationToken cancellationToken); diff --git a/backend/src/CodeSpace.Core/Services/Credentials/IRepositoryReadProbe.cs b/backend/src/CodeSpace.Core/Services/Credentials/IRepositoryReadProbe.cs new file mode 100644 index 000000000..619c6bd5f --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/IRepositoryReadProbe.cs @@ -0,0 +1,29 @@ +using CodeSpace.Core.Persistence.Entities; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// The one question a move asks before it commits: can this credential read that repository, and is it the one that was meant? Asked of the +/// provider as the target credential, the way a bind looks a repository up — a target that cannot would turn a repository's loud failure into a +/// quiet one (runs 404 instead of naming a disconnected credential). +/// +public interface IRepositoryReadProbe +{ + /// + /// Look the repository up by its external id as (whose ProviderInstance may be loaded or not). Only a provider that + /// ANSWERED says anything about the target — a 401, 403 or 404, an insufficient scope, or a grant refused when its token was renewed — and that is a + /// that is not readable. A provider that did not answer (a 5xx, a 429, a dropped connection, a local rate limit) says nothing + /// about it, and throws so nobody is blamed. is whatever the repository + /// is being taken off, for that message; null for one repository on its own. + /// + Task ReadAsync(Credential target, string externalId, string? sourceName, CancellationToken cancellationToken); +} + +/// What the provider said when the target looked a repository up: nothing it could read, or the repository at the path the provider names. +public sealed record RepositoryRead(bool Readable, string? FullPath) +{ + public static readonly RepositoryRead Refused = new(false, null); + + /// Readable, and the path the provider names is the repository's own — the one definition of "the same repository" for a move to another connection: a case-insensitive path, as the provider's lookups are. + public bool IsAt(string fullPath) => Readable && string.Equals(FullPath, fullPath, StringComparison.OrdinalIgnoreCase); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs b/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs index 74a4cf200..3247e552d 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs @@ -1,4 +1,6 @@ using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials.Remedy; +using CodeSpace.Core.Services.ProviderInstances; using CodeSpace.Messages.Dtos.Credentials; using CodeSpace.Messages.Enums; @@ -14,35 +16,64 @@ namespace CodeSpace.Core.Services.Credentials; /// teammate's personal token is never offered, since the repositories would act as that person. A live credential's /// bindings move only when it is disconnected, and then only to another connection of the SAME owner (for a team token, /// another team token): whoever asks, the repositories keep acting as the person — or the team — they acted as. +/// +/// "On the credential's instance" reaches further than the instance itself: a credential on another connection to the +/// same host () takes bindings as one on the same connection does, and is offered after +/// the same connection's, whose credentials move a repository without changing which connection it lives under. The caller names the +/// connections in reach (hostInstanceIds); without them only the credential's own instance is. /// internal static class RelinkSuggestionRanking { - public static IReadOnlyList Rank(Credential from, IEnumerable candidates, Guid callerId) => - candidates - .Where(c => Qualifies(from, c, callerId)) - .Select(c => (Credential: c, Kind: KindOf(c, callerId))) - .OrderBy(offer => offer.Kind) - .ThenByDescending(offer => offer.Credential.CreatedDate) - .ThenBy(offer => offer.Credential.Id) + public static IReadOnlyList Rank(Credential from, IEnumerable candidates, Guid callerId, IReadOnlySet? hostInstanceIds = null) => + Order(from.ProviderInstanceId, candidates.Where(c => Qualifies(from, c, callerId, hostInstanceIds)), callerId) .Select(offer => Suggest(from, offer.Credential, offer.Kind)) .ToList(); /// - /// Live, on the credential's team and instance, not the credential itself, and what the door that would move the - /// bindings accepts: for a live credential (a Disconnect's rule), for a dead one - /// (a relink's). The standing of a person's credential's holder is the one thing read from - /// the database, by . + /// What a Re-link dialog offers for a repository on : every credential among + /// that can authenticate at () and the caller may + /// bind with — their own, or the team's service tokens, never a teammate's — other than the one the repository is leaving. Unlike + /// it does not depend on whether that credential is still live: a repository whose credential merely looks fine is + /// moved off it as readily as a dead one's. The candidates are the caller's to have loaded already, from the connections in reach. /// - public static bool Qualifies(Credential from, Credential candidate, Guid callerId) => + public static IReadOnlyList RankForRepository(Guid providerInstanceId, Credential? leaving, IEnumerable candidates, Guid callerId, DateTimeOffset now) => + Order(providerInstanceId, candidates.Where(c => c.Id != leaving?.Id && CanAuthenticate(c, now) && IsCallersOrTeams(c, callerId)), callerId) + .Select(offer => Suggest(leaving, offer.Credential, offer.Kind)) + .ToList(); + + /// The same connection's credentials first — nothing about the repository's connection changes — then the caller's own, then the team's, each newest first. + private static IEnumerable<(Credential Credential, RelinkSuggestionKind Kind)> Order(Guid providerInstanceId, IEnumerable offered, Guid callerId) => + offered + .Select(c => (Credential: c, Kind: KindOf(c, callerId))) + .OrderBy(offer => offer.Credential.ProviderInstanceId == providerInstanceId ? 0 : 1) + .ThenBy(offer => offer.Kind) + .ThenByDescending(offer => offer.Credential.CreatedDate) + .ThenBy(offer => offer.Credential.Id); + + /// + /// Live, on the credential's team and instance (or one of , the connections to the same host), + /// not the credential itself, and what the door that would move the bindings accepts: for a live credential + /// (a Disconnect's rule), for a dead one (a relink's). The standing + /// of a person's credential's holder is the one thing read from the database, by . + /// + public static bool Qualifies(Credential from, Credential candidate, Guid callerId, IReadOnlySet? hostInstanceIds = null) => candidate.Id != from.Id && candidate.TeamId == from.TeamId - && candidate.ProviderInstanceId == from.ProviderInstanceId + && (candidate.ProviderInstanceId == from.ProviderInstanceId || (hostInstanceIds?.Contains(candidate.ProviderInstanceId) ?? false)) && IsLive(candidate) && (IsLive(from) ? HaveTheSameOwner(from, candidate) : IsCallersOrTeams(candidate, callerId)); /// Connected and not retired: the credential can still authenticate, so moving its bindings is a Disconnect that says where they go. public static bool IsLive(Credential credential) => credential.Status == CredentialStatus.Active && credential.DeletedDate == null; + /// + /// Connected, not retired and — what alone does not say — not a static token past its recorded expiry, which stays Active by status + /// until a probe flips it while the credential list already reads it as rejected. , the definition the health chip and + /// the remedy cards read, so what a person is offered to re-link to and what the list shows as working cannot disagree. + /// + public static bool CanAuthenticate(Credential credential, DateTimeOffset now) => + RemedyResolution.IsLive(new RemedyResolution.CredentialState(credential.Id, credential.Status, credential.AuthType, credential.ExpiresDate, credential.LastValidatedDate, Retired: credential.DeletedDate != null), now); + /// A Disconnect's rule: the same person's — or, for a team token, the team's — never anyone else's. Compared as owner and ownership, so a team token matches only another team token. public static bool HaveTheSameOwner(Credential a, Credential b) => a.OwnerUserId == b.OwnerUserId && a.Ownership == b.Ownership; @@ -56,7 +87,7 @@ private static RelinkSuggestionKind KindOf(Credential c, Guid callerId) => private static bool IsTeamService(Credential c) => c.Ownership == CredentialOwnership.TeamService; - private static RelinkSuggestion Suggest(Credential from, Credential c, RelinkSuggestionKind kind) => new() + private static RelinkSuggestion Suggest(Credential? from, Credential c, RelinkSuggestionKind kind) => new() { CredentialId = c.Id, DisplayName = c.DisplayName, @@ -64,6 +95,8 @@ private static RelinkSuggestionKind KindOf(Credential c, Guid callerId) => Ownership = c.Ownership, Kind = kind, CreatedDate = c.CreatedDate, - ConnectedBeforeDisconnect = from.Status != CredentialStatus.Active && c.CreatedDate < from.LastModifiedDate + ProviderInstanceId = c.ProviderInstanceId, + ProviderInstanceName = c.ProviderInstance?.DisplayName ?? string.Empty, + ConnectedBeforeDisconnect = from is not null && from.Status != CredentialStatus.Active && c.CreatedDate < from.LastModifiedDate }; } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/RelinkVerificationUnavailableException.cs b/backend/src/CodeSpace.Core/Services/Credentials/RelinkVerificationUnavailableException.cs index 64da43552..949e77bed 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/RelinkVerificationUnavailableException.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/RelinkVerificationUnavailableException.cs @@ -14,8 +14,10 @@ public sealed class RelinkVerificationUnavailableException : Exception, IFailure public string Code => FailureCodes.ProviderError; - public RelinkVerificationUnavailableException(string targetName, string sourceName, Exception inner) - : base($"Could not check whether credential '{targetName}' can read the repositories bound through '{sourceName}': the provider did not answer. Nothing was moved. Try again in a moment.", inner) + public RelinkVerificationUnavailableException(string targetName, string? sourceName, Exception inner) + : base(sourceName is null + ? $"Could not check whether credential '{targetName}' can read the repository: the provider did not answer. Nothing was moved. Try again in a moment." + : $"Could not check whether credential '{targetName}' can read the repositories bound through '{sourceName}': the provider did not answer. Nothing was moved. Try again in a moment.", inner) { } } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/RepositoryReadProbe.cs b/backend/src/CodeSpace.Core/Services/Credentials/RepositoryReadProbe.cs new file mode 100644 index 000000000..91390afd8 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/RepositoryReadProbe.cs @@ -0,0 +1,57 @@ +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.OAuth; +using CodeSpace.Core.Services.Providers; +using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.Messages.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.Credentials; + +public sealed class RepositoryReadProbe : IRepositoryReadProbe, IScopedDependency +{ + private readonly CodeSpaceDbContext _db; + private readonly IProviderRegistry _registry; + private readonly ILogger _logger; + + public RepositoryReadProbe(CodeSpaceDbContext db, IProviderRegistry registry, ILogger logger) + { + _db = db; + _registry = registry; + _logger = logger; + } + + public async Task ReadAsync(Credential target, string externalId, string? sourceName, CancellationToken cancellationToken) + { + var instance = target.ProviderInstance ?? await _db.ProviderInstance.AsNoTracking().SingleAsync(p => p.Id == target.ProviderInstanceId, cancellationToken).ConfigureAwait(false); + var catalog = _registry.Require(instance.Provider); + + try + { + var remote = await catalog.GetByExternalIdAsync(new ProviderContext(instance, target), externalId, cancellationToken).ConfigureAwait(false); + + return new RepositoryRead(true, remote.FullPath); + } + catch (Exception ex) when (TargetIsRefused(ex)) + { + _logger.LogWarning(ex, "Credential {CredentialId} could not read repository {ExternalId}; the move onto it is refused", target.Id, externalId); + + return RepositoryRead.Refused; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Could not check whether credential {CredentialId} reads repository {ExternalId}, because the provider did not answer; the move onto it is refused, to be tried again", target.Id, externalId); + + throw new RelinkVerificationUnavailableException(target.DisplayName, sourceName, ex); + } + } + + /// + /// The provider answered, and the answer is about the target: this token is not let in (401, 403 or an insufficient + /// scope), cannot see that project (404), or — for an OAuth connection — the grant itself was refused when its token was + /// refreshed (the provider's answer, or the credential already marked as needing a reconnect for it). Anything else (a 5xx, a 429, a dropped connection, a local rate limit) says nothing about the target. + /// + private static bool TargetIsRefused(Exception ex) => ex is ProviderInsufficientScopeException or OAuthExchangeException or CredentialNeedsReconnectException or ProviderApiException { StatusCode: 401 or 403 or 404 }; +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/StrandedBindingsRefusedException.cs b/backend/src/CodeSpace.Core/Services/Credentials/StrandedBindingsRefusedException.cs new file mode 100644 index 000000000..508fd51e0 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/StrandedBindingsRefusedException.cs @@ -0,0 +1,23 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// The repair sweep found a successor for a dead credential and none of what is bound through it could move there: every repository was +/// refused (the successor's connection already holds the project, or its scopes cannot register the connection's hook). A refusal of that kind does +/// not heal by being asked again, so the sweep treats it as a failure of the candidate — logged with the reasons, and set aside after a few passes +/// in a row — instead of "repairing" it every tick for ever and letting it hold a place in the pass that a repairable credential could have had. +/// Nothing was written. +/// +internal sealed class StrandedBindingsRefusedException : Exception, IFailure +{ + /// Well-formed, and blocked by a rule the sweep cannot change: the person who connected the successor is the one who can (unbind the duplicate, grant the scope). + public FailureKind Kind => FailureKind.Unprocessable; + + public string Code => FailureCodes.InvalidRequest; + + public StrandedBindingsRefusedException(Guid deadCredentialId, Guid successorId, IReadOnlyList reasons) + : base($"None of what is bound through credential {deadCredentialId} can move onto credential {successorId}: {string.Join(" ", reasons.Take(3))}") + { + } +} diff --git a/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceHost.cs b/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceHost.cs new file mode 100644 index 000000000..94627aae0 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceHost.cs @@ -0,0 +1,80 @@ +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Redaction; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.ProviderInstances; + +/// +/// Which provider connections reach the same host — the one definition, so a Re-link dialog, a relink command, a connect's +/// carry-forward and the repair sweep cannot disagree about it. +/// +/// A team may hold several connections to one GitLab: the unique index is per OAuth application (team, provider, base URL, +/// client id), so a second application on the same host is a second connection. Their credentials are not interchangeable at the +/// wire — each is refreshed through its own application — but the repositories behind them are the same repositories, so a +/// repository bound under one may be taken over by a credential of another. Two connections are interchangeable when they are the +/// same team's, the same provider's, and their API roots () are the same service +/// (: scheme, host, port and path). Nothing else makes them so: a different host, port or path, +/// another provider, another team. +/// +public static class ProviderInstanceHost +{ + /// Where provider calls go: the API URL when one is set, the base URL otherwise — the fallback every provider reads. + public static string ApiRoot(ProviderInstance instance) => string.IsNullOrWhiteSpace(instance.ApiUrl) ? instance.BaseUrl : instance.ApiUrl; + + /// Whether the two are one connection or two connections to the same host (see the class summary). Symmetric, and true for a connection against itself. + public static bool AreInterchangeable(ProviderInstance a, ProviderInstance b) => + a.Id == b.Id || (a.TeamId == b.TeamId && a.Provider == b.Provider && CredentialUrl.SameRoot(ApiRoot(a), ApiRoot(b))); + + /// Whether two connections of a team, known by id, are the same or interchangeable — false when either is not a live connection of the team. + public static async Task AreInterchangeableAsync(CodeSpaceDbContext db, Guid teamId, Guid a, Guid b, CancellationToken cancellationToken) + { + if (a == b) return true; + + var instances = await LiveInstancesAsync(db.ProviderInstance.Where(p => p.TeamId == teamId && (p.Id == a || p.Id == b)), cancellationToken).ConfigureAwait(false); + + return instances.Count == 2 && AreInterchangeable(instances[0], instances[1]); + } + + /// The ids of and every live connection of its team interchangeable with it, itself first. + public static async Task> InterchangeableIdsAsync(CodeSpaceDbContext db, ProviderInstance instance, CancellationToken cancellationToken) + { + var teamInstances = await LiveInstancesAsync(db.ProviderInstance.Where(p => p.TeamId == instance.TeamId && p.Provider == instance.Provider), cancellationToken).ConfigureAwait(false); + + return teamInstances.Where(other => other.Id != instance.Id && AreInterchangeable(instance, other)).Select(other => other.Id).Prepend(instance.Id).ToList(); + } + + /// The same as for a connection known by id; just the id when it is not a live connection. + public static async Task> InterchangeableIdsAsync(CodeSpaceDbContext db, Guid providerInstanceId, CancellationToken cancellationToken) + { + var instance = (await LiveInstancesAsync(db.ProviderInstance.Where(p => p.Id == providerInstanceId), cancellationToken).ConfigureAwait(false)).SingleOrDefault(); + + return instance is null ? new[] { providerInstanceId } : await InterchangeableIdsAsync(db, instance, cancellationToken).ConfigureAwait(false); + } + + /// Every set of two or more live connections, across teams, that are interchangeable with one another — the connections the repair sweep must look across. A connection with no sibling is in none. + public static async Task>> SiblingGroupsAsync(CodeSpaceDbContext db, CancellationToken cancellationToken) => + GroupsOf(await LiveInstancesAsync(db.ProviderInstance, cancellationToken).ConfigureAwait(false)); + + /// The groups of two or more among ; pure, so the rule is pinned without a database. + public static IReadOnlyList> GroupsOf(IEnumerable instances) + { + var groups = new List>(); + + foreach (var instance in instances.OrderBy(i => i.Id)) + { + var home = groups.FirstOrDefault(group => AreInterchangeable(group[0], instance)); + + if (home is null) groups.Add(new List { instance }); + else home.Add(instance); + } + + return groups.Where(group => group.Count > 1).Select(group => (IReadOnlyList)group.Select(i => i.Id).ToList()).ToList(); + } + + /// Only what the rule reads — never the OAuth secret a full row carries. + private static async Task> LiveInstancesAsync(IQueryable scope, CancellationToken cancellationToken) => + await scope.AsNoTracking().Where(p => p.DeletedDate == null) + .Select(p => new ProviderInstance { Id = p.Id, TeamId = p.TeamId, Provider = p.Provider, BaseUrl = p.BaseUrl, ApiUrl = p.ApiUrl }) + .ToListAsync(cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs index 9e4777d27..9c225dde0 100644 --- a/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/ActorIdentityResolver.cs @@ -40,6 +40,11 @@ public async Task> ListCandidatesAsync(Guid if (providerInstanceId == null) return Array.Empty(); + return await ListCandidatesOnInstanceAsync(providerInstanceId.Value, cancellationToken).ConfigureAwait(false); + } + + public async Task> ListCandidatesOnInstanceAsync(Guid providerInstanceId, CancellationToken cancellationToken) + { // Every teammate who may act on this provider instance — the EXACT predicate ResolveAsync uses, so every // candidate is guaranteed to resolve (no throw) at write time. var identities = await MayAct(_db.UserProviderIdentity.AsNoTracking()) diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs index 0f1a53c32..a4449d00a 100644 --- a/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs +++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/IActorIdentityResolver.cs @@ -30,4 +30,11 @@ public interface IActorIdentityResolver /// Empty when none qualify or the repo is not the team's. Bots are excluded (they can't link one). /// Task> ListCandidatesAsync(Guid repositoryId, Guid teamId, CancellationToken cancellationToken); + + /// + /// The same predicate for a provider instance known by id: every teammate with a live, usable identity on it, by name. What + /// answers for a repository is this for the repository's instance — and what it will answer for a repository + /// that moves to another connection is this for that connection, which is how a move reads who it would leave unable to act. + /// + Task> ListCandidatesOnInstanceAsync(Guid providerInstanceId, CancellationToken cancellationToken); } diff --git a/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs b/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs index 92f936ba4..2de78fe54 100644 --- a/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs +++ b/backend/src/CodeSpace.Core/Services/Redaction/CredentialUrl.cs @@ -62,6 +62,17 @@ public static bool SameEndpoint(string? stored, string? next) return Uri.Compare(a, b, UriComponents.Scheme | UriComponents.Host | UriComponents.StrongPort, UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0; } + /// + /// Whether two API roots are the same service: (scheme, host, port) and the same path, a trailing + /// slash aside. A GitLab served under /gitlab is not the GitLab at the host's root, which an endpoint alone cannot tell. + /// The one definition of "the same host" for provider connections (ProviderInstanceHost): two connections whose roots + /// are the same reach the same repositories, whatever OAuth application each signs in through. + /// + public static bool SameRoot(string? a, string? b) => SameEndpoint(a, b) && string.Equals(PathOf(a), PathOf(b), StringComparison.Ordinal); + + /// The path of an http(s) URL without its trailing slashes; empty for a blank or non-http value, whose identity has already compared whole. + private static string PathOf(string? url) => HttpUri(url)?.AbsolutePath.TrimEnd('/') ?? string.Empty; + /// /// Whether is the same endpoint as with its scheme moved from http to /// https and nothing else: the same host (case aside) and the same port — each scheme's own default (80 then 443), or one diff --git a/backend/src/CodeSpace.Core/Services/Repositories/RepositoryService.cs b/backend/src/CodeSpace.Core/Services/Repositories/RepositoryService.cs index caf6c90c0..d557198b5 100644 --- a/backend/src/CodeSpace.Core/Services/Repositories/RepositoryService.cs +++ b/backend/src/CodeSpace.Core/Services/Repositories/RepositoryService.cs @@ -1,7 +1,10 @@ using CodeSpace.Core.DependencyInjection; using CodeSpace.Core.Persistence.Db; using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.ProviderInstances; +using CodeSpace.Core.Services.RepositoryBinding; using CodeSpace.Core.Services.Providers; using CodeSpace.Core.Services.Providers.Capabilities; using CodeSpace.Core.Services.Webhooks.Registration; @@ -17,16 +20,22 @@ public sealed class RepositoryService : IRepositoryService, IScopedDependency { private readonly CodeSpaceDbContext _db; private readonly ICurrentTeam _currentTeam; + private readonly ICurrentUser _currentUser; private readonly IProviderRegistry _registry; private readonly IConnectionWebhookCoverageService _hookCoverage; + private readonly IRepositoryRehoming _rehoming; + private readonly IRepositoryReadProbe _probe; private readonly ILogger _logger; - public RepositoryService(CodeSpaceDbContext db, ICurrentTeam currentTeam, IProviderRegistry registry, IConnectionWebhookCoverageService hookCoverage, ILogger logger) + public RepositoryService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, IProviderRegistry registry, IConnectionWebhookCoverageService hookCoverage, IRepositoryRehoming rehoming, IRepositoryReadProbe probe, ILogger logger) { _db = db; _currentTeam = currentTeam; + _currentUser = currentUser; _registry = registry; _hookCoverage = hookCoverage; + _rehoming = rehoming; + _probe = probe; _logger = logger; } @@ -202,13 +211,65 @@ public async Task RelinkCredentialAsync(Guid repositoryId, Guid newCredentialId, var repo = await LoadOwnedRepositoryAsync(repositoryId, teamId, cancellationToken).ConfigureAwait(false); var credential = await LoadActiveCandidateCredentialAsync(newCredentialId, teamId, cancellationToken).ConfigureAwait(false); - EnsureSameProviderInstance(repo, credential); - repo.CredentialId = credential.Id; + if (credential.ProviderInstanceId != repo.ProviderInstanceId) await MoveToAnotherConnectionAsync(repo, credential, teamId, cancellationToken).ConfigureAwait(false); + else repo.CredentialId = credential.Id; + repo.Status = RepositoryStatus.Active; repo.LastError = null; } + /// + /// A credential of another connection to the same host takes the repository to its connection. Refused for a different host with the message a + /// credential of another host always got; and, since this reaches further than the repository's own connection, held to what the other doors hold a + /// move to — the caller's own credential or a team token (the repository would act as that person), a connection that does not already hold the + /// project, a credential whose scopes cover the hook it will register — and to reading the repository where CodeSpace has it, before anything is written. + /// + private async Task MoveToAnotherConnectionAsync(Repository repo, Credential credential, Guid teamId, CancellationToken cancellationToken) + { + await EnsureSameHostAsync(repo, credential, teamId, cancellationToken).ConfigureAwait(false); + EnsureCallerMayBindWith(credential); + await EnsureNotRefusedAsync(repo, credential, cancellationToken).ConfigureAwait(false); + await EnsureReadAsync(repo, credential, cancellationToken).ConfigureAwait(false); + + var staged = await _rehoming.RehomeAsync(repo, credential, cancellationToken).ConfigureAwait(false); + + await _rehoming.DispatchAfterCommitAsync(staged, cancellationToken).ConfigureAwait(false); + } + + private async Task EnsureSameHostAsync(Repository repo, Credential credential, Guid teamId, CancellationToken cancellationToken) + { + if (await ProviderInstanceHost.AreInterchangeableAsync(_db, teamId, repo.ProviderInstanceId, credential.ProviderInstanceId, cancellationToken).ConfigureAwait(false)) return; + + throw new InvalidOperationException($"Credential '{credential.DisplayName}' is for a different provider instance. Pick a credential connected to the same provider as the repository."); + } + + private void EnsureCallerMayBindWith(Credential credential) + { + if (_currentUser.Id is Guid callerId && RelinkSuggestionRanking.IsCallersOrTeams(credential, callerId)) return; + + throw new InvalidOperationException($"Credential '{credential.DisplayName}' is another person's personal connection, and a repository bound through it would act as them. Pick one of your own connections, or a team token."); + } + + private async Task EnsureNotRefusedAsync(Repository repo, Credential credential, CancellationToken cancellationToken) + { + var refused = await _rehoming.RefusalsAsync(new[] { repo }, credential, cancellationToken).ConfigureAwait(false); + + if (refused.TryGetValue(repo.Id, out var reason)) throw new InvalidOperationException(reason); + } + + /// Ask the provider, as the credential, for the repository by its id — and expect it at the path CodeSpace has it, which tells it from another repository (or the same one, renamed since CodeSpace last read it). + private async Task EnsureReadAsync(Repository repo, Credential credential, CancellationToken cancellationToken) + { + var read = await _probe.ReadAsync(credential, repo.ExternalId, sourceName: null, cancellationToken).ConfigureAwait(false); + + if (!read.Readable) + throw new InvalidOperationException($"Credential '{credential.DisplayName}' cannot read '{repo.FullPath}'. Nothing was moved. Pick a credential that reaches it, or unbind the repository first."); + + if (!read.IsAt(repo.FullPath)) + throw new InvalidOperationException($"Credential '{credential.DisplayName}' does not find '{repo.FullPath}' where CodeSpace has it — the provider names that repository '{read.FullPath}'. Nothing was moved. Pick another credential, or unbind the repository and bind it again."); + } + private async Task LoadOwnedRepositoryAsync(Guid id, Guid teamId, CancellationToken cancellationToken) { var repo = await _db.Repository.SingleOrDefaultAsync(r => r.Id == id && r.DeletedDate == null, cancellationToken).ConfigureAwait(false) @@ -237,14 +298,4 @@ private async Task LoadActiveCandidateCredentialAsync(Guid id, Guid return credential; } - - /// - /// A repo can only borrow a credential bound to its own provider instance. Otherwise - /// the OAuth/PAT token would talk to the wrong host and every API call would 401/404. - /// - private static void EnsureSameProviderInstance(Repository repo, Credential credential) - { - if (credential.ProviderInstanceId != repo.ProviderInstanceId) - throw new InvalidOperationException($"Credential '{credential.DisplayName}' is for a different provider instance. Pick a credential connected to the same provider as the repository."); - } } diff --git a/backend/src/CodeSpace.Core/Services/RepositoryBinding/IRepositoryRehoming.cs b/backend/src/CodeSpace.Core/Services/RepositoryBinding/IRepositoryRehoming.cs new file mode 100644 index 000000000..9b4d3098d --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/RepositoryBinding/IRepositoryRehoming.cs @@ -0,0 +1,58 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Dtos.Providers; + +namespace CodeSpace.Core.Services.RepositoryBinding; + +/// +/// Moves a bound repository from one provider connection to another connection to the same host +/// (), onto a credential of the new connection. The repository is the same row — +/// its id, project links, triggers and history stay — and what changes is whom it is bound through: its credential and its connection, +/// always together, and the hooks that deliver its events, which a connection owns. Split in two, as a bind is, because the caller owns +/// the unit of work between staging and dispatch. +/// +/// Deliberately narrow: only the move that changes the connection (a move within one connection is just a new credential, and +/// needs no hook), so the doors that accept it — a relink, a Disconnect that names a target, a connect's carry-forward, the repair +/// sweep — share one definition of what the move touches. +/// +public interface IRepositoryRehoming +{ + /// + /// For each of (on a connection other than 's) that cannot move onto the credential's + /// connection, why — worded for the person who asked; a repository that can is not in the answer. The connection already holds a live row + /// for the same project (a repository is bound once per connection, which the database enforces — and so is a second one in this + /// same batch), or the credential's granted scopes do not cover reading it and registering the hook the connection's webhook + /// scope needs: a move that left the repository unable to receive events would trade a visible failure for a silent one. Reads + /// nothing from the provider. + /// + Task> RefusalsAsync(IReadOnlyList repositories, Credential to, CancellationToken cancellationToken); + + /// + /// Re-point at and the connection it belongs to, as tracked changes, and stage + /// what its hooks need there — the way a bind does: a per-repository connection gets a fresh Pending hook, a connection-wide one an + /// owner hook if none covers the owner yet; the repository's hooks under its old connection are retired (a hook still registered is + /// removed and deleted at the provider once the move commits; the rest are cancelled). Lifting a disconnect's Error is the caller's, + /// as it is for a move that stays on one connection. Call first. + /// + Task RehomeAsync(Repository repository, Credential to, CancellationToken cancellationToken); + + /// Hand what staged to the registrars, and ask the provider to delete the hooks it retired, once the caller's unit of work has committed. + Task DispatchAfterCommitAsync(RehomeStaged staged, CancellationToken cancellationToken); +} + +/// What one left for after the commit: the hooks to register, and the retired ones to delete at the provider. +public sealed record RehomeStaged +{ + public static readonly RehomeStaged Nothing = new(); + + /// The Pending per-repository hook staged for the new connection, when its webhook scope is per repository. + public Guid? RepositoryHookId { get; init; } + + /// The owner hook staged (or revived) on the new connection, when its webhook scope is connection-wide and no live hook covered the owner already. + public Guid? ConnectionHookId { get; init; } + + /// Hooks the repository had registered under its old connection, to delete at the provider through the new credential. + public RetiredRemoteHooks? Retired { get; init; } +} + +/// The provider-side hooks a move retired, with what is needed to delete them as the credential that took the repository over. +public sealed record RetiredRemoteHooks(ProviderInstance Instance, Credential Credential, RemoteRepository Repository, IReadOnlyList HookExternalIds); diff --git a/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.Rehome.cs b/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.Rehome.cs new file mode 100644 index 000000000..7f673a622 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.Rehome.cs @@ -0,0 +1,164 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Providers; +using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.RepositoryBinding; + +/// +/// A repository that moves to another connection to the same host (). It lives with the bind because it is the +/// same business done to an existing row: what a bind stages for a new repository — a per-repository hook, or the owner hook a +/// connection-wide scope needs — a move stages for the connection the repository arrives at, and what an unbind retires — the +/// repository's hooks — a move retires under the connection it leaves. +/// +public sealed partial class RepositoryBindingService +{ + public async Task> RefusalsAsync(IReadOnlyList repositories, Credential to, CancellationToken cancellationToken) + { + var refusals = new Dictionary(); + + if (repositories.Count == 0) return refusals; + + var instance = await InstanceOfAsync(to, cancellationToken).ConfigureAwait(false); + var taken = await ExternalIdsHeldAsync(repositories, instance, cancellationToken).ConfigureAwait(false); + + foreach (var repository in repositories) + { + var refusal = !taken.Add(repository.ExternalId) ? AlreadyBound(repository, instance) : ScopeRefusal(repository, to, instance); + + if (refusal is not null) refusals[repository.Id] = refusal; + } + + return refusals; + } + + public async Task RehomeAsync(Repository repository, Credential to, CancellationToken cancellationToken) + { + var instance = await InstanceOfAsync(to, cancellationToken).ConfigureAwait(false); + var retired = await RetireHooksAsync(repository, instance, to, cancellationToken).ConfigureAwait(false); + + repository.ProviderInstanceId = instance.Id; + repository.CredentialId = to.Id; + + var staged = await StageCoverageAsync(repository, instance, to, cancellationToken).ConfigureAwait(false); + + return staged with { Retired = retired }; + } + + public async Task DispatchAfterCommitAsync(RehomeStaged staged, CancellationToken cancellationToken) + { + if (staged.RepositoryHookId is Guid hookId) + await _postCommit.RunAfterCommitAsync(ct => _registrationDispatcher.DispatchAsync(hookId, ct), cancellationToken).ConfigureAwait(false); + + if (staged.ConnectionHookId is Guid groupHookId) + await _postCommit.RunAfterCommitAsync(ct => _connectionDispatcher.DispatchAsync(groupHookId, ct), cancellationToken).ConfigureAwait(false); + + if (staged.Retired is { } retired) + await _postCommit.RunAfterCommitAsync(ct => DeleteRetiredAtProviderAsync(retired, ct), cancellationToken).ConfigureAwait(false); + } + + private async Task InstanceOfAsync(Credential to, CancellationToken cancellationToken) => + to.ProviderInstance is { } loaded && loaded.Id == to.ProviderInstanceId + ? loaded + : await _db.ProviderInstance.AsNoTracking().SingleAsync(p => p.Id == to.ProviderInstanceId, cancellationToken).ConfigureAwait(false); + + /// + /// The external ids, among these repositories', that the new connection already holds a live row for: idx_repository_provider_instance_external_id_active + /// would refuse the move at the save, with nothing a person could read in it. A set the caller keeps adding to, so two repositories of one batch that name the + /// same project meet each other too. + /// + private async Task> ExternalIdsHeldAsync(IReadOnlyList repositories, ProviderInstance instance, CancellationToken cancellationToken) + { + var ids = repositories.Select(r => r.ExternalId).Distinct().ToList(); + var movingIds = repositories.Select(r => r.Id).ToList(); + + var held = await _db.Repository.AsNoTracking() + .Where(r => r.ProviderInstanceId == instance.Id && r.DeletedDate == null && ids.Contains(r.ExternalId) && !movingIds.Contains(r.Id)) + .Select(r => r.ExternalId) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + return held.ToHashSet(); + } + + private static string AlreadyBound(Repository repository, ProviderInstance instance) => + $"'{repository.FullPath}' is already bound under '{instance.DisplayName}', and a repository is bound once per connection. Unbind one of the two first."; + + /// What a bind asks of a credential before any wire call (), for the hook the new connection's scope will register. + private string? ScopeRefusal(Repository repository, Credential to, ProviderInstance instance) + { + try + { + _scopeChecker.EnsureCapability(to, instance.Provider, typeof(IRepositoryCatalogCapability)); + _scopeChecker.EnsureCapability(to, instance.Provider, RegistersPerRepository(instance) ? typeof(IWebhookRegistrationCapability) : typeof(IConnectionWebhookRegistrationCapability)); + + return null; + } + catch (ProviderInsufficientScopeException ex) + { + return $"Credential '{to.DisplayName}' cannot take '{repository.FullPath}' over: {ex.Message}"; + } + } + + /// + /// The repository's hooks under the connection it leaves, retired as an unbind retires them — a Registered row is removed (it described a hook that is + /// to be deleted), every other in-service row is cancelled so a registrar mid-call cannot complete behind the move — except that the + /// provider is asked to delete the hooks after the commit, not under the transaction. Null when none names a hook at the provider. + /// + private async Task RetireHooksAsync(Repository repository, ProviderInstance instance, Credential to, CancellationToken cancellationToken) + { + var hooks = await LoadAllWebhooksAsync(repository.Id, cancellationToken).ConfigureAwait(false); + var atProvider = hooks.Where(h => !string.IsNullOrEmpty(h.ExternalId)).Select(h => h.ExternalId!).ToList(); + + await CancelNonTerminalWebhooksAsync(repository.Id, cancellationToken).ConfigureAwait(false); + + _db.RepositoryWebhook.RemoveRange(hooks.Where(h => h.RegistrationStatus == RepositoryWebhookRegistrationStatus.Registered)); + + return atProvider.Count == 0 ? null : new RetiredRemoteHooks(instance, to, repository.ToRemoteRepository(), atProvider); + } + + /// + /// What delivers the repository's events at its new connection, the way a bind stages it. Under connection-wide scope the owner hook is flushed + /// before the next repository asks whether one covers its owner: a hook only in the change tracker answers no, and a second would hit the + /// owner's unique index — exactly why a bind flushes it. + /// + private async Task StageCoverageAsync(Repository repository, ProviderInstance instance, Credential to, CancellationToken cancellationToken) + { + if (RegistersPerRepository(instance)) + { + var webhook = NewPendingWebhook(instance.Provider, repository.Id, Guid.NewGuid(), NewWebhookSecret()); + + _db.RepositoryWebhook.Add(webhook); + + return new RehomeStaged { RepositoryHookId = webhook.Id }; + } + + var groupHookId = await _connectionProvisioner.EnsureForOwnerAsync(instance, to.Id, repository.NamespacePath, cancellationToken).ConfigureAwait(false); + + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + return new RehomeStaged { ConnectionHookId = groupHookId }; + } + + private async Task DeleteRetiredAtProviderAsync(RetiredRemoteHooks retired, CancellationToken cancellationToken) + { + if (!_registry.TryGet(retired.Instance.Provider, out var capability)) return; + + var context = new ProviderContext(retired.Instance, retired.Credential); + + foreach (var externalId in retired.HookExternalIds) + { + try + { + await capability!.DeleteWebhookAsync(context, retired.Repository, externalId, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // A hook that cannot be deleted is one the operator removes by hand; the local row is already gone, so a delivery on it is refused. + _logger.LogWarning(ex, "Could not delete remote webhook {WebhookExternalId} of repository {RepositoryPath} after it moved to connection {ProviderInstanceId}", externalId, retired.Repository.FullPath, retired.Instance.Id); + } + } + } +} diff --git a/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs b/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs index 9a79dc10e..3c87bb860 100644 --- a/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs +++ b/backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.cs @@ -20,7 +20,7 @@ namespace CodeSpace.Core.Services.RepositoryBinding; -public sealed class RepositoryBindingService : IRepositoryBindingService, IScopedDependency +public sealed partial class RepositoryBindingService : IRepositoryBindingService, IRepositoryRehoming, IScopedDependency { private readonly CodeSpaceDbContext _db; private readonly IProviderRegistry _registry; @@ -272,8 +272,9 @@ private async Task LoadCredentialAsync(BindContext ctx, Cancellatio /// The chosen credential must belong to the SAME provider instance we're binding against. Both are /// already team-scoped (LoadProviderInstance / LoadCredential), but within one team a caller can still /// pair instance A1 with a credential of instance A2 — the token would then talk to the wrong host and - /// every API call would 401/404 mid-bind. Mirrors RepositoryService's relink-side - /// EnsureSameProviderInstance so bind and relink enforce the identical invariant. + /// every API call would 401/404 mid-bind. A relink accepts a credential of another connection to the same host + /// () because it moves the repository to that connection; a bind names the connection + /// itself, so the pairing here stays exact. /// private static void EnsureCredentialMatchesInstance(BindContext ctx) { @@ -376,10 +377,12 @@ private static BindContext GenerateWebhookIdAndSecret(BindContext ctx) return ctx with { NewWebhookId = Guid.NewGuid(), - WebhookSecret = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + WebhookSecret = NewWebhookSecret() }; } + private static string NewWebhookSecret() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + /// /// Persist the Repository + a Pending RepositoryWebhook + the Project-Repository link /// in the same SaveChanges (one EF transaction). The webhook row carries the durable @@ -514,18 +517,21 @@ private async Task ResolveProjectAsync(BindContext ctx, Cancellatio /// reaches Registered; the secret is encrypted before persistence so an attacker with /// DB read access cannot recover it without the master key. /// - private RepositoryWebhook BuildPendingWebhookEntity(BindContext ctx, Guid repositoryId) + private RepositoryWebhook BuildPendingWebhookEntity(BindContext ctx, Guid repositoryId) => NewPendingWebhook(ctx.Instance.Provider, repositoryId, ctx.NewWebhookId, ctx.WebhookSecret); + + /// The one shape of a freshly staged per-repository hook, whoever stages it: a bind, or a repository that moved to another connection. + private RepositoryWebhook NewPendingWebhook(ProviderKind provider, Guid repositoryId, Guid webhookId, string secret) { - var callbackUrl = $"{_webhookBaseUrl.Value.TrimEnd('/')}/api/webhooks/{ctx.NewWebhookId}"; - var subscribedEvents = _subscriptionRegistry.GetSubscribedRawEvents(ctx.Instance.Provider); + var callbackUrl = $"{_webhookBaseUrl.Value.TrimEnd('/')}/api/webhooks/{webhookId}"; + var subscribedEvents = _subscriptionRegistry.GetSubscribedRawEvents(provider); return new RepositoryWebhook { - Id = ctx.NewWebhookId, + Id = webhookId, RepositoryId = repositoryId, ExternalId = null, CallbackUrl = callbackUrl, - SecretEnc = _encryptor.Encrypt(ctx.WebhookSecret), + SecretEnc = _encryptor.Encrypt(secret), SubscribedEvents = subscribedEvents.ToList(), Active = true, RegistrationStatus = RepositoryWebhookRegistrationStatus.Pending, diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs index 81e3a6c56..3586a2dac 100644 --- a/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs @@ -12,10 +12,12 @@ namespace CodeSpace.Messages.Commands.Credentials; /// /// The source must be dead — Revoked, which includes a departed member's token the team retired and soft-deleted. /// Taking bindings off a live credential is a Disconnect with MoveBindingsToCredentialId. The target must be a live -/// credential of the same provider instance that is the CALLER's own or the team's: a teammate's personal token is -/// refused, because the repositories would then act as that person. Before anything is written the target is asked to +/// credential of the same provider instance — or of another connection of the team to the same host, which takes the repositories to its +/// connection with it (see RelinkRepositoryCredentialCommand; a group hook is keyed to its connection and stays) — that is the CALLER's own or +/// the team's: a teammate's personal token is refused, because the repositories would then act as that person. Before anything is written the target is asked to /// read every repository it would take (the provider's own lookup, as a bind does); one it cannot read — the provider -/// answered 401, 403 or 404, or the token's scope falls short — refuses the whole move and names them, so a relink never +/// answered 401, 403 or 404, or the token's scope falls short — refuses the whole move and names them (onto another connection, one it finds at another +/// path is refused too), so a relink never /// trades a loud failure for a quiet one. A provider that does not answer (an outage, a rate limit) says nothing about the /// target: that is a retryable refusal that blames nobody, and nothing is moved. The check is one call per repository and /// bounded: more than 100 repositories on one credential are refused before any is read, and are re-linked a few at a time. diff --git a/backend/src/CodeSpace.Messages/Commands/Repositories/RelinkRepositoryCredentialCommand.cs b/backend/src/CodeSpace.Messages/Commands/Repositories/RelinkRepositoryCredentialCommand.cs index d4832b6cb..98a487fc6 100644 --- a/backend/src/CodeSpace.Messages/Commands/Repositories/RelinkRepositoryCredentialCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Repositories/RelinkRepositoryCredentialCommand.cs @@ -9,11 +9,17 @@ namespace CodeSpace.Messages.Commands.Repositories; /// Replace a repository's credential pointer. Used after a credential disconnect to point /// the repo at another active credential of the same provider — restores API access /// without re-binding the repo. The new credential MUST belong to the same -/// ProviderInstance as the repo (a GitHub repo can't borrow a GitLab credential). +/// ProviderInstance as the repo, or to another connection of the team to the SAME host (same provider +/// and API root — a team that signs in to one GitLab through two OAuth applications holds two connections to it); +/// a GitHub repo can't borrow a GitLab credential, nor a repo borrow a credential of another host. /// On success the repo flips from Error → Active and LastError is cleared. /// -/// Webhook ingestion is unaffected — webhooks authenticate via the registered secret, -/// not the credential — so this command only fixes the API-call path. +/// Within one connection nothing else changes: webhook ingestion is unaffected — webhooks authenticate via +/// the registered secret, not the credential — so this only fixes the API-call path. A credential of ANOTHER +/// connection moves the repository to that connection with it (the repository is the same row: its id, project links and +/// history stay), after the credential has been asked for the repository by its id and found it at the path CodeSpace has, and only if it is the +/// caller's own or a team token, covers the scopes the new connection's hook needs, and the connection does not already hold the project. The +/// repository's hooks are registered again through the new connection and the old ones retired. /// /// : choosing which credential a repository speaks through is part of /// binding it. The repository marker alone let a Viewer point a repository at any credential in the team. diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs index 6ec9e8b8e..2d872efae 100644 --- a/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs @@ -17,9 +17,11 @@ public enum RelinkSuggestionKind /// /// A credential the caller could move a credential's bindings onto. Only candidates the move would accept are offered. For a -/// credential that is gone: live, on the same provider instance, and either the caller's own or the team's — never a -/// teammate's personal token, because binding repositories to it would make them act as that person. For one still -/// connected, which only a Disconnect that names the target can move: another connection of the same owner. +/// credential that is gone: live, on the same provider instance — or on another connection to the same host — and either the +/// caller's own or the team's, never a teammate's personal token, because binding repositories to it would make them act as that +/// person. For one still connected, which only a Disconnect that names the target can move: another connection of the same owner. +/// Offered the same connection's credentials first, so a candidate whose differs from the +/// repository's is one that also moves the repository to that connection. /// public sealed record RelinkSuggestion { @@ -30,6 +32,20 @@ public sealed record RelinkSuggestion public required RelinkSuggestionKind Kind { get; init; } public required DateTimeOffset CreatedDate { get; init; } + /// The provider connection the credential belongs to: the credential's own, or another connection to the same host — where a repository lives after a move onto it. + public required Guid ProviderInstanceId { get; init; } + + /// That connection's name, for a person to tell two connections to one host apart; empty when it was not read. + public required string ProviderInstanceName { get; init; } + + /// + /// Names of the teammates whose act-as stops working if this credential takes the repository: they have a live identity on the connection + /// the repository is leaving (ListCandidatesAsync offers them as authors today) and none on this credential's connection, and an + /// identity belongs to a connection, so a run that acts as them on the repository asks them to connect there first. Empty for a credential on + /// the repository's own connection — nothing about where it lives changes — and for any candidate whose connection already has them all. + /// + public IReadOnlyList StrandedActAs { get; init; } = Array.Empty(); + /// The candidate was connected before the credential it would replace was disconnected — the connect-then-disconnect shape, where nothing but a person's choice moves the repositories. public required bool ConnectedBeforeDisconnect { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkCandidatesQuery.cs b/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkCandidatesQuery.cs new file mode 100644 index 000000000..8a2b7887d --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkCandidatesQuery.cs @@ -0,0 +1,28 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Credentials; + +/// +/// The credentials the caller could point a repository on this provider connection at, best first — the whole list a Re-link +/// dialog shows, computed here so the dialog filters nothing itself. Every credential that can authenticate — connected, and for a +/// static token not past its recorded expiry, as the credential list reads it — that the caller may bind with, their own +/// or the team's service tokens (never a teammate's personal one, whose repositories would act as that person), on this +/// connection or on any other connection of the team to the same host (ProviderInstanceHost): the same connection's first, +/// then the caller's own, then the team's, each newest first. A candidate on another connection moves the repository to that +/// connection when it is picked, and names the teammates whose act-as that strands (StrandedActAs). +/// is the credential the repository is leaving, when it has one: it is left out of the list, and a candidate connected before it was +/// disconnected is flagged. +/// +/// Membership alone, like . The connection and the credential are read by (id, header +/// team), so another team's id reads as not found; may be soft-deleted, a departed member's retired +/// token being the case that most needs this. +/// +public sealed record GetRelinkCandidatesQuery : IQuery>, IRequireTeamMembership +{ + /// Set by the controller from the route segment via query with { ProviderInstanceId = ... }. + public Guid ProviderInstanceId { get; init; } + + public Guid? FromCredentialId { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs b/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs index 4ee3a345a..8a422a292 100644 --- a/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs +++ b/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs @@ -6,8 +6,8 @@ namespace CodeSpace.Messages.Queries.Credentials; /// /// The credentials the caller could move this credential's bindings onto, best first. For a credential that is gone: their -/// own live connections on the same provider instance (a connection made before this one was disconnected is flagged), -/// then the team's service tokens; a teammate's personal token is never offered. For one still connected — the Disconnect +/// own live connections on the same provider instance — or on another connection of the team to the same host, after the same instance's — +/// (a connection made before this one was disconnected is flagged), then the team's service tokens; a teammate's personal token is never offered. For one still connected — the Disconnect /// dialog asks "where should these go?" — exactly what a Disconnect that names the target accepts: another connection of /// the same owner (for a team token, another team token), so an Admin asking about a teammate's credential is offered the /// teammate's other connections, not their own. Like , the credential may be diff --git a/backend/tests/CodeSpace.E2ETests/Credentials/ConnectionsWithoutWebhookBaseUrlE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Credentials/ConnectionsWithoutWebhookBaseUrlE2ETests.cs new file mode 100644 index 000000000..39ccdea5b --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Credentials/ConnectionsWithoutWebhookBaseUrlE2ETests.cs @@ -0,0 +1,82 @@ +using System.Net; +using System.Text.Json; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Credentials; +using CodeSpace.Messages.Enums; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Credentials; + +/// +/// 🟢 High fidelity (real HTTP pipeline in the Production environment, Postgres): a host outside Development that never set +/// Webhooks:BaseUrl. The service that moves a repository between two connections to one host reads that setting when it is built, and it throws +/// when the key is unset — so connecting, disconnecting or listing a credential, and asking a dialog for its candidates, must not build that service. +/// They did once the successor service took it as a dependency, and every OAuth start answered 500 +/// ( is the other face of this); it is taken lazily now, so only a move between connections +/// needs it, and this pins the credential endpoints that share the dependency. (Listing repositories already needs the key, through the connection-hook +/// coverage the repository service has always taken, and is not what this pins.) +/// +/// Diagnose a red here by starting the API with ASPNETCORE_ENVIRONMENT=Production and no Webhooks__BaseUrl: a 500 with +/// "Webhooks:BaseUrl is required outside Development" in the log means something on these paths builds the repository binding service again. +/// +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class ConnectionsWithoutWebhookBaseUrlE2ETests : IClassFixture +{ + private readonly ProductionWithoutPublicBaseUrlGitWireApiFactory _factory; + private readonly TenantHarness _tenants; + + public ConnectionsWithoutWebhookBaseUrlE2ETests(ProductionWithoutPublicBaseUrlGitWireApiFactory factory) + { + _factory = factory; + _tenants = new TenantHarness(factory); + } + + [Fact] + public async Task Listing_credentials_asking_for_candidates_and_disconnecting_work_on_a_host_with_no_webhook_base_url() + { + var admin = await _tenants.AccountAsync(); + var team = await _tenants.TeamAsync((admin, TeamRole.Owner)); + var (instance, leaving, staying) = await SeedAsync(team, admin); + + var listed = await _tenants.SendAsync(HttpMethod.Get, "/api/credentials", admin, team); + var suggested = await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{leaving}/relink-suggestions", admin, team); + var candidates = await _tenants.SendAsync(HttpMethod.Get, $"/api/provider-instances/{instance}/relink-candidates?fromCredentialId={leaving}", admin, team); + var revoked = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{leaving}/revoke", admin, team, new { moveBindingsToCredentialId = staying }); + + listed.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(listed)); + suggested.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(suggested)); + candidates.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(candidates)); + revoked.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(revoked)); + JsonDocument.Parse(await listed.Content.ReadAsStringAsync()).RootElement.EnumerateArray().Select(c => c.GetProperty("id").GetGuid()).ShouldBe(new[] { leaving, staying }, ignoreOrder: true); + JsonDocument.Parse(await revoked.Content.ReadAsStringAsync()).RootElement.GetProperty("movedRepositoryCount").GetInt32().ShouldBe(1, "a Disconnect that names a target within one connection moves the repository without a hook or a second connection in sight"); + } + + private async Task<(Guid Instance, Guid Leaving, Guid Staying)> SeedAsync(Guid team, Guid owner) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var encryptor = scope.ServiceProvider.GetRequiredService(); + var serializer = scope.ServiceProvider.GetRequiredService(); + + var instance = new ProviderInstance { Id = Guid.NewGuid(), TeamId = team, Provider = ProviderKind.Git, DisplayName = "Git", BaseUrl = $"https://git.stub.test/{Guid.NewGuid():N}", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + Credential Token(string name) => new() { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = instance.Id, OwnerUserId = owner, Ownership = CredentialOwnership.Personal, AuthType = AuthType.Pat, DisplayName = name, EncryptedPayload = encryptor.Encrypt(serializer.Serialize(new PatPayload { Token = $"pat-{name}" })), Status = CredentialStatus.Active, CreatedBy = owner, LastModifiedBy = owner }; + + var leaving = Token("leaving"); + var staying = Token("staying"); + var repository = new Repository { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = instance.Id, CredentialId = leaving.Id, ExternalId = "acme/api", NamespacePath = "acme", Name = "api", FullPath = "acme/api", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = "https://git.stub.test/acme/api", Status = RepositoryStatus.Active }; + + db.ProviderInstance.Add(instance); + db.Credential.AddRange(leaving, staying); + db.Repository.Add(repository); + await db.SaveChangesAsync(); + + return (instance.Id, leaving.Id, staying.Id); + } +} diff --git a/backend/tests/CodeSpace.E2ETests/Credentials/CredentialSameHostE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Credentials/CredentialSameHostE2ETests.cs new file mode 100644 index 000000000..640e2d897 --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Credentials/CredentialSameHostE2ETests.cs @@ -0,0 +1,353 @@ +using System.Net; +using System.Text.Json; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Credentials; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; + +namespace CodeSpace.E2ETests.Credentials; + +/// +/// 🟢 High fidelity — a team that signs in to one GitLab through two OAuth applications holds two provider connections to that host, and a +/// repository bound under one through a credential that died is taken over by the live credential of the other: through the real HTTP surface (routing, +/// JWT, the X-Team-Id scope, the authorization behaviors, model binding, the exception filter), the real GitLab provider and +/// OAuth client, real Postgres, and a on a loopback port as the one fake — it serves the project lookup the move +/// asks as the new credential, and is how the test sees that the lookup was made with the right token. +/// +/// What this pins end to end: the dialog's candidate list (GET /api/provider-instances/{id}/relink-candidates) offers the credential on the sibling +/// connection; POST /api/repositories/{id}/relink-credential and POST /api/credentials/{id}/relink-bindings move the repository to the +/// credential AND its connection — read back through the repository list under each connection — after asking GitLab for the project as that credential, and +/// refuse, writing nothing, when it cannot see the project or the credential is for another host; and what the older API said is still said: the +/// suggestions endpoint answers every field it did, and a relink within one connection is exactly what it was. +/// +[Trait("Category", "E2E")] +[Trait("Category", "ProviderE2E")] +[Trait("Surface", "Http")] +public sealed class CredentialSameHostE2ETests : IClassFixture +{ + private const long ProjectId = 4242; + private const string ProjectPath = "acme/api"; + private const string SecondApplication = "app2"; + + private readonly TaskLaunchApiFactory _factory; + private readonly TenantHarness _tenants; + + public CredentialSameHostE2ETests(TaskLaunchApiFactory factory) + { + _factory = factory; + _tenants = new TenantHarness(factory); + } + + [Fact] + public async Task The_dialogs_candidates_offer_the_callers_live_credential_on_the_other_connection_and_a_relink_takes_the_repository_there() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + + var offered = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/provider-instances/{world.FirstConnection}/relink-candidates?fromCredentialId={world.DeadCredential}", world.BossId, world.TeamId).ConfigureAwait(false)).ConfigureAwait(false); + + offered.GetArrayLength().ShouldBe(1, "the boss's own live credential on the sibling connection — and nothing of a teammate's or of another host's"); + offered[0].GetProperty("credentialId").GetGuid().ShouldBe(world.BossCredentialOnSibling); + offered[0].GetProperty("providerInstanceId").GetGuid().ShouldBe(world.SecondConnection); + offered[0].GetProperty("kind").GetString().ShouldBe("YourConnection"); + + var relink = await _tenants.SendAsync(HttpMethod.Post, $"/api/repositories/{world.Repository}/relink-credential", world.BossId, world.TeamId, new { newCredentialId = world.BossCredentialOnSibling }).ConfigureAwait(false); + + relink.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(relink).ConfigureAwait(false)); + gitlab.Log.ShouldContain(e => e.Kind == StubEvent.Api200 && e.Token == "at-boss" && e.Detail.StartsWith($"/api/v4/projects/{ProjectId} "), "GitLab was asked for the project as the credential that takes it over"); + + var repository = await _tenants.DbAsync(db => db.Repository.AsNoTracking().SingleAsync(r => r.Id == world.Repository)).ConfigureAwait(false); + + (repository.CredentialId, repository.ProviderInstanceId, repository.Status, repository.LastError).ShouldBe((world.BossCredentialOnSibling, world.SecondConnection, RepositoryStatus.Active, null)); + (await ListedUnderAsync(world, world.SecondConnection).ConfigureAwait(false)).ShouldBe(new[] { world.Repository }, "the repository list, as the SPA reads it, shows it under the connection it moved to"); + (await ListedUnderAsync(world, world.FirstConnection).ConfigureAwait(false)).ShouldBeEmpty(); + (await _tenants.DbAsync(db => db.RepositoryWebhook.AsNoTracking().Where(w => w.RepositoryId == world.Repository && w.RegistrationStatus != RepositoryWebhookRegistrationStatus.Cancelled).ToListAsync()).ConfigureAwait(false)) + .ShouldHaveSingleItem("one hook, registered through the new connection").RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Enqueued); + } + + [Fact] + public async Task Relinking_everything_a_dead_credential_held_does_the_same_for_every_repository_and_reports_it() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{world.DeadCredential}/relink-bindings", world.BossId, world.TeamId, new { toCredentialId = world.BossCredentialOnSibling }).ConfigureAwait(false); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response).ConfigureAwait(false)); + (await ReadJsonAsync(response).ConfigureAwait(false)).GetProperty("movedRepositoryCount").GetInt32().ShouldBe(1); + (await ListedUnderAsync(world, world.SecondConnection).ConfigureAwait(false)).ShouldBe(new[] { world.Repository }); + gitlab.Log.ShouldContain(e => e.Kind == StubEvent.Api200 && e.Token == "at-boss" && e.Detail.StartsWith($"/api/v4/projects/{ProjectId} ")); + } + + [Theory] + [InlineData("relink-credential")] + [InlineData("relink-bindings")] + public async Task A_credential_that_cannot_see_the_project_refuses_the_move_and_writes_nothing(string door) + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab, projectVisibleToTheNewCredential: false).ConfigureAwait(false); + + var response = await SendRelinkAsync(world, door, world.BossCredentialOnSibling).ConfigureAwait(false); + + ((int)response.StatusCode).ShouldBeInRange(400, 499, await TenantHarness.DescribeAsync(response).ConfigureAwait(false)); + (await response.Content.ReadAsStringAsync().ConfigureAwait(false)).ShouldContain(ProjectPath, Case.Sensitive, "the refusal names what the credential cannot read"); + var repository = await _tenants.DbAsync(db => db.Repository.AsNoTracking().SingleAsync(r => r.Id == world.Repository)).ConfigureAwait(false); + + (repository.CredentialId, repository.ProviderInstanceId, repository.Status).ShouldBe((world.DeadCredential, world.FirstConnection, RepositoryStatus.Error)); + (await ListedUnderAsync(world, world.FirstConnection).ConfigureAwait(false)).ShouldBe(new[] { world.Repository }); + } + + [Theory] + [InlineData("relink-credential")] + [InlineData("relink-bindings")] + public async Task A_credential_of_another_host_is_refused_with_the_message_it_always_got(string door) + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + + var response = await SendRelinkAsync(world, door, world.BossCredentialOnAnotherHost).ConfigureAwait(false); + + ((int)response.StatusCode).ShouldBeInRange(400, 499, await TenantHarness.DescribeAsync(response).ConfigureAwait(false)); + (await response.Content.ReadAsStringAsync().ConfigureAwait(false)).ShouldContain("is for a different provider instance", Case.Sensitive); + (await _tenants.DbAsync(db => db.Repository.AsNoTracking().SingleAsync(r => r.Id == world.Repository)).ConfigureAwait(false)).CredentialId.ShouldBe(world.DeadCredential); + gitlab.Log.ShouldNotContain(e => e.Kind == StubEvent.Api200 && e.Token == "at-elsewhere", "GitLab was never asked anything as a credential of another host"); + } + + [Fact] + public async Task The_suggestions_endpoint_still_answers_every_field_the_older_dialog_reads_and_a_relink_within_one_connection_is_what_it_was() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + var sameConnection = await SeedCredentialAsync(world.TeamId, world.FirstConnection, world.BossId, "boss-here", gitlab, "app1", "at-here").ConfigureAwait(false); + + var suggestions = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{world.DeadCredential}/relink-suggestions", world.BossId, world.TeamId).ConfigureAwait(false)).ConfigureAwait(false); + + suggestions.EnumerateArray().Select(s => s.GetProperty("credentialId").GetGuid()).ShouldBe(new[] { sameConnection, world.BossCredentialOnSibling }, "the same connection's credential first; an older dialog ranks by this order and filters by connection itself"); + + foreach (var suggestion in suggestions.EnumerateArray()) + { + foreach (var field in new[] { "credentialId", "displayName", "authType", "ownership", "kind", "createdDate", "connectedBeforeDisconnect" }) suggestion.TryGetProperty(field, out _).ShouldBeTrue($"the field '{field}' an older dialog reads"); + + suggestion.GetProperty("kind").GetString().ShouldBe("YourConnection"); + } + + var relink = await _tenants.SendAsync(HttpMethod.Post, $"/api/repositories/{world.Repository}/relink-credential", world.BossId, world.TeamId, new { newCredentialId = sameConnection }).ConfigureAwait(false); + + relink.StatusCode.ShouldBe(HttpStatusCode.NoContent, await TenantHarness.DescribeAsync(relink).ConfigureAwait(false)); + var repository = await _tenants.DbAsync(db => db.Repository.AsNoTracking().SingleAsync(r => r.Id == world.Repository)).ConfigureAwait(false); + + (repository.CredentialId, repository.ProviderInstanceId, repository.Status).ShouldBe((sameConnection, world.FirstConnection, RepositoryStatus.Active)); + gitlab.Log.ShouldNotContain(e => e.Detail.StartsWith($"/api/v4/projects/{ProjectId} "), "a relink within one connection asks GitLab nothing, as it never did"); + } + + [Fact] + public async Task A_disconnect_that_names_the_credential_on_the_other_connection_takes_the_repository_there() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + var leaving = await SeedCredentialAsync(world.TeamId, world.FirstConnection, world.BossId, "boss-leaving", gitlab, "app1", "at-leaving").ConfigureAwait(false); + + await _tenants.DbAsync(async db => + { + await db.Repository.Where(r => r.Id == world.Repository).ExecuteUpdateAsync(s => s.SetProperty(r => r.CredentialId, leaving).SetProperty(r => r.Status, RepositoryStatus.Active).SetProperty(r => r.LastError, (string?)null)); + return 0; + }).ConfigureAwait(false); + + var revoke = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{leaving}/revoke", world.BossId, world.TeamId, new { moveBindingsToCredentialId = world.BossCredentialOnSibling }).ConfigureAwait(false); + + revoke.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(revoke).ConfigureAwait(false)); + (await ReadJsonAsync(revoke).ConfigureAwait(false)).GetProperty("movedRepositoryCount").GetInt32().ShouldBe(1); + (await ListedUnderAsync(world, world.SecondConnection).ConfigureAwait(false)).ShouldBe(new[] { world.Repository }); + } + + [Fact] + public async Task A_candidate_on_the_other_connection_names_the_teammates_who_act_as_themselves_only_on_the_connection_the_repository_leaves() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + var mayaHere = await SeedCredentialAsync(world.TeamId, world.FirstConnection, world.MayaId, "maya-here", gitlab, "app1", "at-maya").ConfigureAwait(false); + var sameConnection = await SeedCredentialAsync(world.TeamId, world.FirstConnection, world.BossId, "boss-here", gitlab, "app1", "at-here").ConfigureAwait(false); + + await _tenants.DbAsync(async db => + { + db.UserProviderIdentity.Add(new UserProviderIdentity { Id = Guid.NewGuid(), UserId = world.MayaId, ProviderInstanceId = world.FirstConnection, CredentialId = mayaHere, ProviderUserId = "gl-maya", ProviderUsername = "maya" }); + await db.SaveChangesAsync(); + + return 0; + }).ConfigureAwait(false); + + var mayaName = await _tenants.DbAsync(db => db.User.AsNoTracking().Where(u => u.Id == world.MayaId).Select(u => u.Name).SingleAsync()).ConfigureAwait(false); + var offered = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/provider-instances/{world.FirstConnection}/relink-candidates?fromCredentialId={world.DeadCredential}", world.BossId, world.TeamId).ConfigureAwait(false)).ConfigureAwait(false); + + string[] StrandedBy(Guid credentialId) => offered.EnumerateArray().Single(c => c.GetProperty("credentialId").GetGuid() == credentialId).GetProperty("strandedActAs").EnumerateArray().Select(n => n.GetString()!).ToArray(); + + StrandedBy(world.BossCredentialOnSibling).ShouldBe(new[] { mayaName }, "Maya acts as herself on the connection the repository would leave, and has no identity on the one it would go to"); + StrandedBy(sameConnection).ShouldBeEmpty("a credential on the repository's own connection strands nobody"); + } + + [Fact] + public async Task A_static_token_past_its_expiry_is_listed_as_rejected_and_is_not_offered_to_re_link_to() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + var expired = await SeedTokenAsync(world, "expired-pat", DateTimeOffset.UtcNow.AddDays(-3), world.FirstConnection).ConfigureAwait(false); + var healthy = await SeedTokenAsync(world, "healthy-pat", DateTimeOffset.UtcNow.AddDays(90), world.FirstConnection).ConfigureAwait(false); + + var listed = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, "/api/credentials", world.BossId, world.TeamId).ConfigureAwait(false)).ConfigureAwait(false); + var offered = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/provider-instances/{world.FirstConnection}/relink-candidates?fromCredentialId={world.DeadCredential}", world.BossId, world.TeamId).ConfigureAwait(false)).ConfigureAwait(false); + + string Row(Guid id, string field) => listed.EnumerateArray().Single(c => c.GetProperty("id").GetGuid() == id).GetProperty(field).GetString()!; + + (Row(expired, "status"), Row(expired, "health")).ShouldBe(("Active", "rejected"), "the list already reads it as rejected while its status still says Active"); + Row(healthy, "health").ShouldBe("active"); + + var offeredIds = offered.EnumerateArray().Select(c => c.GetProperty("credentialId").GetGuid()).ToArray(); + + offeredIds.ShouldContain(healthy); + offeredIds.ShouldNotContain(expired, "the dialog does not offer what the list calls rejected"); + } + + [Fact] + public async Task A_route_this_server_does_not_have_answers_404_with_a_not_found_body_which_is_how_the_dialog_knows_an_older_server_from_a_broken_one() + { + using var gitlab = new GitLabDoorkeeperStub(); + var world = await SeedAsync(gitlab).ConfigureAwait(false); + + var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/provider-instances/{world.FirstConnection}/relink-candidates-this-server-does-not-have", world.BossId, world.TeamId).ConfigureAwait(false); + + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json"); + + using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); + + body.RootElement.GetProperty("code").GetString().ShouldBe("not_found", "the SPA's fallback to its own filter reads any 404 from the candidates route; the body here is what a real older server sends"); + body.RootElement.GetProperty("message").GetString()!.ShouldContain("This server has no", Case.Sensitive); + } + + // ─── Plumbing ────────────────────────────────────────────────────────────── + + private sealed record World(Guid TeamId, Guid BossId, Guid MayaId, Guid FirstConnection, Guid SecondConnection, Guid DeadCredential, Guid BossCredentialOnSibling, Guid BossCredentialOnAnotherHost, Guid Repository); + + /// + /// A team of an Admin (boss), a Member (maya) and a Viewer, and one GitLab — the stub — reached through two connections (two OAuth applications), plus a + /// connection to another host. Maya's credential on the first is dead (disconnected) with a repository bound through it, which the stub knows as project + /// 4242; boss holds a live OAuth credential on the second, the stub's other application, and one on the other host. + /// + private async Task SeedAsync(GitLabDoorkeeperStub gitlab, bool projectVisibleToTheNewCredential = true) + { + gitlab.AddApplication(SecondApplication, "secret2"); + gitlab.AddProject(projectVisibleToTheNewCredential ? ProjectId : ProjectId + 1, ProjectPath); + + var boss = await _tenants.AccountAsync().ConfigureAwait(false); + var maya = await _tenants.AccountAsync().ConfigureAwait(false); + var vera = await _tenants.AccountAsync().ConfigureAwait(false); + var team = await _tenants.TeamAsync((boss, TeamRole.Admin), (maya, TeamRole.Member), (vera, TeamRole.Viewer)).ConfigureAwait(false); + + Guid first, second, elsewhere; + + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var encryptor = scope.ServiceProvider.GetRequiredService(); + + ProviderInstance Connection(string name, string baseUrl, string clientId, string secret) => new() { Id = Guid.NewGuid(), TeamId = team, Provider = ProviderKind.GitLab, DisplayName = $"{name}-{Guid.NewGuid():N}"[..14], BaseUrl = baseUrl, ApiUrl = baseUrl, OauthClientId = clientId, OauthClientSecretEnc = encryptor.Encrypt(secret), CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + var one = Connection("GitLab", gitlab.BaseUrl, GitLabDoorkeeperStub.DefaultClientId, GitLabDoorkeeperStub.DefaultClientSecret); + var two = Connection("GitLab-TED", gitlab.BaseUrl, SecondApplication, "secret2"); + var other = Connection("Elsewhere", $"https://gitlab-{Guid.NewGuid():N}.test", "app3", "secret3"); + + db.ProviderInstance.AddRange(one, two, other); + await db.SaveChangesAsync().ConfigureAwait(false); + + (first, second, elsewhere) = (one.Id, two.Id, other.Id); + } + + var dead = await SeedDeadCredentialAsync(team, first, maya).ConfigureAwait(false); + var bossOnSibling = await SeedCredentialAsync(team, second, boss, "boss-sibling", gitlab, SecondApplication, "at-boss").ConfigureAwait(false); + var bossElsewhere = await SeedCredentialAsync(team, elsewhere, boss, "boss-elsewhere", gitlab, "app1", "at-elsewhere").ConfigureAwait(false); + var repository = await SeedRepositoryAsync(team, first, dead, gitlab).ConfigureAwait(false); + + return new World(team, boss, maya, first, second, dead, bossOnSibling, bossElsewhere, repository); + } + + /// What a Disconnect leaves: the row revoked and the token blanked. + private async Task SeedDeadCredentialAsync(Guid team, Guid connection, Guid owner) => + await _tenants.DbAsync(async db => + { + var credential = new Credential { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = connection, OwnerUserId = owner, Ownership = CredentialOwnership.Personal, AuthType = AuthType.OAuth, DisplayName = "GitLab · maya", EncryptedPayload = string.Empty, Status = CredentialStatus.Revoked, CreatedBy = owner, LastModifiedBy = owner }; + + db.Credential.Add(credential); + await db.SaveChangesAsync(); + + return credential.Id; + }).ConfigureAwait(false); + + /// A live OAuth credential whose pair the stub issued: is live at GitLab for 's account through . + private async Task SeedCredentialAsync(Guid team, Guid connection, Guid owner, string name, GitLabDoorkeeperStub gitlab, string application, string accessToken) + { + gitlab.Seed(application, name, accessToken, $"rt-{accessToken}"); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var payload = new OAuthPayload { AccessToken = accessToken, RefreshToken = $"rt-{accessToken}", ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) }; + var credential = new Credential { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = connection, OwnerUserId = owner, Ownership = CredentialOwnership.Personal, AuthType = AuthType.OAuth, DisplayName = $"GitLab · {name}", EncryptedPayload = scope.ServiceProvider.GetRequiredService().Encrypt(scope.ServiceProvider.GetRequiredService().Serialize(payload)), ExpiresDate = payload.ExpiresAt, Status = CredentialStatus.Active, CreatedBy = owner, LastModifiedBy = owner }; + + db.Credential.Add(credential); + await db.SaveChangesAsync().ConfigureAwait(false); + + return credential.Id; + } + + /// A boss-owned personal access token that is still Active by status, recorded as expiring at . + private async Task SeedTokenAsync(World world, string name, DateTimeOffset expiresAt, Guid connection) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sealedToken = scope.ServiceProvider.GetRequiredService().Encrypt(scope.ServiceProvider.GetRequiredService().Serialize(new PatPayload { Token = $"glpat-{name}" })); + var credential = new Credential { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = connection, OwnerUserId = world.BossId, Ownership = CredentialOwnership.Personal, AuthType = AuthType.Pat, DisplayName = name, EncryptedPayload = sealedToken, ExpiresDate = expiresAt, Status = CredentialStatus.Active, CreatedBy = world.BossId, LastModifiedBy = world.BossId }; + + db.Credential.Add(credential); + await db.SaveChangesAsync().ConfigureAwait(false); + + return credential.Id; + } + + private async Task SeedRepositoryAsync(Guid team, Guid connection, Guid credential, GitLabDoorkeeperStub gitlab) => + await _tenants.DbAsync(async db => + { + var repository = new Repository { Id = Guid.NewGuid(), TeamId = team, ProviderInstanceId = connection, CredentialId = credential, ExternalId = ProjectId.ToString(), NamespacePath = "acme", Name = "api", FullPath = ProjectPath, DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = $"{gitlab.BaseUrl}/{ProjectPath}", CloneUrlHttps = $"{gitlab.BaseUrl}/{ProjectPath}.git", Status = RepositoryStatus.Error, LastError = CredentialRevocationService.DisconnectedRepositoryError }; + + db.Repository.Add(repository); + await db.SaveChangesAsync(); + + return repository.Id; + }).ConfigureAwait(false); + + private async Task SendRelinkAsync(World world, string door, Guid toCredential) => door == "relink-credential" + ? await _tenants.SendAsync(HttpMethod.Post, $"/api/repositories/{world.Repository}/relink-credential", world.BossId, world.TeamId, new { newCredentialId = toCredential }).ConfigureAwait(false) + : await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{world.DeadCredential}/relink-bindings", world.BossId, world.TeamId, new { toCredentialId = toCredential }).ConfigureAwait(false); + + /// The ids GET /api/repositories?providerInstanceId=… lists — what the SPA's repository list shows under a connection. + private async Task ListedUnderAsync(World world, Guid connection) + { + var response = await _tenants.SendAsync(HttpMethod.Get, $"/api/repositories?providerInstanceId={connection}", world.BossId, world.TeamId).ConfigureAwait(false); + + return (await ReadJsonAsync(response).ConfigureAwait(false)).EnumerateArray().Select(r => r.GetProperty("id").GetGuid()).ToArray(); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response).ConfigureAwait(false)); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync().ConfigureAwait(false)); + + return json.RootElement.Clone(); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs index 5cb2e0a66..9ad3bf345 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs @@ -781,6 +781,7 @@ public sealed class ProbeRecorder private readonly string? _refuseFor; private readonly Func? _failure; private readonly List<(Guid Credential, string ExternalId)> _reads = new(); + private readonly List<(Guid Credential, string HookExternalId)> _deletions = new(); /// Refuse the repositories whose external id ends with it; the empty string refuses them all, null none. /// What the provider throws when it refuses; a 404 when left out. @@ -792,9 +793,14 @@ public ProbeRecorder(string? refuseFor = null, Func? failure = null) public IReadOnlyList<(Guid Credential, string ExternalId)> Reads => _reads; + /// The hooks the provider was asked to delete, and as which credential — what a repository that changed connection leaves behind at the provider. + public IReadOnlyList<(Guid Credential, string HookExternalId)> Deletions => _deletions; + public void Install(ContainerBuilder builder) => builder.Register(c => new RecordingRegistry(new BindingMoveKit.GitTestProviderRegistry(new ProviderRegistry(c.Resolve>())), this)).InstancePerLifetimeScope(); + private void Deleted(ProviderContext context, string hookExternalId) => _deletions.Add((context.Credential.Id, hookExternalId)); + private void Read(ProviderContext context, string externalId) { _reads.Add((context.Credential.Id, externalId)); @@ -815,13 +821,48 @@ public RecordingRegistry(IProviderRegistry inner, ProbeRecorder recorder) } public TCapability Require(ProviderKind kind) where TCapability : IProviderCapability => - typeof(TCapability) == typeof(IRepositoryCatalogCapability) ? (TCapability)CatalogProxy.Create(_inner.Require(kind), _recorder) : _inner.Require(kind); + typeof(TCapability) == typeof(IRepositoryCatalogCapability) ? (TCapability)CatalogProxy.Create(_inner.Require(kind), _recorder) + : typeof(TCapability) == typeof(IWebhookRegistrationCapability) ? (TCapability)WebhookProxy.Create(_inner.Require(kind), _recorder) + : _inner.Require(kind); + + public bool TryGet(ProviderKind kind, out TCapability? capability) where TCapability : class, IProviderCapability + { + var found = _inner.TryGet(kind, out capability); - public bool TryGet(ProviderKind kind, out TCapability? capability) where TCapability : class, IProviderCapability => _inner.TryGet(kind, out capability); + if (found && capability is IWebhookRegistrationCapability hooks && typeof(TCapability) == typeof(IWebhookRegistrationCapability)) capability = (TCapability)WebhookProxy.Create(hooks, _recorder); + + return found; + } public IReadOnlyList GetCapabilities(ProviderKind kind) => _inner.GetCapabilities(kind); } + /// Remembers each hook the provider is asked to delete — the call a repository's old hooks end in once it has moved to another connection. + public class WebhookProxy : System.Reflection.DispatchProxy + { + private IWebhookRegistrationCapability _inner = default!; + private ProbeRecorder _recorder = default!; + + public static object Create(IWebhookRegistrationCapability inner, ProbeRecorder recorder) + { + var proxy = Create(); + var self = (WebhookProxy)(object)proxy; + + self._inner = inner; + self._recorder = recorder; + + return proxy; + } + + protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(IWebhookRegistrationCapability.DeleteWebhookAsync)) _recorder.Deleted((ProviderContext)args![0]!, (string)args[2]!); + + try { return targetMethod.Invoke(_inner, args); } + catch (System.Reflection.TargetInvocationException e) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException!).Throw(); throw; } + } + } + public class CatalogProxy : System.Reflection.DispatchProxy { private IRepositoryCatalogCapability _inner = default!; diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Doors.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Doors.cs new file mode 100644 index 000000000..31b930d79 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Doors.cs @@ -0,0 +1,523 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.Providers.Identity; +using CodeSpace.IntegrationTests.OAuth; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Queries.Credentials; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// The doors around a move: the dialog's candidate list, the Disconnect that names a target, a connect's carry-forward and the repair sweep, each across two connections to one host. +public sealed partial class SameHostConnectionsFlowTests +{ + [Fact] + public async Task Candidates_are_the_callers_own_and_the_teams_live_credentials_on_every_connection_to_the_host_best_first() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var otherHost = await _kit.SeedOtherInstanceAsync(world); + var now = DateTimeOffset.UtcNow; + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked, createdDate: now.AddDays(-20)); + + var ownHere = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-own-here", createdDate: now.AddDays(-5)); + var teamHere = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team-here", ownership: CredentialOwnership.TeamService, createdDate: now.AddDays(-1)); + var ownThere = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-own-there", instanceId: sibling, createdDate: now); + var teamThere = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team-there", ownership: CredentialOwnership.TeamService, instanceId: sibling, createdDate: now.AddDays(-3)); + + var teammates = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-teammates", instanceId: sibling); + var anotherHost = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-another-host", instanceId: otherHost); + var revoked = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-revoked", CredentialStatus.Revoked, instanceId: sibling); + var retired = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-retired", instanceId: sibling); + await SoftDeleteCredentialAsync(retired); + + var candidates = await CandidatesAsync(world, world.AdminId, world.InstanceId, dead); + + candidates.Select(c => c.CredentialId).ShouldBe(new[] { ownHere, teamHere, ownThere, teamThere }, "the repository's own connection first — nothing about where it lives changes — then the caller's own before the team's, each newest first"); + candidates.Select(c => c.Kind).ShouldBe(new[] { RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.TeamServiceToken, RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.TeamServiceToken }); + candidates.Select(c => c.ProviderInstanceId).ShouldBe(new[] { world.InstanceId, world.InstanceId, sibling, sibling }); + candidates.ShouldAllBe(c => c.ProviderInstanceName.StartsWith("GitLab"), "a person can tell two connections to one host apart by name"); + + var offered = candidates.Select(c => c.CredentialId).ToHashSet(); + + offered.ShouldNotContain(teammates, "a teammate's personal token would make the repository act as them"); + offered.ShouldNotContain(anotherHost); + offered.ShouldNotContain(revoked); + offered.ShouldNotContain(retired); + offered.ShouldNotContain(dead); + } + + [Fact] + public async Task Candidates_reach_across_the_connections_whichever_one_the_dialog_was_opened_from() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var now = DateTimeOffset.UtcNow; + var here = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-here", createdDate: now); + var there = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-there", instanceId: sibling, createdDate: now.AddDays(-1)); + + (await CandidatesAsync(world, world.AdminId, world.InstanceId)).Select(c => c.CredentialId).ShouldBe(new[] { here, there }); + (await CandidatesAsync(world, world.AdminId, sibling)).Select(c => c.CredentialId).ShouldBe(new[] { there, here }, "opened from the sibling, the sibling's credentials lead"); + } + + [Fact] + public async Task Candidates_leave_out_the_credential_being_left_and_flag_the_one_connected_before_it_was_disconnected() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var dead = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked); + var before = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-before", instanceId: sibling, createdDate: revoked.AddDays(-3)); + var after = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-after", instanceId: sibling, createdDate: revoked.AddDays(2)); + + var candidates = await CandidatesAsync(world, world.AdminId, world.InstanceId, dead); + + candidates.Select(c => (c.CredentialId, c.ConnectedBeforeDisconnect)).ShouldBe(new[] { (after, false), (before, true) }); + } + + [Fact] + public async Task Candidates_do_not_depend_on_the_credential_being_left_being_dead_or_on_there_being_one() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var rejecting = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-rejecting"); + var other = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-other", instanceId: sibling); + + var whileLive = await CandidatesAsync(world, world.AdminId, world.InstanceId, rejecting); + var unbound = await CandidatesAsync(world, world.AdminId, world.InstanceId); + + whileLive.Select(c => c.CredentialId).ShouldBe(new[] { other }, "a repository whose credential merely looks fine is moved off it as readily as a dead one's"); + unbound.Select(c => c.CredentialId).ShouldBe(new[] { rejecting, other }, "and a repository with no credential at all is offered every one"); + } + + [Fact] + public async Task Candidates_leave_out_a_static_token_past_its_expiry_exactly_as_the_credential_list_reads_it() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var now = DateTimeOffset.UtcNow; + + // Newest first would put every expired one at the top, so each is the freshest of its kind: if one were offered, it would lead. + var expiredHere = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-expired-here", createdDate: now, expiresDate: now.AddDays(-3)); + var expiredTeam = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-expired-team", ownership: CredentialOwnership.TeamService, createdDate: now, expiresDate: now.AddHours(-1)); + var expiredThere = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-expired-there", instanceId: sibling, createdDate: now, expiresDate: now.AddMinutes(-5)); + var expiring = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-expiring", createdDate: now.AddDays(-1), expiresDate: now.AddDays(5)); + var healthy = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-healthy", createdDate: now.AddDays(-2), expiresDate: now.AddDays(200)); + var noExpiry = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-no-expiry", createdDate: now.AddDays(-3)); + + var candidates = await CandidatesAsync(world, world.AdminId, world.InstanceId); + var listed = await ListCredentialsAsync(world, world.AdminId); + + candidates.Select(c => c.CredentialId).ShouldBe(new[] { expiring, healthy, noExpiry }, "an expired token is Active by status until a probe flips it, and is still not one a repository can be re-linked to"); + + foreach (var credential in listed.Where(c => new[] { expiredHere, expiredTeam, expiredThere, expiring, healthy, noExpiry }.Contains(c.Id))) + { + var offered = candidates.Any(c => c.CredentialId == credential.Id); + var canAuthenticate = credential.Health is CredentialHealth.Active or CredentialHealth.Expiring; + + offered.ShouldBe(canAuthenticate, $"{credential.DisplayName}: listed as {credential.Health}, offered {offered} — the list and the dialog read one rule"); + } + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_candidate_on_another_connection_names_the_teammates_whose_act_as_the_move_strands_and_the_act_as_reader_agrees(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-target", instanceId: sibling); + var sameConnection = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-same-connection", createdDate: DateTimeOffset.UtcNow.AddDays(-9)); + + // The member acts as themselves on the repository's own connection only; the owner on both, so a move strands nothing of theirs; the viewer has no identity at all. + await SeedIdentityAsync(world.MemberId, world.InstanceId, await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-here")); + await SeedIdentityAsync(world.OwnerId, world.InstanceId, await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner-here")); + await SeedIdentityAsync(world.OwnerId, sibling, await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner-there", instanceId: sibling)); + var memberName = await NameOfAsync(world.MemberId); + + var candidates = await CandidatesAsync(world, world.AdminId, world.InstanceId, dead); + + candidates.Single(c => c.CredentialId == target).StrandedActAs.ShouldBe(new[] { memberName }, "the member can act as themselves here and has no identity on the connection the repository would move to"); + candidates.Single(c => c.CredentialId == sameConnection).StrandedActAs.ShouldBeEmpty("a credential on the repository's own connection moves nothing, so it strands nobody"); + + // The act-as reader says the same, before and after: the people named are exactly those it can no longer resolve once the repository lives on the sibling. + (await ResolveActAsAsync(world.MemberId, world.InstanceId)).ShouldNotBeNull(); + (await ResolveActAsAsync(world.MemberId, sibling)).ShouldBeNull(); + (await ActAsAuthorsAsync(world, repoId)).ShouldBe(new[] { world.MemberId, world.OwnerId }, "before the move both can be an author (the picker lists them by name)"); + + await MoveAsync(door, world, world.AdminId, dead, repoId, target); + + var moved = await _kit.LoadRepositoryAsync(repoId); + + moved.ProviderInstanceId.ShouldBe(sibling); + (await ResolveActAsAsync(world.MemberId, moved.ProviderInstanceId)).ShouldBeNull("the member's identity belongs to the connection the repository left, so acting as them on it now asks them to connect again"); + (await ResolveActAsAsync(world.OwnerId, moved.ProviderInstanceId)).ShouldNotBeNull("the owner had an identity on both"); + (await ActAsAuthorsAsync(world, repoId)).ShouldBe(new[] { world.OwnerId }, "the author picker offers what resolves, which is no longer the member"); + (await ResolveActAsAsync(world.MemberId, world.InstanceId)).ShouldNotBeNull("nothing was taken from the member: their identity is intact on the connection it belongs to"); + + await SeedIdentityAsync(world.MemberId, sibling, await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-there", instanceId: sibling)); + + (await ResolveActAsAsync(world.MemberId, moved.ProviderInstanceId)).ShouldNotBeNull("once they connect on the new connection they act as themselves on the repository again — what the dialog told them"); + } + + [Fact] + public async Task A_candidate_strands_nobody_when_nobody_acts_as_themselves_on_the_repositorys_connection_or_every_one_who_does_has_an_identity_on_the_candidates() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var target = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-target", instanceId: sibling); + + (await CandidatesAsync(world, world.AdminId, world.InstanceId)).Single().StrandedActAs.ShouldBeEmpty("no identity on the repository's connection: nothing to strand"); + + await SeedIdentityAsync(world.MemberId, world.InstanceId, await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-here")); + await SeedIdentityAsync(world.MemberId, sibling, await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-there", instanceId: sibling)); + + (await CandidatesAsync(world, world.AdminId, world.InstanceId)).Single(c => c.CredentialId == target).StrandedActAs.ShouldBeEmpty("the member already has an identity on the candidate's connection"); + } + + [Fact] + public async Task Candidates_for_another_teams_connection_read_as_not_found() + { + var world = await _kit.SeedWorldAsync(); + var elsewhere = await _kit.SeedWorldAsync(); + + var refusal = await Record.ExceptionAsync(() => CandidatesAsync(world, world.AdminId, elsewhere.InstanceId)); + + refusal.ShouldBeOfType(refusal?.Message); + } + + public enum Disconnecting { TheOwnersOtherConnectionOnASibling, TheAskersOwnConnectionOnASibling, ATeamTokenOnASibling } + + [Theory] + [InlineData(Disconnecting.TheOwnersOtherConnectionOnASibling, true)] + [InlineData(Disconnecting.TheAskersOwnConnectionOnASibling, false)] + [InlineData(Disconnecting.ATeamTokenOnASibling, false)] + public async Task Suggestions_for_a_live_credential_reach_a_sibling_exactly_as_far_as_a_disconnect_accepts(Disconnecting candidate, bool offered) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var source = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-source"); + var target = candidate switch + { + Disconnecting.TheOwnersOtherConnectionOnASibling => await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-owners-other", instanceId: sibling), + Disconnecting.TheAskersOwnConnectionOnASibling => await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-askers", instanceId: sibling), + _ => await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team", ownership: CredentialOwnership.TeamService, instanceId: sibling) + }; + await _kit.SeedRepositoryAsync(world, source); + + var suggestions = await SuggestAsync(world, world.AdminId, source); + var refusal = await Record.ExceptionAsync(() => _kit.DisconnectAsync(world, world.AdminId, source, moveBindingsTo: target)); + + suggestions.Select(s => s.CredentialId).Contains(target).ShouldBe(offered, $"{candidate}: offered"); + (refusal == null).ShouldBe(offered, $"{candidate}: the Disconnect {(refusal == null ? "accepted" : "refused")} it — {refusal?.Message}"); + } + + [Fact] + public async Task A_disconnect_that_names_a_credential_on_a_sibling_connection_takes_the_repository_there() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var leaving = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-leaving"); + var target = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-target", instanceId: sibling); + var repoId = await _kit.SeedRepositoryAsync(world, leaving); + + var result = await _kit.DisconnectAsync(world, world.AdminId, leaving, moveBindingsTo: target); + + result.MovedRepositoryCount.ShouldBe(1); + result.MovedToCredentialId.ShouldBe(target); + var repo = await _kit.LoadRepositoryAsync(repoId); + (repo.CredentialId, repo.ProviderInstanceId, repo.Status).ShouldBe((target, sibling, RepositoryStatus.Active)); + (await _kit.LoadCredentialAsync(leaving)).Status.ShouldBe(CredentialStatus.Revoked); + } + + [Fact] + public async Task A_disconnect_whose_named_target_cannot_take_a_repository_refuses_before_it_revokes_anything() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var leaving = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-leaving"); + var target = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-target", instanceId: sibling); + var repoId = await _kit.SeedRepositoryAsync(world, leaving, name: "api"); + await _kit.SeedRepositoryAsync(world, target, instanceId: sibling, name: "api"); + + var refusal = await Record.ExceptionAsync(() => _kit.DisconnectAsync(world, world.AdminId, leaving, moveBindingsTo: target)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("already bound under", Case.Sensitive); + (await _kit.LoadCredentialAsync(leaving)).Status.ShouldBe(CredentialStatus.Active, "a refused target leaves the credential exactly as it was — the provider was never asked to revoke it"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(leaving); + } + + [Fact] + public async Task A_disconnect_that_names_nobody_never_implies_a_sibling_connection() + { + // The implied destination is where reconnecting would have sent it, on this connection. A repository changing connection is a bigger step than + // an owner who happens to hold a credential elsewhere has asked for, so it takes a named target. + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var leaving = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-leaving"); + await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-elsewhere", instanceId: sibling); + var repoId = await _kit.SeedRepositoryAsync(world, leaving); + + var result = await _kit.DisconnectAsync(world, world.AdminId, leaving); + + result.MovedRepositoryCount.ShouldBe(0); + var repo = await _kit.LoadRepositoryAsync(repoId); + (repo.CredentialId, repo.Status).ShouldBe((leaving, RepositoryStatus.Error)); + } + + [Fact] + public async Task Connecting_never_moves_what_another_person_bound_and_a_refused_repository_does_not_stop_the_rest() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var long_ago = DateTimeOffset.UtcNow.AddDays(-10); + var theirs = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-members-dead", CredentialStatus.Revoked, createdDate: long_ago); + var mine = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admins-dead", CredentialStatus.Revoked, createdDate: long_ago); + var members = await _kit.SeedRepositoryAsync(world, theirs, RepositoryStatus.Error); + var blocked = await _kit.SeedRepositoryAsync(world, mine, RepositoryStatus.Error, name: "blocked"); + var free = await _kit.SeedRepositoryAsync(world, mine, RepositoryStatus.Error, name: "free"); + var holder = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-holder", instanceId: sibling); + await _kit.SeedRepositoryAsync(world, holder, instanceId: sibling, name: "blocked"); + + var connected = await _kit.AddTokenAsync(world, world.AdminId, "glpat-new", instanceId: sibling); + + (await _kit.LoadRepositoryAsync(members)).CredentialId.ShouldBe(theirs, "a different person is a different identity: nothing they bound follows someone else's connect"); + (await _kit.LoadRepositoryAsync(free)).CredentialId.ShouldBe(connected, "the repository the connection can take goes"); + var left = await _kit.LoadRepositoryAsync(blocked); + (left.CredentialId, left.ProviderInstanceId, left.Status).ShouldBe((mine, world.InstanceId, RepositoryStatus.Error), "the one the connection already holds a row for stays exactly where it was — the connect itself succeeds"); + } + + [Fact] + public async Task The_sweep_prefers_the_owners_credential_on_the_dead_ones_own_connection() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var dead = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked); + var onSibling = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-newer-on-the-sibling", instanceId: sibling, createdDate: revoked.AddDays(3)); + var here = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-older-here", createdDate: revoked.AddDays(1)); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + await SweepUntilAsync(async () => (await _kit.LoadRepositoryAsync(repoId)).CredentialId != dead); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + (repo.CredentialId, repo.ProviderInstanceId).ShouldBe((here, world.InstanceId), "the credential on the connection the repository already lives under — no change of connection when one is not needed"); + onSibling.ShouldNotBe(repo.CredentialId!.Value); + } + + [Fact] + public async Task The_sweep_never_follows_a_different_person_nor_a_credential_connected_before_the_dead_one_died() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var dead = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked); + await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-another-person", instanceId: sibling, createdDate: revoked.AddDays(2)); + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-before-it-died", instanceId: sibling, createdDate: revoked.AddDays(-2)); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var passes = 0; + + await SweepUntilAsync(() => Task.FromResult(++passes >= 3)); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + (repo.CredentialId, repo.ProviderInstanceId, repo.Status).ShouldBe((dead, world.InstanceId, RepositoryStatus.Error), "a different person is a different identity, and a connection made before the old one died was beside it, not after it"); + } + + [Fact] + public async Task Credentials_whose_repositories_their_successors_connection_cannot_take_do_not_keep_a_repairable_one_waiting() + { + // A refusal that does not heal by being asked again — the successor's connection already holds the project — is a failure of that candidate: it is set aside + // after a few passes in a row, instead of being "repaired" every tick and holding a place at the front of a pass that a repairable credential could have. + var stuck = new List<(Guid Dead, Guid Repository)>(); + + for (var i = 0; i < 5; i++) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var dead = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked, id: FrontOfTheQueue()); + var successor = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-successor", instanceId: sibling, createdDate: revoked.AddDays(1)); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "api"); + await _kit.SeedRepositoryAsync(world, successor, instanceId: sibling, name: "api"); + + stuck.Add((dead, repoId)); + } + + var elsewhere = await _kit.SeedWorldAsync(); + var movableSibling = await _kit.SeedSiblingInstanceAsync(elsewhere); + var movableDead = await _kit.SeedCredentialAsync(elsewhere, elsewhere.OwnerId, "glpat-movable-dead", CredentialStatus.Revoked, createdDate: DateTimeOffset.UtcNow.AddDays(-10)); + var movableSuccessor = await _kit.SeedCredentialAsync(elsewhere, elsewhere.OwnerId, "glpat-movable-successor", instanceId: movableSibling, createdDate: DateTimeOffset.UtcNow.AddDays(-9)); + var movable = await _kit.SeedRepositoryAsync(elsewhere, movableDead, RepositoryStatus.Error); + var passes = 0; + + while ((await _kit.LoadRepositoryAsync(movable)).CredentialId != movableSuccessor && passes < MaxSweeps) + { + passes++; + + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Send(new RepairDisconnectedCredentialBindingsCommand { BatchSize = 1 }); + } + + (await _kit.LoadRepositoryAsync(movable)).CredentialId.ShouldBe(movableSuccessor, "the repairable credential was reached however many refused ones sit ahead of it"); + passes.ShouldBeLessThan(MaxSweeps); + + foreach (var (dead, repoId) in stuck) (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead, "a refused repository stays exactly where it was"); + + await DisposeStuckAsync(stuck.Select(s => s.Repository)); + } + + /// The sweep is deployment-wide: a binding that can never be repaired must not stay a candidate for every later test. + private async Task DisposeStuckAsync(IEnumerable repositoryIds) + { + var ids = repositoryIds.ToList(); + + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Repository.Where(r => ids.Contains(r.Id)).ExecuteUpdateAsync(s => s.SetProperty(r => r.DeletedDate, DateTimeOffset.UtcNow)); + } + + /// An id that sorts ahead of any random one: the sweep reads candidates in id order. + private static Guid FrontOfTheQueue() => Guid.Parse($"00000000-0000-0000-0000-{Guid.NewGuid().ToString("N")[..12]}"); + + [Fact] + public async Task A_group_hook_stays_with_its_connection_and_a_credential_holding_only_one_is_not_repaired_across_connections() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var deadWithHookOnly = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-dead-hook", CredentialStatus.Revoked, createdDate: revoked); + var deadWithRepository = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-dead-repo", CredentialStatus.Revoked, createdDate: revoked); + var hookOnly = await _kit.SeedConnectionWebhookAsync(world, deadWithHookOnly, RepositoryWebhookRegistrationStatus.DeadLettered, ownerPath: "only-hook"); + var hookAndRepository = await _kit.SeedConnectionWebhookAsync(world, deadWithRepository, RepositoryWebhookRegistrationStatus.DeadLettered, ownerPath: "with-repo"); + var repoId = await _kit.SeedRepositoryAsync(world, deadWithRepository, RepositoryStatus.Error); + var before = await DrainedStrandedAsync(); + + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owners-on-sibling", instanceId: sibling, createdDate: revoked.AddDays(1)); + + (await StrandedAsync()).ShouldBe(before, "a credential with nothing but a group hook, whose owner connected only to a sibling, has no successor the hook could follow, so it is not a candidate"); + + await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admins-on-sibling", instanceId: sibling, createdDate: revoked.AddDays(1)); + + (await StrandedAsync()).ShouldBe(before + 1, "one that still holds a repository is"); + + await SweepUntilAsync(async () => (await _kit.LoadRepositoryAsync(repoId)).ProviderInstanceId == sibling); + + (await _kit.LoadRepositoryAsync(repoId)).ProviderInstanceId.ShouldBe(sibling); + (await _kit.LoadConnectionWebhookAsync(hookAndRepository)).CredentialId.ShouldBe(deadWithRepository, "the hook is keyed to its connection and does not move to a credential of another"); + (await _kit.LoadConnectionWebhookAsync(hookOnly)).CredentialId.ShouldBe(deadWithHookOnly); + (await StrandedAsync()).ShouldBe(before, "once the repository has gone, nothing is left for the sweep to repair again and again"); + } + + [Fact] + public async Task Dependents_follow_a_repository_to_the_credential_and_connection_it_moved_to() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + + await RelinkAllAsync(world, world.OwnerId, dead, target); + + (await DependentsAsync(world, world.OwnerId, dead)).Repositories.ShouldBeEmpty(); + var now = await DependentsAsync(world, world.OwnerId, target); + now.Repositories.Select(r => r.Id).ShouldBe(new[] { repoId }); + now.ProviderInstanceId.ShouldBe(sibling); + } + + // ── Reads and seeds ─────────────────────────────────────────────────────────── + + private async Task> CandidatesAsync(BindingMoveKit.World world, Guid userId, Guid providerInstanceId, Guid? leaving = null) + { + using var scope = _kit.Scope(world, userId); + + return await scope.Resolve().Send(new GetRelinkCandidatesQuery { ProviderInstanceId = providerInstanceId, FromCredentialId = leaving }); + } + + private async Task NameOfAsync(Guid userId) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().User.AsNoTracking().Where(u => u.Id == userId).Select(u => u.Name).SingleAsync(); + } + + /// The act-as reader the workflow engine and the picker both go through: the identity a person may act with on a connection, or null. + private async Task ResolveActAsAsync(Guid userId, Guid providerInstanceId) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().ResolveAsync(userId, providerInstanceId, CancellationToken.None); + } + + /// The users the author picker offers for the repository, by id. + private async Task ActAsAuthorsAsync(BindingMoveKit.World world, Guid repositoryId) + { + using var scope = _kit.Fixture.BeginScope(); + + return (await scope.Resolve().ListCandidatesAsync(repositoryId, world.TeamId, CancellationToken.None)).Select(c => c.UserId).ToArray(); + } + + /// The credential list as the SPA reads it — the health it derives is what a person sees on each row. + private async Task> ListCredentialsAsync(BindingMoveKit.World world, Guid userId) + { + using var scope = _kit.Scope(world, userId); + + return await scope.Resolve().Send(new ListCredentialsQuery()); + } + + private async Task DependentsAsync(BindingMoveKit.World world, Guid userId, Guid credentialId) + { + using var scope = _kit.Scope(world, userId); + + return await scope.Resolve().Send(new GetCredentialDependentsQuery { CredentialId = credentialId }); + } + + /// The sweep's backlog over this database. Compared before and after, never against a number: other tests' rows are in it. + private async Task StrandedAsync() + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().CountAsync(CancellationToken.None); + } + + /// + /// The backlog once the sweep has done everything it can: what other tests left behind that it can repair is repaired first, so the sweeps this test + /// then runs repair its own rows and nobody else's, and a count taken before them can be compared with one taken after. + /// + private async Task DrainedStrandedAsync() + { + var count = await StrandedAsync(); + + for (var pass = 1; pass <= MaxSweeps; pass++) + { + using (var scope = _kit.Fixture.BeginScope()) await scope.Resolve().Send(new RepairDisconnectedCredentialBindingsCommand { BatchSize = 200 }); + + var next = await StrandedAsync(); + + if (next == count) return count; + + count = next; + } + + return count; + } + + private async Task SoftDeleteCredentialAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Credential.Where(c => c.Id == id).ExecuteUpdateAsync(s => s.SetProperty(c => c.DeletedDate, DateTimeOffset.UtcNow)); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Moves.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Moves.cs new file mode 100644 index 000000000..fd13a97a9 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Moves.cs @@ -0,0 +1,420 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.IntegrationTests.OAuth; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// What a repository's move to another connection touches and what it keeps, whichever door asked for it, and what it is refused over. +public sealed partial class SameHostConnectionsFlowTests +{ + /// The two doors a person relinks through: one repository, or everything a dead credential held. + public enum Door { RelinkOne, RelinkAll } + + private async Task MoveAsync(Door door, BindingMoveKit.World world, Guid userId, Guid dead, Guid repoId, Guid target, CredentialRelinkFlowTests.ProbeRecorder? provider = null) + { + if (door == Door.RelinkOne) await RelinkOneAsync(world, userId, repoId, target, provider); + else await RelinkAllAsync(world, userId, dead, target, provider); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_move_keeps_the_repositorys_identity_its_project_links_and_its_triggers_and_leaves_the_credential_owners_act_as_row_as_it_was(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-target", instanceId: sibling); + var projectId = await SeedProjectLinkAsync(world, repoId); + var activationId = await SeedActivationAsync(world, repoId); + var identityId = await SeedIdentityAsync(world.AdminId, sibling, target); + var before = await _kit.LoadRepositoryAsync(repoId); + var activationBefore = await LoadActivationConfigAsync(activationId); + + await MoveAsync(door, world, world.AdminId, dead, repoId, target); + + var after = await _kit.LoadRepositoryAsync(repoId); + + after.Id.ShouldBe(before.Id, "the same row: its id is what every project link, trigger and history row points at"); + (after.ExternalId, after.FullPath, after.WebUrl, after.CreatedDate).ShouldBe((before.ExternalId, before.FullPath, before.WebUrl, before.CreatedDate)); + after.ProviderInstanceId.ShouldBe(sibling); + (await LoadLinkAsync(projectId, repoId)).DeletedDate.ShouldBeNull("the project still has its repository"); + (await LoadActivationConfigAsync(activationId)).ShouldBe(activationBefore, "a trigger that names the repository keeps naming it, by the id that did not change"); + + var identity = await LoadIdentityAsync(identityId); + + identity.DeletedDate.ShouldBeNull(); + (identity.CredentialId, identity.ProviderInstanceId).ShouldBe((target, sibling), "the act-as row of the person whose credential took the repository over is untouched by what was bound through it — what a move does to the OTHER people's act-as is the candidate-list test's"); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_repositorys_hooks_are_registered_again_through_the_new_connection_and_the_old_ones_retired(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var registered = await _kit.SeedRepositoryWebhookAsync(repoId, RepositoryWebhookRegistrationStatus.Registered); + var failed = await _kit.SeedRepositoryWebhookAsync(repoId, RepositoryWebhookRegistrationStatus.Failed); + var registeredAtProvider = (await _kit.LoadRepositoryWebhooksAsync(repoId)).Single(w => w.Id == registered).ExternalId!; + + using var dispatches = _kit.Jobs().ManualExecution(); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(); + + await MoveAsync(door, world, world.OwnerId, dead, repoId, target, provider); + + var hooks = await _kit.LoadRepositoryWebhooksAsync(repoId); + var staged = hooks.Single(w => w.Id != registered && w.Id != failed); + + hooks.ShouldNotContain(w => w.Id == registered, "a hook that was registered described the old connection's registration, and is gone"); + hooks.Single(w => w.Id == failed).RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Cancelled, "one that never registered is taken out of service, so no registrar completes it behind the move"); + staged.RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Enqueued, "a fresh hook, handed to the registrar once the move committed"); + staged.CallbackUrl.ShouldEndWith(staged.Id.ToString(), Case.Insensitive, "the callback carries the hook's own id, as a bind's does"); + staged.ExternalId.ShouldBeNull(); + _kit.WasRegistrationDispatched(staged.Id).ShouldBeTrue(); + provider.Deletions.Select(d => $"{d.Credential}|{d.HookExternalId}").ShouldBe(new[] { $"{target}|{registeredAtProvider}" }, "the provider is asked to delete the old hook, as the credential that took the repository over"); + } + + [Fact] + public async Task A_connection_wide_target_gets_one_owner_hook_for_every_repository_under_the_owner_and_the_repositorys_own_hooks_are_retired() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world, webhookScope: ProviderWebhookScope.Connection); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var first = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var second = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var perRepository = await _kit.SeedRepositoryWebhookAsync(first, RepositoryWebhookRegistrationStatus.Registered); + + using var dispatches = _kit.Jobs().ManualExecution(); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(); + + await RelinkAllAsync(world, world.OwnerId, dead, target, provider); + + var owners = await LoadConnectionWebhooksAsync(sibling); + + owners.Count.ShouldBe(1, "two repositories under one owner are covered by one hook: a second would deliver every push twice"); + (owners[0].OwnerPath, owners[0].CredentialId).ShouldBe(("acme", target)); + _kit.WasDispatched(owners[0].Id).ShouldBeTrue("handed to the connection hook's registrar once the move committed"); + (await _kit.LoadRepositoryWebhooksAsync(first)).ShouldBeEmpty("the connection-wide hook covers it; its own hook is retired, or both would deliver"); + (await _kit.LoadRepositoryWebhooksAsync(second)).ShouldBeEmpty("and none is staged for the repository that had none"); + provider.Deletions.Count.ShouldBe(1); + (await _kit.LoadRepositoryWebhooksAsync(first)).ShouldNotContain(w => w.Id == perRepository); + } + + [Fact] + public async Task A_repository_leaving_a_connection_wide_connection_for_a_per_repository_one_gets_a_hook_of_its_own_and_the_old_groups_hook_stays() + { + var world = await _kit.SeedWorldAsync(); + + await _kit.SetWebhookScopeAsync(world.InstanceId, ProviderWebhookScope.Connection); + + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var other = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var groupHook = await _kit.SeedConnectionWebhookAsync(world, dead, RepositoryWebhookRegistrationStatus.Registered); + + using var dispatches = _kit.Jobs().ManualExecution(); + + await RelinkOneAsync(world, world.OwnerId, repoId, target); + + var staged = (await _kit.LoadRepositoryWebhooksAsync(repoId)).ShouldHaveSingleItem("the connection it arrives at registers per repository, so the repository gets its own hook"); + + staged.RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Enqueued); + _kit.WasRegistrationDispatched(staged.Id).ShouldBeTrue(); + (await _kit.LoadConnectionWebhookAsync(groupHook)).RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Registered, "the group hook on the connection it left still covers what stays there: one repository leaving does not retire it"); + (await _kit.LoadRepositoryAsync(other)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task A_connection_wide_target_that_already_covers_the_owner_stages_no_second_hook() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world, webhookScope: ProviderWebhookScope.Connection); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var covering = await _kit.SeedConnectionWebhookAsync(world, target, RepositoryWebhookRegistrationStatus.Registered, instanceId: sibling); + + using var dispatches = _kit.Jobs().ManualExecution(); + + await RelinkAllAsync(world, world.OwnerId, dead, target); + + (await LoadConnectionWebhooksAsync(sibling)).Select(h => h.Id).ShouldBe(new[] { covering }); + _kit.WasDispatched(covering).ShouldBeFalse("a hook that already delivers is left alone"); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_move_onto_a_connection_that_already_holds_the_project_is_refused_and_moves_nothing(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "api"); + await _kit.SeedRepositoryAsync(world, target, instanceId: sibling, name: "api"); + var hook = await _kit.SeedRepositoryWebhookAsync(repoId, RepositoryWebhookRegistrationStatus.Registered); + + var refusal = await Record.ExceptionAsync(() => MoveAsync(door, world, world.OwnerId, dead, repoId, target)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("already bound under", Case.Sensitive, "the refusal says what is in the way, in place of a unique-index violation"); + var repo = await _kit.LoadRepositoryAsync(repoId); + (repo.CredentialId, repo.ProviderInstanceId, repo.Status).ShouldBe((dead, world.InstanceId, RepositoryStatus.Error)); + (await _kit.LoadRepositoryWebhooksAsync(repoId)).Select(w => w.Id).ShouldBe(new[] { hook }, "nor were its hooks touched"); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_credential_whose_scopes_cannot_register_the_connections_hook_is_refused_before_anything_moves(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var readOnly = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-read-only", instanceId: sibling, scopes: new[] { "read_user" }); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(); + + var refusal = await Record.ExceptionAsync(() => MoveAsync(door, world, world.OwnerId, dead, repoId, readOnly, provider)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("cannot take", Case.Sensitive); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + provider.Reads.ShouldBeEmpty("refused on what the credential was granted, before the provider is asked anything"); + + var sufficient = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-api", instanceId: sibling, scopes: new[] { "api" }); + + await MoveAsync(door, world, world.OwnerId, dead, repoId, sufficient); + + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(sufficient, "the same move with the scopes the hook needs goes through"); + } + + [Fact] + public async Task The_target_is_asked_to_read_every_repository_it_takes_to_another_connection_as_itself() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var first = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "first"); + var second = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "second"); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(); + + await RelinkAllAsync(world, world.OwnerId, dead, target, provider); + + provider.Reads.OrderBy(r => r.ExternalId).Select(r => $"{r.Credential}|{r.ExternalId}").ShouldBe(new[] { $"{target}|acme/first", $"{target}|acme/second" }, "each repository, once, as the credential that takes it"); + (await _kit.LoadRepositoryAsync(first)).CredentialId.ShouldBe(target); + (await _kit.LoadRepositoryAsync(second)).CredentialId.ShouldBe(target); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_target_that_cannot_read_the_repository_refuses_a_move_to_another_connection_and_names_it(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "hidden"); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var hook = await _kit.SeedRepositoryWebhookAsync(repoId, RepositoryWebhookRegistrationStatus.Registered); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(refuseFor: "hidden"); + + var refusal = await Record.ExceptionAsync(() => MoveAsync(door, world, world.OwnerId, dead, repoId, target, provider)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("acme/hidden", Case.Sensitive, "the refusal names what the target cannot read"); + var repo = await _kit.LoadRepositoryAsync(repoId); + (repo.CredentialId, repo.ProviderInstanceId).ShouldBe((dead, world.InstanceId)); + (await _kit.LoadRepositoryWebhooksAsync(repoId)).Select(w => w.Id).ShouldBe(new[] { hook }, "and nothing about its hooks changed"); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_repository_the_target_finds_at_another_path_is_not_moved_to_another_connection(Door door) + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "renamed", storedPath: "acme/old-name"); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + + var refusal = await Record.ExceptionAsync(() => MoveAsync(door, world, world.OwnerId, dead, repoId, target, new CredentialRelinkFlowTests.ProbeRecorder())); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("acme/old-name", Case.Sensitive, "it names the path CodeSpace has"); + refusal.Message.ShouldContain("acme/renamed", Case.Sensitive, "and the one the provider gave"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Theory] + [InlineData(Door.RelinkOne)] + [InlineData(Door.RelinkAll)] + public async Task A_move_within_one_connection_never_checks_the_path_and_touches_no_hook(Door door) + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error, name: "renamed", storedPath: "acme/old-name"); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + var hook = await _kit.SeedRepositoryWebhookAsync(repoId, RepositoryWebhookRegistrationStatus.Registered); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(); + + await MoveAsync(door, world, world.OwnerId, dead, repoId, target, provider); + + var repo = await _kit.LoadRepositoryAsync(repoId); + (repo.CredentialId, repo.ProviderInstanceId, repo.Status).ShouldBe((target, world.InstanceId, RepositoryStatus.Active)); + (await _kit.LoadRepositoryWebhooksAsync(repoId)).Select(w => (w.Id, w.RegistrationStatus)).ShouldBe(new[] { (hook, RepositoryWebhookRegistrationStatus.Registered) }, "the registered hook is exactly as it was: only another connection's move registers a hook again"); + provider.Deletions.ShouldBeEmpty(); + provider.Reads.Count.ShouldBe(door == Door.RelinkAll ? 1 : 0, "a single relink within one connection reads nothing, as it never did; relink-all keeps the lookup it always made"); + } + + [Fact] + public async Task A_provider_that_does_not_answer_is_a_retryable_refusal_that_blames_nobody() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + var provider = new CredentialRelinkFlowTests.ProbeRecorder(refuseFor: "", failure: () => new HttpRequestException("connection reset by peer")); + + var refusal = await Record.ExceptionAsync(() => RelinkOneAsync(world, world.OwnerId, repoId, target, provider)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("Try again", Case.Sensitive); + refusal.Message.ShouldNotContain("cannot read", Case.Insensitive, "nothing was learned about the target"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + public enum Refused { AnotherApiPath, ADifferentApiRoot } + + [Theory] + [InlineData(Door.RelinkOne, Refused.AnotherApiPath)] + [InlineData(Door.RelinkAll, Refused.AnotherApiPath)] + [InlineData(Door.RelinkOne, Refused.ADifferentApiRoot)] + [InlineData(Door.RelinkAll, Refused.ADifferentApiRoot)] + public async Task A_connection_to_another_host_is_still_refused_with_the_same_message(Door door, Refused shape) + { + var world = await _kit.SeedWorldAsync(); + var other = shape == Refused.AnotherApiPath ? await _kit.SeedSiblingInstanceAsync(world, apiUrl: $"https://gitlab-{Guid.NewGuid():N}.test/other") : await _kit.SeedOtherInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-elsewhere", instanceId: other); + + var refusal = await Record.ExceptionAsync(() => MoveAsync(door, world, world.OwnerId, dead, repoId, target)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("is for a different provider instance", Case.Sensitive, shape.ToString()); + refusal.Message.ShouldContain("Pick a credential connected to the same provider", Case.Sensitive); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task Another_persons_personal_credential_never_takes_a_repository_to_another_connection_but_a_team_token_does() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var teammates = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-teammates", instanceId: sibling); + var teams = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team", ownership: CredentialOwnership.TeamService, instanceId: sibling); + + var refusal = await Record.ExceptionAsync(() => RelinkOneAsync(world, world.AdminId, repoId, teammates)); + + refusal.ShouldBeOfType(refusal?.Message); + refusal.Message.ShouldContain("another person's personal connection", Case.Sensitive); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + + await RelinkOneAsync(world, world.AdminId, repoId, teams); + + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(teams); + } + + // ── Reads and seeds ─────────────────────────────────────────────────────────── + + private async Task> LoadConnectionWebhooksAsync(Guid instanceId) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().ConnectionWebhook.AsNoTracking().Where(h => h.ProviderInstanceId == instanceId && h.RegistrationStatus != RepositoryWebhookRegistrationStatus.Cancelled).ToListAsync(); + } + + private async Task SeedProjectLinkAsync(BindingMoveKit.World world, Guid repositoryId) + { + var project = new Project { Id = Guid.NewGuid(), TeamId = world.TeamId, Name = "Kept", Slug = $"kept-{Guid.NewGuid():N}"[..14] }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.Project.Add(project); + db.ProjectRepository.Add(new ProjectRepository { ProjectId = project.Id, RepositoryId = repositoryId, TeamId = world.TeamId }); + await db.SaveChangesAsync(); + + return project.Id; + } + + private async Task LoadLinkAsync(Guid projectId, Guid repositoryId) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().ProjectRepository.AsNoTracking().SingleAsync(l => l.ProjectId == projectId && l.RepositoryId == repositoryId); + } + + private async Task SeedActivationAsync(BindingMoveKit.World world, Guid repositoryId) + { + var workflow = new Workflow { Id = Guid.NewGuid(), TeamId = world.TeamId, Slug = $"wf-{Guid.NewGuid():N}"[..14], Name = "Reviews", DefinitionJson = "{}" }; + var activation = new WorkflowActivation { Id = Guid.NewGuid(), WorkflowId = workflow.Id, TypeKey = "trigger.pull_request", ConfigJson = JsonSerializer.Serialize(new { repositoryIds = new[] { repositoryId } }), Enabled = true }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.Workflow.Add(workflow); + db.WorkflowActivation.Add(activation); + await db.SaveChangesAsync(); + + return activation.Id; + } + + private async Task LoadActivationConfigAsync(Guid activationId) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().WorkflowActivation.AsNoTracking().Where(a => a.Id == activationId && a.DeletedDate == null).Select(a => a.ConfigJson).SingleAsync(); + } + + private async Task SeedIdentityAsync(Guid userId, Guid instanceId, Guid credentialId) + { + var identity = new UserProviderIdentity { Id = Guid.NewGuid(), UserId = userId, ProviderInstanceId = instanceId, CredentialId = credentialId, ProviderUserId = $"gl-{Guid.NewGuid():N}"[..10], ProviderUsername = $"gl-user-{Guid.NewGuid():N}"[..14] }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.UserProviderIdentity.Add(identity); + await db.SaveChangesAsync(); + + return identity.Id; + } + + private async Task LoadIdentityAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().UserProviderIdentity.AsNoTracking().SingleAsync(i => i.Id == id); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.cs new file mode 100644 index 000000000..ff087f185 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.cs @@ -0,0 +1,171 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.OAuth; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Commands.Repositories; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Queries.Credentials; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// +/// A team that signs in to one GitLab through two OAuth applications holds two provider connections to the same host. A repository +/// bound under one of them, through a credential that died, could be taken over only by a credential of that same connection: the +/// Re-link dialog offered nothing, the relink commands refused, and a reconnect through the other connection carried nothing +/// forward — so a person who had just connected GitLab still saw "No other working credential". +/// +/// These are the doors, driven through the real mediator pipeline against real Postgres, with one rule behind every one: +/// connections to the same host (same team, provider and API root) are interchangeable for a repository, and what a credential +/// of one takes over from the other keeps its identity — the repository's id, its project links, its history — and moves +/// the repository to the new connection with it. This part is the owner's report as it first reproduced: each door, asked to cross +/// from one connection to its sibling. The hooks a move re-registers, the probe that guards it, the refusals and the +/// candidate list are the other parts. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed partial class SameHostConnectionsFlowTests +{ + private readonly BindingMoveKit _kit; + + public SameHostConnectionsFlowTests(PostgresFixture fixture) { _kit = new BindingMoveKit(fixture); } + + [Fact] + public async Task A_repository_is_relinked_to_the_callers_live_credential_on_a_sibling_connection() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-target", instanceId: sibling); + + await RelinkOneAsync(world, world.AdminId, repoId, target); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(target); + repo.ProviderInstanceId.ShouldBe(sibling, "the repository now lives under the connection its credential belongs to — the two always agree"); + repo.Status.ShouldBe(RepositoryStatus.Active); + repo.LastError.ShouldBeNull(); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("glpat-target", "the next run clones through the credential that took over"); + } + + [Fact] + public async Task Relinking_everything_a_dead_credential_held_reaches_a_sibling_connection() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var first = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var second = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target", instanceId: sibling); + + var result = await RelinkAllAsync(world, world.OwnerId, dead, target); + + result.MovedRepositoryCount.ShouldBe(2); + + foreach (var repoId in new[] { first, second }) + { + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(target); + repo.ProviderInstanceId.ShouldBe(sibling); + repo.Status.ShouldBe(RepositoryStatus.Active); + } + } + + [Fact] + public async Task Suggestions_for_a_dead_credential_offer_a_sibling_connections_credential_after_the_same_connections() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var onSibling = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-sibling", instanceId: sibling, createdDate: DateTimeOffset.UtcNow); + var onSameConnection = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-same", createdDate: DateTimeOffset.UtcNow.AddDays(-5)); + + var suggestions = await SuggestAsync(world, world.OwnerId, dead); + + suggestions.Select(s => s.CredentialId).ShouldBe(new[] { onSameConnection, onSibling }, "the same connection's first, though the sibling's was connected later"); + suggestions.Single(s => s.CredentialId == onSibling).ProviderInstanceId.ShouldBe(sibling); + } + + [Fact] + public async Task Connecting_on_a_sibling_connection_carries_the_owners_stranded_repositories_forward() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var dead = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-dead", CredentialStatus.Revoked, createdDate: DateTimeOffset.UtcNow.AddDays(-10)); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var connected = await _kit.AddTokenAsync(world, world.AdminId, "glpat-new", instanceId: sibling); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(connected, "what the owner bound follows them to the credential they connected after"); + repo.ProviderInstanceId.ShouldBe(sibling); + repo.Status.ShouldBe(RepositoryStatus.Active); + } + + [Fact] + public async Task The_repair_sweep_follows_the_owner_to_a_sibling_connection() + { + var world = await _kit.SeedWorldAsync(); + var sibling = await _kit.SeedSiblingInstanceAsync(world); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var dead = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked); + var successor = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-successor", instanceId: sibling, createdDate: revoked.AddDays(1)); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + await SweepUntilAsync(async () => (await _kit.LoadRepositoryAsync(repoId)).CredentialId == successor); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(successor); + repo.ProviderInstanceId.ShouldBe(sibling); + repo.Status.ShouldBe(RepositoryStatus.Active); + } + + // ── Doors ───────────────────────────────────────────────────────────────────── + + private async Task RelinkOneAsync(BindingMoveKit.World world, Guid userId, Guid repositoryId, Guid to, CredentialRelinkFlowTests.ProbeRecorder? provider = null) + { + using var scope = _kit.Scope(world, userId, provider == null ? null : provider.Install); + + await scope.Resolve().Send(new RelinkRepositoryCredentialCommand { RepositoryId = repositoryId, NewCredentialId = to }); + } + + private async Task RelinkAllAsync(BindingMoveKit.World world, Guid userId, Guid from, Guid to, CredentialRelinkFlowTests.ProbeRecorder? provider = null) + { + using var scope = _kit.Scope(world, userId, provider == null ? null : provider.Install); + + return await scope.Resolve().Send(new RelinkCredentialBindingsCommand { FromCredentialId = from, ToCredentialId = to }); + } + + private async Task> SuggestAsync(BindingMoveKit.World world, Guid userId, Guid credentialId) + { + using var scope = _kit.Scope(world, userId); + + return await scope.Resolve().Send(new GetRelinkSuggestionsQuery { CredentialId = credentialId }); + } + + /// + /// Sweeps until the row the test owns has moved. The repair is deployment-wide, so what one pass repairs says nothing about this + /// test's row: a test waits on the row it owns, never on a tally of the pass. + /// + private async Task SweepUntilAsync(Func> ownedRowHasMoved) + { + for (var pass = 1; pass <= MaxSweeps; pass++) + { + using (var scope = _kit.Fixture.BeginScope()) await scope.Resolve().Send(new RepairDisconnectedCredentialBindingsCommand { BatchSize = 50 }); + + if (await ownedRowHasMoved()) return; + } + } + + private const int MaxSweeps = 20; +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStub.cs b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStub.cs index eb6bb10c8..9bbde1b34 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStub.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStub.cs @@ -59,7 +59,9 @@ public enum StubEvent /// resolves an OAuth token from Authorization: Bearer and looks PRIVATE-TOKEN up as a personal access /// token; an access token is read as PRIVATE-TOKEN alone, see above), GET /api/v4/personal_access_tokens/self /// (an access token's own scopes and expiry), any other GET /api/v4/** as an empty list, and git smart-HTTP -/// GET **/info/refs for both services. Every user has an id of their own, stable for the stub's life +/// GET **/info/refs for both services. Once a project has been added (), GET /api/v4/projects/:id answers the +/// project the way GitLab does — 200 and the project for an id it was given, 404 for any other — to a live token; with none added that call is +/// the empty list it always was. Every user has an id of their own, stable for the stub's life /// (), so a test can tell two accounts apart the way a provider does. /// The git endpoint takes an OAuth access token as HTTP Basic with login oauth2 — what GitLab through 17.x /// requires of an OAuth token — or as Authorization: Bearer; anything else is a 401 with the @@ -89,6 +91,7 @@ public sealed class GitLabDoorkeeperStub : IDisposable private readonly Dictionary _applications = new() { [DefaultClientId] = DefaultClientSecret }; private readonly Dictionary _accessTokens = new(); private readonly Dictionary _userIds = new(); + private readonly Dictionary _projects = new(); private int _sequence; private int _membersFailureStatus; private int _membersFailuresLeft; @@ -157,6 +160,12 @@ public void FailMembers(int status, int times = int.MaxValue) } } + /// A project this GitLab knows, by id and full path: what GET /api/v4/projects/:id answers a live token once any project has been added, and a 404 for every other id. + public void AddProject(long id, string pathWithNamespace) + { + lock (_gate) { _projects[id] = pathWithNamespace; } + } + /// The scopes a personal access token reports for itself at /api/v4/personal_access_tokens/self. public const string PersonalAccessTokenScopes = "api,read_repository"; @@ -358,7 +367,7 @@ private Reply Api(string? authorization, string path) if (path == "/api/v4/personal_access_tokens/self") return Reply.Json(404, """{"message":"404 Not Found"}"""); - return scripted ?? Reply.Json(200, ApiBody(row.User, path)); + return scripted ?? ProjectReply(path) ?? Reply.Json(200, ApiBody(row.User, path)); } } @@ -383,7 +392,7 @@ private Reply TokenApi(string privateToken, string path) if (!live) return Reply.Json(401, UnauthorizedBody); if (path == "/api/v4/personal_access_tokens/self") return Reply.Json(200, SelfBody(token!)); - return scripted ?? Reply.Json(200, ApiBody(token!.User, path)); + return scripted ?? ProjectReply(path) ?? Reply.Json(200, ApiBody(token!.User, path)); } } @@ -401,6 +410,37 @@ private static string SelfBody(AccessToken token) => return Reply.Json(_membersFailureStatus, """{"message":"scripted failure"}"""); } + /// + /// GET /api/v4/projects/:id once projects have been added: the project, or GitLab's 404 for an id it was not given. Null for every other path, and for + /// this call while no project has been added, which keeps its old answer. Only a numeric id is routed: the path form is URL-encoded, and the listener + /// is free to decode it. + /// + private Reply? ProjectReply(string path) + { + const string prefix = "/api/v4/projects/"; + + if (_projects.Count == 0 || !path.StartsWith(prefix, StringComparison.Ordinal) || !long.TryParse(path[prefix.Length..], out var id)) return null; + + if (!_projects.TryGetValue(id, out var fullPath)) return Reply.Json(404, """{"message":"404 Project Not Found"}"""); + + var name = fullPath[(fullPath.LastIndexOf('/') + 1)..]; + var namespacePath = fullPath.Contains('/') ? fullPath[..fullPath.LastIndexOf('/')] : string.Empty; + + return Reply.Json(200, JsonSerializer.Serialize(new + { + id, + name, + path = name, + path_with_namespace = fullPath, + @namespace = new { id = 7, name = namespacePath, path = namespacePath, kind = "group", full_path = namespacePath }, + default_branch = "main", + visibility = "private", + web_url = $"{BaseUrl}/{fullPath}", + http_url_to_repo = $"{BaseUrl}/{fullPath}.git", + archived = false + })); + } + private string ApiBody(string user, string path) { if (path == "/api/v4/user") return JsonSerializer.Serialize(new { id = UserIdOf(user), username = user }); diff --git a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStubTests.cs b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStubTests.cs index a628248d7..42e56968e 100644 --- a/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStubTests.cs +++ b/backend/tests/CodeSpace.IntegrationTests/Infrastructure/GitLabDoorkeeperStubTests.cs @@ -125,6 +125,31 @@ public async Task A_token_past_its_expires_in_is_refused_by_the_api() (await UserStatusAsync("at-0").ConfigureAwait(false)).ShouldBe(HttpStatusCode.Unauthorized); } + [Fact] + public async Task A_project_lookup_answers_the_project_for_an_added_id_and_a_404_for_any_other_but_only_once_a_project_was_added() + { + _gitlab.Seed("app1", "nacho", "at-0", "rt-0"); + + var before = await GetAsync("/api/v4/projects/4242", ("Authorization", "Bearer at-0")).ConfigureAwait(false); + + _gitlab.AddProject(4242, "acme/platform/api"); + + var known = await GetAsync("/api/v4/projects/4242", ("Authorization", "Bearer at-0")).ConfigureAwait(false); + var unknown = await GetAsync("/api/v4/projects/9", ("Authorization", "Bearer at-0")).ConfigureAwait(false); + var withoutToken = await GetAsync("/api/v4/projects/4242").ConfigureAwait(false); + var members = await GetAsync("/api/v4/projects/4242/members/all/51", ("Authorization", "Bearer at-0")).ConfigureAwait(false); + + before.Status.ShouldBe(HttpStatusCode.OK); + before.Json.ValueKind.ShouldBe(JsonValueKind.Array, "with no project added the call answers what it always did (the empty list), so a test that never added one is unchanged"); + known.Status.ShouldBe(HttpStatusCode.OK); + known.Json.GetProperty("id").GetInt64().ShouldBe(4242); + known.Json.GetProperty("path_with_namespace").GetString().ShouldBe("acme/platform/api"); + known.Json.GetProperty("namespace").GetProperty("full_path").GetString().ShouldBe("acme/platform"); + unknown.Status.ShouldBe(HttpStatusCode.NotFound); + withoutToken.Status.ShouldBe(HttpStatusCode.Unauthorized, "a project is for a live token only"); + members.Json.GetProperty("access_level").GetInt32().ShouldBe(30, "the members lookup is a different route and is untouched"); + } + [Fact] public async Task The_api_honours_an_oauth_token_only_as_a_bearer_and_answers_the_members_lookup_for_the_user_asked_about() { diff --git a/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs b/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs index 3c711ba8d..06b141446 100644 --- a/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs +++ b/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs @@ -70,6 +70,34 @@ public async Task SeedOtherInstanceAsync(World world) return instance.Id; } + /// + /// A second connection of the world's team to the SAME host as (the world's own by default) — + /// another OAuth application on one GitLab: same provider and base URL, its own client id, which is what the unique index lets + /// a team hold twice. Credentials and repositories on the two can serve each other, since the repositories behind them are the same. + /// + public async Task SeedSiblingInstanceAsync(World world, Guid? ofInstanceId = null, ProviderWebhookScope webhookScope = ProviderWebhookScope.Repository, string? apiUrl = null) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var of = await db.ProviderInstance.AsNoTracking().SingleAsync(p => p.Id == (ofInstanceId ?? world.InstanceId)); + var suffix = Guid.NewGuid().ToString("N")[..8]; + + var sibling = new ProviderInstance { Id = Guid.NewGuid(), TeamId = of.TeamId, Provider = of.Provider, DisplayName = $"GitLab sibling {suffix}", BaseUrl = of.BaseUrl, ApiUrl = apiUrl ?? of.ApiUrl, WebhookScope = webhookScope, OauthClientId = $"client-{suffix}", OauthClientSecretEnc = scope.Resolve().Encrypt("client-secret") }; + + db.ProviderInstance.Add(sibling); + await db.SaveChangesAsync(); + + return sibling.Id; + } + + /// The connection moved to as an operator's edit leaves it, with nothing registered yet to retire — for a test that needs a connection-wide target. + public async Task SetWebhookScopeAsync(Guid instanceId, ProviderWebhookScope webhookScope) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().ProviderInstance.Where(p => p.Id == instanceId).ExecuteUpdateAsync(s => s.SetProperty(p => p.WebhookScope, webhookScope)); + } + /// A person with a membership in the world's team at — the shape a later joiner or a leaver takes. public async Task SeedMemberAsync(World world, TeamRole role) { @@ -176,14 +204,14 @@ public async Task UnlinkIdentityAsync(World world, Guid userId) /// (two live tokens for one person, a token held by someone who never joined the team). A revoked one carries the blank /// payload a Disconnect leaves; places it before or after another credential's revocation. /// - public async Task SeedCredentialAsync(World world, Guid? ownerId, string token, CredentialStatus status = CredentialStatus.Active, CredentialOwnership ownership = CredentialOwnership.Personal, Guid? instanceId = null, DateTimeOffset? createdDate = null, Guid? id = null) + public async Task SeedCredentialAsync(World world, Guid? ownerId, string token, CredentialStatus status = CredentialStatus.Active, CredentialOwnership ownership = CredentialOwnership.Personal, Guid? instanceId = null, DateTimeOffset? createdDate = null, Guid? id = null, IReadOnlyList? scopes = null, DateTimeOffset? expiresDate = null) { using var scope = _fixture.BeginScope(); var db = scope.Resolve(); var sealedToken = scope.Resolve().Encrypt(scope.Resolve().Serialize(new PatPayload { Token = token })); var created = createdDate ?? DateTimeOffset.UtcNow; - var credential = new Credential { Id = id ?? Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = instanceId ?? world.InstanceId, OwnerUserId = ownerId, Ownership = ownership, AuthType = AuthType.Pat, DisplayName = $"{ownership} {token}", EncryptedPayload = status == CredentialStatus.Revoked ? string.Empty : sealedToken, Status = status, CreatedDate = created, LastModifiedDate = created }; + var credential = new Credential { Id = id ?? Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = instanceId ?? world.InstanceId, OwnerUserId = ownerId, Ownership = ownership, AuthType = AuthType.Pat, DisplayName = $"{ownership} {token}", EncryptedPayload = status == CredentialStatus.Revoked ? string.Empty : sealedToken, Status = status, Scopes = scopes?.ToList(), ExpiresDate = expiresDate, CreatedDate = created, LastModifiedDate = created }; db.Credential.Add(credential); await db.SaveChangesAsync(); @@ -201,10 +229,14 @@ public async Task SeedCredentialAsync(World world, Guid? ownerId, string t return request!.Token; } - public async Task SeedRepositoryAsync(World world, Guid credentialId, RepositoryStatus status = RepositoryStatus.Active, Guid? instanceId = null) + /// + /// A repository bound through . Its external id is its path, which is what the Git test provider answers a lookup with, so a + /// probe reads it where CodeSpace has it; is the path CodeSpace stores it under when that should differ from the provider's — a rename the provider has seen and CodeSpace has not. + /// + public async Task SeedRepositoryAsync(World world, Guid credentialId, RepositoryStatus status = RepositoryStatus.Active, Guid? instanceId = null, string? name = null, string? storedPath = null) { - var name = $"repo-{Guid.NewGuid():N}"[..13]; - var repo = new Repository { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = instanceId ?? world.InstanceId, CredentialId = credentialId, ExternalId = $"acme/{name}", NamespacePath = "acme", Name = name, FullPath = $"acme/{name}", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = $"https://git.test/acme/{name}", CloneUrlHttps = $"https://git.test/acme/{name}.git", Status = status, LastError = status == RepositoryStatus.Error ? "Credential disconnected." : null }; + name ??= $"repo-{Guid.NewGuid():N}"[..13]; + var repo = new Repository { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = instanceId ?? world.InstanceId, CredentialId = credentialId, ExternalId = $"acme/{name}", NamespacePath = "acme", Name = name, FullPath = storedPath ?? $"acme/{name}", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = $"https://git.test/acme/{name}", CloneUrlHttps = $"https://git.test/acme/{name}.git", Status = status, LastError = status == RepositoryStatus.Error ? "Credential disconnected." : null }; using var scope = _fixture.BeginScope(); var db = scope.Resolve(); @@ -215,8 +247,33 @@ public async Task SeedRepositoryAsync(World world, Guid credentialId, Repo return repo.Id; } + /// + /// A per-repository hook as a bind leaves it: and, when Registered, the id the provider gave it. The callback + /// carries the row's own id, as a bind's does. + /// + public async Task SeedRepositoryWebhookAsync(Guid repositoryId, RepositoryWebhookRegistrationStatus status) + { + var id = Guid.NewGuid(); + var registered = status == RepositoryWebhookRegistrationStatus.Registered; + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + db.RepositoryWebhook.Add(new RepositoryWebhook { Id = id, RepositoryId = repositoryId, CallbackUrl = $"https://codespace.test/api/webhooks/{id}", SecretEnc = scope.Resolve().Encrypt("hook-secret"), SubscribedEvents = new List { "push" }, RegistrationStatus = status, ExternalId = registered ? $"hook-{id:N}"[..12] : null, RegisteredAt = registered ? DateTimeOffset.UtcNow.AddDays(-20) : null }); + await db.SaveChangesAsync(); + + return id; + } + + public async Task> LoadRepositoryWebhooksAsync(Guid repositoryId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().RepositoryWebhook.AsNoTracking().Where(w => w.RepositoryId == repositoryId).OrderBy(w => w.CreatedDate).ToListAsync(); + } + /// A group hook on the world's instance, registered through — failed ones carrying the 401 a dying token returns. - public async Task SeedConnectionWebhookAsync(World world, Guid credentialId, RepositoryWebhookRegistrationStatus status) + public async Task SeedConnectionWebhookAsync(World world, Guid credentialId, RepositoryWebhookRegistrationStatus status, Guid? instanceId = null, string ownerPath = "acme") { var id = Guid.NewGuid(); var failed = status is RepositoryWebhookRegistrationStatus.Failed or RepositoryWebhookRegistrationStatus.DeadLettered; @@ -225,7 +282,7 @@ public async Task SeedConnectionWebhookAsync(World world, Guid credentialI using var scope = _fixture.BeginScope(); var db = scope.Resolve(); - db.ConnectionWebhook.Add(new ConnectionWebhook { Id = id, ProviderInstanceId = world.InstanceId, CredentialId = credentialId, OwnerPath = "acme", CallbackUrl = $"https://codespace.test/api/webhooks/connection/{id}", SecretEnc = scope.Resolve().Encrypt("hook-secret"), SubscribedEvents = new List { "push" }, RegistrationStatus = status, Attempts = SeededHookAttempts, NextAttemptAt = DateTimeOffset.UtcNow.AddHours(1), LastError = failed ? "401 Unauthorized" : null, ExternalId = registered ? "4242" : null, RegisteredAt = registered ? DateTimeOffset.UtcNow.AddDays(-20) : null }); + db.ConnectionWebhook.Add(new ConnectionWebhook { Id = id, ProviderInstanceId = instanceId ?? world.InstanceId, CredentialId = credentialId, OwnerPath = ownerPath, CallbackUrl = $"https://codespace.test/api/webhooks/connection/{id}", SecretEnc = scope.Resolve().Encrypt("hook-secret"), SubscribedEvents = new List { "push" }, RegistrationStatus = status, Attempts = SeededHookAttempts, NextAttemptAt = DateTimeOffset.UtcNow.AddHours(1), LastError = failed ? "401 Unauthorized" : null, ExternalId = registered ? "4242" : null, RegisteredAt = registered ? DateTimeOffset.UtcNow.AddDays(-20) : null }); await db.SaveChangesAsync(); return id; @@ -263,6 +320,9 @@ public InMemoryBackgroundJobClient Jobs() public bool WasDispatched(Guid hookId) => Jobs().Calls.Any(c => c.ServiceType == typeof(IConnectionWebhookRegistrar) && Equals(c.FirstArgument, hookId)); + /// A per-repository hook was handed to its registrar. + public bool WasRegistrationDispatched(Guid webhookId) => Jobs().Calls.Any(c => c.ServiceType == typeof(IRepositoryWebhookRegistrar) && Equals(c.FirstArgument, webhookId)); + public static OAuthTokenResponse Token(string accessToken, string? refreshToken) => new() { AccessToken = accessToken, RefreshToken = refreshToken, ExpiresAt = refreshToken == null ? null : DateTimeOffset.UtcNow + TimeSpan.FromHours(2) }; private static ProviderInstance NewInstance(Guid teamId, string suffix, IPayloadEncryptor encryptor) => diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs index 3494b9295..9cc9b001f 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs @@ -71,6 +71,7 @@ public class ResourceAccessMarkerInventoryTests ["LinkProviderIdentityByPatCommand.ProviderInstanceId"] = "UserProviderIdentityService.LoadInstanceAsync loads the instance by (id, header team).", ["UpdateProviderInstanceCommand.ProviderInstanceId"] = "ProviderInstanceService.LoadOwnedInstanceAsync refuses an instance outside the header team.", ["DeleteProviderInstanceCommand.ProviderInstanceId"] = "ProviderInstanceService.LoadOwnedInstanceAsync refuses an instance outside the header team.", + ["GetRelinkCandidatesQuery.ProviderInstanceId"] = "CredentialRelinkService.LoadTeamInstanceAsync loads the instance by (id, header team) and reads another team's as not found; every credential it lists is the header team's.", ["GetProviderInstanceUsageQuery.ProviderInstanceId"] = "ProviderInstanceService.GetUsageAsync counts only repositories and credentials of the header team.", ["ListRepositoriesQuery.ProviderInstanceId"] = "an optional filter over a list RepositoryService already restricts to the header team.", ["ListCredentialsQuery.ProviderInstanceId"] = "an optional filter over a list CredentialService already restricts to the header team.", diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs index 093e60296..b99e46da0 100644 --- a/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs @@ -19,6 +19,7 @@ public class RelinkSuggestionRankingTests private static readonly Guid Caller = Guid.NewGuid(); private static readonly Guid Teammate = Guid.NewGuid(); private static readonly DateTimeOffset Revoked = new(2026, 10, 10, 12, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset Now = new(2026, 10, 19, 12, 0, 0, TimeSpan.Zero); [Fact] public void Own_connections_come_first_newest_first_then_team_tokens_newest_first() @@ -127,6 +128,73 @@ public void A_credential_the_relink_would_refuse_is_never_offered(Excluded shape RelinkSuggestionRanking.Rank(dead, new[] { candidate, kept }, Caller).Select(r => r.CredentialId).ShouldBe(new[] { kept.Id }, $"{shape}"); } + [Fact] + public void A_credential_on_a_sibling_connection_is_offered_only_when_the_caller_names_the_connections_in_reach_and_after_the_same_connections() + { + var sibling = Guid.NewGuid(); + var dead = Dead(); + var onSibling = With(Own(Revoked.AddDays(9)), c => c.ProviderInstanceId = sibling); + var here = Own(Revoked.AddDays(1)); + var candidates = new[] { onSibling, here }; + + RelinkSuggestionRanking.Rank(dead, candidates, Caller).Select(r => r.CredentialId).ShouldBe(new[] { here.Id }, "the connections in reach are the caller's to name; without them only the credential's own is"); + RelinkSuggestionRanking.Rank(dead, candidates, Caller, new HashSet { Instance, sibling }).Select(r => r.CredentialId).ShouldBe(new[] { here.Id, onSibling.Id }, "the same connection first, though the sibling's credential is the newer"); + RelinkSuggestionRanking.Rank(dead, candidates, Caller, new HashSet { Instance, sibling }).Single(r => r.CredentialId == onSibling.Id).ProviderInstanceId.ShouldBe(sibling); + } + + [Fact] + public void A_connection_that_is_not_in_reach_is_never_offered_whatever_else_is_true_of_it() + { + var elsewhere = With(Own(Revoked.AddDays(1)), c => c.ProviderInstanceId = Guid.NewGuid()); + + RelinkSuggestionRanking.Rank(Dead(), new[] { elsewhere }, Caller, new HashSet { Instance }).ShouldBeEmpty(); + } + + [Fact] + public void A_repository_is_offered_every_live_credential_the_caller_may_bind_with_whether_or_not_the_one_it_is_leaving_is_dead() + { + var sibling = Guid.NewGuid(); + var leaving = With(Own(Revoked), c => c.Id = Guid.NewGuid()); + var own = Own(Revoked.AddDays(-1)); + var team = Team_(Revoked.AddDays(5)); + var ownOnSibling = With(Own(Revoked.AddDays(9)), c => c.ProviderInstanceId = sibling); + var teammates = With(Own(Revoked), c => c.OwnerUserId = Teammate); + var revoked = With(Own(Revoked), c => c.Status = CredentialStatus.Revoked); + + var offered = RelinkSuggestionRanking.RankForRepository(Instance, leaving, new[] { leaving, ownOnSibling, teammates, revoked, team, own }, Caller, Now); + + offered.Select(r => r.CredentialId).ShouldBe(new[] { own.Id, team.Id, ownOnSibling.Id }, "the leaving credential is left out although it is live; the same connection's come first, the caller's own before the team's"); + offered.Select(r => r.Kind).ShouldBe(new[] { RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.TeamServiceToken, RelinkSuggestionKind.YourConnection }); + } + + [Theory] + [InlineData(AuthType.Pat, -3, false)] // past its recorded expiry: Active by status, rejected by the health model + [InlineData(AuthType.ProjectAccessToken, 0, false)] // expiring at this very instant has expired (HasExpired reads <=) + [InlineData(AuthType.GroupAccessToken, -1, false)] + [InlineData(AuthType.Pat, 3, true)] // about to expire still authenticates, and the chip says so + [InlineData(AuthType.Pat, null, true)] // no recorded expiry + [InlineData(AuthType.OAuth, -3, true)] // an OAuth credential's expiry is its access token's, which renews itself + public void A_repository_is_offered_a_credential_only_while_the_health_model_says_it_can_authenticate(AuthType authType, int? daysFromNow, bool offered) + { + var own = With(Own(Revoked), c => { c.AuthType = authType; c.ExpiresDate = daysFromNow is { } days ? Now.AddDays(days) : null; }); + var team = With(Team_(Revoked), c => { c.AuthType = authType == AuthType.OAuth ? AuthType.GroupAccessToken : authType; c.ExpiresDate = authType != AuthType.OAuth && daysFromNow is { } days ? Now.AddDays(days) : null; }); // a team token is never an OAuth one + var healthy = Own(Revoked.AddDays(-1)); + + var ids = RelinkSuggestionRanking.RankForRepository(Instance, leaving: null, new[] { own, team, healthy }, Caller, Now).Select(r => r.CredentialId).ToHashSet(); + + ids.Contains(own.Id).ShouldBe(offered, $"{authType} expiring {daysFromNow} days from now: its own row"); + ids.Contains(team.Id).ShouldBe(offered, "the team's token, on the same terms"); + ids.ShouldContain(healthy.Id, "a healthy credential is offered whatever its neighbours' state"); + } + + [Fact] + public void A_repository_with_no_credential_to_leave_flags_nothing_as_connected_before_a_disconnect() + { + var offered = RelinkSuggestionRanking.RankForRepository(Instance, leaving: null, new[] { Own(Revoked.AddDays(-30)) }, Caller, Now); + + offered.Select(r => r.ConnectedBeforeDisconnect).ShouldBe(new[] { false }); + } + [Fact] public void Credentials_made_at_the_same_instant_keep_a_stable_order() { diff --git a/backend/tests/CodeSpace.UnitTests/Providers/ProviderInstanceHostTests.cs b/backend/tests/CodeSpace.UnitTests/Providers/ProviderInstanceHostTests.cs new file mode 100644 index 000000000..ccea6b309 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Providers/ProviderInstanceHostTests.cs @@ -0,0 +1,87 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.ProviderInstances; +using CodeSpace.Messages.Enums; +using Shouldly; + +namespace CodeSpace.UnitTests.Providers; + +/// +/// When two of a team's provider connections are the same host: the one definition a Re-link dialog, a relink command, a connect's carry-forward and +/// the repair sweep share. Same team, same provider, API roots that are the same service — ApiUrl, or the base URL when unset — and +/// nothing else. A second OAuth application on one GitLab is a second connection with the same root; another host, port or path is not. +/// +[Trait("Category", "Unit")] +public class ProviderInstanceHostTests +{ + private static readonly Guid Team = Guid.NewGuid(); + + [Theory] + [InlineData("https://gitlab.acme.test", null, "https://gitlab.acme.test", null, true)] + [InlineData("https://gitlab.acme.test", null, "https://GITLAB.acme.test/", null, true)] + [InlineData("https://gitlab.acme.test", null, "https://gitlab.acme.test", "https://gitlab.acme.test", true)] // an API URL that says what the base URL would + [InlineData("https://gitlab.acme.test", "https://gitlab.acme.test/", "https://gitlab.acme.test", null, true)] + [InlineData("https://gitlab.acme.test", "https://api.acme.test", "https://gitlab.acme.test", "https://api.acme.test", true)] + [InlineData("https://gitlab.acme.test", null, "https://gitlab.acme.test/gitlab", null, false)] // another path + [InlineData("https://gitlab.acme.test", null, "https://gitlab.acme.test:8443", null, false)] // another port + [InlineData("https://gitlab.acme.test", null, "http://gitlab.acme.test", null, false)] // another scheme + [InlineData("https://gitlab.acme.test", null, "https://gitlab.other.test", null, false)] // another host + [InlineData("https://gitlab.acme.test", "https://api.acme.test", "https://gitlab.acme.test", null, false)] // the same base URL, calls going elsewhere: what a repository is read through is the API + public void Two_connections_are_interchangeable_when_their_API_roots_are_the_same_service(string baseA, string? apiA, string baseB, string? apiB, bool expected) + { + var a = Instance(baseA, apiA); + var b = Instance(baseB, apiB); + + ProviderInstanceHost.AreInterchangeable(a, b).ShouldBe(expected); + ProviderInstanceHost.AreInterchangeable(b, a).ShouldBe(expected, "symmetric"); + } + + [Fact] + public void A_connection_is_interchangeable_with_itself_and_a_different_team_or_provider_never_is() + { + var gitlab = Instance("https://gitlab.acme.test"); + + ProviderInstanceHost.AreInterchangeable(gitlab, gitlab).ShouldBeTrue(); + ProviderInstanceHost.AreInterchangeable(gitlab, With(Instance("https://gitlab.acme.test"), i => i.TeamId = Guid.NewGuid())).ShouldBeFalse("another team's connection is another team's"); + ProviderInstanceHost.AreInterchangeable(gitlab, With(Instance("https://gitlab.acme.test"), i => i.Provider = ProviderKind.GitHub)).ShouldBeFalse("the same address under another provider is not the same service"); + } + + [Fact] + public void Groups_are_the_sets_of_two_or_more_and_a_lone_connection_is_in_none() + { + var one = Instance("https://gitlab.acme.test"); + var sameHost = Instance("https://gitlab.acme.test/"); + var alsoSameHost = Instance("https://GITLAB.acme.test"); + var lone = Instance("https://gitlab.other.test"); + var otherTeams = With(Instance("https://gitlab.acme.test"), i => i.TeamId = Guid.NewGuid()); + var pair = Instance("https://gitlab.third.test"); + var pairToo = Instance("https://gitlab.third.test"); + + var groups = ProviderInstanceHost.GroupsOf(new[] { one, lone, sameHost, otherTeams, pair, alsoSameHost, pairToo }); + + groups.Count.ShouldBe(2); + groups.Single(g => g.Contains(one.Id)).OrderBy(id => id).ShouldBe(new[] { one.Id, sameHost.Id, alsoSameHost.Id }.OrderBy(id => id), "three connections to one host"); + groups.Single(g => g.Contains(pair.Id)).OrderBy(id => id).ShouldBe(new[] { pair.Id, pairToo.Id }.OrderBy(id => id)); + groups.SelectMany(g => g).ShouldNotContain(lone.Id); + groups.SelectMany(g => g).ShouldNotContain(otherTeams.Id, "another team's connection to the same host is in no group of this team's"); + } + + [Fact] + public void The_groups_do_not_depend_on_the_order_the_connections_are_read_in() + { + var instances = Enumerable.Range(0, 6).Select(i => Instance(i % 2 == 0 ? "https://gitlab.acme.test" : "https://gitlab.third.test")).ToList(); + + var forward = ProviderInstanceHost.GroupsOf(instances).Select(g => g.OrderBy(id => id).ToList()).OrderBy(g => g[0]).ToList(); + var backward = ProviderInstanceHost.GroupsOf(Enumerable.Reverse(instances)).Select(g => g.OrderBy(id => id).ToList()).OrderBy(g => g[0]).ToList(); + + forward.Select(g => string.Join(",", g)).ShouldBe(backward.Select(g => string.Join(",", g))); + } + + private static ProviderInstance Instance(string baseUrl, string? apiUrl = null) => new() { Id = Guid.NewGuid(), TeamId = Team, Provider = ProviderKind.GitLab, DisplayName = "GitLab", BaseUrl = baseUrl, ApiUrl = apiUrl }; + + private static ProviderInstance With(ProviderInstance instance, Action change) + { + change(instance); + + return instance; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Redaction/CredentialUrlTests.cs b/backend/tests/CodeSpace.UnitTests/Redaction/CredentialUrlTests.cs index 3fd1c253a..10d322552 100644 --- a/backend/tests/CodeSpace.UnitTests/Redaction/CredentialUrlTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Redaction/CredentialUrlTests.cs @@ -64,6 +64,37 @@ public void Masked_never_contains_the_embedded_credentials() [InlineData("gw.example:4000", "gw.example:4000", true)] public void SameEndpoint_compares_scheme_host_and_port(string? stored, string? next, bool expected) => CredentialUrl.SameEndpoint(stored, next).ShouldBe(expected); + [Theory] + [InlineData("https://gitlab.acme.test", "https://gitlab.acme.test", true)] + [InlineData("https://gitlab.acme.test", "https://gitlab.acme.test/", true)] // a trailing slash is not a path + [InlineData("https://gitlab.acme.test/gitlab", "https://gitlab.acme.test/gitlab/", true)] + [InlineData("https://GITLAB.acme.test:443/gitlab", "https://gitlab.acme.test/gitlab", true)] // host case and the default port, as SameEndpoint reads them + [InlineData("https://a:b@gitlab.acme.test/gitlab", "https://gitlab.acme.test/gitlab?x=1", true)] // userinfo and query are not part of the root + [InlineData(null, null, true)] + [InlineData("https://gitlab.acme.test", "https://gitlab.acme.test/gitlab", false)] // a GitLab under /gitlab is not the one at the root + [InlineData("https://gitlab.acme.test/gitlab", "https://gitlab.acme.test/other", false)] + [InlineData("https://gitlab.acme.test/GitLab", "https://gitlab.acme.test/gitlab", false)] // a path is case-sensitive + [InlineData("https://gitlab.acme.test", "https://gitlab.acme.test:8443", false)] // everything SameEndpoint refuses, SameRoot refuses + [InlineData("https://gitlab.acme.test", "http://gitlab.acme.test", false)] + [InlineData("https://gitlab.acme.test", "https://attacker.test", false)] + [InlineData("https://gitlab.acme.test", null, false)] + [InlineData("gitlab.acme.test:4000", "gitlab.acme.test:4000", true)] // not an absolute http(s) URL: it only matches itself + [InlineData("gitlab.acme.test:4000", "gitlab.acme.test:5000", false)] + public void SameRoot_is_the_same_endpoint_and_the_same_path(string? a, string? b, bool expected) + { + CredentialUrl.SameRoot(a, b).ShouldBe(expected); + CredentialUrl.SameRoot(b, a).ShouldBe(expected, "symmetric"); + } + + [Theory] + [InlineData("https://gitlab.acme.test/api/v4", "https://gitlab.acme.test:443/api/v4/")] + [InlineData("https://gitlab.acme.test", "https://gitlab.acme.test/")] + public void SameRoot_agrees_with_SameEndpoint_wherever_the_endpoint_is_the_same_and_the_path_is_(string a, string b) + { + CredentialUrl.SameEndpoint(a, b).ShouldBeTrue("fixture check: the endpoint is the same"); + CredentialUrl.SameRoot(a, b).ShouldBeTrue(); + } + [Theory] [InlineData("http://gw.example", "https://gw.example", true)] // the default ports, 80 to 443 [InlineData("http://gw.example:80", "https://gw.example:443", true)] // spelled out diff --git a/backend/tests/TESTING.md b/backend/tests/TESTING.md index 8abe5beca..c92272678 100644 --- a/backend/tests/TESTING.md +++ b/backend/tests/TESTING.md @@ -56,6 +56,8 @@ Provider-credential tests share two more pieces, both in `CodeSpace.IntegrationT `expires_at` at `personal_access_tokens/self`, 401 once revoked or expired) and can script the members lookup to fail (`FailMembers(status, times)`). Every user has an id of their own (`UserIdOf`) and a personal access token can be seeded for one (`SeedPersonalAccessToken`), so a test can tell two accounts apart and drive a pasted token, as the in-place re-authorization tests do. + Once a project has been added (`AddProject(id, path)`) `GET /api/v4/projects/:id` answers that project to a live token — and 404 for any other id — so a + repository move that asks the new credential for the project is seen from the stub's log (`Log`, the token and the path) as well as in the database. Its counters (`Count(StubEvent.Refused)`, …) are how a test says "GitLab was never shown a consumed refresh token". `GitLabDoorkeeperStubTests` pins what it does, so a red test built on it is the product's and not the stub's. - `SilentProviderHost` (`CodeSpace.IntegrationTests/Webhooks`, linked into the unit project like `StubProviderHost`) — a loopback provider that diff --git a/frontend/src/api/oauth.ts b/frontend/src/api/oauth.ts index 61e0eb7a2..513b85961 100644 --- a/frontend/src/api/oauth.ts +++ b/frontend/src/api/oauth.ts @@ -121,6 +121,23 @@ export const oauthApi = { getRelinkSuggestions: (credentialId: string) => fetchJson(`/api/credentials/${encodeURIComponent(credentialId)}/relink-suggestions`), + // Every credential a repository on this provider connection could be re-linked to, best first — the whole list the Re-link dialog shows, computed by the + // backend: the caller's own and the team's service tokens, on this connection or another one to the same host. Null from an API that predates it, which the + // dialog answers by filtering the credential list itself, as it did. Any 404 means that: a server without the route does not answer an empty 404 but the + // JSON { code: "not_found" } its fallback endpoint writes (Startup's MapFallback), a gateway in front of it may answer anything, and the route's own 404 — a + // connection that is not the team's — leaves the same fallback an empty list to show. Telling them apart by code is how an older server's rollback broke the dialog. + getRelinkCandidates: async (providerInstanceId: string, fromCredentialId: string | null): Promise => { + const from = fromCredentialId ? `?fromCredentialId=${encodeURIComponent(fromCredentialId)}` : ""; + + try { + return await fetchJson(`/api/provider-instances/${encodeURIComponent(providerInstanceId)}/relink-candidates${from}`); + } catch (err) { + if (err instanceof ApiError && err.status === 404) return null; + + throw err; + } + }, + // Pre-revoke preview — drives the confirm dialog's impact wording. getCredentialUsage: (credentialId: string) => fetchJson(`/api/credentials/${encodeURIComponent(credentialId)}/usage`), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 2eaeed5f0..4a3c03a26 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -178,6 +178,11 @@ export interface RelinkSuggestion { kind: "YourConnection" | "TeamServiceToken" | "OwnersOtherConnection"; createdDate: string; connectedBeforeDisconnect: boolean; + /** The provider connection the credential belongs to — the repository's own, or another connection to the same host, where picking it also moves the repository. Absent from an API that predates it. */ + providerInstanceId?: string; + providerInstanceName?: string; + /** Names of the teammates whose acting-as on the repository's connection stops working if this credential is picked: they can act as themselves there and not on this credential's connection. Empty for a credential on the repository's own connection; absent from an API that predates it. */ + strandedActAs?: string[]; } export interface ReplaceCredentialTokenResponse { diff --git a/frontend/src/components/remedy/ProviderRemedyCard.test.tsx b/frontend/src/components/remedy/ProviderRemedyCard.test.tsx index 6bb219218..19446cde9 100644 --- a/frontend/src/components/remedy/ProviderRemedyCard.test.tsx +++ b/frontend/src/components/remedy/ProviderRemedyCard.test.tsx @@ -50,6 +50,11 @@ function renderCard(r: ProviderRemedy, props: { onRerun?: () => void; rawDetail? const base: Record = { "/api/users/me": { id: "u1", email: "u@test.local", name: "Maya", teams: [team], permissions: [], passwordMustChange: false }, "/relink-suggestions": [{ credentialId: "c-team", displayName: "Acme group token", authType: "GroupAccessToken", ownership: "TeamService", kind: "TeamServiceToken", createdDate: "2026-02-02T00:00:00Z", connectedBeforeDisconnect: false }], + // The Re-link dialog's list, as the backend computes it: the caller's own and the team's tokens that may take the repository, in the order to offer them. + "/relink-candidates": [ + { credentialId: "c-team", displayName: "Acme group token", authType: "GroupAccessToken", ownership: "TeamService", kind: "TeamServiceToken", createdDate: "2026-02-02T00:00:00Z", connectedBeforeDisconnect: false, providerInstanceId: "pi1", providerInstanceName: "Acme GitLab" }, + { credentialId: "c-mine", displayName: "Maya's GitLab", authType: "OAuth", ownership: "Personal", kind: "YourConnection", createdDate: "2026-02-01T00:00:00Z", connectedBeforeDisconnect: false, providerInstanceId: "pi1", providerInstanceName: "Acme GitLab" }, + ], "/capabilities": { credentialId: "any", grantedScopes: [], capabilities: [] }, "/api/provider-instances": [instance], "/api/credentials": credentials, @@ -62,7 +67,12 @@ function renderCard(r: ProviderRemedy, props: { onRerun?: () => void; rawDetail? const key = Object.keys(routes).find((k) => url.includes(k)); const body = key === undefined ? undefined : routes[key]; - return new Response(body === undefined ? "" : JSON.stringify(body), { status: body === undefined ? 404 : 200, headers: { "Content-Type": "application/json" } }); + + // A route a test leaves unanswered is one this server does not have, and the real server says so the way Startup's MapFallback does: a 404 WITH a + // JSON body whose code is "not_found" — not an empty 404, which no CodeSpace API produces. + if (body === undefined) return new Response(JSON.stringify({ code: "not_found", message: `This server has no ${new URL(url, "http://host").pathname}. Check the host — the API and the web app are different origins.` }), { status: 404, headers: { "Content-Type": "application/json" } }); + + return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); })); render( @@ -354,7 +364,7 @@ describe("ProviderRemedyCard — each button does what it says", () => { expect(put.body).toEqual({ token: "glpat-fresh" }); }); - it("Re-link offers only the caller's own and the team's credentials on the repository's provider, best suggestion first, and moves the repository", async () => { + it("Re-link offers what the backend says may take the repository, in its order, and moves the repository", async () => { const calls = renderCard(remedy(), {}, { "/api/repositories/r1/relink-credential": {} }); fireEvent.click(screen.getByRole("button", { name: "Re-link repository" })); @@ -362,6 +372,26 @@ describe("ProviderRemedyCard — each button does what it says", () => { const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); await waitFor(() => expect(within(dialog).getByText("Acme group token")).toBeTruthy()); + const offered = within(dialog).getAllByRole("listitem").map((e) => e.textContent); + expect(offered).toEqual([expect.stringContaining("Acme group token"), expect.stringContaining("Maya's GitLab")]); // the backend's list, untouched: the dialog drops and reorders nothing + + fireEvent.click(within(dialog).getByText("Acme group token")); + + await waitFor(() => expect(calls.some((c) => c.method === "POST" && c.url.endsWith("/api/repositories/r1/relink-credential"))).toBe(true)); + expect(calls.find((c) => c.url.endsWith("/api/repositories/r1/relink-credential"))!.body).toEqual({ newCredentialId: "c-team" }); + expect(calls.some((c) => c.url.includes("/api/provider-instances/pi1/relink-candidates?fromCredentialId=c-dead"))).toBe(true); + expect(calls.some((c) => c.url.includes("/relink-suggestions"))).toBe(false); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Re-link credential" })).toBeNull()); + }); + + it("Re-link against an API that predates the list offers only the caller's own and the team's credentials on the repository's provider, best suggestion first", async () => { + const calls = renderCard(remedy(), {}, { "/relink-candidates": undefined, "/api/repositories/r1/relink-credential": {} }); + + fireEvent.click(screen.getByRole("button", { name: "Re-link repository" })); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Acme group token")).toBeTruthy()); + const offered = within(dialog).getAllByRole("listitem").map((e) => e.textContent); expect(offered).toEqual([expect.stringContaining("Acme group token"), expect.stringContaining("Maya's GitLab")]); // the suggestion first; never Ada's personal token, the dead one, a revoked one or another host's expect(within(dialog).queryByText("Ada's token")).toBeNull(); @@ -376,7 +406,7 @@ describe("ProviderRemedyCard — each button does what it says", () => { }); it("Re-link says what to do when no credential can take the repository", async () => { - renderCard(remedy(), {}, { "/api/credentials": [] }); + renderCard(remedy(), {}, { "/relink-candidates": [] }); fireEvent.click(screen.getByRole("button", { name: "Re-link repository" })); diff --git a/frontend/src/components/remedy/RelinkDialog.test.tsx b/frontend/src/components/remedy/RelinkDialog.test.tsx new file mode 100644 index 000000000..bc2585926 --- /dev/null +++ b/frontend/src/components/remedy/RelinkDialog.test.tsx @@ -0,0 +1,288 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { MeTeam, RelinkSuggestion } from "@/api/types"; +import { DialogProvider } from "@/components/dialog"; +import type { ConnectionIssue } from "@/lib/credentialHealth"; + +import { RelinkDialog } from "./RelinkDialog"; +import { RepositoryConnectionFix } from "./RepositoryConnectionIssue"; + +/** + * The Re-link dialog shows the list the BACKEND computed for the repository's provider connection — the caller's own and the team's service + * tokens, on that connection and on any other connection to the same host — and filters nothing of its own. A team that signs in to one GitLab + * through two OAuth applications holds two connections to it; the dialog used to keep only the credentials whose connection was the repository's + * and told a person who had just connected GitLab that nothing could take the repository. Only against an API that predates the list (a 404 + * on the route — with the JSON body the real server's fallback writes, not an empty one) does it filter the credential list itself, as it did. + */ +const team: MeTeam = { id: "t1", slug: "acme", name: "Acme", kind: "Workspace", role: "Admin", permissions: ["repos.manage", "credentials.manage"], memberCount: 3, repositoryCount: 1, projectCount: 1, workflowCount: 0 }; + +const candidate = (id: string, name: string, over: Partial = {}): RelinkSuggestion => ({ + credentialId: id, displayName: name, authType: "OAuth", ownership: "Personal", kind: "YourConnection", createdDate: "2026-02-01T00:00:00Z", connectedBeforeDisconnect: false, providerInstanceId: "pi1", providerInstanceName: "GitLab", ...over, +}); + +type Call = { method: string; url: string; body: unknown }; +type Answer = { status?: number; body?: unknown }; + +const credentialsOnTwoConnections = [ + { id: "c-dead", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u9", ownerUserName: "Nacho", ownership: "Personal", authType: "OAuth", displayName: "GitLab · nacho", status: "Error", health: "rejected", createdDate: "2026-01-01T00:00:00Z" }, + { id: "c-here", teamId: "t1", providerInstanceId: "pi1", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType: "OAuth", displayName: "Maya's GitLab", status: "Active", health: "active", createdDate: "2026-02-01T00:00:00Z" }, + { id: "c-there", teamId: "t1", providerInstanceId: "pi1b", ownerUserId: "u1", ownerUserName: "Maya", ownership: "Personal", authType: "OAuth", displayName: "Maya's GitLab_TED", status: "Active", health: "active", createdDate: "2026-02-09T00:00:00Z" }, +]; + +function stubApi(answers: Record) { + localStorage.setItem("codespace.jwt", "test-jwt"); + localStorage.setItem("codespace.activeTeamId", team.id); + + const calls: Call[] = []; + const routes: Record = { "/api/users/me": { id: "u1", email: "u@test.local", name: "Maya", teams: [team], permissions: [], passwordMustChange: false }, ...answers }; + + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + + const key = Object.keys(routes).find((k) => url.includes(k)); + + // A route nothing here answers is one this server does not have, and the real server says so the way Startup's MapFallback does: a 404 WITH a + // JSON body whose code is "not_found" — not an empty 404, which no CodeSpace API produces. + if (key === undefined) return new Response(JSON.stringify({ code: "not_found", message: `This server has no ${new URL(url, "http://host").pathname}. Check the host — the API and the web app are different origins.` }), { status: 404, headers: { "Content-Type": "application/json" } }); + + const answer = routes[key]; + const explicit = answer !== null && typeof answer === "object" && ("status" in (answer as object) || "body" in (answer as object)) ? (answer as Answer) : { body: answer }; + + return new Response(explicit.body === undefined ? "" : JSON.stringify(explicit.body), { status: explicit.status ?? 200, headers: { "Content-Type": "application/json" } }); + })); + + return calls; +} + +function renderDialog(props: Partial> = {}) { + const onClose = vi.fn(); + const onRelinked = vi.fn(); + + render( + + + , + ); + + return { onClose, onRelinked }; +} + +afterEach(() => { localStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + +describe("RelinkDialog — the backend's candidate set", () => { + it("renders exactly the list the backend computed, in its order, naming the connection of each when they differ, and moves the repository onto the one picked", async () => { + const calls = stubApi({ + "/relink-candidates": [ + candidate("c-here", "Maya's GitLab"), + candidate("c-team", "Acme group token", { ownership: "TeamService", kind: "TeamServiceToken", authType: "GroupAccessToken" }), + candidate("c-there", "Maya's GitLab_TED", { providerInstanceId: "pi1b", providerInstanceName: "GitLab_TED" }), + ], + "/api/repositories/r1/relink-credential": {}, + }); + const { onClose, onRelinked } = renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Maya's GitLab_TED")).toBeTruthy()); + + expect(within(dialog).getAllByRole("listitem").map((e) => e.textContent)).toEqual([ + expect.stringContaining("Maya's GitLab"), + expect.stringContaining("Acme group token"), + expect.stringContaining("Maya's GitLab_TED"), + ]); + expect(within(dialog).getByText("on GitLab_TED")).toBeTruthy(); + expect(within(dialog).getAllByRole("listitem")[2].textContent, "the row that changes the connection says so, in words beside it").toContain("Moves the repository to GitLab_TED, another connection to the same host."); + expect(within(dialog).getAllByRole("listitem")[0].textContent).not.toMatch(/Moves/); + expect(within(dialog).getAllByText("on GitLab")).toHaveLength(2); + + fireEvent.click(within(dialog).getByText("Maya's GitLab_TED")); + + await waitFor(() => expect(onRelinked).toHaveBeenCalled()); + expect(calls.find((c) => c.url.endsWith("/api/repositories/r1/relink-credential"))!.body).toEqual({ newCredentialId: "c-there" }); + expect(onClose).toHaveBeenCalled(); + expect(calls.map((c) => c.url).find((u) => u.includes("/relink-candidates"))).toMatch(/\/api\/provider-instances\/pi1\/relink-candidates\?fromCredentialId=c-dead$/); + expect(calls.some((c) => c.url.endsWith("/api/credentials")), "the dialog read no credential list: there is nothing for it to filter").toBe(false); + expect(calls.some((c) => c.url.includes("/relink-suggestions")), "and asked for no suggestions to order by").toBe(false); + }); + + it("names the connection and says the pick moves the repository when every candidate is on one other connection — the owner's own case", async () => { + stubApi({ + "/relink-candidates": [candidate("c-there", "mars", { providerInstanceId: "pi-ted", providerInstanceName: "GitLab_TED" })], + "/api/repositories/r1/relink-credential": {}, + }); + renderDialog({ repositories: [{ id: "r1", fullPath: "solar/OMEProject-Api" }] }); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + const row = (await within(dialog).findByText("mars")).closest("button")!; + + expect(within(row).getByText("on GitLab_TED"), "the row says which connection it is on, though no other row differs").toBeTruthy(); + expect(row.textContent).toMatch(/Moves the repository to GitLab_TED/); + }); + + it("says in the plural that a pick moves every repository when several are re-linked at once", async () => { + stubApi({ "/relink-candidates": [candidate("c-there", "mars", { providerInstanceId: "pi-ted", providerInstanceName: "GitLab_TED" })] }); + renderDialog({ repositories: [{ id: "r1", fullPath: "acme/api" }, { id: "r2", fullPath: "acme/web" }] }); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + + expect((await within(dialog).findByText("mars")).closest("button")!.textContent).toMatch(/Moves these repositories to GitLab_TED/); + }); + + it("says nothing about a move for a credential on the repository's own connection, even when it is the only one the backend offers", async () => { + stubApi({ "/relink-candidates": [candidate("c-here", "Maya's GitLab")] }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + const row = (await within(dialog).findByText("Maya's GitLab")).closest("button")!; + + expect(within(row).queryByText(/^on /)).toBeNull(); + expect(row.textContent).not.toMatch(/moves/); + }); + + it("says whose acting-as a move strands, by name, on the row that would strand it — and only on that row", async () => { + stubApi({ + "/relink-candidates": [ + candidate("c-here", "Maya's GitLab"), + candidate("c-there", "Maya's GitLab_TED", { providerInstanceId: "pi1b", providerInstanceName: "GitLab_TED", strandedActAs: ["Nacho", "Ada"] }), + candidate("c-team-there", "Acme token on TED", { ownership: "TeamService", kind: "TeamServiceToken", providerInstanceId: "pi1b", providerInstanceName: "GitLab_TED", strandedActAs: [] }), + ], + }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + const rows = await within(dialog).findAllByRole("listitem"); + + expect(rows[0].textContent).not.toMatch(/acting as/i); + expect(rows[1].textContent).toContain("Acting as Nacho and Ada on this repository stops working until they connect GitLab_TED."); + expect(rows[2].textContent, "a candidate that strands nobody says nothing of it").not.toMatch(/acting as/i); + }); + + it("shortens a long list of stranded people to three names and a count", async () => { + stubApi({ "/relink-candidates": [candidate("c-there", "Maya's GitLab_TED", { providerInstanceId: "pi1b", providerInstanceName: "GitLab_TED", strandedActAs: ["Ada", "Bo", "Cy", "Di", "Ed"] })] }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + + expect((await within(dialog).findByText("Maya's GitLab_TED")).closest("button")!.textContent).toContain("Acting as Ada, Bo, Cy and 2 others on this repository stops working until they connect GitLab_TED."); + }); + + it("shows no connection names when every candidate is on the repository's own connection, and asks for no credential to leave when there is none", async () => { + const calls = stubApi({ "/relink-candidates": [candidate("c-here", "Maya's GitLab"), candidate("c-team", "Acme group token", { ownership: "TeamService", kind: "TeamServiceToken" })] }); + renderDialog({ fromCredentialId: null }); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Acme group token")).toBeTruthy()); + + expect(within(dialog).queryByText(/^on /)).toBeNull(); + expect(within(dialog).getByText("Team service")).toBeTruthy(); + expect(calls.map((c) => c.url).find((u) => u.includes("/relink-candidates"))).toMatch(/\/api\/provider-instances\/pi1\/relink-candidates$/); + }); + + it("flags the connection made before the old one was disconnected", async () => { + stubApi({ "/relink-candidates": [candidate("c-here", "Maya's GitLab", { connectedBeforeDisconnect: true })] }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + + await waitFor(() => expect(within(dialog).getByText("connected before the old one was disconnected")).toBeTruthy()); + }); + + it("says nothing can take the repository only when the backend offers nothing", async () => { + stubApi({ "/relink-candidates": [] }); + const onOpenProviders = vi.fn(); + renderDialog({ onOpenProviders }); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("No other working credential")).toBeTruthy()); + + fireEvent.click(within(dialog).getByRole("button", { name: /Open Providers/ })); + expect(onOpenProviders).toHaveBeenCalled(); + }); + + it("shows a refusal from the move and stays open, so another credential can be picked", async () => { + stubApi({ + "/relink-candidates": [candidate("c-there", "Maya's GitLab_TED", { providerInstanceId: "pi1b", providerInstanceName: "GitLab_TED" })], + "/api/repositories/r1/relink-credential": { status: 409, body: { code: "invalid_operation", message: "Credential 'Maya's GitLab_TED' cannot read 'acme/api'. Nothing was moved." } }, + }); + const { onClose, onRelinked } = renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + fireEvent.click(await within(dialog).findByText("Maya's GitLab_TED")); + + await waitFor(() => expect(within(dialog).getByRole("alert").textContent).toContain("cannot read 'acme/api'")); + expect(onClose).not.toHaveBeenCalled(); + expect(onRelinked).not.toHaveBeenCalled(); + expect(within(dialog).getByText("Maya's GitLab_TED")).toBeTruthy(); + }); + + it("shows an error that is not a missing route as an error, not as having no credential", async () => { + stubApi({ "/relink-candidates": { status: 500, body: { code: "internal_error", message: "An unexpected error occurred." } } }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + + await waitFor(() => expect(within(dialog).getByRole("alert").textContent).toContain("An unexpected error occurred.")); + expect(within(dialog).queryByText("No other working credential")).toBeNull(); + }); +}); + +describe("RelinkDialog — against an API that predates the list", () => { + it("answers the real older server — a 404 whose body says the route does not exist — with the list it builds itself, not with an error", async () => { + const calls = stubApi({ + "/api/credentials/c-dead/relink-suggestions": [], + "/api/credentials": credentialsOnTwoConnections, + }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Maya's GitLab")).toBeTruthy()); + + expect(within(dialog).queryByRole("alert"), "a missing route is not an error a person has to read").toBeNull(); + expect(within(dialog).getAllByRole("listitem")).toHaveLength(1); + expect(calls.some((c) => c.url.includes("/relink-candidates"))).toBe(true); + }); + + it("builds the list itself: the repository's own connection's credentials the caller may bind with, ordered by the suggestions — and never another connection's", async () => { + const calls = stubApi({ + "/api/credentials/c-dead/relink-suggestions": [{ credentialId: "c-here", displayName: "Maya's GitLab", authType: "OAuth", ownership: "Personal", kind: "YourConnection", createdDate: "2026-02-01T00:00:00Z", connectedBeforeDisconnect: true }], + "/api/credentials": credentialsOnTwoConnections, + }); + renderDialog(); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Maya's GitLab")).toBeTruthy()); + + expect(within(dialog).getAllByRole("listitem").map((e) => e.textContent)).toEqual([expect.stringContaining("Maya's GitLab")]); + expect(within(dialog).queryByText("Maya's GitLab_TED"), "an older server cannot say a sibling connection's credential may take it").toBeNull(); + expect(within(dialog).getByText("connected before the old one was disconnected")).toBeTruthy(); + expect(calls.some((c) => c.url.includes("/relink-candidates"))).toBe(true); + expect(calls.some((c) => c.url.includes("/api/credentials/c-dead/relink-suggestions"))).toBe(true); + }); +}); + +describe("RepositoryConnectionFix — the same list", () => { + const issue: ConnectionIssue = { kind: "disconnected", label: "credential disconnected", title: "“GitLab · nacho” was disconnected." }; + + it("opens the dialog for the repository's own connection and offers the credential on the sibling connection", async () => { + const calls = stubApi({ "/relink-candidates": [candidate("c-there", "Maya's GitLab_TED", { providerInstanceId: "pi1b", providerInstanceName: "GitLab_TED" })], "/api/credentials": credentialsOnTwoConnections, "/api/provider-instances": [] }); + + render( + + + + + , + ); + + fireEvent.click(await screen.findByRole("button", { name: /Re-link credential/ })); + + const dialog = await screen.findByRole("dialog", { name: "Re-link credential" }); + await waitFor(() => expect(within(dialog).getByText("Maya's GitLab_TED")).toBeTruthy()); + + expect(within(dialog).getByText("Re-link solar/OMEProject-Api")).toBeTruthy(); + expect(within(dialog).queryByText("No other working credential")).toBeNull(); + expect(calls.map((c) => c.url).find((u) => u.includes("/relink-candidates"))).toMatch(/provider-instances\/pi1\/relink-candidates\?fromCredentialId=c-dead$/); + }); +}); diff --git a/frontend/src/components/remedy/RelinkDialog.tsx b/frontend/src/components/remedy/RelinkDialog.tsx index 2eab841ef..ed08d3019 100644 --- a/frontend/src/components/remedy/RelinkDialog.tsx +++ b/frontend/src/components/remedy/RelinkDialog.tsx @@ -3,7 +3,8 @@ import { createPortal } from "react-dom"; import { Ic } from "@/_imported/ai-code-space/icons"; import { ApiError } from "@/api/request"; -import { useCredentials, useRelinkSuggestions } from "@/hooks/use-credentials"; +import type { CredentialSummary, RelinkSuggestion } from "@/api/types"; +import { useCredentials, useRelinkCandidates, useRelinkSuggestions } from "@/hooks/use-credentials"; import { useMe } from "@/hooks/use-me"; import { useRelinkRepositoryCredential } from "@/hooks/use-repositories"; import { bindableCredentials, credentialOwnershipLabel, sortCredentialsByPreference } from "@/lib/credentialOrdering"; @@ -17,9 +18,9 @@ export interface RelinkRepository { interface RelinkDialogProps { /** The repositories to move onto the credential that is picked — one, or every one a run touched. */ repositories: RelinkRepository[]; - /** Only a credential of the repositories' own provider connection can take them; a token never talks to another host. */ + /** The provider connection the repositories live under: the backend offers the credentials of this connection and of any other connection to the same host. */ providerInstanceId: string; - /** The credential they are moving off, when there is one: it is asked for suggestions, which put the person's own connection first. */ + /** The credential they are moving off, when there is one: it is left out of the list, and the connection made before it was disconnected is flagged. */ fromCredentialId?: string | null; onClose: () => void; /** Called once every repository is on the new credential, before the dialog closes. */ @@ -28,16 +29,36 @@ interface RelinkDialogProps { onOpenProviders?: () => void; } +/** One row the dialog offers: the credential, whose it is, and — when the list spans connections — which connection it is on. */ +interface Offer { + id: string; + displayName: string; + ownerLabel: string; + teamService: boolean; + connectedBefore: boolean; + connectionName: string | null; + /** Picking it moves the repository to another connection to the same host. */ + changesConnection: boolean; + /** Teammates whose acting-as on the repository stops working if it is picked — they act as themselves through the connection it leaves. */ + strandedActAs: string[]; +} + /** - * Pick the credential a repository (or several) runs through from now on. Only credentials the caller may bind with are offered — - * their own and the team's service tokens, exactly the set the Add Repository picker offers — because a repository bound through a - * teammate's personal token acts as that person, and stops the day they leave. Suggestions from the backend order the list (the - * connection made before the dead one was disconnected comes first) and never widen it. + * Pick the credential a repository (or several) runs through from now on. The backend says which credentials may take it: the caller's own and + * the team's service tokens — never a teammate's personal token, which would make the repository act as that person and stop the day they leave + * — on the repository's connection and on any other connection to the same host, already in the order to offer them (the repository's own + * connection first, the caller's own before the team's). A credential on another connection moves the repository to that connection when it is picked, + * which is said beside it, together with whose acting-as on the repository that strands. The dialog filters nothing of its own. + * + * Only against an API that predates that list (it answers the route with a 404) does the dialog build the list itself, as it did: the credentials of + * the repositories' own connection that the caller may bind with, ordered by the suggestions for the credential being left. */ export function RelinkDialog({ repositories, providerInstanceId, fromCredentialId = null, onClose, onRelinked, onOpenProviders }: RelinkDialogProps) { - const credentials = useCredentials(); + const candidates = useRelinkCandidates(providerInstanceId, fromCredentialId); + const olderApi = candidates.data === null; + const credentials = useCredentials(olderApi); const me = useMe(); - const suggestions = useRelinkSuggestions(fromCredentialId); + const suggestions = useRelinkSuggestions(fromCredentialId, olderApi); const relink = useRelinkRepositoryCredential(); const [error, setError] = useState(null); const [pickedId, setPickedId] = useState(null); @@ -48,14 +69,14 @@ export function RelinkDialog({ repositories, providerInstanceId, fromCredentialI return () => window.removeEventListener("keydown", onKey); }, [onClose]); - const candidates = useMemo(() => { - const live = (credentials.data ?? []).filter(c => c.providerInstanceId === providerInstanceId && c.id !== fromCredentialId && isLiveCredential(c)); - const rank = new Map((suggestions.data ?? []).map((s, index) => [s.credentialId, index])); + const offers = useMemo(() => { + if (candidates.data) return offersFromBackend(candidates.data, providerInstanceId, me.data?.name); - return sortCredentialsByPreference(bindableCredentials(live, me.data?.id)).sort((a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)); - }, [credentials.data, me.data?.id, providerInstanceId, fromCredentialId, suggestions.data]); + return olderApi ? offersFromCredentialList(credentials.data ?? [], suggestions.data ?? [], providerInstanceId, fromCredentialId, me.data?.id) : []; + }, [candidates.data, olderApi, credentials.data, suggestions.data, providerInstanceId, fromCredentialId, me.data?.id, me.data?.name]); - const connectedBefore = useMemo(() => new Set((suggestions.data ?? []).filter(s => s.connectedBeforeDisconnect).map(s => s.credentialId)), [suggestions.data]); + const loading = candidates.isLoading || (olderApi && credentials.isLoading); + const loadError = candidates.isError ? candidates.error.message : null; const pick = async (credentialId: string) => { setError(null); @@ -92,9 +113,11 @@ export function RelinkDialog({ repositories, providerInstanceId, fromCredentialI )} - {credentials.isLoading &&
Loading…
} + {loading &&
Loading…
} - {!credentials.isLoading && candidates.length === 0 && ( + {loadError &&
Couldn’t load the credentials this could be re-linked to: {loadError}
} + + {!loading && !loadError && offers.length === 0 && (
No other working credential
Nothing connected for this provider can take {repositories.length === 1 ? "this repository" : "these repositories"} yet — only your own connections and the team’s service tokens are offered. Connect one, then come back and pick it here.
@@ -102,17 +125,21 @@ export function RelinkDialog({ repositories, providerInstanceId, fromCredentialI
)} - {candidates.length > 0 && ( + {offers.length > 0 && (
- {candidates.map(c => ( - ))}
@@ -129,3 +156,54 @@ export function RelinkDialog({ repositories, providerInstanceId, fromCredentialI document.body, ); } + +/** + * The backend's list as the rows to show, in the order it gave. The connection a row is on is named when the list reaches past the repository's own + * connection — counted with the repository's own connection among those it spans, so a list whose every credential is on one OTHER connection names it + * too — and a team with a single connection sees what it always saw. + */ +function offersFromBackend(candidates: RelinkSuggestion[], providerInstanceId: string, myName: string | undefined): Offer[] { + const spansConnections = new Set([providerInstanceId, ...candidates.map(c => c.providerInstanceId ?? providerInstanceId)]).size > 1; + + return candidates.map(c => ({ + id: c.credentialId, + displayName: c.displayName, + ownerLabel: c.ownership === "TeamService" ? "Team service" : myName ?? "You", + teamService: c.ownership === "TeamService", + connectedBefore: c.connectedBeforeDisconnect, + connectionName: spansConnections && c.providerInstanceName ? c.providerInstanceName : null, + changesConnection: c.providerInstanceId != null && c.providerInstanceId !== providerInstanceId, + strandedActAs: c.strandedActAs ?? [], + })); +} + +/** What picking a credential on another connection does, said beside it: the repository moves there, and acting as the people whose identity lives only on the connection it leaves stops working until they connect. */ +function moveNote(offer: Offer, repositoryCount: number): string { + const connection = offer.connectionName ?? "that connection"; + const moves = `Moves ${repositoryCount === 1 ? "the repository" : "these repositories"} to ${connection}, another connection to the same host.`; + + return offer.strandedActAs.length === 0 ? moves : `${moves} Acting as ${listNames(offer.strandedActAs)} on ${repositoryCount === 1 ? "this repository" : "these repositories"} stops working until they connect ${connection}.`; +} + +/** "Ada", "Ada and Bo", "Ada, Bo and Cy", "Ada, Bo, Cy and 2 others" — never a wall of names. */ +function listNames(names: string[]): string { + const shown = 3; + + if (names.length === 1) return names[0]; + if (names.length <= shown) return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`; + + const others = names.length - shown; + + return `${names.slice(0, shown).join(", ")} and ${others} other${others === 1 ? "" : "s"}`; +} + +/** What the dialog did before the backend sent the list: the connection's own live credentials the caller may bind with, ordered by the suggestions. */ +function offersFromCredentialList(credentials: CredentialSummary[], suggestions: RelinkSuggestion[], providerInstanceId: string, fromCredentialId: string | null, myId: string | undefined): Offer[] { + const live = credentials.filter(c => c.providerInstanceId === providerInstanceId && c.id !== fromCredentialId && isLiveCredential(c)); + const rank = new Map(suggestions.map((s, index) => [s.credentialId, index])); + const connectedBefore = new Set(suggestions.filter(s => s.connectedBeforeDisconnect).map(s => s.credentialId)); + + return sortCredentialsByPreference(bindableCredentials(live, myId)) + .sort((a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)) + .map(c => ({ id: c.id, displayName: c.displayName, ownerLabel: credentialOwnershipLabel(c), teamService: c.ownership === "TeamService", connectedBefore: connectedBefore.has(c.id), connectionName: null, changesConnection: false, strandedActAs: [] })); +} diff --git a/frontend/src/hooks/use-credentials.ts b/frontend/src/hooks/use-credentials.ts index 50235c741..b8a320f1d 100644 --- a/frontend/src/hooks/use-credentials.ts +++ b/frontend/src/hooks/use-credentials.ts @@ -14,8 +14,8 @@ export const ACCESSIBLE_REPOS_PAGE_SIZE = 30; * both GitHub and GitLab; bigger pages = fewer round-trips to load everything. */ const EAGER_FETCH_NETWORK_PAGE_SIZE = 100; -export function useCredentials() { - return useQuery({ queryKey: ["credentials"], queryFn: () => oauthApi.listCredentials() }); +export function useCredentials(enabled = true) { + return useQuery({ queryKey: ["credentials"], queryFn: () => oauthApi.listCredentials(), enabled }); } export function useProviderInstances() { @@ -295,6 +295,20 @@ export function useRelinkSuggestions(credentialId: string | null, enabled = true }); } +/** + * What a Re-link dialog offers for a repository on a provider connection, from the backend: the caller's own and the team's credentials on that + * connection and on any other connection to the same host, best first. `data` is null when the API predates it, which the dialog answers by + * filtering the credential list itself. Asked only when the dialog is open, for the reason the suggestions are. + */ +export function useRelinkCandidates(providerInstanceId: string | null, fromCredentialId: string | null) { + return useQuery({ + queryKey: ["relink-candidates", providerInstanceId, fromCredentialId], + queryFn: () => oauthApi.getRelinkCandidates(providerInstanceId!, fromCredentialId), + enabled: providerInstanceId != null, + staleTime: 0, + }); +} + /** Backend-driven defaults for the "Add provider" form. Cached aggressively — the source * is a static IProviderModule, so the values change only on a backend deploy. */ export function useProviderDefaults(provider: ProviderKind | null) { diff --git a/frontend/src/styles/ai-code-space.css b/frontend/src/styles/ai-code-space.css index 971073718..c97a9dadb 100644 --- a/frontend/src/styles/ai-code-space.css +++ b/frontend/src/styles/ai-code-space.css @@ -6560,7 +6560,10 @@ button.rmd-subject:hover { background: var(--panel-2); border-color: var(--line- .rmd-candidate { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; padding: 9px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel); color: var(--ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: background .12s, border-color .12s; } .rmd-candidate:hover:not(:disabled) { background: var(--panel-2); border-color: var(--line-2); } .rmd-candidate:disabled { opacity: .6; cursor: default; } +.rmd-candidate-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .rmd-candidate-main { display: inline-flex; align-items: center; gap: 7px; min-width: 0; flex-wrap: wrap; } +.rmd-candidate-note { font-size: 11.5px; line-height: 1.5; color: var(--ink-2); } +.rmd-candidate-main .cn-status[data-connection="true"] { text-transform: none; } .rmd-candidate-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rmd-candidate-go { flex: none; font-size: 12px; color: var(--accent); font-weight: 500; } /* The repository page's connection row, above the tabs: chip, the sentence behind it, then the fixes. */