From 2035ca5d3c49990e5edb4cabe2c6afee2c3b1236 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Mon, 19 Oct 2026 19:29:34 +0800 Subject: [PATCH] Let connections to one host take over each other's repositories A team that signs in to one GitLab through two OAuth applications holds two provider connections to that host. A repository bound under one of them through a credential that died could only be taken over by a credential of that same connection: the Re-link dialog filtered the credential list by connection and said nothing could take it, the two relink commands refused, and neither a connect's carry-forward nor the repair sweep looked past the dead credential's own connection. Two connections of a team with the same provider and API root are now interchangeable for a repository (ProviderInstanceHost, built on CredentialUrl.SameRoot, one definition for every door). - The Re-link dialog renders a candidate list the backend computes (GET /provider-instances/{id}/relink-candidates): the caller's own and the team's credentials that can still authenticate, on the connection and its siblings, the same connection first. A static token past its expiry is not offered, as the credential list already reads it as rejected (the one definition RemedyResolution.IsLive). Against an API without the route (any 404 from it: a real older server answers with its fallback's JSON body, not an empty one) the dialog filters the credential list itself, as before. - A candidate on another connection says so beside it: the connection it is on and that picking it moves the repository there, even when it is the only candidate. Identities that let a teammate act as themselves belong to a connection, so the candidate also names the teammates whose act-as would stop working on the repository until they connect there (StrandedActAs, read through the act-as resolver's own predicate; one more resolver method, ListCandidatesOnInstanceAsync, that the per-repository listing now delegates to). Nothing is read for it when no candidate is on another connection. - RelinkRepositoryCredentialCommand and RelinkCredentialBindingsCommand accept a credential on a sibling connection. The target is asked for the repository by id and must find it at the path CodeSpace has, then the repository's credential and connection move together, its hooks are registered again through the new connection and the old ones retired. The id, project links, triggers and history stay. Another host is refused with the message it always got. - Connect-time carry-forward, the repair sweep and a Disconnect that names a target follow the same rule, keyed on the same owner. A repository the new connection cannot take (it already holds the project, or the credential's scopes cannot register the hook) stays where it was; a group hook stays with its connection. A repair whose every repository is refused counts as a failure of that credential so it backs off instead of holding a place in the pass. - The service that moves a repository between connections is taken lazily by the successor service: it reads Webhooks:BaseUrl when built, and connecting or disconnecting a credential must not need it. No migration. A repository always has a credential of its own connection, so older pods, an older SPA and a rollback read every row this writes. --- .../ProviderInstancesController.cs | 13 + .../GetRelinkCandidatesQueryHandler.cs | 16 + .../CredentialRelinkService.Candidates.cs | 74 +++ .../CredentialRelinkService.Probe.cs | 63 +-- .../CredentialRelinkService.Suggestions.cs | 22 +- .../Credentials/CredentialRelinkService.cs | 12 +- .../CredentialSuccessionService.Move.cs | 53 +- .../CredentialSuccessionService.Revive.cs | 2 +- .../CredentialSuccessionService.cs | 157 ++++-- .../Credentials/ICredentialRelinkService.cs | 8 +- .../ICredentialSuccessionService.cs | 24 +- .../Credentials/IRepositoryReadProbe.cs | 29 + .../Credentials/RelinkSuggestionRanking.cs | 63 ++- .../RelinkVerificationUnavailableException.cs | 6 +- .../Credentials/RepositoryReadProbe.cs | 57 ++ .../StrandedBindingsRefusedException.cs | 23 + .../ProviderInstances/ProviderInstanceHost.cs | 80 +++ .../Identity/ActorIdentityResolver.cs | 5 + .../Identity/IActorIdentityResolver.cs | 7 + .../Services/Redaction/CredentialUrl.cs | 11 + .../Repositories/RepositoryService.cs | 77 ++- .../RepositoryBinding/IRepositoryRehoming.cs | 58 ++ .../RepositoryBindingService.Rehome.cs | 164 ++++++ .../RepositoryBindingService.cs | 24 +- .../RelinkCredentialBindingsCommand.cs | 8 +- .../RelinkRepositoryCredentialCommand.cs | 12 +- .../Dtos/Credentials/RelinkSuggestion.cs | 22 +- .../Credentials/GetRelinkCandidatesQuery.cs | 28 + .../Credentials/GetRelinkSuggestionsQuery.cs | 4 +- ...onnectionsWithoutWebhookBaseUrlE2ETests.cs | 82 +++ .../Credentials/CredentialSameHostE2ETests.cs | 353 ++++++++++++ .../Credentials/CredentialRelinkFlowTests.cs | 45 +- .../SameHostConnectionsFlowTests.Doors.cs | 523 ++++++++++++++++++ .../SameHostConnectionsFlowTests.Moves.cs | 420 ++++++++++++++ .../SameHostConnectionsFlowTests.cs | 171 ++++++ .../Infrastructure/GitLabDoorkeeperStub.cs | 46 +- .../GitLabDoorkeeperStubTests.cs | 25 + .../OAuth/BindingMoveKit.cs | 74 ++- .../ResourceAccessMarkerInventoryTests.cs | 1 + .../RelinkSuggestionRankingTests.cs | 68 +++ .../Providers/ProviderInstanceHostTests.cs | 87 +++ .../Redaction/CredentialUrlTests.cs | 31 ++ backend/tests/TESTING.md | 2 + frontend/src/api/oauth.ts | 17 + frontend/src/api/types.ts | 5 + .../remedy/ProviderRemedyCard.test.tsx | 36 +- .../components/remedy/RelinkDialog.test.tsx | 288 ++++++++++ .../src/components/remedy/RelinkDialog.tsx | 130 ++++- frontend/src/hooks/use-credentials.ts | 18 +- frontend/src/styles/ai-code-space.css | 3 + 50 files changed, 3351 insertions(+), 196 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkCandidatesQueryHandler.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Candidates.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/IRepositoryReadProbe.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/RepositoryReadProbe.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/StrandedBindingsRefusedException.cs create mode 100644 backend/src/CodeSpace.Core/Services/ProviderInstances/ProviderInstanceHost.cs create mode 100644 backend/src/CodeSpace.Core/Services/RepositoryBinding/IRepositoryRehoming.cs create mode 100644 backend/src/CodeSpace.Core/Services/RepositoryBinding/RepositoryBindingService.Rehome.cs create mode 100644 backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkCandidatesQuery.cs create mode 100644 backend/tests/CodeSpace.E2ETests/Credentials/ConnectionsWithoutWebhookBaseUrlE2ETests.cs create mode 100644 backend/tests/CodeSpace.E2ETests/Credentials/CredentialSameHostE2ETests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Doors.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.Moves.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Credentials/SameHostConnectionsFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Providers/ProviderInstanceHostTests.cs create mode 100644 frontend/src/components/remedy/RelinkDialog.test.tsx 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. */