diff --git a/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs b/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs index 390e9ef43..da13c1049 100644 --- a/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs +++ b/backend/src/CodeSpace.Api/Controllers/CredentialsController.cs @@ -5,6 +5,7 @@ using CodeSpace.Messages.Queries.Credentials; using MediatR; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace CodeSpace.Api.Controllers; @@ -89,17 +90,49 @@ public async Task AddGroupAccessToken([FromBody] AddGroupAccessTo return Ok(new { id }); } + /// + /// Disconnect. The body is optional — a client that sends none behaves as it always has — and may name where the + /// credential's repositories and group hooks go (moveBindingsToCredentialId). Route's credentialId is + /// authoritative: merged into the command with with so a body that disagrees cannot redirect the revoke. + /// [HttpPost("{credentialId:guid}/revoke")] - public async Task Revoke([FromRoute] Guid credentialId, CancellationToken cancellationToken) + public async Task Revoke([FromRoute] Guid credentialId, [FromBody(EmptyBodyBehavior = EmptyBodyBehavior.Allow)] RevokeCredentialCommand? command, CancellationToken cancellationToken) + { + var result = await _mediator.Send((command ?? new RevokeCredentialCommand()) with { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false); + return Ok(result); + } + + /// + /// Move every repository and group hook still bound through a dead credential onto another credential in one action. + /// Route's fromCredentialId is authoritative; the body carries toCredentialId. + /// + [HttpPost("{fromCredentialId:guid}/relink-bindings")] + public async Task RelinkBindings([FromRoute] Guid fromCredentialId, [FromBody] RelinkCredentialBindingsCommand command, CancellationToken cancellationToken) + { + var result = await _mediator.Send(command with { FromCredentialId = fromCredentialId }, cancellationToken).ConfigureAwait(false); + return Ok(result); + } + + /// What leans on the credential — repositories with their projects, group hooks, act-as identities, and the workflow triggers naming those repositories. Live or dead. + [HttpGet("{credentialId:guid}/dependents")] + public async Task Dependents([FromRoute] Guid credentialId, CancellationToken cancellationToken) + { + var result = await _mediator.Send(new GetCredentialDependentsQuery { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false); + return Ok(result); + } + + /// The credentials the caller could move this one's bindings onto, best first: their own live connections, then the team's service tokens. + [HttpGet("{credentialId:guid}/relink-suggestions")] + public async Task RelinkSuggestions([FromRoute] Guid credentialId, CancellationToken cancellationToken) { - var result = await _mediator.Send(new RevokeCredentialCommand { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false); + var result = await _mediator.Send(new GetRelinkSuggestionsQuery { CredentialId = credentialId }, cancellationToken).ConfigureAwait(false); return Ok(result); } /// - /// Pre-revoke preview: how many repositories will be marked Error if this credential - /// gets disconnected. UI uses this to put the right number in the confirm dialog so - /// the user isn't surprised by a silent cascade. + /// Pre-revoke preview: what a Disconnect of this credential, asked by the caller right now, will do — how many + /// repositories will be marked Error, or how many will instead move to the person's one other live connection. + /// UI uses this to put the right number in the confirm dialog so the user isn't surprised by a silent cascade. /// [HttpGet("{credentialId:guid}/usage")] public async Task Usage([FromRoute] Guid credentialId, CancellationToken cancellationToken) diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RelinkCredentialBindingsCommandHandler.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RelinkCredentialBindingsCommandHandler.cs new file mode 100644 index 000000000..9345abd89 --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RelinkCredentialBindingsCommandHandler.cs @@ -0,0 +1,15 @@ +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Messages.Commands.Credentials; +using MediatR; + +namespace CodeSpace.Core.Handlers.CommandHandlers.Credentials; + +public sealed class RelinkCredentialBindingsCommandHandler : IRequestHandler +{ + private readonly ICredentialRelinkService _service; + + public RelinkCredentialBindingsCommandHandler(ICredentialRelinkService service) { _service = service; } + + public async Task Handle(RelinkCredentialBindingsCommand request, CancellationToken cancellationToken) => + await _service.RelinkAllAsync(request.FromCredentialId, request.ToCredentialId, cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RevokeCredentialCommandHandler.cs b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RevokeCredentialCommandHandler.cs index 118f0df9b..2c8029576 100644 --- a/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RevokeCredentialCommandHandler.cs +++ b/backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RevokeCredentialCommandHandler.cs @@ -12,5 +12,5 @@ public sealed class RevokeCredentialCommandHandler : IRequestHandler Handle(RevokeCredentialCommand request, CancellationToken cancellationToken) => - await _service.RevokeAsync(request.CredentialId, cancellationToken).ConfigureAwait(false); + await _service.RevokeAsync(request.CredentialId, request.MoveBindingsToCredentialId, cancellationToken).ConfigureAwait(false); } diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetCredentialDependentsQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetCredentialDependentsQueryHandler.cs new file mode 100644 index 000000000..97601ecf9 --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetCredentialDependentsQueryHandler.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 GetCredentialDependentsQueryHandler : IRequestHandler +{ + private readonly ICredentialRelinkService _service; + + public GetCredentialDependentsQueryHandler(ICredentialRelinkService service) { _service = service; } + + public async Task Handle(GetCredentialDependentsQuery request, CancellationToken cancellationToken) => + await _service.GetDependentsAsync(request.CredentialId, cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkSuggestionsQueryHandler.cs b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkSuggestionsQueryHandler.cs new file mode 100644 index 000000000..0ba763371 --- /dev/null +++ b/backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkSuggestionsQueryHandler.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 GetRelinkSuggestionsQueryHandler : IRequestHandler> +{ + private readonly ICredentialRelinkService _service; + + public GetRelinkSuggestionsQueryHandler(ICredentialRelinkService service) { _service = service; } + + public async Task> Handle(GetRelinkSuggestionsQuery request, CancellationToken cancellationToken) => + await _service.GetSuggestionsAsync(request.CredentialId, cancellationToken).ConfigureAwait(false); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Context.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Context.cs new file mode 100644 index 000000000..4461eb19a --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Context.cs @@ -0,0 +1,16 @@ +using CodeSpace.Core.Persistence.Entities; + +namespace CodeSpace.Core.Services.Credentials; + +public sealed partial class CredentialRelinkService +{ + /// What one relink-all has established so far; each pipeline step reads and fills it. + private sealed class RelinkContext + { + public Credential From { get; set; } = default!; + public Credential To { get; set; } = default!; + public List Repositories { get; set; } = new(); + } + + private sealed record BoundRepository(string ExternalId, string FullPath); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Dependents.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Dependents.cs new file mode 100644 index 000000000..ef1f78d66 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Dependents.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Workflows.RunSources.Matchers; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials; + +/// What leans on a credential, read the way a move would carry it: the same team, the same provider instance, in service. +public sealed partial class CredentialRelinkService +{ + public async Task GetDependentsAsync(Guid credentialId, CancellationToken cancellationToken) + { + var credential = await LoadTeamCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false); + var repositories = await LoadDependentRepositoriesAsync(credential, cancellationToken).ConfigureAwait(false); + + return new CredentialDependents + { + CredentialId = credential.Id, + DisplayName = credential.DisplayName, + Status = credential.Status, + Retired = credential.DeletedDate != null, + OwnerUserId = credential.OwnerUserId, + ProviderInstanceId = credential.ProviderInstanceId, + Repositories = repositories, + ConnectionWebhooks = await LoadDependentConnectionWebhooksAsync(credential, cancellationToken).ConfigureAwait(false), + Identities = await LoadDependentIdentitiesAsync(credential, cancellationToken).ConfigureAwait(false), + Activations = await LoadDependentActivationsAsync(credential, repositories, cancellationToken).ConfigureAwait(false) + }; + } + + private async Task> LoadDependentRepositoriesAsync(Credential credential, CancellationToken cancellationToken) + { + var rows = await _db.Repository.AsNoTracking() + .Where(r => r.CredentialId == credential.Id && r.TeamId == credential.TeamId && r.ProviderInstanceId == credential.ProviderInstanceId && r.DeletedDate == null) + .OrderBy(r => r.FullPath) + .Select(r => new { r.Id, r.FullPath, r.Status, r.LastError }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var projects = await LoadProjectsByRepositoryAsync(rows.Select(r => r.Id).ToList(), cancellationToken).ConfigureAwait(false); + + return rows.Select(r => new DependentRepository { Id = r.Id, FullPath = r.FullPath, Status = r.Status, LastError = r.LastError, Projects = projects[r.Id] }).ToList(); + } + + private async Task>> LoadProjectsByRepositoryAsync(List repositoryIds, CancellationToken cancellationToken) + { + var links = await _db.ProjectRepository.AsNoTracking() + .Where(l => repositoryIds.Contains(l.RepositoryId) && l.DeletedDate == null && l.Project.DeletedDate == null) + .OrderBy(l => l.Project.Name) + .Select(l => new { l.RepositoryId, l.Project.Id, l.Project.Name, l.Project.Slug }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var byRepository = repositoryIds.ToDictionary(id => id, _ => new List()); + + foreach (var link in links) byRepository[link.RepositoryId].Add(new DependentProject { Id = link.Id, Name = link.Name, Slug = link.Slug }); + + return byRepository; + } + + private async Task> LoadDependentConnectionWebhooksAsync(Credential credential, CancellationToken cancellationToken) => + await _db.ConnectionWebhook.AsNoTracking() + .Where(w => w.CredentialId == credential.Id && w.ProviderInstanceId == credential.ProviderInstanceId && WebhookRegistrationLifecycle.InService.Contains(w.RegistrationStatus)) + .OrderBy(w => w.OwnerPath) + .Select(w => new DependentConnectionWebhook { Id = w.Id, OwnerPath = w.OwnerPath, RegistrationStatus = w.RegistrationStatus }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + private async Task> LoadDependentIdentitiesAsync(Credential credential, CancellationToken cancellationToken) + { + var rows = await _db.UserProviderIdentity.AsNoTracking() + .Where(i => i.CredentialId == credential.Id && i.DeletedDate == null) + .OrderBy(i => i.ProviderUsername) + .Select(i => new { i.Id, i.UserId, i.ProviderUsername }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var userIds = rows.Select(r => r.UserId).ToList(); + var names = await _db.User.IgnoreQueryFilters().AsNoTracking().Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id, u => u.Name, cancellationToken).ConfigureAwait(false); + + return rows.Select(r => new DependentIdentity { Id = r.Id, UserId = r.UserId, UserName = names.GetValueOrDefault(r.UserId), ProviderUsername = r.ProviderUsername }).ToList(); + } + + /// + /// The team's live triggers that name any of the repositories. Read from the activation's own config the way the + /// matchers read it (), so a shape they learn is learned here: a trigger + /// that names a stranded repository fires on nothing until the repository is bound again. + /// + private async Task> LoadDependentActivationsAsync(Credential credential, List repositories, CancellationToken cancellationToken) + { + if (repositories.Count == 0) return new List(); + + var rows = await _db.WorkflowActivation.AsNoTracking() + .Where(a => a.DeletedDate == null && a.Workflow.TeamId == credential.TeamId && a.Workflow.DeletedDate == null) + .OrderBy(a => a.Workflow.Name).ThenBy(a => a.TypeKey) + .Select(a => new { a.Id, a.WorkflowId, WorkflowName = a.Workflow.Name, a.TypeKey, a.Enabled, a.ConfigJson }) + .ToListAsync(cancellationToken).ConfigureAwait(false); + + var bound = repositories.Select(r => r.Id).ToHashSet(); + + return rows + .Select(a => new { Row = a, Named = NamedAmong(a.ConfigJson, bound) }) + .Where(a => a.Named.Count > 0) + .Select(a => new DependentActivation { ActivationId = a.Row.Id, WorkflowId = a.Row.WorkflowId, WorkflowName = a.Row.WorkflowName, TypeKey = a.Row.TypeKey, Enabled = a.Row.Enabled, RepositoryIds = a.Named }) + .ToList(); + } + + /// The bound repositories an activation config names. A config that is not JSON names none. + private static List NamedAmong(string configJson, HashSet bound) + { + try + { + using var document = JsonDocument.Parse(configJson); + + return ActivationRepositoryReferences.Read(document.RootElement).Where(bound.Contains).ToList(); + } + catch (JsonException) + { + return new List(); + } + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs new file mode 100644 index 000000000..a477b0c9e --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs @@ -0,0 +1,103 @@ +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 partial class CredentialRelinkService +{ + /// + /// Most repositories one relink asks the target to read. The check is one provider call per repository, in sequence — the + /// target may refresh its token on the first, and a refresh must not race itself — inside the request's database + /// transaction; past this many the request would outlast the provider's patience and the client's. More than this are + /// re-linked a few at a time (unbind what is no longer wanted, or re-link one repository at a time). + /// + public const int MaxRepositoriesVerified = 100; + + /// Most repositories named in a refusal — enough to act on, short enough to read. + private const int NamedUnreadableLimit = 5; + + private async Task LoadBoundRepositoriesAsync(CancellationToken cancellationToken) + { + var from = _ctx.From; + + _ctx.Repositories = await _db.Repository.AsNoTracking() + .Where(r => r.CredentialId == from.Id && r.TeamId == from.TeamId && r.ProviderInstanceId == from.ProviderInstanceId && r.DeletedDate == null) + .OrderBy(r => r.FullPath) + .Select(r => new BoundRepository(r.ExternalId, r.FullPath)) + .ToListAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Ask the provider, as the target, for each repository it is about to take — the lookup a bind makes. A target that + /// cannot read one would turn that repository's loud failure into a quiet one (runs 404 instead of naming a + /// 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. + /// + private async Task EnsureTargetReadsEveryRepositoryAsync(CancellationToken cancellationToken) + { + if (_ctx.Repositories.Count == 0) return; + + EnsureFewEnoughToVerify(); + + var catalog = _registry.Require(_ctx.To.ProviderInstance.Provider); + var context = new ProviderContext(_ctx.To.ProviderInstance, _ctx.To); + var unreadable = new List(); + + foreach (var repository in _ctx.Repositories) + { + if (!await CanReadAsync(catalog, context, repository, cancellationToken).ConfigureAwait(false)) unreadable.Add(repository.FullPath); + } + + if (unreadable.Count == 0) return; + + throw new InvalidOperationException(UnreadableMessage(unreadable)); + } + + private void EnsureFewEnoughToVerify() + { + if (_ctx.Repositories.Count <= MaxRepositoriesVerified) return; + + 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. 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 ProviderApiException { StatusCode: 401 or 403 or 404 }; + + private string UnreadableMessage(List unreadable) + { + var named = string.Join(", ", unreadable.Take(NamedUnreadableLimit)); + var more = unreadable.Count > NamedUnreadableLimit ? $" and {unreadable.Count - NamedUnreadableLimit} more" : string.Empty; + + 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."; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs new file mode 100644 index 000000000..21215ac5f --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs @@ -0,0 +1,42 @@ +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials; + +public sealed partial class CredentialRelinkService +{ + public async Task> GetSuggestionsAsync(Guid credentialId, CancellationToken cancellationToken) + { + 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); + + return RelinkSuggestionRanking.Rank(from, accepted, callerId); + } + + /// 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) + .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) + { + var accepted = new List(); + + foreach (var candidate in candidates.Where(c => RelinkSuggestionRanking.Qualifies(from, c, callerId))) + { + if (await _succession.RefusalAsync(from, candidate, cancellationToken).ConfigureAwait(false) is null) accepted.Add(candidate); + } + + return accepted; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs new file mode 100644 index 000000000..e2cda21d3 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs @@ -0,0 +1,123 @@ +using CodeSpace.Core.Authorization; +using CodeSpace.Core.DependencyInjection; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Constants; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// The relink-all pipeline: check who is asking, load the dead credential and its replacement, refuse every target the move +/// would not accept, ask the target to read each repository it is about to take, then move. Every step either enriches +/// _ctx or throws; nothing is written until the last line, so a refusal leaves the team exactly as it was. +/// Dependents and suggestions are the same service's read side — see the partials. +/// +public sealed partial class CredentialRelinkService : ICredentialRelinkService, IScopedDependency +{ + private readonly CodeSpaceDbContext _db; + private readonly ICurrentTeam _currentTeam; + private readonly ICurrentUser _currentUser; + private readonly TeamMembershipResolver _membership; + private readonly ICredentialSuccessionService _succession; + private readonly IProviderRegistry _registry; + private readonly ILogger _logger; + + private RelinkContext _ctx = new(); + + public CredentialRelinkService(CodeSpaceDbContext db, ICurrentTeam currentTeam, ICurrentUser currentUser, TeamMembershipResolver membership, ICredentialSuccessionService succession, IProviderRegistry registry, ILogger logger) + { + _db = db; + _currentTeam = currentTeam; + _currentUser = currentUser; + _membership = membership; + _succession = succession; + _registry = registry; + _logger = logger; + } + + public async Task RelinkAllAsync(Guid fromCredentialId, Guid toCredentialId, CancellationToken cancellationToken) + { + _ctx = new RelinkContext(); + + await EnsureMayManageCredentialsAsync(cancellationToken).ConfigureAwait(false); + await LoadSourceAsync(fromCredentialId, cancellationToken).ConfigureAwait(false); + EnsureSourceIsDead(); + + await LoadTargetAsync(toCredentialId, cancellationToken).ConfigureAwait(false); + EnsureTargetIsTheCallersOrTheTeams(); + await EnsureTargetMayTakeOverAsync(cancellationToken).ConfigureAwait(false); + + await LoadBoundRepositoriesAsync(cancellationToken).ConfigureAwait(false); + await EnsureTargetReadsEveryRepositoryAsync(cancellationToken).ConfigureAwait(false); + + var moved = await _succession.MoveBindingsAsync(_ctx.From, _ctx.To, cancellationToken).ConfigureAwait(false); + + return BuildResult(moved.RepositoryCount, moved.ConnectionWebhookCount); + } + + /// + /// The command's marker holds the half. This is the other half, asserted here + /// because the credential marker that would carry it refuses the soft-deleted credential this runs for. + /// + private async Task EnsureMayManageCredentialsAsync(CancellationToken cancellationToken) + { + var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName); + + var role = await _membership.ResolveRoleAsync(teamId, cancellationToken).ConfigureAwait(false); + + if (!TeamPermissionMatrix.IsGranted(role, TeamPermissions.CredentialsManage)) + throw TenantAccessDeniedException.MissingTeamPermission(_currentUser.Id, teamId, role, TeamPermissions.CredentialsManage); + } + + private async Task LoadSourceAsync(Guid id, CancellationToken cancellationToken) => + _ctx.From = await LoadTeamCredentialAsync(id, cancellationToken).ConfigureAwait(false); + + private async Task LoadTargetAsync(Guid id, CancellationToken cancellationToken) => + _ctx.To = await LoadTeamCredentialAsync(id, cancellationToken).ConfigureAwait(false); + + /// Moving bindings off a live credential is a Disconnect that says where they go — otherwise this could quietly change which grant a working repository acts through. + private void EnsureSourceIsDead() + { + var from = _ctx.From; + + if (RelinkSuggestionRanking.IsLive(from)) + throw new InvalidOperationException($"Credential '{from.DisplayName}' is still connected. Disconnect it, choosing where its repositories go, rather than relinking from a live credential."); + } + + /// Repositories bound through someone's personal connection act as that person; so the target is the caller's own, or the team's. + private void EnsureTargetIsTheCallersOrTheTeams() + { + if (_currentUser.Id is Guid callerId && RelinkSuggestionRanking.IsCallersOrTeams(_ctx.To, callerId)) return; + + throw new InvalidOperationException($"Credential '{_ctx.To.DisplayName}' is another person's personal connection, and repositories bound through it would act as them. Pick one of your own connections, or a team token."); + } + + private async Task EnsureTargetMayTakeOverAsync(CancellationToken cancellationToken) + { + if (await _succession.RefusalAsync(_ctx.From, _ctx.To, cancellationToken).ConfigureAwait(false) is { } refusal) + throw new InvalidOperationException(refusal); + } + + private RelinkCredentialBindingsResult BuildResult(int repositories, int connectionWebhooks) => new() + { + FromCredentialId = _ctx.From.Id, + ToCredentialId = _ctx.To.Id, + MovedRepositoryCount = repositories, + MovedConnectionWebhookCount = connectionWebhooks + }; + + /// The team's credential by id, soft-deleted or not. One answer — not found — for a missing id and one from another team. + private async Task LoadTeamCredentialAsync(Guid id, CancellationToken cancellationToken) + { + var teamId = _currentTeam.Id ?? throw TenantAccessDeniedException.NoTeamSelected(_currentUser.Id, HeaderCurrentTeam.HeaderName); + + return await _db.Credential.AsNoTracking().Include(c => c.ProviderInstance).SingleOrDefaultAsync(c => c.Id == id && c.TeamId == teamId, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException($"credential {id} not found"); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs index a96bcbed1..23698c829 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialRevocationService.cs @@ -11,12 +11,25 @@ namespace CodeSpace.Core.Services.Credentials; public sealed class CredentialRevocationService : ICredentialRevocationService, IScopedDependency { + /// + /// What a repository left on a disconnected credential says, here and in every run that reads it: only the remedies a + /// person can perform today — a team Admin re-linking the repository to an active credential of the same provider; the + /// owner reconnecting, for a personal connection, which carries the repository to the new connection; and the way out when + /// neither is wanted. Re-linking everything on the credential at once is an API (RelinkCredentialBindingsCommand) + /// with no screen yet, so it is not offered here: a message that names a step nobody can find sends them hunting. + /// + public const string DisconnectedRepositoryError = "Credential disconnected. Re-link this repository to an active credential of the same provider (a team Admin can), or unbind it; if it was a personal connection, its owner reconnecting the provider moves it too."; + + private const string SweepName = "departed-credential-retirement"; + private readonly CodeSpaceDbContext _db; + private readonly ISweepFailureBackoff _backoff; private readonly ILogger _logger; - public CredentialRevocationService(CodeSpaceDbContext db, ILogger logger) + public CredentialRevocationService(CodeSpaceDbContext db, ISweepFailureBackoff backoff, ILogger logger) { _db = db; + _backoff = backoff; _logger = logger; } @@ -53,39 +66,30 @@ public async Task RetireDepartedInTeamAsync(Guid teamId, Guid userId, Cancellati foreach (var credential in departed) await RetireAsync(credential, cancellationToken).ConfigureAwait(false); } - public async Task RetireDepartedAsync(int batchSize, CancellationToken cancellationToken) + public async Task RetireDepartedAsync(int batchSize, CancellationToken cancellationToken) => + await new SweepPass(SweepName, _backoff, _logger).RunAsync(batchSize, LoadDepartedAsync, RetireDepartedOneAsync, cancellationToken).ConfigureAwait(false); + + /// The next page of departed credentials in id order, read behind a cursor so a pass walks the whole backlog instead of re-reading the front of it, which is where a credential that can never be retired stays. + private async Task> LoadDepartedAsync(Guid? after, int take, CancellationToken cancellationToken) { - var departedIds = await LoadDepartedIdsAsync(batchSize, cancellationToken).ConfigureAwait(false); + var departed = Departed().AsNoTracking(); - var retired = 0; + if (after is Guid cursor) departed = departed.Where(c => c.Id.CompareTo(cursor) > 0); - foreach (var credentialId in departedIds) - { - try - { - if (await RetireDepartedOneAsync(credentialId, cancellationToken).ConfigureAwait(false)) retired++; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Retiring credential {CredentialId}, whose person is no longer in its team, failed; the pass continues — the credential stays a candidate", credentialId); - } - } + var ids = await departed.OrderBy(c => c.Id).Select(c => c.Id).Take(take).ToListAsync(cancellationToken).ConfigureAwait(false); - return retired; + return ids.Select(id => new DepartedCredential(id)).ToList(); } - private async Task> LoadDepartedIdsAsync(int batchSize, CancellationToken cancellationToken) => - await Departed().AsNoTracking().OrderBy(c => c.Id).Select(c => c.Id).Take(batchSize).ToListAsync(cancellationToken).ConfigureAwait(false); - /// /// Re-read under the candidate rule and retired on its own save, so one failure undoes no other. A person who rejoined /// since the candidate list was read keeps the token. /// - private async Task RetireDepartedOneAsync(Guid credentialId, CancellationToken cancellationToken) + private async Task RetireDepartedOneAsync(DepartedCredential candidate, CancellationToken cancellationToken) { try { - var credential = await Departed().SingleOrDefaultAsync(c => c.Id == credentialId, cancellationToken).ConfigureAwait(false); + var credential = await Departed().SingleOrDefaultAsync(c => c.Id == candidate.Id, cancellationToken).ConfigureAwait(false); if (credential == null) return false; @@ -144,14 +148,19 @@ private static void MarkRetired(Credential credential) /// private async Task MarkDependentRepositoriesUnauthorisedAsync(Guid credentialId, CancellationToken cancellationToken) { - var affected = await _db.Repository + var candidates = await _db.Repository .Where(r => r.CredentialId == credentialId && r.DeletedDate == null && r.Status != RepositoryStatus.Error) .ToListAsync(cancellationToken).ConfigureAwait(false); + // Filtered again on the tracked rows, not only in SQL: a Disconnect that moved this credential's repositories in the + // same unit of work has re-pointed them in memory while the database still names the dead credential, and they + // are no longer this credential's to break. + var affected = candidates.Where(r => r.CredentialId == credentialId).ToList(); + foreach (var repo in affected) { repo.Status = RepositoryStatus.Error; - repo.LastError = "Credential disconnected. Re-link this repository to an active credential of the same provider, or unbind it."; + repo.LastError = DisconnectedRepositoryError; } return affected.Count; @@ -173,4 +182,6 @@ private async Task SoftDeleteDependentIdentitiesAsync(Guid credentialId, Cancell var now = DateTimeOffset.UtcNow; foreach (var identity in identities) identity.DeletedDate = now; } + + private sealed record DepartedCredential(Guid Id) : ISweepCandidate; } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Move.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Move.cs new file mode 100644 index 000000000..6b0a792f9 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Move.cs @@ -0,0 +1,57 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using Microsoft.EntityFrameworkCore; + +namespace CodeSpace.Core.Services.Credentials; + +/// Where a Disconnect sends what was bound through the credential: the one the caller names, or — when the owner disconnects their own — the one their other connection makes obvious. +public sealed partial class CredentialService +{ + private async Task ResolveSuccessorAsync(Credential credential, Guid? requestedId, CancellationToken cancellationToken) + { + if (requestedId is Guid id) return await LoadRequestedSuccessorAsync(credential, id, cancellationToken).ConfigureAwait(false); + + return await FindImpliedSuccessorAsync(credential, cancellationToken).ConfigureAwait(false); + } + + /// + /// The credential the caller chose. It must be another connection of the SAME owner — an Admin disconnecting a teammate's + /// credential may send its bindings to that teammate's other connection, never to their own — and what the move itself + /// requires of any target. Refused here, before anything is revoked. + /// + private async Task LoadRequestedSuccessorAsync(Credential credential, Guid id, CancellationToken cancellationToken) + { + var successor = await _db.Credential.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id && c.TeamId == credential.TeamId && c.DeletedDate == null, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException($"credential {id} not found"); + + if (!RelinkSuggestionRanking.HaveTheSameOwner(credential, successor)) + throw new InvalidOperationException($"Credential '{successor.DisplayName}' belongs to someone else. Bindings move on Disconnect only to another connection of the same owner — or, for a team token, another team token."); + + if (await _succession.RefusalAsync(credential, successor, cancellationToken).ConfigureAwait(false) is { } refusal) + throw new InvalidOperationException(refusal); + + return successor; + } + + /// + /// Nobody was named. When the person whose connection this is disconnects it themselves and holds exactly one other live + /// connection on the same instance, that is where reconnecting would have sent it — so it is where it goes, and + /// connect-then-disconnect ends where disconnect-then-connect does ( + /// holds the rule, and why it stops there). Nothing is implied for anyone else's, a credential already disconnected, or a + /// team-service token, and a default that would be refused is no default: Disconnect itself must never fail because of it. + /// + private async Task FindImpliedSuccessorAsync(Credential credential, CancellationToken cancellationToken) + { + if (_currentUser.Id is not Guid callerId) return null; + + return await _succession.FindOnlyOtherConnectionAsync(credential, callerId, cancellationToken).ConfigureAwait(false); + } + + private async Task MoveBindingsAsync(Credential credential, Credential? successor, CancellationToken cancellationToken) + { + if (successor == null) return BindingsMoved.None; + + return await _succession.MoveBindingsAsync(credential, successor, cancellationToken).ConfigureAwait(false); + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs index 4c6c0b3fe..e173afde0 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialService.cs @@ -19,7 +19,7 @@ namespace CodeSpace.Core.Services.Credentials; -public sealed class CredentialService : ICredentialService, IScopedDependency +public sealed partial class CredentialService : ICredentialService, IScopedDependency { private readonly CodeSpaceDbContext _db; private readonly ICurrentTeam _currentTeam; @@ -86,13 +86,21 @@ public async Task> ListAsync(Guid? providerInst public async Task GetUsageAsync(Guid credentialId, CancellationToken cancellationToken) { + var credential = await LoadCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false); + + // What a Disconnect asked right now would do — so what the confirm dialog warns about is what then happens: the same + // resolution of the implied successor, by the same person. + var successor = await ResolveSuccessorAsync(credential, requestedId: null, cancellationToken).ConfigureAwait(false); + + var bound = _db.Repository.AsNoTracking().Where(r => r.CredentialId == credentialId && r.DeletedDate == null); + + if (successor != null) + return new CredentialUsage { CredentialId = credentialId, ActiveRepositoryCount = 0, WillMoveRepositoryCount = await bound.CountAsync(cancellationToken).ConfigureAwait(false), WillMoveToDisplayName = successor.DisplayName }; + // Active = not soft-deleted AND not already flipped to Error by a prior revoke. // Counting only Status=Active gives the right "you're about to break N working // repositories" number; previously-broken ones don't need to be in the warning. - var activeCount = await _db.Repository - .AsNoTracking() - .Where(r => r.CredentialId == credentialId && r.DeletedDate == null && r.Status == RepositoryStatus.Active) - .CountAsync(cancellationToken).ConfigureAwait(false); + var activeCount = await bound.CountAsync(r => r.Status == RepositoryStatus.Active, cancellationToken).ConfigureAwait(false); return new CredentialUsage { CredentialId = credentialId, ActiveRepositoryCount = activeCount }; } @@ -182,27 +190,18 @@ public async Task AddAsync(AddCredentialInput input, CancellationToken can return credential.Id; } - public async Task RevokeAsync(Guid credentialId, CancellationToken cancellationToken) + public async Task RevokeAsync(Guid credentialId, Guid? moveBindingsToCredentialId, CancellationToken cancellationToken) { var credential = await LoadCredentialAsync(credentialId, cancellationToken).ConfigureAwait(false); - var alreadyRevoked = credential.Status == CredentialStatus.Revoked; + // Before the provider is asked to revoke anything: a refused target must leave the credential exactly as it was. + var successor = await ResolveSuccessorAsync(credential, moveBindingsToCredentialId, cancellationToken).ConfigureAwait(false); - // Skip provider revoke + local-state update when already Revoked — the token is gone, - // nothing to revoke. ALWAYS run the cascade below: it's the only path that heals an - // out-of-sync Repository row (Active pointing at a Revoked credential), which can - // happen if the cascade didn't exist when the credential was first revoked. The - // Status != Error filter inside MarkDependentRepositoriesUnauthorisedAsync makes the - // call idempotent. - bool providerOk = true; - string? providerError = null; - - if (!alreadyRevoked) - { - (providerOk, providerError) = await TryRevokeAtProviderAsync(credential, cancellationToken).ConfigureAwait(false); - MarkRevokedLocally(credential, providerError); - } + var (providerOk, providerError) = await EndAsync(credential, cancellationToken).ConfigureAwait(false); + // Moved BEFORE the cascade, which then finds nothing left bound to the credential: the other order would turn + // every repository Error and lift it again, and a Paused or Unreachable one would come back Active. + var moved = await MoveBindingsAsync(credential, successor, cancellationToken).ConfigureAwait(false); var affectedRepoCount = await _revocation.CascadeAsync(credential.Id, cancellationToken).ConfigureAwait(false); return new RevokeCredentialResult @@ -210,10 +209,29 @@ public async Task RevokeAsync(Guid credentialId, Cancell CredentialId = credential.Id, ProviderAcknowledged = providerOk, ProviderError = providerError, - AffectedRepositoryCount = affectedRepoCount + AffectedRepositoryCount = affectedRepoCount, + MovedRepositoryCount = moved.RepositoryCount, + MovedConnectionWebhookCount = moved.ConnectionWebhookCount, + MovedToCredentialId = moved.Any ? successor!.Id : null }; } + /// + /// Skip provider revoke + local-state update when already Revoked — the token is gone, nothing to revoke. The cascade + /// that follows ALWAYS runs: it is the only path that heals an out-of-sync Repository row (Active pointing at a Revoked + /// credential), which can happen if the cascade didn't exist when the credential was first revoked. Its + /// Status != Error filter makes the call idempotent. + /// + private async Task<(bool Ok, string? Error)> EndAsync(Credential credential, CancellationToken cancellationToken) + { + if (credential.Status == CredentialStatus.Revoked) return (Ok: true, Error: null); + + var (providerOk, providerError) = await TryRevokeAtProviderAsync(credential, cancellationToken).ConfigureAwait(false); + MarkRevokedLocally(credential, providerError); + + return (providerOk, providerError); + } + public async Task ListAccessibleRepositoriesAsync(Guid credentialId, string? search, int page, int perPage, CancellationToken cancellationToken) { var credential = await _db.Credential diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs new file mode 100644 index 000000000..0b2fc02e2 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs @@ -0,0 +1,71 @@ +using CodeSpace.Core.Authorization; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Core.Persistence.Entities; +using Microsoft.EntityFrameworkCore; + +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. +/// +public sealed partial class CredentialSuccessionService +{ + public async Task RefusalAsync(Credential from, Credential to, CancellationToken cancellationToken) + { + if (from.Id == to.Id) return "A credential cannot take over its own bindings."; + + 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 (to.Status != CredentialStatus.Active) return $"Credential '{to.DisplayName}' is {to.Status} — pick an Active credential."; + + return await LackOfStandingAsync(to, cancellationToken).ConfigureAwait(false); + } + + public async Task FindOnlyOtherConnectionAsync(Credential from, Guid callerId, CancellationToken cancellationToken) + { + if (!IsTheCallersOwnLiveConnection(from, callerId)) return null; + + var others = await _db.Credential.AsNoTracking() + .Where(c => c.TeamId == from.TeamId && c.ProviderInstanceId == from.ProviderInstanceId && c.OwnerUserId == from.OwnerUserId && c.Ownership == CredentialOwnership.Personal) + .Where(c => c.Status == CredentialStatus.Active && c.DeletedDate == null && c.Id != from.Id) + .Take(2).ToListAsync(cancellationToken).ConfigureAwait(false); + + if (others.Count != 1) return null; + + return await RefusalAsync(from, others[0], cancellationToken).ConfigureAwait(false) is null ? others[0] : null; + } + + private static bool IsTheCallersOwnLiveConnection(Credential from, Guid callerId) => + RelinkSuggestionRanking.IsLive(from) && from.Ownership == CredentialOwnership.Personal && from.OwnerUserId == callerId; + + public async Task MoveBindingsAsync(Credential from, Credential to, CancellationToken cancellationToken) + { + if (await RefusalAsync(from, to, cancellationToken).ConfigureAwait(false) is { } refusal) + throw new InvalidOperationException(refusal); + + var adopted = await AdoptBindingsAsync(to, new List { from.Id }, cancellationToken).ConfigureAwait(false); + + await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); + + return new BindingsMoved { RepositoryCount = adopted.RepositoryCount, ConnectionWebhookCount = adopted.ConnectionWebhookCount }; + } + + /// + /// 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 + /// to the team and has no person to ask about. + /// + private async Task LackOfStandingAsync(Credential to, CancellationToken cancellationToken) + { + if (to.Ownership != CredentialOwnership.Personal || to.OwnerUserId is not Guid ownerId) return null; + + if (await TeamStanding.HeldAsync(_db, to.TeamId, ownerId, cancellationToken).ConfigureAwait(false)) return null; + + return $"Credential '{to.DisplayName}' belongs to someone who no longer has standing in this team, so it would be retired and the repositories stranded again."; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs index 1cf7de36b..61e7769c2 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.cs @@ -14,23 +14,31 @@ namespace CodeSpace.Core.Services.Credentials; /// 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. Ownership is the whole key — a teammate connecting the same /// provider is a different identity, and moving the owner's bindings onto it would change who they act as. A live -/// credential has no successor, whatever its owner connects beside it. +/// credential has no successor, whatever its owner connects beside it — its bindings move only when it is disconnected +/// (, with the destination a person named, or — when the owner disconnects their own — the +/// one other connection implies) or a person relinks them +/// (), each a deliberate act rather than a side effect of connecting. The repair sweep +/// stays the fallback for what none of those doors reached, and walks its backlog in bounded pages (). /// -public sealed class CredentialSuccessionService : ICredentialSuccessionService, IScopedDependency +public sealed partial class CredentialSuccessionService : ICredentialSuccessionService, IScopedDependency { + private const string SweepName = "credential-binding-repair"; + /// The hook states a reconnect revives, as a bind revives them: the ones the dead credential's failures put there. private static readonly RepositoryWebhookRegistrationStatus[] Revivable = { RepositoryWebhookRegistrationStatus.Failed, RepositoryWebhookRegistrationStatus.DeadLettered }; private readonly CodeSpaceDbContext _db; private readonly IConnectionWebhookRegistrationDispatcher _hookDispatcher; private readonly IPostCommitActions _postCommit; + private readonly ISweepFailureBackoff _backoff; private readonly ILogger _logger; - public CredentialSuccessionService(CodeSpaceDbContext db, IConnectionWebhookRegistrationDispatcher hookDispatcher, IPostCommitActions postCommit, ILogger logger) + public CredentialSuccessionService(CodeSpaceDbContext db, IConnectionWebhookRegistrationDispatcher hookDispatcher, IPostCommitActions postCommit, ISweepFailureBackoff backoff, ILogger logger) { _db = db; _hookDispatcher = hookDispatcher; _postCommit = postCommit; + _backoff = backoff; _logger = logger; } @@ -42,32 +50,13 @@ public async Task CarryForwardAsync(Credential successor, CancellationToken canc if (predecessorIds.Count == 0) return; - var revivedHookIds = await AdoptBindingsAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); + var adopted = await AdoptBindingsAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); - await DispatchAfterCommitAsync(revivedHookIds, cancellationToken).ConfigureAwait(false); + await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); } - public async Task RepairStrandedAsync(int batchSize, CancellationToken cancellationToken) - { - var stranded = await LoadStrandedAsync(batchSize, cancellationToken).ConfigureAwait(false); - - var repaired = 0; - - foreach (var candidate in stranded) - { - try - { - await RepairAsync(candidate, cancellationToken).ConfigureAwait(false); - repaired++; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning(ex, "Moving the bindings of disconnected credential {CredentialId} to {SuccessorCredentialId} failed; the pass continues — the credential stays a candidate", candidate.DeadId, candidate.SuccessorId); - } - } - - return repaired; - } + public async Task RepairStrandedAsync(int batchSize, CancellationToken cancellationToken) => + await new SweepPass(SweepName, _backoff, _logger).RunAsync(batchSize, LoadStrandedAsync, RepairAsync, cancellationToken).ConfigureAwait(false); /// /// The owner's revoked credentials on the successor's instance and team. Filtered on the tracked entities, not in @@ -88,12 +77,17 @@ private async Task> LoadDisconnectedPredecessorIdsAsync(Credential su /// newest Active credential on the same instance and team connected at or after the revocation (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 credential with no successor — a disconnect - /// nobody reconnected — is not a candidate, so it never crowds a repairable one out of the batch. + /// nobody reconnected — is not a candidate, so it never crowds a repairable one out of the batch. Ordered by id and + /// read in pages: is the last id of the previous page, so the pass walks the whole backlog + /// instead of re-reading its first rows every time. /// - private async Task> LoadStrandedAsync(int batchSize, CancellationToken cancellationToken) + private async Task> LoadStrandedAsync(Guid? after, int take, CancellationToken cancellationToken) { - var rows = await _db.Credential.AsNoTracking() - .Where(dead => dead.Status == CredentialStatus.Revoked && dead.OwnerUserId != null) + var revoked = _db.Credential.AsNoTracking().Where(dead => dead.Status == CredentialStatus.Revoked && dead.OwnerUserId != null); + + if (after is Guid cursor) revoked = revoked.Where(dead => dead.Id.CompareTo(cursor) > 0); + + var rows = await revoked .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 @@ -107,23 +101,25 @@ private async Task> LoadStrandedAsync(int batchSize, Ca }) .Where(candidate => candidate.SuccessorId != null) .OrderBy(candidate => candidate.DeadId) - .Take(batchSize) + .Take(take) .ToListAsync(cancellationToken).ConfigureAwait(false); return rows.Select(row => new StrandedCredential(row.DeadId, row.SuccessorId!.Value)).ToList(); } /// Move one disconnected credential's bindings and save them on their own, then hand its revived hooks to the registrar. - private async Task RepairAsync(StrandedCredential candidate, CancellationToken cancellationToken) + private async Task RepairAsync(StrandedCredential candidate, CancellationToken cancellationToken) { try { var successor = await _db.Credential.SingleAsync(c => c.Id == candidate.SuccessorId, cancellationToken).ConfigureAwait(false); - var revivedHookIds = await AdoptBindingsAsync(successor, new List { candidate.DeadId }, cancellationToken).ConfigureAwait(false); + var adopted = await AdoptBindingsAsync(successor, new List { candidate.DeadId }, cancellationToken).ConfigureAwait(false); await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - await DispatchAfterCommitAsync(revivedHookIds, cancellationToken).ConfigureAwait(false); + await DispatchAfterCommitAsync(adopted.RevivedHookIds, cancellationToken).ConfigureAwait(false); + + return true; } finally { @@ -131,8 +127,8 @@ private async Task RepairAsync(StrandedCredential candidate, CancellationToken c } } - /// Re-point every live binding of at as tracked changes. Returns the group hooks now waiting for a registrar. - private async Task> AdoptBindingsAsync(Credential successor, List predecessorIds, CancellationToken cancellationToken) + /// Re-point every live binding of at as tracked changes. Returns what moved, and the group hooks now waiting for a registrar. + private async Task AdoptBindingsAsync(Credential successor, List predecessorIds, CancellationToken cancellationToken) { var repositories = await LoadBoundRepositoriesAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); var hooks = await LoadBoundConnectionWebhooksAsync(successor, predecessorIds, cancellationToken).ConfigureAwait(false); @@ -143,7 +139,7 @@ private async Task> AdoptBindingsAsync(Credential successor, List 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); - return hooks.Where(h => h.RegistrationStatus == RepositoryWebhookRegistrationStatus.Pending).Select(h => h.Id).ToList(); + return new Adopted(repositories.Count, hooks.Count, hooks.Where(h => h.RegistrationStatus == RepositoryWebhookRegistrationStatus.Pending).Select(h => h.Id).ToList()); } private async Task> LoadBoundRepositoriesAsync(Credential successor, List predecessorIds, CancellationToken cancellationToken) => @@ -191,5 +187,10 @@ private async Task DispatchAfterCommitAsync(List hookIds, CancellationToke await _postCommit.RunAfterCommitAsync(ct => _hookDispatcher.DispatchAsync(hookId, ct), cancellationToken).ConfigureAwait(false); } - private sealed record StrandedCredential(Guid DeadId, Guid SuccessorId); + private sealed record StrandedCredential(Guid DeadId, Guid SuccessorId) : ISweepCandidate + { + public Guid Id => DeadId; + } + + private sealed record Adopted(int RepositoryCount, int ConnectionWebhookCount, List RevivedHookIds); } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs new file mode 100644 index 000000000..40b885b0f --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs @@ -0,0 +1,26 @@ +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Dtos.Credentials; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// What to do about a credential that is gone and still has things bound through it: see what is waiting, see where it +/// could go, and send all of it there in one action. The credential may be soft-deleted — a departed member's retired +/// token is the case this exists for — so every method loads it by (id, the header team) rather than through the +/// credential authorization marker, which refuses a deleted one. +/// +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. + Task> GetSuggestionsAsync(Guid credentialId, 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 + /// 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/ICredentialRevocationService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRevocationService.cs index ec138c71d..ad183820a 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRevocationService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialRevocationService.cs @@ -36,7 +36,9 @@ public interface ICredentialRevocationService /// Retire up to personal tokens whose person no longer has standing in the team they were /// lent to — no membership there and not an instance Admin — left behind by departures from before departures retired /// tokens, by an older pod, or by an instance Admin losing the role; each exactly as a departure retires it and saved - /// on its own. A token that cannot be attributed to an account is never one. Returns how many were retired. + /// on its own. A token that cannot be attributed to an account is never one. Returns how many were retired. A token that + /// fails is skipped and logged and the pass goes on past it — however many fail, they cannot crowd a healthy one out — and + /// one that has failed several passes in a row is set aside for a while (). /// Task RetireDepartedAsync(int batchSize, CancellationToken cancellationToken); } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs index de2dd3d0a..e3a6b12cf 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialService.cs @@ -14,9 +14,21 @@ namespace CodeSpace.Core.Services.Credentials; public interface ICredentialService { Task> ListAsync(Guid? providerInstanceId, CancellationToken cancellationToken); + + /// What a Disconnect of this credential, asked by the caller right now, would do: how many working repositories it would break, or how many it would carry to which connection instead. Task GetUsageAsync(Guid credentialId, CancellationToken cancellationToken); + Task GetCapabilitiesAsync(Guid credentialId, CancellationToken cancellationToken); Task AddAsync(AddCredentialInput input, CancellationToken cancellationToken); - Task RevokeAsync(Guid credentialId, CancellationToken cancellationToken); + + /// + /// Revoke the credential and break (or move) what leans on it. names the + /// credential its repositories and group hooks follow in the same unit of work; left null, they follow the owner's + /// only other live connection when the owner is the one disconnecting a connection that is still live and there is + /// exactly one, and otherwise turn Error — see + /// . A target that may not take them is refused before + /// the provider is asked to revoke anything. + /// + Task RevokeAsync(Guid credentialId, Guid? moveBindingsToCredentialId, CancellationToken cancellationToken); Task ListAccessibleRepositoriesAsync(Guid credentialId, string? search, int page, int perPage, CancellationToken cancellationToken); } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs index aea0ec244..6bfb4bc2a 100644 --- a/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs +++ b/backend/src/CodeSpace.Core/Services/Credentials/ICredentialSuccessionService.cs @@ -1,4 +1,5 @@ using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Dtos.Credentials; namespace CodeSpace.Core.Services.Credentials; @@ -21,7 +22,42 @@ public interface ICredentialSuccessionService /// /// Apply the same rule to bindings a connect path left behind — rows stranded before connects carried them forward, /// or by an older pod during a rolling deploy: up to revoked credentials that still have - /// live bindings AND an owner's reconnect to follow, each saved on its own. Returns how many were repaired. + /// live bindings AND an owner's reconnect to follow, each saved on its own. Returns how many were repaired. A + /// credential that fails is skipped and logged, and the pass goes on to the ones after it — however many fail, they + /// cannot crowd a repairable credential out of the pass — and one that has failed several passes in a row is set aside + /// for a while rather than attempted, and warned about, every tick. A pass is bounded in attempts as well as in + /// repairs (). /// Task RepairStrandedAsync(int batchSize, CancellationToken cancellationToken); + + /// + /// 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: + /// 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 + /// between — all of them the person's to decide by naming a target. Never fails: it is a default a caller chose for + /// itself. + /// + Task FindOnlyOtherConnectionAsync(Credential from, Guid callerId, CancellationToken cancellationToken); + + /// + /// 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 + /// which target is the caller's rule, not this one. + /// + Task RefusalAsync(Credential from, Credential to, CancellationToken cancellationToken); + + /// + /// 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. + /// Tracked changes only. Returns what moved. + /// + Task MoveBindingsAsync(Credential from, Credential to, CancellationToken cancellationToken); } diff --git a/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs b/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs new file mode 100644 index 000000000..74a4cf200 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs @@ -0,0 +1,69 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// Which credentials may take a credential's bindings, and in what order they are offered. Pure, so the rules and the order +/// are pinned without a database — and so the dialog that offers a target and the door that accepts it read one rule. +/// +/// Two doors, two rules, chosen by whether the credential is still live. A dead credential's bindings are moved by +/// a team Admin with a relink: onto the caller's own live connections first — newest first, because the connection a person +/// just made is the one they are most likely replacing the dead one with — then the team's service tokens, newest first; a +/// 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. +/// +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) + .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 . + /// + public static bool Qualifies(Credential from, Credential candidate, Guid callerId) => + candidate.Id != from.Id + && candidate.TeamId == from.TeamId + && candidate.ProviderInstanceId == from.ProviderInstanceId + && 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; + + /// 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; + + /// A relink's rule: a person's own connection, or a team-service token — never another person's personal one. + public static bool IsCallersOrTeams(Credential candidate, Guid callerId) => IsCallers(candidate, callerId) || IsTeamService(candidate); + + private static RelinkSuggestionKind KindOf(Credential c, Guid callerId) => + IsCallers(c, callerId) ? RelinkSuggestionKind.YourConnection : IsTeamService(c) ? RelinkSuggestionKind.TeamServiceToken : RelinkSuggestionKind.OwnersOtherConnection; + + private static bool IsCallers(Credential c, Guid callerId) => c.Ownership == CredentialOwnership.Personal && c.OwnerUserId == callerId; + + private static bool IsTeamService(Credential c) => c.Ownership == CredentialOwnership.TeamService; + + private static RelinkSuggestion Suggest(Credential from, Credential c, RelinkSuggestionKind kind) => new() + { + CredentialId = c.Id, + DisplayName = c.DisplayName, + AuthType = c.AuthType, + Ownership = c.Ownership, + Kind = kind, + CreatedDate = c.CreatedDate, + ConnectedBeforeDisconnect = 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 new file mode 100644 index 000000000..64da43552 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/RelinkVerificationUnavailableException.cs @@ -0,0 +1,21 @@ +using CodeSpace.Messages.Failures; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// Thrown when a relink could not find out whether its target can read a repository, because the provider did not answer +/// in a way that says anything about the target — an outage, a dropped connection, a rate limit. Nothing was learned, so +/// the refusal must not blame the target ("pick a credential that reaches them" is the wrong advice for an outage); the +/// remedy is to try again, and nothing was moved. The provider's own error is the inner exception and is logged, not shown. +/// +public sealed class RelinkVerificationUnavailableException : Exception, IFailure +{ + public FailureKind Kind => FailureKind.Unavailable; + + 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) + { + } +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/SweepFailureBackoff.cs b/backend/src/CodeSpace.Core/Services/Credentials/SweepFailureBackoff.cs new file mode 100644 index 000000000..31ecb1ce5 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/SweepFailureBackoff.cs @@ -0,0 +1,78 @@ +using System.Collections.Concurrent; +using CodeSpace.Core.DependencyInjection; + +namespace CodeSpace.Core.Services.Credentials; + +/// +/// What a recurring sweep remembers about the candidates it could not handle. A candidate that fails stays a candidate — +/// the next tick may succeed — but one that has failed several ticks running is not going to heal in the next five minutes, +/// and attempting it again every tick forever costs a burst of queries and a warning each time. After +/// failures in a row it is set aside for a window and not attempted; +/// when the window ends it is attempted once, and a failure sets it aside for another. A success forgets it. +/// +/// Only an optimisation of the sweep, never its correctness: the sweep's fairness comes from walking its backlog in +/// pages, so a set-aside candidate costs the sweep nothing it needs. The memory is the process's own — a restart, or another +/// worker, simply attempts the candidate again. +/// +public interface ISweepFailureBackoff +{ + /// True while is set aside from . + bool IsBackedOff(string sweep, Guid candidateId); + + /// Remember one more failure in a row. Returns true when it set the candidate aside, so the caller can say so once. + bool RecordFailure(string sweep, Guid candidateId); + + /// Forget the candidate's failures. + void RecordSuccess(string sweep, Guid candidateId); +} + +public sealed class SweepFailureBackoff : ISweepFailureBackoff, ISingletonDependency +{ + /// Failures in a row after which a candidate is set aside. + public const int FailuresBeforeBackoff = 3; + + /// How long a candidate stays set aside — about twelve ticks of a five-minute sweep. + public static readonly TimeSpan BackoffWindow = TimeSpan.FromHours(1); + + /// A failure this old counts for nothing: the candidate went quiet, and is judged afresh. + public static readonly TimeSpan ForgetAfter = TimeSpan.FromHours(24); + + private const int PruneAbove = 1024; + + private readonly ConcurrentDictionary<(string Sweep, Guid CandidateId), Standing> _standing = new(); + private readonly TimeProvider _clock; + + public SweepFailureBackoff(TimeProvider clock) { _clock = clock; } + + public bool IsBackedOff(string sweep, Guid candidateId) + { + if (!_standing.TryGetValue((sweep, candidateId), out var standing)) return false; + + return standing.Failures >= FailuresBeforeBackoff && _clock.GetUtcNow() - standing.LastFailure < BackoffWindow; + } + + public bool RecordFailure(string sweep, Guid candidateId) + { + var now = _clock.GetUtcNow(); + + var standing = _standing.AddOrUpdate((sweep, candidateId), _ => new Standing(1, now), (_, current) => Next(current, now)); + + if (_standing.Count > PruneAbove) PruneForgotten(now); + + return standing.Failures >= FailuresBeforeBackoff; + } + + public void RecordSuccess(string sweep, Guid candidateId) => _standing.TryRemove((sweep, candidateId), out _); + + private static Standing Next(Standing current, DateTimeOffset now) => + now - current.LastFailure > ForgetAfter ? new Standing(1, now) : new Standing(current.Failures + 1, now); + + /// Candidates fixed some other way never succeed through the sweep, so nothing else would ever forget them. + private void PruneForgotten(DateTimeOffset now) + { + foreach (var (key, standing) in _standing) + if (now - standing.LastFailure > ForgetAfter) _standing.TryRemove(key, out _); + } + + private readonly record struct Standing(int Failures, DateTimeOffset LastFailure); +} diff --git a/backend/src/CodeSpace.Core/Services/Credentials/SweepPass.cs b/backend/src/CodeSpace.Core/Services/Credentials/SweepPass.cs new file mode 100644 index 000000000..4a8f86064 --- /dev/null +++ b/backend/src/CodeSpace.Core/Services/Credentials/SweepPass.cs @@ -0,0 +1,142 @@ +using Microsoft.Extensions.Logging; + +namespace CodeSpace.Core.Services.Credentials; + +/// A row a sweep may act on: ordered by this id, so a pass can walk the backlog with a cursor. +internal interface ISweepCandidate +{ + Guid Id { get; } +} + +/// The next page of candidates whose id is greater than (from the start when null), in id order, at most of them. +internal delegate Task> LoadSweepPage(Guid? after, int take, CancellationToken cancellationToken); + +/// Act on one candidate on its own save. True when it did what the sweep is for; false when there turned out to be nothing to do. A throw is a failure. +internal delegate Task AttemptSweepCandidate(TCandidate candidate, CancellationToken cancellationToken); + +/// The bounds of a , per slot of its batch. +internal static class SweepPassBounds +{ + /// Candidates a pass may attempt per slot of its batch: a candidate that fails is attempted, and uses up one of these, but not a slot of the batch. + public const int AttemptsPerBatchSlot = 4; + + /// Candidates a pass may look at per slot of its batch, set-aside ones included — generous, because it is what lets a pass walk past them. + public const int ExaminedPerBatchSlot = 100; +} + +/// +/// One pass of a recurring sweep over a backlog that is not allowed to starve: the departed-member retirement and the +/// stranded-binding repair both read their candidates in id order and keep a candidate that fails, which is right — the +/// next tick may succeed — and which, with every pass reading from the front, let a batch's worth of candidates that can +/// never succeed hold every place in it for good. +/// +/// So a pass walks the backlog in pages behind a cursor: a failure is logged and skipped, costs no place in the batch +/// and is not looked at again by this pass. It stops when batchSize candidates are done or the backlog ends — and is +/// bounded in work as well as in results: it attempts at most candidates per slot of the +/// batch, and examines at most , which is also what ends a walk whose loader stopped +/// honouring its cursor. A candidate that has failed several passes in a row is set aside by +/// and neither attempted nor logged until its window ends. +/// +internal sealed class SweepPass where TCandidate : ISweepCandidate +{ + private readonly string _name; + private readonly ISweepFailureBackoff _backoff; + private readonly ILogger _logger; + + public SweepPass(string name, ISweepFailureBackoff backoff, ILogger logger) + { + _name = name; + _backoff = backoff; + _logger = logger; + } + + public async Task RunAsync(int batchSize, LoadSweepPage loadPage, AttemptSweepCandidate attempt, CancellationToken cancellationToken) + { + var tally = new Tally(batchSize); + Guid? after = null; + + while (tally.HasRoom) + { + var page = await loadPage(after, batchSize, cancellationToken).ConfigureAwait(false); + + if (page.Count == 0 || DidNotAdvance(page, after)) break; + + await WorkPageAsync(page, attempt, tally, cancellationToken).ConfigureAwait(false); + + if (page.Count < batchSize) break; + + after = page[^1].Id; + } + + return tally.Done; + } + + /// + /// A page that ends where the previous one did means the loader ignored its cursor; reading on would read the same + /// page for ever. Compared for equality, not order: a does not sort in .NET as a Postgres uuid does, + /// and the examined cap is what ends a loader that fails in some way this does not name. + /// + private bool DidNotAdvance(List page, Guid? after) + { + if (after is not Guid cursor || page[^1].Id != cursor) return false; + + _logger.LogError("{Sweep}: the page after {Cursor} ended at the same candidate; the pass stops rather than read the same candidates again", _name, cursor); + + return true; + } + + private async Task WorkPageAsync(List page, AttemptSweepCandidate attempt, Tally tally, CancellationToken cancellationToken) + { + foreach (var candidate in page) + { + if (!tally.HasRoom) return; + + tally.Examined++; + + if (_backoff.IsBackedOff(_name, candidate.Id)) continue; + + tally.Attempts++; + + if (await TryAsync(candidate, attempt, cancellationToken).ConfigureAwait(false)) tally.Done++; + } + } + + private async Task TryAsync(TCandidate candidate, AttemptSweepCandidate attempt, CancellationToken cancellationToken) + { + try + { + var done = await attempt(candidate, cancellationToken).ConfigureAwait(false); + + _backoff.RecordSuccess(_name, candidate.Id); + + return done; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + LogFailure(ex, candidate.Id, _backoff.RecordFailure(_name, candidate.Id)); + + return false; + } + } + + private void LogFailure(Exception ex, Guid candidateId, bool setAside) + { + if (setAside) + _logger.LogWarning(ex, "{Sweep}: candidate {CandidateId} failed again and is set aside — it is not attempted again until its back-off window ends", _name, candidateId); + else + _logger.LogWarning(ex, "{Sweep}: candidate {CandidateId} failed; the pass continues — it stays a candidate", _name, candidateId); + } + + private sealed class Tally + { + private readonly int _batchSize; + + public Tally(int batchSize) { _batchSize = batchSize; } + + public int Done { get; set; } + public int Attempts { get; set; } + public int Examined { get; set; } + + public bool HasRoom => Done < _batchSize && Attempts < (long)_batchSize * SweepPassBounds.AttemptsPerBatchSlot && Examined < (long)_batchSize * SweepPassBounds.ExaminedPerBatchSlot; + } +} diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs index 9d67d3a99..262a01205 100644 --- a/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs +++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/IUserProviderIdentityService.cs @@ -22,7 +22,7 @@ public interface IUserProviderIdentityService /// The caller's live linked identities (one per provider instance), newest first. Task> ListMineAsync(CancellationToken cancellationToken); - /// Unlink one of the caller's identities: soft-delete it and retire its credential, cascading to the repositories bound through it. No-op if already gone. + /// Unlink one of the caller's identities: soft-delete it and retire its credential — carrying what is bound through it to the caller's one other live connection when they hold exactly one, as a Disconnect does, and cascading to what is left. No-op if already gone. Task UnlinkAsync(Guid identityId, CancellationToken cancellationToken); /// diff --git a/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs b/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs index ec7ed6faf..68d381b7c 100644 --- a/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs +++ b/backend/src/CodeSpace.Core/Services/Providers/Identity/UserProviderIdentityService.cs @@ -104,6 +104,8 @@ public async Task UnlinkAsync(Guid identityId, CancellationToken cancellationTok identity.DeletedDate = DateTimeOffset.UtcNow; + await MoveBindingsToOnlyOtherConnectionAsync(identity.Credential, userId, cancellationToken).ConfigureAwait(false); + // Retire the backing credential through the same cascade a Disconnect runs — an unlinked identity must leave // nothing usable at rest, and a repository bound through its token must say it needs a new one rather than // stay Active on a blanked payload. Still the caller's own token, from whichever team they ask. @@ -112,6 +114,21 @@ public async Task UnlinkAsync(Guid identityId, CancellationToken cancellationTok await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + /// + /// An unlink ends the person's connection exactly as a Disconnect does, so what a Disconnect that names nobody carries + /// forward — everything bound through it, onto their one other live connection — an unlink carries forward too, before + /// the cascade that would otherwise strand it. The same rule, the same refusals, one place: + /// . + /// + private async Task MoveBindingsToOnlyOtherConnectionAsync(Credential ending, Guid userId, CancellationToken cancellationToken) + { + var successor = await _succession.FindOnlyOtherConnectionAsync(ending, userId, cancellationToken).ConfigureAwait(false); + + if (successor == null) return; + + await _succession.MoveBindingsAsync(ending, successor, cancellationToken).ConfigureAwait(false); + } + public async Task EnsureIdentityForCredentialAsync(ProviderInstance instance, Credential credential, Guid userId, CancellationToken cancellationToken) { if (!ConnectedBy(credential, userId)) return; diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs new file mode 100644 index 000000000..81e3a6c56 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs @@ -0,0 +1,48 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Commands.Credentials; + +/// +/// Move EVERY live repository and group hook still bound through a dead credential onto another credential, in one +/// action — the recovery for a Disconnect or a departure that nothing carried forward. The per-repository +/// RelinkRepositoryCredentialCommand fixes one repository and never touches a group hook; a credential holding +/// forty of them and a hook was forty clicks and a hook that stayed dead. +/// +/// 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 +/// 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 +/// 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. +/// +/// Needs both (declared here) and +/// (asserted by the service). The credential-keyed marker is not used for +/// the source: it refuses a soft-deleted credential, which is exactly the one this command exists for. The service loads +/// both credentials by (id, header team) instead. +/// +public sealed record RelinkCredentialBindingsCommand : ICommand, IRequireTeamPermission +{ + public string RequiredPermission => TeamPermissions.ReposManage; + + /// Set by the controller from the route segment via `command with { FromCredentialId = ... }`. Non-required so System.Text.Json doesn't 400-fail when the body omits it (URL is authoritative). + public Guid FromCredentialId { get; init; } + + public required Guid ToCredentialId { get; init; } +} + +public sealed record RelinkCredentialBindingsResult +{ + public required Guid FromCredentialId { get; init; } + public required Guid ToCredentialId { get; init; } + + /// Repositories re-pointed — and, where the dead credential's disconnect had turned them Error, returned to Active. + public required int MovedRepositoryCount { get; init; } + + /// Group hooks re-pointed; a failed or dead-lettered one is revived and handed to the registrar. + public required int MovedConnectionWebhookCount { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RepairDisconnectedCredentialBindingsCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RepairDisconnectedCredentialBindingsCommand.cs index a8a3f6bd5..cd6351e43 100644 --- a/backend/src/CodeSpace.Messages/Commands/Credentials/RepairDisconnectedCredentialBindingsCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RepairDisconnectedCredentialBindingsCommand.cs @@ -16,6 +16,6 @@ namespace CodeSpace.Messages.Commands.Credentials; /// public sealed record RepairDisconnectedCredentialBindingsCommand : ICommand, INonTransactionalCommand { - /// Disconnected credentials repaired per tick — bounds each pass. + /// Disconnected credentials repaired per tick — bounds each pass, which also attempts a bounded multiple of it: credentials that fail do not count as repaired, are not retried again by the same pass, and after several passes in a row are set aside for a while. public int BatchSize { get; init; } = 50; } diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs index 6f3ee8765..d2cf49695 100644 --- a/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RetireDepartedMembersCredentialsCommand.cs @@ -20,6 +20,6 @@ namespace CodeSpace.Messages.Commands.Credentials; /// public sealed record RetireDepartedMembersCredentialsCommand : ICommand, INonTransactionalCommand { - /// Tokens retired per tick — bounds each pass. + /// Tokens retired per tick — bounds each pass, which also attempts a bounded multiple of it: tokens that fail do not count as retired, are not retried again by the same pass, and after several passes in a row are set aside for a while, so tokens that can never be retired cannot keep a healthy departed member's from being reached. public int BatchSize { get; init; } = 50; } diff --git a/backend/src/CodeSpace.Messages/Commands/Credentials/RevokeCredentialCommand.cs b/backend/src/CodeSpace.Messages/Commands/Credentials/RevokeCredentialCommand.cs index d60ad27b4..8f8620bb7 100644 --- a/backend/src/CodeSpace.Messages/Commands/Credentials/RevokeCredentialCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Credentials/RevokeCredentialCommand.cs @@ -18,7 +18,27 @@ namespace CodeSpace.Messages.Commands.Credentials; /// public sealed record RevokeCredentialCommand : ICommand, IRequireCredentialManagement { - public required Guid CredentialId { get; init; } + /// Set by the controller from the route segment via `command with { CredentialId = ... }`. Non-required so System.Text.Json doesn't 400-fail when the optional body omits it (URL is authoritative). + public Guid CredentialId { get; init; } + + /// + /// Where the repositories and group hooks bound through this credential go, in the same unit of work as the + /// disconnect — so there is no moment they sit on a revoked row. It must be a live credential of the same owner (a + /// team-service credential's successor is another team-service credential) on the same provider instance, and its + /// owner must still stand in the team; anything else is refused before the provider is asked to revoke anything. + /// + /// Left out, the answer is the one reconnecting would give — but only for the person it would be given to. When + /// the caller is the credential's owner, it is still connected, and they hold exactly one other live credential on the + /// same instance, the bindings follow it, because connecting that one first and disconnecting this one second must end + /// where disconnecting first and connecting second always did. In every other case nothing moves and the repositories + /// turn Error, exactly as a Disconnect always did: someone else's (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 — naming the target is how an + /// Admin moves it), one already disconnected (a retry is not a new choice), none to follow, or two or more to choose + /// between — the ambiguity is the person's to resolve, here or with RelinkCredentialBindingsCommand after. A + /// team-service credential never auto-moves: two team tokens are rarely interchangeable (a group token reaches only its + /// group). GET /credentials/{id}/usage previews which of these a Disconnect will be. + /// + public Guid? MoveBindingsToCredentialId { get; init; } } public sealed record RevokeCredentialResult @@ -38,4 +58,13 @@ public sealed record RevokeCredentialResult /// Zero when the credential wasn't tied to any active repo — disconnect is silent. /// public int AffectedRepositoryCount { get; init; } + + /// Live repositories that followed the disconnect to another credential instead of turning Error. Zero when nothing moved. + public int MovedRepositoryCount { get; init; } + + /// Group hooks that followed with them. + public int MovedConnectionWebhookCount { get; init; } + + /// The credential the bindings now run through; null when nothing moved. + public Guid? MovedToCredentialId { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Commands/Identity/UnlinkProviderIdentityCommand.cs b/backend/src/CodeSpace.Messages/Commands/Identity/UnlinkProviderIdentityCommand.cs index 0d5bbf49e..d312a2b96 100644 --- a/backend/src/CodeSpace.Messages/Commands/Identity/UnlinkProviderIdentityCommand.cs +++ b/backend/src/CodeSpace.Messages/Commands/Identity/UnlinkProviderIdentityCommand.cs @@ -4,7 +4,11 @@ namespace CodeSpace.Messages.Commands.Identity; -/// Unlink one of the caller's OWN provider identities (soft-delete + clear token material). +/// +/// Unlink one of the caller's OWN provider identities (soft-delete + clear token material). The credential behind it ends +/// exactly as a Disconnect ends one, so the repositories and group hooks bound through it follow the person's one other +/// live connection on that provider instance when they hold exactly one, and turn "needs a new credential" otherwise. +/// public sealed record UnlinkProviderIdentityCommand : ICommand, IRequireTeamMembership { public required Guid IdentityId { get; init; } diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/BindingsMoved.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/BindingsMoved.cs new file mode 100644 index 000000000..178835d21 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/BindingsMoved.cs @@ -0,0 +1,13 @@ +namespace CodeSpace.Messages.Dtos.Credentials; + +/// What a move of one credential's bindings onto another carried: the live repositories re-pointed, and the group hooks re-pointed with them. +public sealed record BindingsMoved +{ + public static BindingsMoved None { get; } = new(); + + public int RepositoryCount { get; init; } + + public int ConnectionWebhookCount { get; init; } + + public bool Any => RepositoryCount + ConnectionWebhookCount > 0; +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialDependents.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialDependents.cs new file mode 100644 index 000000000..11b0b8f56 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialDependents.cs @@ -0,0 +1,76 @@ +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Messages.Dtos.Credentials; + +/// +/// Everything in a team that leans on one credential — what a Disconnect would break, or what a dead credential left +/// stranded. Read for a live credential before disconnecting it, and for a disconnected or retired one to see what is +/// waiting for a new home. +/// +public sealed record CredentialDependents +{ + public required Guid CredentialId { get; init; } + public required string DisplayName { get; init; } + public required CredentialStatus Status { get; init; } + + /// Soft-deleted: a departed member's token the team retired. It is gone from the credential list, but what was bound through it is not. + public required bool Retired { get; init; } + + public Guid? OwnerUserId { get; init; } + public required Guid ProviderInstanceId { get; init; } + + /// Live repositories bound through the credential, whatever their status, each with the projects it belongs to. + public required IReadOnlyList Repositories { get; init; } + + /// Group or organization hooks that register, retry and delete through the credential, other than ones already retired. + public required IReadOnlyList ConnectionWebhooks { get; init; } + + /// Act-as identities — a person's linked provider account — backed by the credential. A Disconnect ends them; moving bindings does not. + public required IReadOnlyList Identities { get; init; } + + /// Workflow triggers that name any of the repositories above: what stops firing for them while it is stranded. + public required IReadOnlyList Activations { get; init; } +} + +public sealed record DependentRepository +{ + public required Guid Id { get; init; } + public required string FullPath { get; init; } + public required RepositoryStatus Status { get; init; } + public string? LastError { get; init; } + public required IReadOnlyList Projects { get; init; } +} + +public sealed record DependentProject +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public required string Slug { get; init; } +} + +public sealed record DependentConnectionWebhook +{ + public required Guid Id { get; init; } + public required string OwnerPath { get; init; } + public required RepositoryWebhookRegistrationStatus RegistrationStatus { get; init; } +} + +public sealed record DependentIdentity +{ + public required Guid Id { get; init; } + public required Guid UserId { get; init; } + public string? UserName { get; init; } + public required string ProviderUsername { get; init; } +} + +public sealed record DependentActivation +{ + public required Guid ActivationId { get; init; } + public required Guid WorkflowId { get; init; } + public required string WorkflowName { get; init; } + public required string TypeKey { get; init; } + public required bool Enabled { get; init; } + + /// The dependent repositories this trigger names. + public required IReadOnlyList RepositoryIds { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs b/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs new file mode 100644 index 000000000..6ec9e8b8e --- /dev/null +++ b/backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs @@ -0,0 +1,35 @@ +using CodeSpace.Messages.Enums; + +namespace CodeSpace.Messages.Dtos.Credentials; + +/// Whose connection a suggestion is. Declared best-first: suggestions are ordered by it, then newest first. +public enum RelinkSuggestionKind +{ + /// A live Personal credential of the person asking. + YourConnection, + + /// A live team-service token — the team's, owned by no person, so it survives anyone leaving. + TeamServiceToken, + + /// Another live Personal credential of the person the credential belongs to, offered to someone else asking about a credential that is still connected: a Disconnect moves its repositories only onto the same owner's other connections. + OwnersOtherConnection +} + +/// +/// 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. +/// +public sealed record RelinkSuggestion +{ + public required Guid CredentialId { get; init; } + public required string DisplayName { get; init; } + public required AuthType AuthType { get; init; } + public required CredentialOwnership Ownership { get; init; } + public required RelinkSuggestionKind Kind { get; init; } + public required DateTimeOffset CreatedDate { get; init; } + + /// 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/Exceptions/CredentialDisconnectedException.cs b/backend/src/CodeSpace.Messages/Exceptions/CredentialDisconnectedException.cs index a88c1efb5..e01e21058 100644 --- a/backend/src/CodeSpace.Messages/Exceptions/CredentialDisconnectedException.cs +++ b/backend/src/CodeSpace.Messages/Exceptions/CredentialDisconnectedException.cs @@ -6,8 +6,10 @@ namespace CodeSpace.Messages.Exceptions; /// Thrown when something tries to authenticate through a credential that was disconnected. A disconnect revokes the /// row and blanks its encrypted payload, so there is nothing left to decrypt — and Data Protection would report that /// empty payload as one "not protected with this protection provider", which sends a reader hunting for a key-ring -/// fault that does not exist. The remedy is a live credential: reconnect the provider (repositories bound through the -/// disconnected one follow their owner's reconnect), or re-link to another active credential. +/// fault that does not exist. The remedy is a live credential, by the steps a person can take today: a team Admin +/// re-links the repository to an active credential of the same provider, or, for a personal connection, its owner +/// reconnects the provider and what was bound through it follows. (Re-linking every repository on the credential at once +/// is an API with no screen yet, so the message does not send anyone looking for it.) /// public sealed class CredentialDisconnectedException : Exception, IFailure { @@ -20,7 +22,7 @@ public sealed class CredentialDisconnectedException : Exception, IFailure public Guid CredentialId { get; } public CredentialDisconnectedException(Guid credentialId, string displayName) - : base($"The credential '{displayName}' was disconnected, so nothing bound through it can authenticate. Reconnect the provider, or re-link the repository to an active credential, then retry.") + : base($"The credential '{displayName}' was disconnected, so nothing bound through it can authenticate. A team Admin can re-link the repository to an active credential of the same provider; if it was a personal connection, its owner reconnecting the provider moves it too. Then retry.") { CredentialId = credentialId; } diff --git a/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialDependentsQuery.cs b/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialDependentsQuery.cs new file mode 100644 index 000000000..d22f59353 --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialDependentsQuery.cs @@ -0,0 +1,20 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Mediation; + +namespace CodeSpace.Messages.Queries.Credentials; + +/// +/// What leans on a credential: the repositories bound through it (with their projects), the group hooks it registered, +/// the act-as identities it backs, and the workflow triggers that name those repositories. For a live credential it is +/// the Disconnect confirmation's "this will affect"; for a disconnected or retired one it is the list of what is waiting +/// for a new home. +/// +/// Membership alone, like the repository list it summarizes. Not keyed by the credential marker, which refuses a +/// soft-deleted credential — a departed member's retired token is the one a team most needs to ask about — so the +/// service loads it by (id, header team). +/// +public sealed record GetCredentialDependentsQuery : IQuery, IRequireTeamMembership +{ + public Guid CredentialId { get; init; } +} diff --git a/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialUsageQuery.cs b/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialUsageQuery.cs index 6b6b9dc49..6c1d1e979 100644 --- a/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialUsageQuery.cs +++ b/backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialUsageQuery.cs @@ -4,10 +4,10 @@ namespace CodeSpace.Messages.Queries.Credentials; /// -/// Pre-disconnect preview: how many repositories will lose their auth source if this -/// credential is revoked. Lets the UI surface impact in the confirm dialog so the user -/// understands what they're about to break — "Disconnect? 3 repositories will need a -/// new credential." Cheaper than running the actual revoke and reading the result. +/// Pre-disconnect preview: what a Disconnect of this credential, asked by the caller right now, would do to the +/// repositories bound through it. Lets the UI surface impact in the confirm dialog so the user understands what they're +/// about to break — "Disconnect? 3 repositories will need a new credential" — or what will carry on without a break — "3 +/// repositories will move to your other connection". Cheaper than running the actual revoke and reading the result. /// public sealed record GetCredentialUsageQuery : IQuery, IRequireCredentialAccess { @@ -17,5 +17,13 @@ public sealed record GetCredentialUsageQuery : IQuery, IRequire public sealed record CredentialUsage { public required Guid CredentialId { get; init; } + + /// Working repositories the Disconnect would turn to "needs a new credential". Zero when they would follow another connection instead — see . public required int ActiveRepositoryCount { get; init; } + + /// Repositories the Disconnect would move, in the same unit of work, to the person's one other live connection () rather than break. Zero when nothing would move. + public int WillMoveRepositoryCount { get; init; } + + /// The display name of the connection they would move to; null when nothing would move. + public string? WillMoveToDisplayName { get; init; } } diff --git a/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs b/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs new file mode 100644 index 000000000..4ee3a345a --- /dev/null +++ b/backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs @@ -0,0 +1,19 @@ +using CodeSpace.Messages.Authorization; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Mediation; + +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 +/// 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 +/// soft-deleted, so it is loaded by (id, header team) rather than through the credential marker. +/// +public sealed record GetRelinkSuggestionsQuery : IQuery>, IRequireTeamMembership +{ + public Guid CredentialId { get; init; } +} diff --git a/backend/tests/CodeSpace.E2ETests/Credentials/CredentialBindingsHttpE2ETests.cs b/backend/tests/CodeSpace.E2ETests/Credentials/CredentialBindingsHttpE2ETests.cs new file mode 100644 index 000000000..b47c1ca7c --- /dev/null +++ b/backend/tests/CodeSpace.E2ETests/Credentials/CredentialBindingsHttpE2ETests.cs @@ -0,0 +1,427 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.Providers; +using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.E2ETests.Infrastructure; +using CodeSpace.IntegrationTests.Binding; +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; + +/// +/// Where a credential's repositories and group hooks go when it is disconnected or its owner leaves, through the whole +/// HTTP surface: POST /credentials/{id}/revoke with and without a body, GET /credentials/{id}/dependents and +/// /relink-suggestions, and POST /credentials/{id}/relink-bindings. +/// +/// Tier: 🟢 High-fidelity (Rule 12) — the real ASP.NET pipeline (routing, JWT auth, the X-Team-Id scope, model +/// binding including the optional revoke body, the controllers, the exception filter, the mediator and its authorization +/// behaviors), real Postgres, and the real job drain: a hook revived by a move is registered again by the production +/// registrar. One documented seam — the provider is the Git test provider (whoami, catalog, hook registration), so +/// nothing real is contacted. "A run on the repository succeeds" is the run's own first read, the production +/// on the host's container, which presents the credential the repository is bound +/// to — and refuses a revoked one. +/// +[Trait("Category", "E2E")] +[Trait("Surface", "Http")] +public sealed class CredentialBindingsHttpE2ETests : IClassFixture +{ + private readonly GitProviderApiFactory _factory; + private readonly TenantHarness _tenants; + + public CredentialBindingsHttpE2ETests(GitProviderApiFactory factory) + { + _factory = factory; + _tenants = new TenantHarness(factory); + } + + [Fact] + public async Task A_disconnect_naming_the_successor_moves_the_repository_and_the_next_run_clones_through_it() + { + var world = await SeedWorldAsync(); + var dying = await SeedCredentialAsync(world, world.OwnerId, "pat-dying"); + var successor = await SeedCredentialAsync(world, world.OwnerId, "pat-successor"); + var spare = await SeedCredentialAsync(world, world.OwnerId, "pat-spare"); + var repoId = await SeedRepositoryAsync(world, dying); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dying}/revoke", world.OwnerId, world.TeamId, new { moveBindingsToCredentialId = successor }); + + var body = await ReadJsonAsync(response); + + body.GetProperty("movedRepositoryCount").GetInt32().ShouldBe(1); + body.GetProperty("movedToCredentialId").GetGuid().ShouldBe(successor, "two other connections were live, so only the named one could be the destination"); + body.GetProperty("affectedRepositoryCount").GetInt32().ShouldBe(0); + + var repo = await LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(successor); + repo.Status.ShouldBe(RepositoryStatus.Active); + (await ResolveCloneTokenAsync(world, repoId)).ShouldBe("pat-successor", "the run's first read presents the successor's token"); + (await LoadCredentialAsync(spare)).Status.ShouldBe(CredentialStatus.Active); + (await LoadCredentialAsync(dying)).Status.ShouldBe(CredentialStatus.Revoked); + } + + [Theory] + [InlineData(1, true)] + [InlineData(2, false)] + public async Task A_disconnect_with_no_body_behaves_as_it_always_did_unless_the_owner_has_exactly_one_other_connection(int others, bool moves) + { + // The Providers modal sends no body today: POST with nothing. That is the request that stranded repositories, and + // the one an unchanged client keeps sending. + var world = await SeedWorldAsync(); + var dying = await SeedCredentialAsync(world, world.OwnerId, "pat-dying"); + var survivors = new List(); + + for (var i = 0; i < others; i++) survivors.Add(await SeedCredentialAsync(world, world.OwnerId, $"pat-other-{i}")); + + var repoId = await SeedRepositoryAsync(world, dying); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dying}/revoke", world.OwnerId, world.TeamId); + + var body = await ReadJsonAsync(response); + var repo = await LoadRepositoryAsync(repoId); + + body.GetProperty("movedRepositoryCount").GetInt32().ShouldBe(moves ? 1 : 0); + body.GetProperty("affectedRepositoryCount").GetInt32().ShouldBe(moves ? 0 : 1); + repo.CredentialId.ShouldBe(moves ? survivors[0] : dying); + repo.Status.ShouldBe(moves ? RepositoryStatus.Active : RepositoryStatus.Error); + } + + [Theory] + [InlineData(1, true)] + [InlineData(2, false)] + public async Task The_usage_preview_says_what_the_disconnect_with_no_body_then_does(int others, bool moves) + { + // The confirm dialog quotes GET /usage and then POSTs the disconnect with no body: what the first says must be what the + // second does, or the dialog warns about a break that will not happen — or promises a move that will not. + var world = await SeedWorldAsync(); + var dying = await SeedCredentialAsync(world, world.OwnerId, "pat-dying"); + var survivors = new List(); + + for (var i = 0; i < others; i++) survivors.Add(await SeedCredentialAsync(world, world.OwnerId, $"pat-other-{i}")); + + await SeedRepositoryAsync(world, dying); + await SeedRepositoryAsync(world, dying); + + var usage = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{dying}/usage", world.OwnerId, world.TeamId)); + var body = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dying}/revoke", world.OwnerId, world.TeamId)); + + usage.GetProperty("activeRepositoryCount").GetInt32().ShouldBe(moves ? 0 : 2); + usage.GetProperty("willMoveRepositoryCount").GetInt32().ShouldBe(moves ? 2 : 0); + (usage.GetProperty("willMoveToDisplayName").GetString() is { } named ? named : null).ShouldBe(moves ? "pat-other-0" : null); + + body.GetProperty("affectedRepositoryCount").GetInt32().ShouldBe(usage.GetProperty("activeRepositoryCount").GetInt32()); + body.GetProperty("movedRepositoryCount").GetInt32().ShouldBe(usage.GetProperty("willMoveRepositoryCount").GetInt32()); + } + + [Fact] + public async Task An_admin_disconnecting_a_teammates_credential_with_no_body_moves_nothing_and_the_preview_says_so() + { + var world = await SeedWorldAsync(); + var theirs = await SeedCredentialAsync(world, world.MemberId, "pat-member-first"); + var theirsOther = await SeedCredentialAsync(world, world.MemberId, "pat-member-second"); + var repoId = await SeedRepositoryAsync(world, theirs); + + var usage = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{theirs}/usage", world.AdminId, world.TeamId)); + var body = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{theirs}/revoke", world.AdminId, world.TeamId)); + var repo = await LoadRepositoryAsync(repoId); + + usage.GetProperty("activeRepositoryCount").GetInt32().ShouldBe(1); + usage.GetProperty("willMoveRepositoryCount").GetInt32().ShouldBe(0); + body.GetProperty("movedRepositoryCount").GetInt32().ShouldBe(0, "the teammate's other connection is the teammate's to choose: an Admin names it, or nothing moves"); + repo.CredentialId.ShouldBe(theirs); + repo.Status.ShouldBe(RepositoryStatus.Error); + (await LoadCredentialAsync(theirsOther)).Status.ShouldBe(CredentialStatus.Active); + } + + [Fact] + public async Task For_a_live_credential_the_suggestions_are_the_owners_other_connections_which_the_disconnect_accepts() + { + var world = await SeedWorldAsync(); + var theirs = await SeedCredentialAsync(world, world.MemberId, "pat-member-first"); + var theirsOther = await SeedCredentialAsync(world, world.MemberId, "pat-member-second"); + await SeedCredentialAsync(world, world.AdminId, "pat-admin"); + await SeedCredentialAsync(world, ownerId: null, "pat-team", CredentialOwnership.TeamService); + var repoId = await SeedRepositoryAsync(world, theirs); + + var suggestions = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{theirs}/relink-suggestions", world.AdminId, world.TeamId)); + + suggestions.EnumerateArray().Select(s => s.GetProperty("credentialId").GetGuid()).ShouldBe(new[] { theirsOther }, "not the Admin's own connection, and not the team's token: a Disconnect would refuse both"); + suggestions[0].GetProperty("kind").ToString().ShouldBe("OwnersOtherConnection"); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{theirs}/revoke", world.AdminId, world.TeamId, new { moveBindingsToCredentialId = theirsOther }); + + (await ReadJsonAsync(response)).GetProperty("movedRepositoryCount").GetInt32().ShouldBe(1); + (await LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(theirsOther); + } + + [Fact] + public async Task The_route_decides_which_credential_is_revoked_whatever_the_body_says() + { + var world = await SeedWorldAsync(); + var named = await SeedCredentialAsync(world, world.OwnerId, "pat-named-in-route"); + var other = await SeedCredentialAsync(world, world.OwnerId, "pat-named-in-body"); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{named}/revoke", world.OwnerId, world.TeamId, new { credentialId = other }); + + response.StatusCode.ShouldBe(HttpStatusCode.OK, await TenantHarness.DescribeAsync(response)); + (await LoadCredentialAsync(named)).Status.ShouldBe(CredentialStatus.Revoked); + (await LoadCredentialAsync(other)).Status.ShouldBe(CredentialStatus.Active, "a body that disagrees with the URL cannot redirect the revoke"); + } + + [Fact] + public async Task A_refused_target_is_a_400_that_says_why_and_the_credential_stays_connected() + { + var world = await SeedWorldAsync(); + var dying = await SeedCredentialAsync(world, world.OwnerId, "pat-dying"); + var teammates = await SeedCredentialAsync(world, world.AdminId, "pat-teammates"); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dying}/revoke", world.OwnerId, world.TeamId, new { moveBindingsToCredentialId = teammates }); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, await TenantHarness.DescribeAsync(response)); + (await ReadJsonAsync(response, requireSuccess: false)).GetProperty("message").GetString().ShouldContain("belongs to someone else"); + (await LoadCredentialAsync(dying)).Status.ShouldBe(CredentialStatus.Active); + } + + [Fact] + public async Task When_an_owner_leaves_an_admin_sees_what_waits_picks_a_home_and_relinks_it_all_in_one_call() + { + var world = await SeedWorldAsync(); + var leaver = await _tenants.AccountAsync(); + await _tenants.AddMemberAsync(world.TeamId, leaver, TeamRole.Member); + var theirs = await SeedCredentialAsync(world, leaver, "pat-leaver"); + var first = await SeedRepositoryAsync(world, theirs); + var second = await SeedRepositoryAsync(world, theirs); + var hookId = await SeedHookAsync(world, theirs, RepositoryWebhookRegistrationStatus.DeadLettered); + var adminsOwn = await SeedCredentialAsync(world, world.AdminId, "pat-admin"); + var teamToken = await SeedCredentialAsync(world, ownerId: null, "pat-team", CredentialOwnership.TeamService); + await SeedCredentialAsync(world, world.OwnerId, "pat-owners-personal"); + + var removal = await _tenants.SendAsync(HttpMethod.Delete, $"/api/teams/members/{leaver}", world.OwnerId, world.TeamId); + removal.IsSuccessStatusCode.ShouldBeTrue(await TenantHarness.DescribeAsync(removal)); + + var listed = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, "/api/credentials", world.AdminId, world.TeamId)); + listed.EnumerateArray().Select(c => c.GetProperty("id").GetGuid()).ShouldNotContain(theirs, "fixture check: the retired token is out of the credential list, so a UI built on it could not even name it"); + + // What is waiting — asked of the retired credential, by a Viewer-level read (membership is read access). + var viewer = await _tenants.AccountAsync(); + await _tenants.AddMemberAsync(world.TeamId, viewer, TeamRole.Viewer); + var dependents = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{theirs}/dependents", viewer, world.TeamId)); + + dependents.GetProperty("retired").GetBoolean().ShouldBeTrue(); + dependents.GetProperty("repositories").EnumerateArray().Select(r => r.GetProperty("id").GetGuid()).ShouldBe(new[] { first, second }, ignoreOrder: true); + dependents.GetProperty("connectionWebhooks").GetArrayLength().ShouldBe(1); + + // Where it could go — the caller's own connection, then the team's token; never the Owner's personal one. + var suggestions = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{theirs}/relink-suggestions", world.AdminId, world.TeamId)); + suggestions.EnumerateArray().Select(s => s.GetProperty("credentialId").GetGuid()).ShouldBe(new[] { adminsOwn, teamToken }); + suggestions[0].GetProperty("kind").ToString().ShouldBe("YourConnection"); + + // One call. + var relink = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{theirs}/relink-bindings", world.AdminId, world.TeamId, new { toCredentialId = adminsOwn }); + var result = await ReadJsonAsync(relink); + + result.GetProperty("movedRepositoryCount").GetInt32().ShouldBe(2); + result.GetProperty("movedConnectionWebhookCount").GetInt32().ShouldBe(1); + + foreach (var repoId in new[] { first, second }) + { + (await LoadRepositoryAsync(repoId)).Status.ShouldBe(RepositoryStatus.Active); + (await ResolveCloneTokenAsync(world, repoId)).ShouldBe("pat-admin", "a run on either repository clones through the Admin's connection"); + } + + // Hooks revive: the move handed the buried hook to the registrar, and the registrar — running through the hook's NEW + // credential — registers it. + await _factory.JobClient.DrainAsync(); + + var hook = await LoadHookAsync(hookId); + + hook.CredentialId.ShouldBe(adminsOwn); + hook.RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Registered, "a dead-lettered group hook that gained a live credential registers again"); + + var after = await ReadJsonAsync(await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{theirs}/dependents", viewer, world.TeamId)); + after.GetProperty("repositories").GetArrayLength().ShouldBe(0, "nothing is left waiting on the retired credential"); + after.GetProperty("connectionWebhooks").GetArrayLength().ShouldBe(0); + } + + [Theory] + [InlineData(TeamRole.Viewer)] + [InlineData(TeamRole.Member)] + public async Task Below_admin_the_bulk_relink_is_forbidden_but_the_reads_are_not(TeamRole role) + { + var world = await SeedWorldAsync(); + var caller = await _tenants.AccountAsync(); + await _tenants.AddMemberAsync(world.TeamId, caller, role); + var dead = await SeedCredentialAsync(world, world.MemberId, "pat-dead", status: CredentialStatus.Revoked); + var target = await SeedCredentialAsync(world, caller, "pat-callers"); + var repoId = await SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var relink = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dead}/relink-bindings", caller, world.TeamId, new { toCredentialId = target }); + var dependents = await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{dead}/dependents", caller, world.TeamId); + var suggestions = await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{dead}/relink-suggestions", caller, world.TeamId); + + relink.StatusCode.ShouldBe(HttpStatusCode.Forbidden, await TenantHarness.DescribeAsync(relink)); + dependents.StatusCode.ShouldBe(HttpStatusCode.OK, "membership is read access"); + suggestions.StatusCode.ShouldBe(HttpStatusCode.OK, "membership is read access"); + (await LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task Another_teams_admin_reaches_none_of_it_and_a_foreign_credential_reads_as_not_found() + { + var world = await SeedWorldAsync(); + var outsider = await _tenants.AccountAsync(); + var outsiderTeam = await _tenants.TeamAsync((outsider, TeamRole.Owner)); + var dead = await SeedCredentialAsync(world, world.MemberId, "pat-dead", status: CredentialStatus.Revoked); + var target = await SeedCredentialAsync(world, world.AdminId, "pat-admin"); + var repoId = await SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var asOutsiderToThisTeam = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dead}/relink-bindings", outsider, world.TeamId, new { toCredentialId = target }); + var asOutsiderInTheirTeam = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dead}/relink-bindings", outsider, outsiderTeam, new { toCredentialId = target }); + var dependentsInTheirTeam = await _tenants.SendAsync(HttpMethod.Get, $"/api/credentials/{dead}/dependents", outsider, outsiderTeam); + + asOutsiderToThisTeam.StatusCode.ShouldBe(HttpStatusCode.Forbidden, await TenantHarness.DescribeAsync(asOutsiderToThisTeam)); + asOutsiderInTheirTeam.StatusCode.ShouldBe(HttpStatusCode.NotFound, "a credential of another team reads as not found, so it cannot be told from one that does not exist"); + dependentsInTheirTeam.StatusCode.ShouldBe(HttpStatusCode.NotFound, await TenantHarness.DescribeAsync(dependentsInTheirTeam)); + (await LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task A_relink_onto_a_teammates_personal_connection_is_a_400_that_says_whose_it_is() + { + var world = await SeedWorldAsync(); + var dead = await SeedCredentialAsync(world, world.MemberId, "pat-dead", status: CredentialStatus.Revoked); + var teammates = await SeedCredentialAsync(world, world.OwnerId, "pat-owners"); + await SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var response = await _tenants.SendAsync(HttpMethod.Post, $"/api/credentials/{dead}/relink-bindings", world.AdminId, world.TeamId, new { toCredentialId = teammates }); + + response.StatusCode.ShouldBe(HttpStatusCode.BadRequest, await TenantHarness.DescribeAsync(response)); + (await ReadJsonAsync(response, requireSuccess: false)).GetProperty("message").GetString().ShouldContain("another person's personal connection"); + } + + // ─── Plumbing ──────────────────────────────────────────────────────────────── + + private sealed record World(Guid TeamId, Guid OwnerId, Guid AdminId, Guid MemberId, Guid InstanceId); + + private async Task SeedWorldAsync() + { + var owner = await _tenants.AccountAsync(); + var admin = await _tenants.AccountAsync(); + var member = await _tenants.AccountAsync(); + var teamId = await _tenants.TeamAsync((owner, TeamRole.Owner), (admin, TeamRole.Admin), (member, TeamRole.Member)); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var suffix = Guid.NewGuid().ToString("N")[..10]; + var instance = new ProviderInstance { Id = Guid.NewGuid(), TeamId = teamId, Provider = ProviderKind.GitLab, DisplayName = "GitLab", BaseUrl = $"https://gitlab.bindings.test/{suffix}", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + db.ProviderInstance.Add(instance); + await db.SaveChangesAsync(); + + return new World(teamId, owner, admin, member, instance.Id); + } + + private async Task SeedCredentialAsync(World world, Guid? ownerId, string token, CredentialOwnership? ownership = null, CredentialStatus status = CredentialStatus.Active) + { + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var sealedToken = scope.ServiceProvider.GetRequiredService().Encrypt(scope.ServiceProvider.GetRequiredService().Serialize(new PatPayload { Token = token })); + + var credential = new Credential { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = world.InstanceId, OwnerUserId = ownerId, Ownership = ownership ?? (ownerId == null ? CredentialOwnership.TeamService : CredentialOwnership.Personal), AuthType = AuthType.Pat, DisplayName = token, EncryptedPayload = status == CredentialStatus.Revoked ? string.Empty : sealedToken, Status = status, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + db.Credential.Add(credential); + await db.SaveChangesAsync(); + + return credential.Id; + } + + private async Task SeedRepositoryAsync(World world, Guid credentialId, RepositoryStatus status = RepositoryStatus.Active) + { + var suffix = Guid.NewGuid().ToString("N")[..10]; + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var repository = new Repository { Id = Guid.NewGuid(), TeamId = world.TeamId, ProviderInstanceId = world.InstanceId, CredentialId = credentialId, ExternalId = $"acme/ext-{suffix}", NamespacePath = "acme", Name = $"app-{suffix}", FullPath = $"acme/app-{suffix}", DefaultBranch = "main", Visibility = RepositoryVisibility.Private, WebUrl = "https://gitlab.bindings.test", CloneUrlHttps = $"https://gitlab.bindings.test/acme/app-{suffix}.git", Status = status, LastError = status == RepositoryStatus.Error ? "Credential disconnected." : null, CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }; + + db.Repository.Add(repository); + await db.SaveChangesAsync(); + + return repository.Id; + } + + private async Task SeedHookAsync(World world, Guid credentialId, RepositoryWebhookRegistrationStatus status) + { + var id = Guid.NewGuid(); + + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + db.ConnectionWebhook.Add(new ConnectionWebhook { Id = id, ProviderInstanceId = world.InstanceId, CredentialId = credentialId, OwnerPath = "acme", CallbackUrl = $"https://codespace.test/api/webhooks/connection/{id}", SecretEnc = scope.ServiceProvider.GetRequiredService().Encrypt("hook-secret"), SubscribedEvents = new List { "push" }, RegistrationStatus = status, Attempts = 3, NextAttemptAt = DateTimeOffset.UtcNow.AddHours(1), LastError = "401 Unauthorized", CreatedBy = SystemUsers.SeederId, LastModifiedBy = SystemUsers.SeederId }); + await db.SaveChangesAsync(); + + return id; + } + + private Task LoadRepositoryAsync(Guid id) => _tenants.DbAsync(db => db.Repository.AsNoTracking().SingleAsync(r => r.Id == id)); + + private Task LoadCredentialAsync(Guid id) => _tenants.DbAsync(db => db.Credential.AsNoTracking().SingleAsync(c => c.Id == id)); + + private Task LoadHookAsync(Guid id) => _tenants.DbAsync(db => db.ConnectionWebhook.AsNoTracking().SingleAsync(h => h.Id == id)); + + /// What every repository-bound run does first, on the host's own container: the production resolver, which presents the bound credential and refuses a revoked one. + private async Task ResolveCloneTokenAsync(World world, Guid repositoryId) + { + using var scope = _factory.Services.CreateScope(); + + var request = await scope.ServiceProvider.GetRequiredService().ResolveByRepositoryIdAsync(repositoryId, world.TeamId, CancellationToken.None); + + return request!.Token; + } + + private static async Task ReadJsonAsync(HttpResponseMessage response, bool requireSuccess = true) + { + if (requireSuccess) response.IsSuccessStatusCode.ShouldBeTrue(await TenantHarness.DescribeAsync(response)); + + return (await response.Content.ReadFromJsonAsync())!; + } + + /// + /// The app with one seam: the repositories live on a GitLab instance, so the production GitLab auth strategies read every + /// credential, and the Git test provider answers what GitLab's API would — whoami, catalog, hook registration. + /// + public sealed class GitProviderApiFactory : TaskLaunchApiFactory + { + protected override void ConfigureContractTestServices(ContainerBuilder builder) + { + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsImplementedInterfaces().InstancePerLifetimeScope(); + builder.Register(c => new GitStandsInForGitLabRegistry(new ProviderRegistry(c.Resolve>()))).InstancePerLifetimeScope(); + } + } + + /// Answers every capability lookup with the Git test provider's, so reading a repository or registering a hook contacts nothing real. + private sealed class GitStandsInForGitLabRegistry : IProviderRegistry + { + private readonly IProviderRegistry _inner; + + public GitStandsInForGitLabRegistry(IProviderRegistry inner) { _inner = inner; } + + public TCapability Require(ProviderKind kind) where TCapability : IProviderCapability => _inner.Require(ProviderKind.Git); + + public bool TryGet(ProviderKind kind, out TCapability? capability) where TCapability : class, IProviderCapability => _inner.TryGet(ProviderKind.Git, out capability); + + public IReadOnlyList GetCapabilities(ProviderKind kind) => _inner.GetCapabilities(ProviderKind.Git); + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs new file mode 100644 index 000000000..5cb2e0a66 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs @@ -0,0 +1,854 @@ +using System.Text.Json; +using Autofac; +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.OAuth; +using CodeSpace.Core.Services.Providers; +using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.Core.Services.Identity; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.OAuth; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Commands.Teams; +using CodeSpace.Messages.Constants; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Dtos.Providers; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using CodeSpace.Messages.Failures; +using CodeSpace.Messages.Queries.Credentials; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.Credentials; + +/// +/// Recovering what a dead credential left behind, in one action, through the real mediator pipeline: a member who leaves +/// takes their token with them (the team retires it, soft-deleted and out of the credential list), and every repository +/// and group hook bound through it stays pointed at a row that can never authenticate. Nothing carries them forward — the +/// person is gone — and the only repair there was is the per-repository Re-link, which never touched a group hook. +/// +/// Real Postgres; the production pipeline and services; the provider is the fixture's Git test provider, wrapped +/// where a test needs it to refuse or to say who it was asked as. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class CredentialRelinkFlowTests +{ + private readonly BindingMoveKit _kit; + + public CredentialRelinkFlowTests(PostgresFixture fixture) { _kit = new BindingMoveKit(fixture); } + + [Fact] + public async Task An_admin_moves_everything_a_departed_member_left_to_one_of_their_own_connections() + { + var world = await _kit.SeedWorldAsync(); + var leaver = await _kit.SeedMemberAsync(world, TeamRole.Member); + var theirs = await _kit.SeedCredentialAsync(world, leaver, "glpat-leaver"); + var first = await _kit.SeedRepositoryAsync(world, theirs); + var second = await _kit.SeedRepositoryAsync(world, theirs); + var stranded = await _kit.SeedConnectionWebhookAsync(world, theirs, RepositoryWebhookRegistrationStatus.DeadLettered); + var adminsOwn = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin"); + + await RemoveFromTeamAsync(world, leaver); + + (await _kit.LoadCredentialAsync(theirs)).DeletedDate.ShouldNotBeNull("fixture check: the departure retired the token — soft-deleted, out of the credential list"); + (await _kit.LoadRepositoryAsync(first)).Status.ShouldBe(RepositoryStatus.Error, "fixture check: and stranded what was bound through it"); + + using var dispatches = _kit.Jobs().ManualExecution(); + var result = await RelinkAsync(world, world.AdminId, theirs, adminsOwn); + + result.MovedRepositoryCount.ShouldBe(2); + result.MovedConnectionWebhookCount.ShouldBe(1); + + foreach (var repoId in new[] { first, second }) + { + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(adminsOwn); + repo.Status.ShouldBe(RepositoryStatus.Active); + repo.LastError.ShouldBeNull(); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("glpat-admin", "the next run clones through the credential that took over"); + } + + var hook = await _kit.LoadConnectionWebhookAsync(stranded); + + hook.CredentialId.ShouldBe(adminsOwn); + hook.RegistrationStatus.ShouldBe(RepositoryWebhookRegistrationStatus.Enqueued, "a hook the dead credential's failures buried is revived"); + _kit.WasDispatched(stranded).ShouldBeTrue("and handed to the registrar once the relink committed"); + } + + [Theory] + [InlineData(CredentialOwnership.Personal)] + [InlineData(CredentialOwnership.TeamService)] + public async Task The_target_may_be_the_callers_own_connection_or_a_team_token(CredentialOwnership targetKind) + { + 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); + var target = targetKind == CredentialOwnership.Personal + ? await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target") + : await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-target", ownership: CredentialOwnership.TeamService); + + await RelinkAsync(world, world.OwnerId, dead, target); + + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(target); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("glpat-target"); + } + + [Fact] + public async Task Relinking_again_moves_nothing_the_second_time_and_never_touches_a_binding_elsewhere() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var elsewhere = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-elsewhere"); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + var stranded = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var untouched = await _kit.SeedRepositoryAsync(world, elsewhere); + var removed = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + await SoftDeleteRepositoryAsync(removed); + + var first = await RelinkAsync(world, world.OwnerId, dead, target); + var second = await RelinkAsync(world, world.OwnerId, dead, target); + + first.MovedRepositoryCount.ShouldBe(1, "only the live repository bound through the dead credential"); + second.MovedRepositoryCount.ShouldBe(0); + second.MovedConnectionWebhookCount.ShouldBe(0); + (await _kit.LoadRepositoryAsync(stranded)).CredentialId.ShouldBe(target); + (await _kit.LoadRepositoryAsync(untouched)).CredentialId.ShouldBe(elsewhere, "a repository bound through another credential is nobody's to move"); + (await _kit.LoadRepositoryAsync(removed)).CredentialId.ShouldBe(dead, "an unbound repository is not live and stays as it was"); + } + + public enum Refusal { SourceStillConnected, TargetIsAnotherPersonsConnection, TargetOnAnotherInstance, TargetRevoked, TargetIsTheSource, SourceInAnotherTeam, TargetInAnotherTeam, NoSuchTarget, TargetRetired } + + [Theory] + [InlineData(Refusal.SourceStillConnected, typeof(InvalidOperationException))] + [InlineData(Refusal.TargetIsAnotherPersonsConnection, typeof(InvalidOperationException))] + [InlineData(Refusal.TargetOnAnotherInstance, typeof(InvalidOperationException))] + [InlineData(Refusal.TargetRevoked, typeof(InvalidOperationException))] + [InlineData(Refusal.TargetIsTheSource, typeof(InvalidOperationException))] + [InlineData(Refusal.SourceInAnotherTeam, typeof(KeyNotFoundException))] + [InlineData(Refusal.TargetInAnotherTeam, typeof(KeyNotFoundException))] + [InlineData(Refusal.NoSuchTarget, typeof(KeyNotFoundException))] + [InlineData(Refusal.TargetRetired, typeof(InvalidOperationException))] + public async Task A_relink_the_move_cannot_accept_is_refused_and_moves_nothing(Refusal shape, Type expected) + { + 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); + var hookId = await _kit.SeedConnectionWebhookAsync(world, dead, RepositoryWebhookRegistrationStatus.Registered); + var (from, to) = await ShapeAsync(world, shape, dead); + + var refusal = await Record.ExceptionAsync(() => RelinkAsync(world, world.OwnerId, from, to)); + + refusal.ShouldNotBeNull($"{shape}: refused"); + refusal.ShouldBeOfType(expected, $"{shape}: {refusal.Message}"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead, $"{shape}: nothing moved"); + (await _kit.LoadConnectionWebhookAsync(hookId)).CredentialId.ShouldBe(dead, $"{shape}: nor did the hook"); + } + + [Fact] + public async Task A_target_that_cannot_read_a_repository_refuses_the_whole_move_and_names_it() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-narrow"); + var reachable = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var hidden = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var hiddenPath = (await _kit.LoadRepositoryAsync(hidden)).FullPath; + var hookId = await _kit.SeedConnectionWebhookAsync(world, dead, RepositoryWebhookRegistrationStatus.Registered); + var provider = new ProbeRecorder(refuseFor: hiddenPath); + + var refusal = await Should.ThrowAsync(() => RelinkAsync(world, world.OwnerId, dead, target, provider)); + + refusal.Message.ShouldContain(hiddenPath, Case.Sensitive, "the refusal names what the target cannot read, so the person knows what to unbind or which credential to pick"); + refusal.Message.ShouldContain("Nothing was moved"); + (await _kit.LoadRepositoryAsync(reachable)).CredentialId.ShouldBe(dead, "the reachable repository did not move either — a relink never leaves the dead credential half-emptied"); + (await _kit.LoadRepositoryAsync(hidden)).CredentialId.ShouldBe(dead); + (await _kit.LoadConnectionWebhookAsync(hookId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task The_target_is_asked_to_read_every_repository_it_will_take_as_itself() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + var repositories = new[] { await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error), await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error) }; + var provider = new ProbeRecorder(); + + await RelinkAsync(world, world.OwnerId, dead, target, provider); + + provider.Reads.Select(read => read.Credential).Distinct().ShouldBe(new[] { target }, "the lookup is made with the credential about to take the repositories, never the dead one"); + provider.Reads.Select(read => read.ExternalId).ShouldBe((await Task.WhenAll(repositories.Select(_kit.LoadRepositoryAsync))).Select(r => r.ExternalId), ignoreOrder: true); + } + + public enum ProbeFailure { Unauthorized401, Forbidden403, NotFound404, InsufficientScope, OAuthGrantRefused, ServiceUnavailable503, ConnectionDropped, RateLimited } + + [Theory] + [InlineData(ProbeFailure.Unauthorized401, true)] + [InlineData(ProbeFailure.Forbidden403, true)] + [InlineData(ProbeFailure.NotFound404, true)] + [InlineData(ProbeFailure.InsufficientScope, true)] + [InlineData(ProbeFailure.OAuthGrantRefused, true)] + [InlineData(ProbeFailure.ServiceUnavailable503, false)] + [InlineData(ProbeFailure.ConnectionDropped, false)] + [InlineData(ProbeFailure.RateLimited, false)] + public async Task A_target_that_cannot_read_a_repository_is_told_apart_from_a_provider_that_could_not_answer(ProbeFailure failure, bool targetCannotRead) + { + // Only a provider that ANSWERED — this token is not let in, or cannot see that project — says anything about the + // target. An outage, a dropped connection or a rate limit says nothing, and "pick a credential that reaches them" is + // the wrong advice for it: the refusal must say the check could not be made, and that trying again is the remedy. + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + var hookId = await _kit.SeedConnectionWebhookAsync(world, dead, RepositoryWebhookRegistrationStatus.Registered); + var provider = new ProbeRecorder(refuseFor: string.Empty, failure: () => ProviderFailure(failure)); + + var refusal = await Record.ExceptionAsync(() => RelinkAsync(world, world.OwnerId, dead, target, provider)); + + refusal.ShouldNotBeNull($"{failure}: refused"); + refusal.Message.ShouldContain("Nothing was moved"); + + if (targetCannotRead) + { + refusal.ShouldBeOfType($"{failure}: {refusal.Message}"); + refusal.Message.ShouldContain("cannot read"); + } + else + { + refusal.ShouldBeAssignableTo($"{failure}: {refusal.Message}").Kind.ShouldBe(FailureKind.Unavailable, "retrying later is what could change the answer"); + refusal.Message.ShouldNotContain("cannot read", Case.Insensitive, $"{failure}: nothing was learned about the target, so the refusal must not blame it"); + refusal.Message.ShouldContain("try again", Case.Insensitive); + } + + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead, $"{failure}: nothing moved"); + (await _kit.LoadConnectionWebhookAsync(hookId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task A_provider_that_could_not_answer_ends_the_check_at_the_first_repository() + { + // An outage is the same answer for every repository: asking the other ninety-nine would only hold the request, and + // its database transaction, open for as long as the provider takes to say it again. + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + + for (var i = 0; i < 4; i++) await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var provider = new ProbeRecorder(refuseFor: string.Empty, failure: () => ProviderFailure(ProbeFailure.ServiceUnavailable503)); + + await Should.ThrowAsync(() => RelinkAsync(world, world.OwnerId, dead, target, provider)); + + provider.Reads.Count.ShouldBe(1); + } + + [Fact] + public async Task A_relink_of_more_repositories_than_one_action_verifies_is_refused_before_any_is_read() + { + // One provider call per repository, in sequence, inside the request's database transaction: unbounded, that is a request + // that outlasts the provider's patience and the client's. Past the bound the move is refused and says what to do. + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + var hookId = await _kit.SeedConnectionWebhookAsync(world, dead, RepositoryWebhookRegistrationStatus.Registered); + var repositories = new List(); + + for (var i = 0; i <= CredentialRelinkService.MaxRepositoriesVerified; i++) repositories.Add(await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error)); + + var provider = new ProbeRecorder(); + + var refusal = await Should.ThrowAsync(() => RelinkAsync(world, world.OwnerId, dead, target, provider)); + + refusal.Message.ShouldContain($"at most {CredentialRelinkService.MaxRepositoriesVerified}"); + refusal.Message.ShouldContain("Nothing was moved"); + provider.Reads.ShouldBeEmpty("the bound is checked before the first call, not after a hundred of them"); + (await _kit.LoadRepositoryAsync(repositories[0])).CredentialId.ShouldBe(dead); + (await _kit.LoadConnectionWebhookAsync(hookId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task A_relink_of_exactly_the_bound_is_verified_and_moves() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-target"); + + for (var i = 0; i < CredentialRelinkService.MaxRepositoriesVerified; i++) await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var provider = new ProbeRecorder(); + + var result = await RelinkAsync(world, world.OwnerId, dead, target, provider); + + result.MovedRepositoryCount.ShouldBe(CredentialRelinkService.MaxRepositoriesVerified); + provider.Reads.Count.ShouldBe(CredentialRelinkService.MaxRepositoriesVerified, "each one asked, as the target"); + } + + [Fact] + public async Task The_service_checks_credentials_manage_by_itself_not_only_through_the_commands_marker() + { + // The command's marker holds repos.manage; credentials.manage is asserted in the service because the credential marker + // that would carry it refuses a retired credential. Both are Admin-tier today, so no role holds one and not the other and + // the role matrix above cannot tell the two checks apart. Asked of the service directly, as a role below both, only the + // service's own check can refuse. + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-members-own"); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + using var scope = _kit.Scope(world, world.MemberId); + + var refusal = await Should.ThrowAsync(() => scope.Resolve().RelinkAllAsync(dead, target, CancellationToken.None)); + + refusal.Reason.ShouldContain(TeamPermissions.CredentialsManage); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Theory] + [InlineData(TeamRole.Viewer, false)] + [InlineData(TeamRole.Member, false)] + [InlineData(TeamRole.Admin, true)] + [InlineData(TeamRole.Owner, true)] + public async Task Relinking_takes_repos_manage_and_credentials_manage_so_it_is_an_admins(TeamRole role, bool allowed) + { + var world = await _kit.SeedWorldAsync(); + var caller = await _kit.SeedMemberAsync(world, role); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(world, caller, "glpat-callers"); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + var outcome = await Record.ExceptionAsync(() => RelinkAsync(world, caller, dead, target)); + + if (allowed) + { + outcome.ShouldBeNull($"{role}: {outcome?.Message}"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(target); + return; + } + + outcome.ShouldBeOfType($"{role}: below Admin"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task Someone_outside_the_team_and_an_admin_of_another_team_relink_nothing() + { + var world = await _kit.SeedWorldAsync(); + var elsewhere = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var target = await _kit.SeedCredentialAsync(elsewhere, elsewhere.OwnerId, "glpat-their-own"); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + using var scope = _kit.Fixture.BeginScopeAs(elsewhere.OwnerId, world.TeamId); + + await Should.ThrowAsync(() => scope.Resolve().Send(new RelinkCredentialBindingsCommand { FromCredentialId = dead, ToCredentialId = target })); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task An_instance_admin_relinks_in_a_team_they_hold_no_membership_in() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked); + var instanceAdmin = await _kit.SeedMemberAsync(world, TeamRole.Viewer); + var target = await _kit.SeedCredentialAsync(world, instanceAdmin, "glpat-instance-admin"); + var repoId = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + using var scope = _kit.Scope(world, instanceAdmin, b => b.RegisterInstance(new TestCurrentUser(instanceAdmin, "instance-admin", Roles.Admin)).As().SingleInstance()); + await scope.Resolve().Send(new RelinkCredentialBindingsCommand { FromCredentialId = dead, ToCredentialId = target }); + + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(target, "the instance Admin stands as an Owner in every team, whatever role a membership row gives them"); + } + + [Fact] + public async Task Suggestions_offer_the_callers_own_live_connections_first_then_team_tokens_and_never_a_teammates() + { + var world = await _kit.SeedWorldAsync(); + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + var dead = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked); + + var before = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin-before", createdDate: revoked.AddDays(-3)); + var after = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin-after", createdDate: revoked.AddDays(2)); + var olderTeam = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team-older", ownership: CredentialOwnership.TeamService, createdDate: revoked.AddDays(-5)); + var newerTeam = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team-newer", ownership: CredentialOwnership.TeamService, createdDate: revoked.AddDays(5)); + + var ownersPersonal = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner"); + var revokedOwn = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin-revoked", CredentialStatus.Revoked); + var otherInstance = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin-elsewhere", instanceId: await _kit.SeedOtherInstanceAsync(world)); + var retired = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin-retired"); + await SoftDeleteCredentialAsync(retired); + + var suggestions = await SuggestAsync(world, world.AdminId, dead); + + suggestions.Select(s => s.CredentialId).ShouldBe(new[] { after, before, newerTeam, olderTeam }, "own connections newest first, then the team's tokens newest first"); + suggestions.Select(s => s.Kind).ShouldBe(new[] { RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.TeamServiceToken, RelinkSuggestionKind.TeamServiceToken }); + suggestions.Select(s => s.ConnectedBeforeDisconnect).ShouldBe(new[] { false, true, false, true }, "the connect-then-disconnect shape is flagged — a connection made before the dead one was disconnected"); + + var offered = suggestions.Select(s => s.CredentialId).ToHashSet(); + + offered.ShouldNotContain(ownersPersonal, "a teammate's personal token would make the repositories act as them"); + offered.ShouldNotContain(revokedOwn); + offered.ShouldNotContain(otherInstance); + offered.ShouldNotContain(retired); + offered.ShouldNotContain(dead); + } + + [Fact] + public async Task Suggestions_for_a_live_credential_exclude_itself_and_flag_nothing_as_disconnected_first() + { + // The Disconnect dialog asks "where should these go?" about a credential that is still live. + var world = await _kit.SeedWorldAsync(); + var live = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-live"); + var other = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-other"); + + var suggestions = await SuggestAsync(world, world.AdminId, live); + + suggestions.Select(s => s.CredentialId).ShouldBe(new[] { other }); + suggestions[0].ConnectedBeforeDisconnect.ShouldBeFalse("nothing has been disconnected yet"); + } + + public enum Asker { TheOwnerOfTheCredential, AnAdminForATeammate } + + public enum Candidate { TheOwnersOtherConnection, TheAskersOwnConnection, ATeamServiceToken, AnotherPersonsConnection, OnAnotherInstance, Revoked } + + [Theory] + [InlineData(Asker.TheOwnerOfTheCredential, Candidate.TheOwnersOtherConnection, true)] + [InlineData(Asker.TheOwnerOfTheCredential, Candidate.ATeamServiceToken, false)] + [InlineData(Asker.TheOwnerOfTheCredential, Candidate.AnotherPersonsConnection, false)] + [InlineData(Asker.TheOwnerOfTheCredential, Candidate.OnAnotherInstance, false)] + [InlineData(Asker.TheOwnerOfTheCredential, Candidate.Revoked, false)] + [InlineData(Asker.AnAdminForATeammate, Candidate.TheOwnersOtherConnection, true)] + [InlineData(Asker.AnAdminForATeammate, Candidate.TheAskersOwnConnection, false)] + [InlineData(Asker.AnAdminForATeammate, Candidate.ATeamServiceToken, false)] + [InlineData(Asker.AnAdminForATeammate, Candidate.AnotherPersonsConnection, false)] + public async Task Suggestions_for_a_live_credential_are_exactly_the_targets_a_disconnect_accepts(Asker asker, Candidate candidate, bool offered) + { + // The Disconnect dialog asks "where should these go?" about a credential that is still live, and a Disconnect that names + // one of the answers must not refuse it — nor may the dialog omit the only target the Disconnect would accept. A live + // credential's bindings may follow only to another connection of the SAME owner; the relink-all rule (the caller's own, + // or the team's) is for a dead one. + var world = await _kit.SeedWorldAsync(); + var (caller, owner) = asker == Asker.TheOwnerOfTheCredential ? (world.OwnerId, world.OwnerId) : (world.AdminId, world.MemberId); + var source = await _kit.SeedCredentialAsync(world, owner, "glpat-source"); + var target = await SeedCandidateAsync(world, candidate, caller, owner); + await _kit.SeedRepositoryAsync(world, source); + + var suggestions = await SuggestAsync(world, caller, source); + var refusal = await Record.ExceptionAsync(() => _kit.DisconnectAsync(world, caller, source, moveBindingsTo: target)); + + suggestions.Select(s => s.CredentialId).Contains(target).ShouldBe(offered, $"{asker} / {candidate}: offered"); + (refusal == null).ShouldBe(offered, $"{asker} / {candidate}: the Disconnect {(refusal == null ? "accepted" : "refused")} it — {refusal?.Message}"); + } + + [Fact] + public async Task Suggestions_for_a_live_credential_leave_out_a_connection_whose_holder_no_longer_stands_in_the_team() + { + // The departed-token sweep retires a personal credential whose person has no standing in the team, so a Disconnect + // refuses to move repositories onto one — and the dialog must not offer what the Disconnect would refuse. + var world = await _kit.SeedWorldAsync(); + var stranger = await SeedStrangerAsync(); + var source = await _kit.SeedCredentialAsync(world, stranger, "glpat-stranger-a"); + var other = await _kit.SeedCredentialAsync(world, stranger, "glpat-stranger-b"); + 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: other)); + + suggestions.ShouldBeEmpty(); + refusal.ShouldBeOfType().Message.ShouldContain("no longer has standing"); + } + + private async Task SeedCandidateAsync(BindingMoveKit.World world, Candidate candidate, Guid asker, Guid owner) + { + switch (candidate) + { + case Candidate.TheOwnersOtherConnection: return await _kit.SeedCredentialAsync(world, owner, "glpat-owners-other"); + case Candidate.TheAskersOwnConnection: return await _kit.SeedCredentialAsync(world, asker, "glpat-askers"); + case Candidate.ATeamServiceToken: return await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team", ownership: CredentialOwnership.TeamService); + case Candidate.AnotherPersonsConnection: return await _kit.SeedCredentialAsync(world, world.ViewerId, "glpat-viewers"); + case Candidate.OnAnotherInstance: return await _kit.SeedCredentialAsync(world, owner, "glpat-elsewhere", instanceId: await _kit.SeedOtherInstanceAsync(world)); + case Candidate.Revoked: return await _kit.SeedCredentialAsync(world, owner, "glpat-gone", CredentialStatus.Revoked); + default: throw new ArgumentOutOfRangeException(nameof(candidate), candidate, "a shape the test does not seed"); + } + } + + [Fact] + public async Task Suggestions_for_a_credential_of_another_team_read_as_not_found() + { + var world = await _kit.SeedWorldAsync(); + var elsewhere = await _kit.SeedWorldAsync(); + var foreign = await _kit.SeedCredentialAsync(elsewhere, elsewhere.OwnerId, "glpat-foreign"); + + await Should.ThrowAsync(() => SuggestAsync(world, world.AdminId, foreign)); + await Should.ThrowAsync(() => DependentsAsync(world, world.AdminId, foreign)); + } + + [Fact] + public async Task Dependents_list_what_leans_on_a_retired_credential_with_the_projects_and_triggers_that_name_it() + { + var world = await _kit.SeedWorldAsync(); + var leaver = await _kit.SeedMemberAsync(world, TeamRole.Member); + var theirs = await _kit.SeedCredentialAsync(world, leaver, "glpat-leaver"); + var repoId = await _kit.SeedRepositoryAsync(world, theirs); + var quiet = await _kit.SeedRepositoryAsync(world, theirs); + var elsewhere = await _kit.SeedRepositoryAsync(world, await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner")); + var hookId = await _kit.SeedConnectionWebhookAsync(world, theirs, RepositoryWebhookRegistrationStatus.Registered); + await _kit.SeedConnectionWebhookAsync(world, theirs, RepositoryWebhookRegistrationStatus.Cancelled); + var identityId = await SeedIdentityAsync(world, leaver, theirs); + var (alpha, beta) = (await SeedProjectAsync(world, "Alpha"), await SeedProjectAsync(world, "Beta")); + await LinkProjectAsync(world, alpha, repoId); + await LinkProjectAsync(world, beta, repoId); + var triggering = await SeedActivationAsync(world, "Review on push", new { repositories = new[] { new { repositoryId = repoId.ToString() } } }); + await SeedActivationAsync(world, "Names someone else's", new { repositoryId = elsewhere.ToString() }); + await SeedActivationAsync(world, "Names nothing", new { }); + + await RemoveFromTeamAsync(world, leaver); + + var dependents = await DependentsAsync(world, world.ViewerId, theirs); + + dependents.Retired.ShouldBeTrue("a departed member's token is soft-deleted, and the team can still ask what is left on it"); + dependents.Status.ShouldBe(CredentialStatus.Revoked); + dependents.Repositories.Select(r => r.Id).ShouldBe(new[] { repoId, quiet }, ignoreOrder: true); + dependents.Repositories.Single(r => r.Id == repoId).Projects.Select(p => p.Name).ShouldBe(new[] { "Alpha", "Beta" }); + dependents.Repositories.Single(r => r.Id == quiet).Projects.ShouldBeEmpty(); + dependents.Repositories.ShouldAllBe(r => r.Status == RepositoryStatus.Error && r.LastError == CredentialRevocationService.DisconnectedRepositoryError); + dependents.ConnectionWebhooks.Select(h => h.Id).ShouldBe(new[] { hookId }, "a retired hook is not waiting for anything"); + dependents.Identities.ShouldBeEmpty("departing soft-deleted the act-as identity with the token"); + dependents.Activations.Select(a => a.ActivationId).ShouldBe(new[] { triggering }); + dependents.Activations[0].RepositoryIds.ShouldBe(new[] { repoId }); + dependents.Activations[0].WorkflowName.ShouldBe("Review on push"); + identityId.ShouldNotBe(Guid.Empty); + } + + [Fact] + public async Task Dependents_leave_out_what_is_removed_and_what_belongs_to_another_team() + { + // Each of these names a repository bound through the credential and none of them is waiting on it: a removed repository, + // a removed project, a removed project link, a removed trigger, a trigger of a removed workflow, and — the one that + // would be a leak, not a miscount — another team's trigger that names this team's repository id. + var world = await _kit.SeedWorldAsync(); + var elsewhere = await _kit.SeedWorldAsync(); + var theirs = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-theirs", CredentialStatus.Revoked); + var live = await _kit.SeedRepositoryAsync(world, theirs, RepositoryStatus.Error); + var removed = await _kit.SeedRepositoryAsync(world, theirs, RepositoryStatus.Error); + await SoftDeleteRepositoryAsync(removed); + + var kept = await SeedProjectAsync(world, "Kept"); + var removedProject = await SeedProjectAsync(world, "RemovedProject"); + var removedLink = await SeedProjectAsync(world, "RemovedLink"); + await LinkProjectAsync(world, kept, live); + await LinkProjectAsync(world, removedProject, live); + await LinkProjectAsync(world, removedLink, live); + await SoftDeleteProjectAsync(removedProject); + await SoftDeleteProjectLinkAsync(removedLink, live); + + var naming = new { repositories = new[] { new { repositoryId = live.ToString() } } }; + var listed = await SeedActivationAsync(world, "Listed", naming); + await SoftDeleteActivationAsync(await SeedActivationAsync(world, "Removed trigger", naming)); + var underRemovedWorkflow = await SeedActivationAsync(world, "Removed workflow", naming); + await SoftDeleteWorkflowAsync(await WorkflowOfAsync(underRemovedWorkflow)); + await SeedActivationAsync(elsewhere, "Another team's", naming); + + var dependents = await DependentsAsync(world, world.AdminId, theirs); + + dependents.Repositories.Select(r => r.Id).ShouldBe(new[] { live }, "a removed repository is not waiting for anything"); + dependents.Repositories[0].Projects.Select(p => p.Name).ShouldBe(new[] { "Kept" }, "a removed project, and a project link that was removed, are not the repository's projects"); + dependents.Activations.Select(a => a.ActivationId).ShouldBe(new[] { listed }, "only this team's live triggers of live workflows"); + } + + [Fact] + public async Task Dependents_of_a_live_credential_include_the_identity_a_disconnect_would_end() + { + var world = await _kit.SeedWorldAsync(); + var live = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-live"); + var identityId = await SeedIdentityAsync(world, world.AdminId, live); + + var dependents = await DependentsAsync(world, world.AdminId, live); + + dependents.Retired.ShouldBeFalse(); + dependents.Identities.Select(i => i.Id).ShouldBe(new[] { identityId }); + dependents.Identities[0].UserId.ShouldBe(world.AdminId); + dependents.Identities[0].UserName.ShouldNotBeNullOrEmpty(); + } + + /// What a provider throws, as the resilience layer hands it to a caller, for each way a lookup can fail. + private static Exception ProviderFailure(ProbeFailure failure) => failure switch + { + ProbeFailure.Unauthorized401 => new ProviderApiException(ProviderKind.GitLab, 401, "GetByExternalIdAsync", "401 Unauthorized", new InvalidOperationException("token rejected")), + ProbeFailure.Forbidden403 => new ProviderApiException(ProviderKind.GitLab, 403, "GetByExternalIdAsync", "403 Forbidden", new InvalidOperationException("not a member")), + ProbeFailure.NotFound404 => new ProviderApiException(ProviderKind.GitLab, 404, "GetByExternalIdAsync", "404 Project Not Found", new InvalidOperationException("not visible to this token")), + ProbeFailure.InsufficientScope => new ProviderInsufficientScopeException(ProviderKind.GitLab, nameof(IRepositoryCatalogCapability), new[] { "read_api" }, new[] { "read_user" }), + ProbeFailure.OAuthGrantRefused => new OAuthExchangeException("invalid_grant", "the refresh token was revoked", null), + ProbeFailure.ServiceUnavailable503 => new ProviderApiException(ProviderKind.GitLab, 503, "GetByExternalIdAsync", "503 Service Unavailable", new InvalidOperationException("upstream down")), + ProbeFailure.ConnectionDropped => new HttpRequestException("connection reset by peer"), + ProbeFailure.RateLimited => new CodeSpace.Core.Services.Providers.Resilience.ProviderRateLimitedException(Guid.NewGuid(), "GetByExternalIdAsync"), + _ => throw new ArgumentOutOfRangeException(nameof(failure), failure, "a failure the test does not throw") + }; + + // ── Doors ───────────────────────────────────────────────────────────────────── + + private async Task RelinkAsync(BindingMoveKit.World world, Guid userId, Guid from, Guid to, 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 }); + } + + 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 }); + } + + private async Task RemoveFromTeamAsync(BindingMoveKit.World world, Guid userId) + { + using var scope = _kit.Scope(world, world.OwnerId); + + await scope.Resolve().Send(new RemoveTeamMemberCommand { UserId = userId }); + } + + // ── Shapes ──────────────────────────────────────────────────────────────────── + + private async Task<(Guid From, Guid To)> ShapeAsync(BindingMoveKit.World world, Refusal shape, Guid dead) + { + var good = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-good"); + + switch (shape) + { + case Refusal.SourceStillConnected: return (await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-live-source"), good); + case Refusal.TargetIsAnotherPersonsConnection: return (dead, await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-teammates")); + case Refusal.TargetOnAnotherInstance: return (dead, await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-elsewhere", instanceId: await _kit.SeedOtherInstanceAsync(world))); + case Refusal.TargetRevoked: return (dead, await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-gone", CredentialStatus.Revoked)); + case Refusal.TargetIsTheSource: return (dead, dead); + case Refusal.SourceInAnotherTeam: return (await ForeignCredentialAsync(), good); + case Refusal.TargetInAnotherTeam: return (dead, await ForeignCredentialAsync()); + case Refusal.NoSuchTarget: return (dead, Guid.NewGuid()); + case Refusal.TargetRetired: return (dead, await RetiredAsync(world)); + default: throw new ArgumentOutOfRangeException(nameof(shape), shape, "a shape the test does not seed"); + } + } + + private async Task ForeignCredentialAsync() + { + var elsewhere = await _kit.SeedWorldAsync(); + + return await _kit.SeedCredentialAsync(elsewhere, elsewhere.OwnerId, "glpat-foreign"); + } + + private async Task RetiredAsync(BindingMoveKit.World world) + { + var retired = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-retired"); + await SoftDeleteCredentialAsync(retired); + + return retired; + } + + // ── Seeds ───────────────────────────────────────────────────────────────────── + + /// A person with an account and no membership in the team — what a departed member is, before the sweep retires what they lent. + private async Task SeedStrangerAsync() + { + var stranger = new User { Id = Guid.NewGuid(), Email = $"stranger-{Guid.NewGuid():N}@x", Name = "stranger" }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.User.Add(stranger); + await db.SaveChangesAsync(); + + return stranger.Id; + } + + 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)); + } + + private async Task SoftDeleteRepositoryAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Repository.Where(r => r.Id == id).ExecuteUpdateAsync(s => s.SetProperty(r => r.DeletedDate, DateTimeOffset.UtcNow)); + } + + private async Task SoftDeleteProjectAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Project.Where(p => p.Id == id).ExecuteUpdateAsync(s => s.SetProperty(p => p.DeletedDate, DateTimeOffset.UtcNow)); + } + + private async Task SoftDeleteProjectLinkAsync(Guid projectId, Guid repositoryId) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().ProjectRepository.Where(l => l.ProjectId == projectId && l.RepositoryId == repositoryId).ExecuteUpdateAsync(s => s.SetProperty(l => l.DeletedDate, DateTimeOffset.UtcNow)); + } + + private async Task SoftDeleteActivationAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().WorkflowActivation.Where(a => a.Id == id).ExecuteUpdateAsync(s => s.SetProperty(a => a.DeletedDate, DateTimeOffset.UtcNow)); + } + + private async Task WorkflowOfAsync(Guid activationId) + { + using var scope = _kit.Fixture.BeginScope(); + + return await scope.Resolve().WorkflowActivation.AsNoTracking().Where(a => a.Id == activationId).Select(a => a.WorkflowId).SingleAsync(); + } + + private async Task SoftDeleteWorkflowAsync(Guid id) + { + using var scope = _kit.Fixture.BeginScope(); + + await scope.Resolve().Workflow.Where(w => w.Id == id).ExecuteUpdateAsync(s => s.SetProperty(w => w.DeletedDate, DateTimeOffset.UtcNow)); + } + + private async Task SeedIdentityAsync(BindingMoveKit.World world, Guid userId, Guid credentialId) + { + var identity = new UserProviderIdentity { Id = Guid.NewGuid(), UserId = userId, ProviderInstanceId = world.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 SeedProjectAsync(BindingMoveKit.World world, string name) + { + var project = new Project { Id = Guid.NewGuid(), TeamId = world.TeamId, Name = name, Slug = $"{name.ToLowerInvariant()}-{Guid.NewGuid():N}"[..14] }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.Project.Add(project); + await db.SaveChangesAsync(); + + return project.Id; + } + + private async Task LinkProjectAsync(BindingMoveKit.World world, Guid projectId, Guid repositoryId) + { + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.ProjectRepository.Add(new ProjectRepository { ProjectId = projectId, RepositoryId = repositoryId, TeamId = world.TeamId }); + await db.SaveChangesAsync(); + } + + private async Task SeedActivationAsync(BindingMoveKit.World world, string workflowName, object config) + { + var workflow = new Workflow { Id = Guid.NewGuid(), TeamId = world.TeamId, Slug = $"wf-{Guid.NewGuid():N}"[..14], Name = workflowName, DefinitionJson = "{}" }; + var activation = new WorkflowActivation { Id = Guid.NewGuid(), WorkflowId = workflow.Id, TypeKey = "trigger.pull_request", ConfigJson = JsonSerializer.Serialize(config), 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; + } + + /// + /// The provider's catalog, answering as the Git test provider does but remembering who it was asked as — and, for one + /// repository path, refusing the way a provider answers a token that cannot see a project. A dispatch proxy rather than a + /// class implementing the capability: the fixture registers every capability implementation in this assembly it finds. + /// + public sealed class ProbeRecorder + { + private readonly string? _refuseFor; + private readonly Func? _failure; + private readonly List<(Guid Credential, string ExternalId)> _reads = 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. + public ProbeRecorder(string? refuseFor = null, Func? failure = null) + { + _refuseFor = refuseFor; + _failure = failure; + } + + public IReadOnlyList<(Guid Credential, string ExternalId)> Reads => _reads; + + public void Install(ContainerBuilder builder) => + builder.Register(c => new RecordingRegistry(new BindingMoveKit.GitTestProviderRegistry(new ProviderRegistry(c.Resolve>())), this)).InstancePerLifetimeScope(); + + private void Read(ProviderContext context, string externalId) + { + _reads.Add((context.Credential.Id, externalId)); + + if (_refuseFor != null && externalId.EndsWith(_refuseFor, StringComparison.Ordinal)) + throw _failure?.Invoke() ?? new ProviderApiException(ProviderKind.GitLab, 404, "GetByExternalIdAsync", "404 Project Not Found", new InvalidOperationException("not visible to this token")); + } + + private sealed class RecordingRegistry : IProviderRegistry + { + private readonly IProviderRegistry _inner; + private readonly ProbeRecorder _recorder; + + public RecordingRegistry(IProviderRegistry inner, ProbeRecorder recorder) + { + _inner = inner; + _recorder = recorder; + } + + public TCapability Require(ProviderKind kind) where TCapability : IProviderCapability => + typeof(TCapability) == typeof(IRepositoryCatalogCapability) ? (TCapability)CatalogProxy.Create(_inner.Require(kind), _recorder) : _inner.Require(kind); + + public bool TryGet(ProviderKind kind, out TCapability? capability) where TCapability : class, IProviderCapability => _inner.TryGet(kind, out capability); + + public IReadOnlyList GetCapabilities(ProviderKind kind) => _inner.GetCapabilities(kind); + } + + public class CatalogProxy : System.Reflection.DispatchProxy + { + private IRepositoryCatalogCapability _inner = default!; + private ProbeRecorder _recorder = default!; + + public static object Create(IRepositoryCatalogCapability inner, ProbeRecorder recorder) + { + var proxy = Create(); + var self = (CatalogProxy)(object)proxy; + + self._inner = inner; + self._recorder = recorder; + + return proxy; + } + + protected override object? Invoke(System.Reflection.MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod!.Name == nameof(IRepositoryCatalogCapability.GetByExternalIdAsync)) + { + try { _recorder.Read((ProviderContext)args![0]!, (string)args[1]!); } + catch (Exception refusal) { return Task.FromException(refusal); } + } + + try { return targetMethod.Invoke(_inner, args); } + catch (System.Reflection.TargetInvocationException e) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(e.InnerException!).Throw(); throw; } + } + } + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs b/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs new file mode 100644 index 000000000..3c711ba8d --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs @@ -0,0 +1,295 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Agents.Workspace; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Core.Services.Identity; +using CodeSpace.Core.Services.OAuth; +using CodeSpace.Core.Services.Providers.Capabilities; +using CodeSpace.Core.Services.Webhooks.Registration; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.IntegrationTests.Infrastructure.Jobs; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Commands.Identity; +using CodeSpace.Messages.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Queries.Credentials; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.OAuth; + +/// +/// The people, provider and credential flows the binding-move tests share: a team with one person of each role, one +/// provider instance, and the same doors a person uses to hold a connection — the Providers modal's "Use a token", +/// "Connect with OAuth" and "Disconnect" — driven through the real mediator pipeline, so the authorization behaviors, +/// the unit of work and the post-commit drain are the production ones. Only the provider's own edges are the test +/// doubles the fixture already registers (the Git test provider's whoami and catalog, 's +/// token endpoint). +/// +internal sealed class BindingMoveKit +{ + private readonly PostgresFixture _fixture; + + public BindingMoveKit(PostgresFixture fixture) { _fixture = fixture; } + + public sealed record World(Guid TeamId, Guid InstanceId, Guid OwnerId, Guid AdminId, Guid MemberId, Guid ViewerId, StubOAuthClient OAuth); + + public PostgresFixture Fixture => _fixture; + + public async Task SeedWorldAsync() + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var team = new Team { Id = Guid.NewGuid(), Slug = $"t-{suffix}", Name = "Team" }; + var users = new[] { "owner", "admin", "member", "viewer" }.Select(label => new User { Id = Guid.NewGuid(), Email = $"{label}-{suffix}@x", Name = $"{label}-{suffix}" }).ToArray(); + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var instance = NewInstance(team.Id, suffix, scope.Resolve()); + + db.Team.Add(team); + db.User.AddRange(users); + db.TeamMembership.AddRange(Membership(team, users[0], TeamRole.Owner), Membership(team, users[1], TeamRole.Admin), Membership(team, users[2], TeamRole.Member), Membership(team, users[3], TeamRole.Viewer)); + db.ProviderInstance.Add(instance); + await db.SaveChangesAsync(); + + return new World(team.Id, instance.Id, users[0].Id, users[1].Id, users[2].Id, users[3].Id, new StubOAuthClient(ProviderKind.GitLab, Token("unused", null))); + } + + /// A second provider instance of the same team — a repository and a credential on different instances can never serve each other. + public async Task SeedOtherInstanceAsync(World world) + { + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + var instance = NewInstance(world.TeamId, Guid.NewGuid().ToString("N")[..8], scope.Resolve()); + + db.ProviderInstance.Add(instance); + await db.SaveChangesAsync(); + + return instance.Id; + } + + /// 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) + { + var suffix = Guid.NewGuid().ToString("N")[..8]; + var user = new User { Id = Guid.NewGuid(), Email = $"{role}-{suffix}@x", Name = $"{role}-{suffix}" }; + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + db.User.Add(user); + db.TeamMembership.Add(new TeamMembership { Id = Guid.NewGuid(), TeamId = world.TeamId, UserId = user.Id, Role = role }); + await db.SaveChangesAsync(); + + return user.Id; + } + + /// + /// A scope for one request as (anonymous when null). The provider is a GitLab instance, so the real GitLab auth + /// strategies read every credential; its API answers come from the Git test provider, and its token endpoint from + /// . runs last and wins, for a test that needs the provider to refuse. + /// + public ILifetimeScope Scope(World world, Guid? userId, Action? configure = null) => _fixture.BeginScope(b => + { + b.RegisterInstance(new SingleClientRegistry(world.OAuth)).SingleInstance(); + b.Register(c => new GitTestProviderRegistry(new ProviderRegistry(c.Resolve>()))).InstancePerLifetimeScope(); + + if (userId is Guid id) + { + b.RegisterInstance(new TestCurrentUser(id, "tester")).As().SingleInstance(); + b.RegisterInstance(new TestCurrentTeam(world.TeamId)).As().SingleInstance(); + } + + configure?.Invoke(b); + }); + + /// The Providers modal's "Connect with OAuth": Init as the person, then the provider's anonymous callback. + public async Task ConnectOAuthAsync(World world, Guid userId, string accessToken) + { + world.OAuth.ExchangeResult = Token(accessToken, $"rt-{accessToken}"); + world.OAuth.RefreshFailure = null; + + InitCredentialOAuthResult init; + + using (var scope = Scope(world, userId)) + init = await scope.Resolve().Send(new InitCredentialOAuthCommand { ProviderInstanceId = world.InstanceId, DisplayName = "owner's GitLab" }); + + using var callback = Scope(world, userId: null); + + return (await callback.Resolve().Send(new CompleteCredentialOAuthCommand { State = init.State, Code = "code-from-provider", BrowserNonce = init.BrowserNonce })).CredentialId.ShouldNotBeNull(); + } + + /// The Providers modal's "Use a token": the identity link, which mints a Personal token credential owned by the person. + public async Task LinkTokenAsync(World world, Guid userId, string token) + { + using (var scope = Scope(world, userId)) + await scope.Resolve().Send(new LinkProviderIdentityByPatCommand { ProviderInstanceId = world.InstanceId, AccessToken = token }); + + using var verify = _fixture.BeginScope(); + + return await verify.Resolve().Credential.AsNoTracking() + .Where(c => c.ProviderInstanceId == world.InstanceId && c.OwnerUserId == userId && c.AuthType == AuthType.Pat && c.Status == CredentialStatus.Active) + .OrderByDescending(c => c.CreatedDate).Select(c => c.Id).FirstAsync(); + } + + /// POST /api/credentials/pat: another live token for its owner, with no identity link. + public async Task AddTokenAsync(World world, Guid userId, string token, Guid? instanceId = null, CredentialOwnership ownership = CredentialOwnership.Personal) + { + using var scope = Scope(world, userId); + + return await scope.Resolve().Send(new AddCredentialCommand { ProviderInstanceId = instanceId ?? world.InstanceId, DisplayName = $"{ownership} {token}", Payload = new PatPayload { Token = token }, Ownership = ownership }); + } + + /// The Personal tab's "Disconnect" — with nothing said about where its repositories go, as the Providers modal sends it today. + public async Task DisconnectAsync(World world, Guid userId, Guid credentialId, Guid? moveBindingsTo = null) + { + using var scope = Scope(world, userId); + + return await scope.Resolve().Send(new RevokeCredentialCommand { CredentialId = credentialId, MoveBindingsToCredentialId = moveBindingsTo }); + } + + /// The Disconnect dialog's preview, asked as before anything is disconnected. + public async Task UsageAsync(World world, Guid userId, Guid credentialId) + { + using var scope = Scope(world, userId); + + return await scope.Resolve().Send(new GetCredentialUsageQuery { CredentialId = credentialId }); + } + + /// The Connected Identities page's "Unlink": the person's one live identity on the world's instance, ended through the mediator. + public async Task UnlinkIdentityAsync(World world, Guid userId) + { + Guid identityId; + + using (var verify = _fixture.BeginScope()) + identityId = await verify.Resolve().UserProviderIdentity.AsNoTracking().Where(i => i.UserId == userId && i.ProviderInstanceId == world.InstanceId && i.DeletedDate == null).Select(i => i.Id).SingleAsync(); + + using var scope = Scope(world, userId); + + await scope.Resolve().Send(new UnlinkProviderIdentityCommand { IdentityId = identityId }); + } + + /// + /// A credential written straight to the table, sealed the way a connect seals it — for a shape no single door produces + /// (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) + { + 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 }; + + db.Credential.Add(credential); + await db.SaveChangesAsync(); + + return credential.Id; + } + + /// What every repository-bound run does first: resolve the clone URL and token for its repository. + public async Task ResolveCloneTokenAsync(World world, Guid repositoryId) + { + using var scope = Scope(world, userId: null); + + var request = await scope.Resolve().ResolveByRepositoryIdAsync(repositoryId, world.TeamId, CancellationToken.None); + + return request!.Token; + } + + public async Task SeedRepositoryAsync(World world, Guid credentialId, RepositoryStatus status = RepositoryStatus.Active, Guid? instanceId = 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 }; + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + db.Repository.Add(repo); + await db.SaveChangesAsync(); + + return repo.Id; + } + + /// 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) + { + var id = Guid.NewGuid(); + var failed = status is RepositoryWebhookRegistrationStatus.Failed or RepositoryWebhookRegistrationStatus.DeadLettered; + var registered = status == RepositoryWebhookRegistrationStatus.Registered; + + 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 }); + await db.SaveChangesAsync(); + + return id; + } + + public const int SeededHookAttempts = 3; + + public async Task LoadRepositoryAsync(Guid repositoryId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().Repository.AsNoTracking().SingleAsync(r => r.Id == repositoryId); + } + + public async Task LoadCredentialAsync(Guid credentialId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().Credential.AsNoTracking().SingleAsync(c => c.Id == credentialId); + } + + public async Task LoadConnectionWebhookAsync(Guid hookId) + { + using var scope = _fixture.BeginScope(); + + return await scope.Resolve().ConnectionWebhook.AsNoTracking().SingleAsync(w => w.Id == hookId); + } + + public InMemoryBackgroundJobClient Jobs() + { + using var scope = _fixture.BeginScope(); + + return scope.Resolve(); + } + + public bool WasDispatched(Guid hookId) => Jobs().Calls.Any(c => c.ServiceType == typeof(IConnectionWebhookRegistrar) && Equals(c.FirstArgument, hookId)); + + 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) => + new() { Id = Guid.NewGuid(), TeamId = teamId, Provider = ProviderKind.GitLab, DisplayName = $"GitLab {suffix}", BaseUrl = $"https://gitlab-{suffix}.test", OauthClientId = "client", OauthClientSecretEnc = encryptor.Encrypt("client-secret") }; + + private static TeamMembership Membership(Team team, User user, TeamRole role) => new() { Id = Guid.NewGuid(), TeamId = team.Id, UserId = user.Id, Role = role }; + + /// Answers the GitLab instance's capability lookups with the Git test provider's, so connecting, token-linking and reading a repository contact nothing real. + internal sealed class GitTestProviderRegistry : IProviderRegistry + { + private readonly IProviderRegistry _inner; + + public GitTestProviderRegistry(IProviderRegistry inner) { _inner = inner; } + + public TCapability Require(ProviderKind kind) where TCapability : IProviderCapability => _inner.Require(ProviderKind.Git); + + public bool TryGet(ProviderKind kind, out TCapability? capability) where TCapability : class, IProviderCapability => _inner.TryGet(ProviderKind.Git, out capability); + + public IReadOnlyList GetCapabilities(ProviderKind kind) => _inner.GetCapabilities(ProviderKind.Git); + } + + private sealed class SingleClientRegistry : IOAuthClientRegistry + { + private readonly IOAuthClient _client; + + public SingleClientRegistry(IOAuthClient client) { _client = client; } + + public IOAuthClient Get(ProviderKind kind) => _client; + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/OAuth/CredentialBindingMoveFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/OAuth/CredentialBindingMoveFlowTests.cs new file mode 100644 index 000000000..49fbbd6b0 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/OAuth/CredentialBindingMoveFlowTests.cs @@ -0,0 +1,541 @@ +using Autofac; +using CodeSpace.Core.Authorization; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Credentials; +using CodeSpace.Messages.Enums; +using CodeSpace.Messages.Exceptions; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Shouldly; + +namespace CodeSpace.IntegrationTests.OAuth; + +/// +/// What happens to the repositories and group hooks bound through a credential when it is Disconnected from the +/// Providers modal, in either order of the two things a person does to swap a connection: connect the new one and +/// disconnect the old one. +/// +/// Disconnect-then-connect always carried the bindings forward (the connect path moves everything the owner's +/// revoked credentials still hold). Connect-then-disconnect did not: the owner's other connection was already live, so +/// the connect had nothing to carry, and the recurring repair only follows a connection made after the revocation. The +/// repository stayed on a revoked row — Error, every run refused — for good, and the only way back was somebody else +/// finding the Re-link button. The end state of the two orders must be the same, so a Disconnect that names no target +/// sends the bindings where the owner's one other connection makes obvious, and one that names a target sends them there, +/// in the same unit of work as the revoke. +/// +/// Real Postgres, the real mediator pipeline for connect, token link and disconnect (authorization behaviors +/// included), and the run's own read (IAgentWorkspaceResolver) through the real GitLab auth strategies. Only the +/// provider's edges are the fixture's test doubles. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class CredentialBindingMoveFlowTests +{ + private readonly BindingMoveKit _kit; + + public CredentialBindingMoveFlowTests(PostgresFixture fixture) { _kit = new BindingMoveKit(fixture); } + + [Fact] + public async Task Disconnecting_the_token_after_connecting_oauth_beside_it_carries_its_repository_and_hook_to_the_oauth_connection() + { + var world = await _kit.SeedWorldAsync(); + var token = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-old"); + var repoId = await _kit.SeedRepositoryAsync(world, token); + var hookId = await _kit.SeedConnectionWebhookAsync(world, token, RepositoryWebhookRegistrationStatus.Registered); + + var oauth = await _kit.ConnectOAuthAsync(world, world.OwnerId, "at-new"); + + var result = await _kit.DisconnectAsync(world, world.OwnerId, token); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(oauth, "the owner's other connection takes over, as it would had they connected it after the disconnect"); + repo.Status.ShouldBe(RepositoryStatus.Active); + repo.LastError.ShouldBeNull(); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("at-new", "the next run clones through the connection that took over"); + (await _kit.LoadConnectionWebhookAsync(hookId)).CredentialId.ShouldBe(oauth, "a group hook records the credential it registers and deletes through"); + + result.MovedRepositoryCount.ShouldBe(1); + result.MovedConnectionWebhookCount.ShouldBe(1); + result.MovedToCredentialId.ShouldBe(oauth); + result.AffectedRepositoryCount.ShouldBe(0, "nothing was left to break, so the Disconnect dialog has nothing to warn about"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Both_orders_of_swapping_a_connection_end_in_the_same_place(bool connectFirst) + { + var world = await _kit.SeedWorldAsync(); + var token = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-old"); + var repoId = await _kit.SeedRepositoryAsync(world, token); + Guid oauth; + + if (connectFirst) + { + oauth = await _kit.ConnectOAuthAsync(world, world.OwnerId, "at-swapped"); + await _kit.DisconnectAsync(world, world.OwnerId, token); + } + else + { + await _kit.DisconnectAsync(world, world.OwnerId, token); + oauth = await _kit.ConnectOAuthAsync(world, world.OwnerId, "at-swapped"); + } + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(oauth); + repo.Status.ShouldBe(RepositoryStatus.Active); + repo.LastError.ShouldBeNull(); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("at-swapped"); + } + + public enum Ambiguity { TwoOtherLiveConnections, OnlyAnotherPersonsConnection, OnlyAConnectionOnAnotherInstance, OnlyARevokedConnection, NoOtherConnection, OnlyATeamServiceToken } + + [Theory] + [InlineData(Ambiguity.TwoOtherLiveConnections)] + [InlineData(Ambiguity.OnlyAnotherPersonsConnection)] + [InlineData(Ambiguity.OnlyAConnectionOnAnotherInstance)] + [InlineData(Ambiguity.OnlyARevokedConnection)] + [InlineData(Ambiguity.NoOtherConnection)] + [InlineData(Ambiguity.OnlyATeamServiceToken)] + public async Task A_disconnect_that_names_no_target_moves_nothing_unless_the_owner_has_exactly_one_other_live_connection(Ambiguity shape) + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-dying"); + var repoId = await _kit.SeedRepositoryAsync(world, dead); + + await SeedAroundAsync(world, shape); + + var result = await _kit.DisconnectAsync(world, world.OwnerId, dead); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(dead, $"{shape}: no single successor is obvious, so the repository stays where a person put it"); + repo.Status.ShouldBe(RepositoryStatus.Error); + repo.LastError.ShouldBe(CredentialRevocationService.DisconnectedRepositoryError); + result.MovedRepositoryCount.ShouldBe(0); + result.MovedToCredentialId.ShouldBeNull(); + result.AffectedRepositoryCount.ShouldBe(1, "the Disconnect dialog still warns about what it broke"); + + var failure = await Should.ThrowAsync(() => _kit.ResolveCloneTokenAsync(world, repoId)); + failure.CredentialId.ShouldBe(dead); + } + + [Fact] + public async Task A_team_service_token_never_moves_on_its_own_even_beside_another_team_token() + { + // Two team tokens are rarely interchangeable: a group token reaches only its group's projects. Choosing between them is + // somebody's decision, not a default. + var world = await _kit.SeedWorldAsync(); + var groupToken = await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-group-a", ownership: CredentialOwnership.TeamService); + await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-group-b", ownership: CredentialOwnership.TeamService); + var repoId = await _kit.SeedRepositoryAsync(world, groupToken); + + var result = await _kit.DisconnectAsync(world, world.AdminId, groupToken); + + (await _kit.LoadRepositoryAsync(repoId)).Status.ShouldBe(RepositoryStatus.Error); + result.MovedRepositoryCount.ShouldBe(0); + } + + [Fact] + public async Task Naming_the_target_moves_the_bindings_even_when_the_owner_holds_two_other_connections() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-dying"); + var wrongOne = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-not-this"); + var chosen = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-this-one"); + var repoId = await _kit.SeedRepositoryAsync(world, dead); + + var result = await _kit.DisconnectAsync(world, world.OwnerId, dead, moveBindingsTo: chosen); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(chosen); + repo.Status.ShouldBe(RepositoryStatus.Active); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("glpat-this-one"); + (await _kit.LoadCredentialAsync(dead)).Status.ShouldBe(CredentialStatus.Revoked, "the disconnect itself still happened"); + (await _kit.LoadCredentialAsync(wrongOne)).Status.ShouldBe(CredentialStatus.Active); + result.MovedToCredentialId.ShouldBe(chosen); + } + + public enum Target { AnotherPersonsConnection, OnAnotherInstance, Revoked, TheCredentialItself, ATeamServiceToken, ForeignToTheTeam, NoSuchCredential } + + [Theory] + [InlineData(Target.AnotherPersonsConnection)] + [InlineData(Target.OnAnotherInstance)] + [InlineData(Target.Revoked)] + [InlineData(Target.TheCredentialItself)] + [InlineData(Target.ATeamServiceToken)] + [InlineData(Target.ForeignToTheTeam)] + [InlineData(Target.NoSuchCredential)] + public async Task A_target_the_move_cannot_accept_is_refused_before_anything_is_revoked(Target shape) + { + var world = await _kit.SeedWorldAsync(); + var oauth = await _kit.ConnectOAuthAsync(world, world.OwnerId, "at-dying"); + var repoId = await _kit.SeedRepositoryAsync(world, oauth); + var target = await SeedTargetAsync(world, shape, oauth); + + var refusal = await Record.ExceptionAsync(() => _kit.DisconnectAsync(world, world.OwnerId, oauth, moveBindingsTo: target)); + + refusal.ShouldNotBeNull($"{shape}: the move is refused"); + refusal.ShouldBeOfType(shape is Target.NoSuchCredential or Target.ForeignToTheTeam ? typeof(KeyNotFoundException) : typeof(InvalidOperationException), $"{shape}: {refusal.Message}"); + + var credential = await _kit.LoadCredentialAsync(oauth); + + credential.Status.ShouldBe(CredentialStatus.Active, $"{shape}: a refused move leaves the credential connected"); + credential.EncryptedPayload.ShouldNotBeNullOrEmpty($"{shape}: its token material is intact"); + world.OAuth.LastRevoke.ShouldBeNull($"{shape}: the provider was never asked to revoke the grant"); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(oauth); + (await _kit.LoadRepositoryAsync(repoId)).Status.ShouldBe(RepositoryStatus.Active); + } + + [Theory] + [InlineData(RepositoryWebhookRegistrationStatus.DeadLettered, RepositoryWebhookRegistrationStatus.Enqueued, true)] + [InlineData(RepositoryWebhookRegistrationStatus.Failed, RepositoryWebhookRegistrationStatus.Enqueued, true)] + [InlineData(RepositoryWebhookRegistrationStatus.Registered, RepositoryWebhookRegistrationStatus.Registered, true)] + [InlineData(RepositoryWebhookRegistrationStatus.Cancelled, RepositoryWebhookRegistrationStatus.Cancelled, false)] + public async Task A_group_hook_follows_the_disconnect_and_a_buried_one_is_revived_and_dispatched(RepositoryWebhookRegistrationStatus before, RepositoryWebhookRegistrationStatus after, bool follows) + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-dying"); + var successor = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-successor"); + var hookId = await _kit.SeedConnectionWebhookAsync(world, dead, before); + + using var dispatches = _kit.Jobs().ManualExecution(); + var result = await _kit.DisconnectAsync(world, world.OwnerId, dead); + + var hook = await _kit.LoadConnectionWebhookAsync(hookId); + + hook.CredentialId.ShouldBe(follows ? successor : dead, $"a {before} hook {(follows ? "is in service, so it follows" : "is retired, so it stays")}"); + hook.RegistrationStatus.ShouldBe(after); + hook.Attempts.ShouldBe(after == RepositoryWebhookRegistrationStatus.Enqueued ? 0 : BindingMoveKit.SeededHookAttempts, "a revival resets the backoff ladder, exactly as a bind's does"); + _kit.WasDispatched(hookId).ShouldBe(after == RepositoryWebhookRegistrationStatus.Enqueued, "only a revived hook is handed to the registrar, and only once the disconnect committed"); + result.MovedConnectionWebhookCount.ShouldBe(follows ? 1 : 0); + result.MovedToCredentialId.ShouldBe(follows ? successor : null); + } + + [Theory] + [InlineData(RepositoryStatus.Paused, RepositoryStatus.Paused)] + [InlineData(RepositoryStatus.Unreachable, RepositoryStatus.Unreachable)] + [InlineData(RepositoryStatus.Error, RepositoryStatus.Active)] + public async Task A_repository_keeps_the_status_that_is_its_own_when_it_moves(RepositoryStatus before, RepositoryStatus after) + { + // Paused and Unreachable say something about the repository, not about its credential: a person paused it, the provider + // could not reach it. Only the Error a disconnect sets is lifted by finding a live credential. + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-dying"); + var successor = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-successor"); + var repoId = await _kit.SeedRepositoryAsync(world, dead, before); + + await _kit.DisconnectAsync(world, world.OwnerId, dead); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(successor); + repo.Status.ShouldBe(after); + } + + [Fact] + public async Task A_disconnect_with_nothing_bound_reports_nothing_moved_and_still_revokes() + { + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-unused"); + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-spare"); + + var result = await _kit.DisconnectAsync(world, world.OwnerId, dead); + + result.MovedRepositoryCount.ShouldBe(0); + result.MovedConnectionWebhookCount.ShouldBe(0); + result.MovedToCredentialId.ShouldBeNull("nothing moved, so there is no destination to report"); + (await _kit.LoadCredentialAsync(dead)).Status.ShouldBe(CredentialStatus.Revoked); + } + + [Fact] + public async Task Naming_a_target_for_an_already_disconnected_credential_moves_what_is_still_bound_to_it() + { + // The Disconnect that stranded these ran before this change, or without a target. Saying where they go now is the + // same request, and it is idempotent in the way the cascade it replaces always was. + var world = await _kit.SeedWorldAsync(); + var dead = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-old"); + var repoId = await _kit.SeedRepositoryAsync(world, dead); + await _kit.DisconnectAsync(world, world.OwnerId, dead); + (await _kit.LoadRepositoryAsync(repoId)).Status.ShouldBe(RepositoryStatus.Error, "fixture check: the first disconnect stranded it"); + + var spare = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-spare"); + var again = await _kit.DisconnectAsync(world, world.OwnerId, dead, moveBindingsTo: spare); + + again.MovedRepositoryCount.ShouldBe(1); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(spare); + (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("glpat-spare"); + } + + [Fact] + public async Task A_member_disconnects_their_own_credential_and_moves_its_bindings_to_their_other_one() + { + // The Personal tab's Disconnect is every member's own, and it stays so with a target: moving what is bound through your + // own credential onto your own other credential gives the team no power over repositories it did not already have — + // connecting the second one after the disconnect would have carried them the same way. + var world = await _kit.SeedWorldAsync(); + var first = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-first"); + var second = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-second"); + var repoId = await _kit.SeedRepositoryAsync(world, first); + + var result = await _kit.DisconnectAsync(world, world.MemberId, first, moveBindingsTo: second); + + result.MovedRepositoryCount.ShouldBe(1); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(second); + } + + [Theory] + [InlineData(TeamRole.Viewer)] + [InlineData(TeamRole.Member)] + public async Task Below_admin_a_move_does_not_make_a_teammates_credential_disconnectable(TeamRole role) + { + var world = await _kit.SeedWorldAsync(); + var caller = await _kit.SeedMemberAsync(world, role); + var owners = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner-a"); + var ownersOther = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner-b"); + await _kit.SeedRepositoryAsync(world, owners); + + await Should.ThrowAsync(() => _kit.DisconnectAsync(world, caller, owners, moveBindingsTo: ownersOther)); + + (await _kit.LoadCredentialAsync(owners)).Status.ShouldBe(CredentialStatus.Active); + } + + [Fact] + public async Task An_admin_disconnecting_a_teammates_credential_may_move_it_only_to_that_teammates_other_connection() + { + var world = await _kit.SeedWorldAsync(); + var owners = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner-a"); + var ownersOther = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-owner-b"); + var admins = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin"); + var repoId = await _kit.SeedRepositoryAsync(world, owners); + + var refusal = await Record.ExceptionAsync(() => _kit.DisconnectAsync(world, world.AdminId, owners, moveBindingsTo: admins)); + + refusal.ShouldBeOfType("the repositories would silently start acting as the Admin"); + (await _kit.LoadCredentialAsync(owners)).Status.ShouldBe(CredentialStatus.Active, "refused before anything was revoked"); + + var result = await _kit.DisconnectAsync(world, world.AdminId, owners, moveBindingsTo: ownersOther); + + result.MovedRepositoryCount.ShouldBe(1); + (await _kit.LoadRepositoryAsync(repoId)).CredentialId.ShouldBe(ownersOther); + } + + [Fact] + public async Task A_target_held_by_someone_without_standing_in_the_team_is_refused() + { + // The departed-token sweep retires a personal credential whose person has no standing in the team. Moving + // repositories onto one would hand them to the next tick's retirement, stranded again. + var world = await _kit.SeedWorldAsync(); + var stranger = await SeedStrangerAsync(); + var disconnecting = await _kit.SeedCredentialAsync(world, stranger, "glpat-stranger-a"); + var target = await _kit.SeedCredentialAsync(world, stranger, "glpat-stranger-b"); + await _kit.SeedRepositoryAsync(world, disconnecting); + + var refusal = await Should.ThrowAsync(() => _kit.DisconnectAsync(world, world.AdminId, disconnecting, moveBindingsTo: target)); + + refusal.Message.ShouldContain("no longer has standing"); + (await _kit.LoadCredentialAsync(disconnecting)).Status.ShouldBe(CredentialStatus.Active); + } + + [Fact] + public async Task An_admin_disconnecting_a_teammates_credential_with_no_target_moves_nothing_even_beside_their_one_other_connection() + { + // The default is the answer reconnecting would give, and only the person whose connection it is reconnects. An Admin + // cutting off a teammate's token must not have the repository carried onto the teammate's other grant instead: it would go + // on acting as that person, with no way for the Admin to say otherwise. Naming the target is how an Admin moves it. + var world = await _kit.SeedWorldAsync(); + var theirs = await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-first"); + await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-member-second"); + var adminsOwn = await _kit.SeedCredentialAsync(world, world.AdminId, "glpat-admin-only-one"); + var repoId = await _kit.SeedRepositoryAsync(world, theirs); + + var result = await _kit.DisconnectAsync(world, world.AdminId, theirs); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + repo.CredentialId.ShouldBe(theirs); + repo.Status.ShouldBe(RepositoryStatus.Error); + result.MovedRepositoryCount.ShouldBe(0); + result.MovedToCredentialId.ShouldBeNull(); + result.AffectedRepositoryCount.ShouldBe(1); + (await _kit.LoadCredentialAsync(theirs)).Status.ShouldBe(CredentialStatus.Revoked, "the disconnect itself still happened"); + (await _kit.LoadCredentialAsync(adminsOwn)).Status.ShouldBe(CredentialStatus.Active, "and the Admin's own single connection was never a candidate: it would have made the repository act as the Admin"); + } + + [Fact] + public async Task Disconnecting_a_credential_that_is_already_disconnected_with_no_target_moves_nothing_on_its_own() + { + // A, B and C are the owner's. A goes while two others are live: nothing moves, the repository turns Error. B goes. + // Disconnecting A again — an idempotent retry, a double click — must not now find C the sole survivor and carry a + // repository the owner left stranded onto a connection they never chose for it. + var world = await _kit.SeedWorldAsync(); + var a = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-a"); + var b = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-b"); + var c = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-c"); + var repoId = await _kit.SeedRepositoryAsync(world, a); + + await _kit.DisconnectAsync(world, world.OwnerId, a); + await _kit.DisconnectAsync(world, world.OwnerId, b); + + var again = await _kit.DisconnectAsync(world, world.OwnerId, a); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + again.MovedRepositoryCount.ShouldBe(0); + again.MovedToCredentialId.ShouldBeNull(); + repo.CredentialId.ShouldBe(a); + repo.Status.ShouldBe(RepositoryStatus.Error); + (await _kit.LoadCredentialAsync(c)).Status.ShouldBe(CredentialStatus.Active); + } + + [Fact] + public async Task The_implicit_successor_is_never_a_credential_the_move_would_refuse() + { + // Disconnect must never fail because of a default it chose for itself. The person with no standing in the team has one + // other live token — the sole candidate — and the move refuses it, so the default is no default at all. + var world = await _kit.SeedWorldAsync(); + var stranger = await SeedStrangerAsync(); + var disconnecting = await _kit.SeedCredentialAsync(world, stranger, "glpat-stranger-a"); + await _kit.SeedCredentialAsync(world, stranger, "glpat-stranger-b"); + var from = await _kit.LoadCredentialAsync(disconnecting); + + using var scope = _kit.Fixture.BeginScope(); + + (await scope.Resolve().FindOnlyOtherConnectionAsync(from, stranger, CancellationToken.None)).ShouldBeNull(); + } + + [Theory] + [InlineData(1, true)] + [InlineData(2, false)] + public async Task Unlinking_the_identity_ends_the_connection_the_way_a_disconnect_does(int liveConnections, bool moves) + { + // The Connected Identities page's Unlink retires the credential behind the identity through the same cascade as a + // Disconnect, so the same shape strands the same repository: the person connected OAuth beside their token and removed + // the OAuth identity. Their one other live connection is where a Disconnect would have sent it. + var world = await _kit.SeedWorldAsync(); + var token = await _kit.LinkTokenAsync(world, world.OwnerId, "glpat-first"); + var oauth = await _kit.ConnectOAuthAsync(world, world.OwnerId, "at-linked"); + + for (var i = 1; i < liveConnections; i++) await _kit.SeedCredentialAsync(world, world.OwnerId, $"glpat-extra-{i}"); + + var repoId = await _kit.SeedRepositoryAsync(world, oauth); + var hookId = await _kit.SeedConnectionWebhookAsync(world, oauth, RepositoryWebhookRegistrationStatus.Registered); + + await _kit.UnlinkIdentityAsync(world, world.OwnerId); + + var repo = await _kit.LoadRepositoryAsync(repoId); + + (await _kit.LoadCredentialAsync(oauth)).DeletedDate.ShouldNotBeNull("the unlink still retires the credential behind the identity"); + repo.CredentialId.ShouldBe(moves ? token : oauth); + repo.Status.ShouldBe(moves ? RepositoryStatus.Active : RepositoryStatus.Error); + (await _kit.LoadConnectionWebhookAsync(hookId)).CredentialId.ShouldBe(moves ? token : oauth); + + if (moves) (await _kit.ResolveCloneTokenAsync(world, repoId)).ShouldBe("glpat-first", "the next run clones through the connection that took over"); + } + + public enum UsageShape { OwnerWithOneOtherConnection, OwnerWithTwoOtherConnections, AdminForATeammateWithOneOtherConnection, TeamServiceTokenBesideAnother } + + [Theory] + [InlineData(UsageShape.OwnerWithOneOtherConnection)] + [InlineData(UsageShape.OwnerWithTwoOtherConnections)] + [InlineData(UsageShape.AdminForATeammateWithOneOtherConnection)] + [InlineData(UsageShape.TeamServiceTokenBesideAnother)] + public async Task The_disconnect_preview_says_what_the_disconnect_then_does(UsageShape shape) + { + // The confirm dialog quotes the preview. A preview that still said "this will break N repositories" for a disconnect + // that moves them all is a warning about something that will not happen. + var world = await _kit.SeedWorldAsync(); + var (caller, owner, ownership) = shape switch + { + UsageShape.AdminForATeammateWithOneOtherConnection => (world.AdminId, (Guid?)world.MemberId, CredentialOwnership.Personal), + UsageShape.TeamServiceTokenBesideAnother => (world.AdminId, (Guid?)null, CredentialOwnership.TeamService), + _ => (world.OwnerId, (Guid?)world.OwnerId, CredentialOwnership.Personal) + }; + + var dying = await _kit.SeedCredentialAsync(world, owner, "glpat-dying", ownership: ownership); + + for (var i = 0; i < (shape == UsageShape.OwnerWithTwoOtherConnections ? 2 : 1); i++) await _kit.SeedCredentialAsync(world, owner, $"glpat-other-{i}", ownership: ownership); + + await _kit.SeedRepositoryAsync(world, dying); + await _kit.SeedRepositoryAsync(world, dying); + + var usage = await _kit.UsageAsync(world, caller, dying); + var result = await _kit.DisconnectAsync(world, caller, dying); + + usage.ActiveRepositoryCount.ShouldBe(result.AffectedRepositoryCount, $"{shape}: the repositories the preview warns about are the ones the disconnect then breaks"); + usage.WillMoveRepositoryCount.ShouldBe(result.MovedRepositoryCount, $"{shape}: and the ones it says will move are the ones that do"); + usage.WillMoveToDisplayName.ShouldBe(result.MovedToCredentialId is Guid movedTo ? (await _kit.LoadCredentialAsync(movedTo)).DisplayName : null, $"{shape}: naming where"); + } + + // ── Shapes ──────────────────────────────────────────────────────────────────── + + private async Task SeedAroundAsync(BindingMoveKit.World world, Ambiguity shape) + { + switch (shape) + { + case Ambiguity.TwoOtherLiveConnections: + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-other-1"); + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-other-2"); + break; + case Ambiguity.OnlyAnotherPersonsConnection: + await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-teammates"); + break; + case Ambiguity.OnlyAConnectionOnAnotherInstance: + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-elsewhere", instanceId: await _kit.SeedOtherInstanceAsync(world)); + break; + case Ambiguity.OnlyARevokedConnection: + await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-gone", CredentialStatus.Revoked); + break; + case Ambiguity.OnlyATeamServiceToken: + await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team", ownership: CredentialOwnership.TeamService); + break; + case Ambiguity.NoOtherConnection: + break; + default: + throw new ArgumentOutOfRangeException(nameof(shape), shape, "a shape the test does not seed"); + } + } + + /// The Guid a disconnect is told to send the bindings to, for each way of naming something it must not accept. + private async Task SeedTargetAsync(BindingMoveKit.World world, Target shape, Guid disconnecting) + { + switch (shape) + { + case Target.AnotherPersonsConnection: return await _kit.SeedCredentialAsync(world, world.MemberId, "glpat-teammates"); + case Target.OnAnotherInstance: return await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-elsewhere", instanceId: await _kit.SeedOtherInstanceAsync(world)); + case Target.Revoked: return await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-gone", CredentialStatus.Revoked); + case Target.TheCredentialItself: return disconnecting; + case Target.ATeamServiceToken: return await _kit.SeedCredentialAsync(world, ownerId: null, "glpat-team", ownership: CredentialOwnership.TeamService); + case Target.ForeignToTheTeam: return await SeedCredentialInAnotherTeamAsync(); + case Target.NoSuchCredential: return Guid.NewGuid(); + default: throw new ArgumentOutOfRangeException(nameof(shape), shape, "a shape the test does not seed"); + } + } + + private async Task SeedCredentialInAnotherTeamAsync() + { + var other = await _kit.SeedWorldAsync(); + + return await _kit.SeedCredentialAsync(other, other.OwnerId, "glpat-other-team"); + } + + /// A person with an account and no membership in the team — what a departed member is, before the sweep retires what they lent. + private async Task SeedStrangerAsync() + { + var stranger = new CodeSpace.Core.Persistence.Entities.User { Id = Guid.NewGuid(), Email = $"stranger-{Guid.NewGuid():N}@x", Name = "stranger" }; + + using var scope = _kit.Fixture.BeginScope(); + var db = scope.Resolve(); + + db.User.Add(stranger); + await db.SaveChangesAsync(); + + return stranger.Id; + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/OAuth/RepairSweepFairnessFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/OAuth/RepairSweepFairnessFlowTests.cs new file mode 100644 index 000000000..541606eb4 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/OAuth/RepairSweepFairnessFlowTests.cs @@ -0,0 +1,191 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Npgsql; +using Shouldly; + +namespace CodeSpace.IntegrationTests.OAuth; + +/// +/// The recurring repair of stranded bindings takes its candidates in id order, a batch a tick. A candidate that fails +/// stays a candidate — the right call, since the next tick may succeed — but with the batch read from the front every +/// time, fifty candidates that can never be repaired held the whole batch for good, and nobody behind them was ever +/// reached. The pass now walks the backlog in pages, so a failure costs one attempt and no place in the batch. +/// +/// A real failure, not a stub: a trigger on the repository table refuses the one update that re-points a chosen +/// repository, so the production service meets exactly what a constraint violation or a deadlock would hand it. The +/// poisoned credentials get ids that sort ahead of every random one, which is what puts them at the front of the batch. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class RepairSweepFairnessFlowTests +{ + private const int MaxPasses = 50; + + private readonly PostgresFixture _fixture; + private readonly BindingMoveKit _kit; + + public RepairSweepFairnessFlowTests(PostgresFixture fixture) + { + _fixture = fixture; + _kit = new BindingMoveKit(fixture); + } + + [Fact] + public async Task Credentials_that_can_never_be_repaired_do_not_starve_the_ones_behind_them() + { + var world = await _kit.SeedWorldAsync(); + var poisoned = new[] { await SeedStrandedAsync(world, FrontOfTheQueue()), await SeedStrandedAsync(world, FrontOfTheQueue()) }; + var healthy = await SeedStrandedAsync(world, deadId: null); + + await using var poison = await PoisonAsync(poisoned.Select(p => p.Repository)); + + // One credential a pass: a batch the two poisoned ones alone would fill from the front. + var passes = await SweepUntilNothingIsRepairedAsync(batchSize: 1); + + var repo = await _kit.LoadRepositoryAsync(healthy.Repository); + + repo.CredentialId.ShouldBe(healthy.Successor, "the credential behind the poisoned ones was reached, however many fail ahead of it"); + repo.Status.ShouldBe(RepositoryStatus.Active); + passes.ShouldBeGreaterThanOrEqualTo(1); + + foreach (var stuck in poisoned) + (await _kit.LoadRepositoryAsync(stuck.Repository)).CredentialId.ShouldBe(stuck.Dead, "a repair that failed leaves its binding exactly where it was — nothing half-moved"); + } + + [Fact] + public async Task A_pass_repairs_no_more_than_its_batch_however_many_candidates_wait() + { + var world = await _kit.SeedWorldAsync(); + + for (var i = 0; i < 3; i++) await SeedStrandedAsync(world, deadId: null); + + using var scope = _fixture.BeginScope(); + + var repaired = await scope.Resolve().Send(new RepairDisconnectedCredentialBindingsCommand { BatchSize = 2 }); + + repaired.ShouldBe(2, "bounded: the third waits for the next tick"); + + await SweepUntilNothingIsRepairedAsync(batchSize: 2); + } + + [Fact] + public async Task A_candidate_that_has_failed_several_passes_running_is_left_alone_for_a_while() + { + // A failure the next tick may not repeat is worth another attempt; one that has repeated three ticks running is not + // going to heal in the next five minutes, and trying it again every tick forever costs a query storm and a warning + // each time. It stops being attempted for a while — and one that recovered sooner is repaired as soon as it can be. + // Two worlds, so each candidate has an owner — and a successor — of its own: the repair follows an owner's NEWEST live connection. + var transient = await SeedStrandedAsync(await _kit.SeedWorldAsync(), FrontOfTheQueue()); + var permanent = await SeedStrandedAsync(await _kit.SeedWorldAsync(), FrontOfTheQueue()); + + await using var transientPoison = await PoisonAsync(new[] { transient.Repository }); + await using var permanentPoison = await PoisonAsync(new[] { permanent.Repository }); + + for (var pass = 1; pass < SweepFailureBackoff.FailuresBeforeBackoff; pass++) await SweepOnceAsync(); + + await transientPoison.DisarmAsync(); + await SweepOnceAsync(); + + (await _kit.LoadRepositoryAsync(transient.Repository)).CredentialId.ShouldBe(transient.Successor, "one failure short of being set aside is still worth another attempt, which succeeded"); + (await _kit.LoadRepositoryAsync(permanent.Repository)).CredentialId.ShouldBe(permanent.Dead, "fixture check: still poisoned, so that attempt failed — its last one before it is set aside"); + + await permanentPoison.DisarmAsync(); + await SweepOnceAsync(); + + (await _kit.LoadRepositoryAsync(permanent.Repository)).CredentialId.ShouldBe(permanent.Dead, "failing that many passes in a row put it out of the sweep's rotation, so a pass within the window does not attempt it even though it would now succeed"); + } + + private async Task SweepOnceAsync() + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Send(new RepairDisconnectedCredentialBindingsCommand { BatchSize = 50 }); + } + + private async Task SweepUntilNothingIsRepairedAsync(int batchSize) + { + for (var pass = 1; pass <= MaxPasses; pass++) + { + using var scope = _fixture.BeginScope(); + + if (await scope.Resolve().Send(new RepairDisconnectedCredentialBindingsCommand { BatchSize = batchSize }) == 0) return pass; + } + + throw new ShouldAssertException($"the repair still repaired something after {MaxPasses} passes of {batchSize}"); + } + + private sealed record Stranded(Guid Dead, Guid Successor, Guid Repository); + + /// A revoked credential of the owner's with a repository still bound to it, and a live one connected after the revocation to follow. + private async Task SeedStrandedAsync(BindingMoveKit.World world, Guid? deadId) + { + var revoked = DateTimeOffset.UtcNow.AddDays(-10); + + var dead = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-dead", CredentialStatus.Revoked, createdDate: revoked, id: deadId); + var successor = await _kit.SeedCredentialAsync(world, world.OwnerId, "glpat-successor", createdDate: revoked.AddDays(1)); + var repository = await _kit.SeedRepositoryAsync(world, dead, RepositoryStatus.Error); + + return new Stranded(dead, successor, repository); + } + + /// An id that sorts ahead of any random one: the batch reads candidates in id order. + private static Guid FrontOfTheQueue() => Guid.Parse($"00000000-0000-0000-0000-{Guid.NewGuid().ToString("N")[..12]}"); + + private async Task PoisonAsync(IEnumerable repositoryIds) + { + var poison = new Poison(_fixture.ConnectionString, repositoryIds.ToList()); + + await poison.ArmAsync(); + + return poison; + } + + /// A trigger refusing every update of the named repository rows, so the repair that re-points one throws from the database, as a real constraint would. + private sealed class Poison : IAsyncDisposable + { + private readonly string _connectionString; + private readonly List _repositoryIds; + private readonly string _name = $"poison_{Guid.NewGuid():N}"; + + public Poison(string connectionString, List repositoryIds) + { + _connectionString = connectionString; + _repositoryIds = repositoryIds; + } + + public async Task ArmAsync() + { + var ids = string.Join(", ", _repositoryIds.Select(id => $"'{id}'")); + + await ExecuteAsync($"CREATE FUNCTION {_name}() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'poisoned repository'; END $$ LANGUAGE plpgsql; " + + $"CREATE TRIGGER {_name} BEFORE UPDATE ON repository FOR EACH ROW WHEN (OLD.id IN ({ids})) EXECUTE FUNCTION {_name}();"); + } + + /// Let the poisoned rows be updated again, as a transient failure that has passed. + public async Task DisarmAsync() => await ExecuteAsync($"DROP TRIGGER IF EXISTS {_name} ON repository; DROP FUNCTION IF EXISTS {_name}();"); + + /// Disarm, then soft-delete what was poisoned: the sweep is deployment-wide, and a binding that can never be repaired must not stay a candidate for every later test. + public async ValueTask DisposeAsync() + { + var ids = string.Join(", ", _repositoryIds.Select(id => $"'{id}'")); + + await ExecuteAsync($"DROP TRIGGER IF EXISTS {_name} ON repository; DROP FUNCTION IF EXISTS {_name}(); UPDATE repository SET deleted_date = now() WHERE id IN ({ids});"); + } + + private async Task ExecuteAsync(string sql) + { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + } +} diff --git a/backend/tests/CodeSpace.IntegrationTests/OAuth/RetireSweepFairnessFlowTests.cs b/backend/tests/CodeSpace.IntegrationTests/OAuth/RetireSweepFairnessFlowTests.cs new file mode 100644 index 000000000..8de7c7a48 --- /dev/null +++ b/backend/tests/CodeSpace.IntegrationTests/OAuth/RetireSweepFairnessFlowTests.cs @@ -0,0 +1,183 @@ +using Autofac; +using CodeSpace.Core.Persistence.Db; +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.IntegrationTests.Infrastructure; +using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Enums; +using MediatR; +using Npgsql; +using Shouldly; + +namespace CodeSpace.IntegrationTests.OAuth; + +/// +/// The recurring retirement of departed members' tokens has the same shape as the repair of stranded bindings: it takes +/// its candidates in id order, a batch a tick, and a candidate that fails stays a candidate. Read from the front every +/// time, fifty tokens that could never be retired held the whole batch for good — and a healthy departed member's token, +/// the one the sweep exists to end, kept authenticating repositories behind them. Both sweeps now walk their backlog in +/// pages, so a failure costs one attempt and no place in the batch. +/// +/// A real failure, not a stub: a trigger on the credential table refuses the one update that retires a chosen +/// token, so the production service meets exactly what a constraint violation or a deadlock would hand it. The poisoned +/// tokens get ids that sort ahead of every random one, which is what puts them at the front of the batch. +/// +[Collection(PostgresCollection.Name)] +[Trait("Category", "Integration")] +public sealed class RetireSweepFairnessFlowTests +{ + private const int MaxPasses = 50; + + private readonly PostgresFixture _fixture; + private readonly BindingMoveKit _kit; + + public RetireSweepFairnessFlowTests(PostgresFixture fixture) + { + _fixture = fixture; + _kit = new BindingMoveKit(fixture); + } + + [Fact] + public async Task Tokens_that_can_never_be_retired_do_not_starve_the_departed_token_behind_them() + { + var world = await _kit.SeedWorldAsync(); + var stranger = await SeedStrangerAsync(); + var poisoned = new[] { await _kit.SeedCredentialAsync(world, stranger, "glpat-poison-a", id: FrontOfTheQueue()), await _kit.SeedCredentialAsync(world, stranger, "glpat-poison-b", id: FrontOfTheQueue()) }; + var healthy = await _kit.SeedCredentialAsync(world, stranger, "glpat-healthy"); + + await using var poison = await PoisonAsync(poisoned); + + // One token a pass: a batch the two poisoned ones alone would fill from the front. + var passes = await SweepUntilRetiredAsync(healthy, batchSize: 1); + + var retired = await _kit.LoadCredentialAsync(healthy); + + retired.DeletedDate.ShouldNotBeNull("the departed member's token behind the poisoned ones was reached, however many fail ahead of it"); + retired.Status.ShouldBe(CredentialStatus.Revoked); + retired.EncryptedPayload.ShouldBeEmpty("a retired token has nothing left to authenticate with"); + passes.ShouldBeGreaterThanOrEqualTo(1); + + foreach (var stuck in poisoned) + { + var credential = await _kit.LoadCredentialAsync(stuck); + + credential.DeletedDate.ShouldBeNull("a retirement that failed leaves the token exactly as it was — nothing half-done"); + credential.Status.ShouldBe(CredentialStatus.Active); + } + } + + [Fact] + public async Task A_token_that_has_failed_several_passes_running_is_left_alone_for_a_while_and_one_that_recovered_sooner_is_retired() + { + // The same memory the repair sweep keeps (see RepairSweepFairnessFlowTests), through the retirement's own wiring: the + // sweep runs in a fresh scope each tick, so what it remembers must outlive a scope. + var world = await _kit.SeedWorldAsync(); + var transient = await _kit.SeedCredentialAsync(world, await SeedStrangerAsync(), "glpat-transient", id: FrontOfTheQueue()); + var permanent = await _kit.SeedCredentialAsync(world, await SeedStrangerAsync(), "glpat-permanent", id: FrontOfTheQueue()); + + await using var transientPoison = await PoisonAsync(new[] { transient }); + await using var permanentPoison = await PoisonAsync(new[] { permanent }); + + for (var pass = 1; pass < SweepFailureBackoff.FailuresBeforeBackoff; pass++) await SweepOnceAsync(); + + await transientPoison.DisarmAsync(); + await SweepOnceAsync(); + + (await _kit.LoadCredentialAsync(transient)).DeletedDate.ShouldNotBeNull("one failure short of being set aside is still worth another attempt, which succeeded"); + (await _kit.LoadCredentialAsync(permanent)).DeletedDate.ShouldBeNull("fixture check: still poisoned, so that attempt failed — its last one before it is set aside"); + + await permanentPoison.DisarmAsync(); + await SweepOnceAsync(); + + (await _kit.LoadCredentialAsync(permanent)).DeletedDate.ShouldBeNull("failing that many passes in a row put it out of the sweep's rotation, so a pass within the window does not attempt it even though it would now succeed"); + } + + private async Task SweepOnceAsync() + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Send(new RetireDepartedMembersCredentialsCommand { BatchSize = 50 }); + } + + private async Task SweepUntilRetiredAsync(Guid credentialId, int batchSize) + { + for (var pass = 1; pass <= MaxPasses; pass++) + { + using var scope = _fixture.BeginScope(); + + await scope.Resolve().Send(new RetireDepartedMembersCredentialsCommand { BatchSize = batchSize }); + + if ((await _kit.LoadCredentialAsync(credentialId)).DeletedDate != null) return pass; + } + + throw new ShouldAssertException($"the departed member's token was still live after {MaxPasses} passes of {batchSize}: the sweep never got past the tokens that fail"); + } + + /// A person with an account and no membership in the team — what a departed member is, before the sweep retires what they lent. + private async Task SeedStrangerAsync() + { + var stranger = new User { Id = Guid.NewGuid(), Email = $"stranger-{Guid.NewGuid():N}@x", Name = "stranger" }; + + using var scope = _fixture.BeginScope(); + var db = scope.Resolve(); + + db.User.Add(stranger); + await db.SaveChangesAsync(); + + return stranger.Id; + } + + /// An id that sorts ahead of any random one: the batch reads candidates in id order. + private static Guid FrontOfTheQueue() => Guid.Parse($"00000000-0000-0000-0000-{Guid.NewGuid().ToString("N")[..12]}"); + + private async Task PoisonAsync(IEnumerable credentialIds) + { + var poison = new Poison(_fixture.ConnectionString, credentialIds.ToList()); + + await poison.ArmAsync(); + + return poison; + } + + /// A trigger refusing every update of the named credential rows, so the retirement of one throws from the database, as a real constraint would. + private sealed class Poison : IAsyncDisposable + { + private readonly string _connectionString; + private readonly List _credentialIds; + private readonly string _name = $"poison_{Guid.NewGuid():N}"; + + public Poison(string connectionString, List credentialIds) + { + _connectionString = connectionString; + _credentialIds = credentialIds; + } + + public async Task ArmAsync() + { + var ids = string.Join(", ", _credentialIds.Select(id => $"'{id}'")); + + await ExecuteAsync($"CREATE FUNCTION {_name}() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'poisoned credential'; END $$ LANGUAGE plpgsql; " + + $"CREATE TRIGGER {_name} BEFORE UPDATE ON credential FOR EACH ROW WHEN (OLD.id IN ({ids})) EXECUTE FUNCTION {_name}();"); + } + + /// Let the poisoned rows be updated again, as a transient failure that has passed. + public async Task DisarmAsync() => await ExecuteAsync($"DROP TRIGGER IF EXISTS {_name} ON credential; DROP FUNCTION IF EXISTS {_name}();"); + + /// Disarm, then retire what was poisoned: the sweep is deployment-wide, and a token that can never be retired must not stay a candidate for every later test. + public async ValueTask DisposeAsync() + { + var ids = string.Join(", ", _credentialIds.Select(id => $"'{id}'")); + + await ExecuteAsync($"DROP TRIGGER IF EXISTS {_name} ON credential; DROP FUNCTION IF EXISTS {_name}(); UPDATE credential SET deleted_date = now(), status = 'Revoked' WHERE id IN ({ids});"); + } + + private async Task ExecuteAsync(string sql) + { + await using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + await using var command = new NpgsqlCommand(sql, connection); + await command.ExecuteNonQueryAsync(); + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs index 02ad0130e..3494b9295 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/ResourceAccessMarkerInventoryTests.cs @@ -55,6 +55,10 @@ public class ResourceAccessMarkerInventoryTests ["RevokeStorageCredentialCommand.CredentialId"] = "a storage credential; StorageCredentialService loads it by (header team, id).", ["GetStorageCredentialQuery.CredentialId"] = "a storage credential; StorageCredentialService loads it by (header team, id).", + // The credential marker refuses a soft-deleted credential, and a departed member's retired token is the one these two exist for. + ["GetCredentialDependentsQuery.CredentialId"] = "CredentialRelinkService.LoadTeamCredentialAsync loads the credential by (id, header team), soft-deleted or not, and reads it as not found otherwise.", + ["GetRelinkSuggestionsQuery.CredentialId"] = "the same CredentialRelinkService.LoadTeamCredentialAsync (id, header team) load as GetCredentialDependentsQuery.", + // Bind names an instance AND a credential; the marker can vouch for one id, the service vouches for both. ["BindRepositoryCommand.CredentialId"] = "RepositoryBindingService.LoadCredentialAsync loads the credential by (id, header team).", ["BindRepositoryCommand.ProviderInstanceId"] = "RepositoryBindingService.LoadProviderInstanceAsync loads the instance by (id, header team), and the credential must belong to it.", diff --git a/backend/tests/CodeSpace.UnitTests/Architecture/TeamWritePermissionAdoptionTests.cs b/backend/tests/CodeSpace.UnitTests/Architecture/TeamWritePermissionAdoptionTests.cs index 7da3338e1..6bc39d144 100644 --- a/backend/tests/CodeSpace.UnitTests/Architecture/TeamWritePermissionAdoptionTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Architecture/TeamWritePermissionAdoptionTests.cs @@ -55,6 +55,7 @@ public void Every_team_write_declares_a_permission_or_is_self_service() [InlineData("UnbindRepositoryCommand", TeamPermissions.ReposManage)] [InlineData("RelinkRepositoryCredentialCommand", TeamPermissions.ReposManage)] [InlineData("TestRepositoryBindingCommand", TeamPermissions.ReposManage)] + [InlineData("RelinkCredentialBindingsCommand", TeamPermissions.ReposManage)] public void A_write_addressed_by_a_resource_id_holds_the_bar_of_the_write_that_creates_it(string command, string permission) { // These carried only their resource marker, which proves team and membership and no role — so a Viewer diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/CredentialResolverTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/CredentialResolverTests.cs index 8e86ee6c9..90fe025ac 100644 --- a/backend/tests/CodeSpace.UnitTests/Credentials/CredentialResolverTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Credentials/CredentialResolverTests.cs @@ -43,6 +43,35 @@ public void A_disconnected_credential_names_the_disconnect_instead_of_decrypting failure.Message.ShouldContain("'alice's GitLab' was disconnected"); } + [Fact] + public void The_disconnect_names_the_remedies_a_person_can_perform_today_and_who_may_apply_them() + { + // A run's failure is the one place most people meet a disconnect, so it must name what actually fixes it: a team + // Admin re-linking the repository, or the owner of a personal connection reconnecting. Not a step that has no screen + // yet — re-linking every repository on the credential at once is an API only — and not "re-link" said to someone who + // is not allowed to. + var credential = BuildCredential(CredentialStatus.Revoked, string.Empty); + + var message = Should.Throw(() => new CredentialResolver(_encryptor, _serializer).Resolve(credential)).Message; + + message.ShouldContain("team Admin"); + message.ShouldContain("re-link the repository"); + message.ShouldContain("its owner reconnecting the provider"); + message.ShouldNotContain("at once", Case.Insensitive, "the all-at-once relink has no screen, so no message sends anyone looking for it"); + } + + [Fact] + public void A_repository_left_on_a_disconnected_credential_carries_the_same_remedies_in_its_own_error() + { + var error = CredentialRevocationService.DisconnectedRepositoryError; + + error.ShouldContain("Re-link this repository"); + error.ShouldContain("a team Admin can"); + error.ShouldContain("its owner reconnecting the provider"); + error.ShouldContain("unbind"); + error.ShouldNotContain("at once", Case.Insensitive, "the all-at-once relink has no screen, so no message sends anyone looking for it"); + } + private static Credential BuildCredential(CredentialStatus status, string encryptedPayload) => new() { Id = Guid.NewGuid(), AuthType = AuthType.Pat, DisplayName = "alice's GitLab", Status = status, EncryptedPayload = encryptedPayload }; } diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs new file mode 100644 index 000000000..093e60296 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs @@ -0,0 +1,164 @@ +using CodeSpace.Core.Persistence.Entities; +using CodeSpace.Core.Services.Credentials; +using CodeSpace.Messages.Dtos.Credentials; +using CodeSpace.Messages.Enums; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials; + +/// +/// Which credentials are offered to take a dead one's bindings, and in what order: the caller's own live connections first +/// (newest first — the one just made is the one most likely replacing the dead one), then the team's service tokens +/// (newest first). A teammate's personal token is never offered, nor anything the relink itself would refuse. +/// +[Trait("Category", "Unit")] +public class RelinkSuggestionRankingTests +{ + private static readonly Guid Team = Guid.NewGuid(); + private static readonly Guid Instance = Guid.NewGuid(); + 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); + + [Fact] + public void Own_connections_come_first_newest_first_then_team_tokens_newest_first() + { + var dead = Dead(); + var ownOld = Own(Revoked.AddDays(-3)); + var ownNew = Own(Revoked.AddDays(2)); + var teamOld = Team_(Revoked.AddDays(-9)); + var teamNew = Team_(Revoked.AddDays(9)); + + var ranked = RelinkSuggestionRanking.Rank(dead, new[] { teamOld, ownOld, teamNew, ownNew }, Caller); + + ranked.Select(r => r.CredentialId).ShouldBe(new[] { ownNew.Id, ownOld.Id, teamNew.Id, teamOld.Id }); + ranked.Select(r => r.Kind).ShouldBe(new[] { RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.YourConnection, RelinkSuggestionKind.TeamServiceToken, RelinkSuggestionKind.TeamServiceToken }); + } + + [Fact] + public void A_connection_made_before_the_credential_was_disconnected_is_flagged() + { + var dead = Dead(); + + var ranked = RelinkSuggestionRanking.Rank(dead, new[] { Own(Revoked.AddMinutes(-1)), Own(Revoked.AddMinutes(1)) }, Caller); + + ranked.Select(r => r.ConnectedBeforeDisconnect).ShouldBe(new[] { false, true }, "newest first: the one made after is not the connect-then-disconnect shape"); + } + + [Fact] + public void Nothing_is_flagged_for_a_credential_that_is_still_live() + { + var live = Own(Revoked.AddDays(-60)); + var other = Own(Revoked.AddDays(-30)); + + var ranked = RelinkSuggestionRanking.Rank(live, new[] { other }, Caller); + + ranked.Select(r => r.CredentialId).ShouldBe(new[] { other.Id }); + ranked.ShouldAllBe(r => !r.ConnectedBeforeDisconnect, "nothing has been disconnected yet"); + } + + [Fact] + public void A_live_credential_may_hand_its_bindings_only_to_another_connection_of_its_own_owner() + { + // A Disconnect of a live credential moves its repositories to the SAME owner's other connection; the relink rule + // (the caller's own, or the team's) belongs to a dead credential. Offered to the owner, the target is theirs; offered + // to an Admin asking about a teammate's credential, it is the teammate's — never the Admin's own. + var live = With(Own(Revoked.AddDays(-5)), c => c.OwnerUserId = Teammate); + var owners = With(Own(Revoked.AddDays(-1)), c => c.OwnerUserId = Teammate); + var callers = Own(Revoked.AddDays(-2)); + var team = Team_(Revoked.AddDays(-3)); + var someoneElses = With(Own(Revoked.AddDays(-4)), c => c.OwnerUserId = Guid.NewGuid()); + + var asAdmin = RelinkSuggestionRanking.Rank(live, new[] { callers, team, owners, someoneElses }, Caller); + + asAdmin.Select(r => r.CredentialId).ShouldBe(new[] { owners.Id }); + asAdmin[0].Kind.ShouldBe(RelinkSuggestionKind.OwnersOtherConnection); + + var asOwner = RelinkSuggestionRanking.Rank(live, new[] { callers, team, owners, someoneElses }, Teammate); + + asOwner.Select(r => r.CredentialId).ShouldBe(new[] { owners.Id }); + asOwner[0].Kind.ShouldBe(RelinkSuggestionKind.YourConnection); + } + + [Fact] + public void A_live_team_token_hands_its_bindings_only_to_another_team_token() + { + var live = Team_(Revoked.AddDays(-5)); + var otherTeam = Team_(Revoked.AddDays(-1)); + + RelinkSuggestionRanking.Rank(live, new[] { Own(Revoked), otherTeam }, Caller).Select(r => r.CredentialId).ShouldBe(new[] { otherTeam.Id }); + } + + [Fact] + public void Two_credentials_have_the_same_owner_when_person_and_ownership_both_match() + { + RelinkSuggestionRanking.HaveTheSameOwner(Team_(Revoked), Team_(Revoked)).ShouldBeTrue("two team tokens: no owner on either"); + RelinkSuggestionRanking.HaveTheSameOwner(Own(Revoked), Own(Revoked)).ShouldBeTrue(); + RelinkSuggestionRanking.HaveTheSameOwner(Own(Revoked), With(Own(Revoked), c => c.OwnerUserId = Teammate)).ShouldBeFalse(); + RelinkSuggestionRanking.HaveTheSameOwner(Team_(Revoked), With(Own(Revoked), c => c.OwnerUserId = null)).ShouldBeFalse("an owner-less personal token is not a team token"); + } + + public enum Excluded { ATeammatesPersonalToken, ARevokedConnection, ARetiredConnection, AnotherInstance, AnotherTeam, TheCredentialItself, APersonalTokenWithNoOwner } + + [Theory] + [InlineData(Excluded.ATeammatesPersonalToken)] + [InlineData(Excluded.ARevokedConnection)] + [InlineData(Excluded.ARetiredConnection)] + [InlineData(Excluded.AnotherInstance)] + [InlineData(Excluded.AnotherTeam)] + [InlineData(Excluded.TheCredentialItself)] + [InlineData(Excluded.APersonalTokenWithNoOwner)] + public void A_credential_the_relink_would_refuse_is_never_offered(Excluded shape) + { + var dead = Dead(); + var kept = Own(Revoked.AddDays(1)); + var candidate = shape switch + { + Excluded.ATeammatesPersonalToken => With(Own(Revoked), c => c.OwnerUserId = Teammate), + Excluded.ARevokedConnection => With(Own(Revoked), c => c.Status = CredentialStatus.Revoked), + Excluded.ARetiredConnection => With(Own(Revoked), c => c.DeletedDate = Revoked), + Excluded.AnotherInstance => With(Own(Revoked), c => c.ProviderInstanceId = Guid.NewGuid()), + Excluded.AnotherTeam => With(Own(Revoked), c => c.TeamId = Guid.NewGuid()), + Excluded.TheCredentialItself => dead, + Excluded.APersonalTokenWithNoOwner => With(Own(Revoked), c => c.OwnerUserId = null), + _ => throw new ArgumentOutOfRangeException(nameof(shape), shape, "a shape the test does not build") + }; + + RelinkSuggestionRanking.Rank(dead, new[] { candidate, kept }, Caller).Select(r => r.CredentialId).ShouldBe(new[] { kept.Id }, $"{shape}"); + } + + [Fact] + public void Credentials_made_at_the_same_instant_keep_a_stable_order() + { + var dead = Dead(); + var a = Own(Revoked.AddDays(1)); + var b = Own(Revoked.AddDays(1)); + + var first = RelinkSuggestionRanking.Rank(dead, new[] { a, b }, Caller).Select(r => r.CredentialId); + var second = RelinkSuggestionRanking.Rank(dead, new[] { b, a }, Caller).Select(r => r.CredentialId); + + first.ShouldBe(second, "the order cannot depend on the order the database happened to return them in"); + } + + [Fact] + public void Nothing_to_offer_is_an_empty_list_not_an_error() + { + RelinkSuggestionRanking.Rank(Dead(), Array.Empty(), Caller).ShouldBeEmpty(); + } + + private static Credential Dead() => Make(Guid.NewGuid(), Teammate, CredentialOwnership.Personal, CredentialStatus.Revoked, Revoked, Revoked); + + private static Credential Own(DateTimeOffset created) => Make(Guid.NewGuid(), Caller, CredentialOwnership.Personal, CredentialStatus.Active, created, created); + + private static Credential Team_(DateTimeOffset created) => Make(Guid.NewGuid(), null, CredentialOwnership.TeamService, CredentialStatus.Active, created, created); + + private static Credential Make(Guid id, Guid? owner, CredentialOwnership ownership, CredentialStatus status, DateTimeOffset created, DateTimeOffset modified) => + new() { Id = id, TeamId = Team, ProviderInstanceId = Instance, OwnerUserId = owner, Ownership = ownership, Status = status, AuthType = AuthType.Pat, DisplayName = $"{ownership} {id:N}"[..16], CreatedDate = created, LastModifiedDate = modified }; + + private static Credential With(Credential credential, Action change) + { + change(credential); + + return credential; + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/SweepFailureBackoffTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/SweepFailureBackoffTests.cs new file mode 100644 index 000000000..13d6d3bfe --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/SweepFailureBackoffTests.cs @@ -0,0 +1,86 @@ +using CodeSpace.Core.Services.Credentials; +using Microsoft.Extensions.Time.Testing; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials; + +/// +/// The memory a recurring sweep keeps about candidates it could not handle: a few failures in a row set a candidate aside for +/// a window, the window ends, a success forgets, and a failure from long ago is not held against a candidate that went quiet. +/// +[Trait("Category", "Unit")] +public class SweepFailureBackoffTests +{ + private const string Sweep = "test-sweep"; + + private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 10, 18, 9, 0, 0, TimeSpan.Zero)); + private readonly SweepFailureBackoff _backoff; + private readonly Guid _candidate = Guid.NewGuid(); + + public SweepFailureBackoffTests() { _backoff = new SweepFailureBackoff(_clock); } + + [Fact] + public void A_candidate_is_set_aside_by_exactly_the_failure_that_reaches_the_threshold() + { + for (var failure = 1; failure < SweepFailureBackoff.FailuresBeforeBackoff; failure++) + { + _backoff.RecordFailure(Sweep, _candidate).ShouldBeFalse($"failure {failure} of {SweepFailureBackoff.FailuresBeforeBackoff}: still a candidate"); + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeFalse(); + } + + _backoff.RecordFailure(Sweep, _candidate).ShouldBeTrue("the failure that sets it aside says so, so the sweep can log it once"); + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeTrue(); + } + + [Fact] + public void The_window_ends_and_one_more_failure_sets_the_candidate_aside_again() + { + SetAside(); + + _clock.Advance(SweepFailureBackoff.BackoffWindow - TimeSpan.FromSeconds(1)); + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeTrue("still inside the window"); + + _clock.Advance(TimeSpan.FromSeconds(2)); + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeFalse("the window ended, so the candidate is attempted once more"); + + _backoff.RecordFailure(Sweep, _candidate).ShouldBeTrue("one failure — not a fresh three — sets it aside again: it already failed that many in a row"); + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeTrue(); + } + + [Fact] + public void A_success_forgets_the_failures() + { + for (var failure = 1; failure < SweepFailureBackoff.FailuresBeforeBackoff; failure++) _backoff.RecordFailure(Sweep, _candidate); + + _backoff.RecordSuccess(Sweep, _candidate); + + for (var failure = 1; failure < SweepFailureBackoff.FailuresBeforeBackoff; failure++) _backoff.RecordFailure(Sweep, _candidate).ShouldBeFalse("failures in a row, not failures in total"); + + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeFalse(); + } + + [Fact] + public void A_failure_from_long_ago_is_not_held_against_a_candidate_that_went_quiet() + { + for (var failure = 1; failure < SweepFailureBackoff.FailuresBeforeBackoff; failure++) _backoff.RecordFailure(Sweep, _candidate); + + _clock.Advance(SweepFailureBackoff.ForgetAfter + TimeSpan.FromMinutes(1)); + + _backoff.RecordFailure(Sweep, _candidate).ShouldBeFalse("judged afresh: this is its first failure"); + _backoff.IsBackedOff(Sweep, _candidate).ShouldBeFalse(); + } + + [Fact] + public void What_is_set_aside_is_one_candidate_of_one_sweep() + { + SetAside(); + + _backoff.IsBackedOff("another-sweep", _candidate).ShouldBeFalse("the same id in another sweep is another candidate"); + _backoff.IsBackedOff(Sweep, Guid.NewGuid()).ShouldBeFalse(); + } + + private void SetAside() + { + for (var failure = 0; failure < SweepFailureBackoff.FailuresBeforeBackoff; failure++) _backoff.RecordFailure(Sweep, _candidate); + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Credentials/SweepPassTests.cs b/backend/tests/CodeSpace.UnitTests/Credentials/SweepPassTests.cs new file mode 100644 index 000000000..8fb98ad15 --- /dev/null +++ b/backend/tests/CodeSpace.UnitTests/Credentials/SweepPassTests.cs @@ -0,0 +1,214 @@ +using CodeSpace.Core.Services.Credentials; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using Shouldly; + +namespace CodeSpace.UnitTests.Credentials; + +/// +/// One pass of a recurring sweep over a backlog that must not starve: it walks the backlog in pages behind a cursor, so a +/// candidate that cannot succeed costs one attempt and no place in the batch; it is bounded in attempts and in candidates +/// examined as well as in results; and a candidate that keeps failing is set aside rather than attempted every tick. +/// +[Trait("Category", "Unit")] +public class SweepPassTests +{ + private const string Sweep = "test-sweep"; + + private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 10, 18, 9, 0, 0, TimeSpan.Zero)); + private readonly SweepFailureBackoff _backoff; + private readonly RecordingLogger _logger = new(); + private readonly Dictionary _attempts = new(); + + public SweepPassTests() { _backoff = new SweepFailureBackoff(_clock); } + + [Fact] + public async Task Candidates_that_always_fail_do_not_take_the_places_of_the_ones_behind_them() + { + var backlog = Backlog(5); + var failing = new HashSet { 0, 1 }; + + var done = await RunAsync(backlog, batchSize: 2, failing); + + done.ShouldBe(2); + // The two that fail were passed over, and the pass reached the two behind them. + Attempted().ShouldBe(new[] { 0, 1, 2, 3 }); + } + + [Fact] + public async Task A_pass_stops_when_its_batch_is_done_however_much_is_left() + { + var done = await RunAsync(Backlog(10), batchSize: 3, failing: new HashSet()); + + done.ShouldBe(3); + Attempted().ShouldBe(new[] { 0, 1, 2 }); + } + + [Fact] + public async Task A_pass_ends_with_the_backlog() + { + var done = await RunAsync(Backlog(3), batchSize: 50, failing: new HashSet()); + + done.ShouldBe(3); + Attempted().ShouldBe(new[] { 0, 1, 2 }); + } + + [Fact] + public async Task A_candidate_with_nothing_to_do_is_not_counted_and_is_not_a_failure() + { + var backlog = Backlog(3); + + var done = await new SweepPass(Sweep, _backoff, _logger).RunAsync(50, Loader(backlog), (c, _) => { Count(c); return Task.FromResult(c.Index != 1); }, CancellationToken.None); + + done.ShouldBe(2, "the one that found nothing to do is not retired work"); + _logger.Warnings.ShouldBeEmpty("and is not logged as a failure"); + } + + [Fact] + public async Task A_pass_attempts_a_bounded_number_however_many_candidates_fail() + { + var backlog = Backlog(100); + + var done = await RunAsync(backlog, batchSize: 1, failing: Enumerable.Range(0, 100).ToHashSet()); + + done.ShouldBe(0); + _attempts.Values.Sum().ShouldBe(SweepPassBounds.AttemptsPerBatchSlot, "attempts are bounded by the batch, not only the successes"); + } + + [Fact] + public async Task A_pass_examines_a_bounded_number_even_when_every_candidate_is_set_aside() + { + var backlog = Backlog(1000); + + foreach (var candidate in backlog) SetAside(candidate); + + var done = await RunAsync(backlog, batchSize: 1, failing: new HashSet()); + + done.ShouldBe(0); + _attempts.ShouldBeEmpty("a candidate that is set aside is not attempted"); + _logger.Reads.ShouldBeLessThanOrEqualTo(SweepPassBounds.ExaminedPerBatchSlot + 1, "the walk through set-aside candidates ends with the examined bound"); + } + + [Fact] + public async Task A_loader_that_ignores_its_cursor_ends_the_pass_instead_of_hanging_it() + { + var page = Backlog(2); + var reads = 0; + + LoadSweepPage ignoresItsCursor = (_, _, _) => + { + if (++reads > 100) throw new ShouldAssertException("the pass kept reading the same page"); + + return Task.FromResult(page.ToList()); + }; + + var done = await new SweepPass(Sweep, _backoff, _logger).RunAsync(2, ignoresItsCursor, (c, _) => { Count(c); return Task.FromResult(false); }, CancellationToken.None); + + done.ShouldBe(0); + reads.ShouldBe(2, "the second page ended where the first did, so the pass stopped"); + _logger.Errors.ShouldNotBeEmpty("and said why"); + } + + [Fact] + public async Task A_candidate_that_keeps_failing_is_set_aside_logged_once_and_attempted_again_when_the_window_ends() + { + var backlog = Backlog(2); + var failing = new HashSet { 0 }; + + for (var pass = 0; pass < SweepFailureBackoff.FailuresBeforeBackoff; pass++) await RunAsync(backlog, batchSize: 50, failing); + + _attempts[0].ShouldBe(SweepFailureBackoff.FailuresBeforeBackoff); + _logger.Warnings.Count(w => w.Contains("set aside")).ShouldBe(1, "said once, by the failure that set it aside"); + + await RunAsync(backlog, batchSize: 50, failing); + await RunAsync(backlog, batchSize: 50, failing); + + _attempts[0].ShouldBe(SweepFailureBackoff.FailuresBeforeBackoff, "not attempted while set aside"); + _logger.Warnings.Count.ShouldBe(SweepFailureBackoff.FailuresBeforeBackoff, "and not logged while set aside"); + _attempts[1].ShouldBe(SweepFailureBackoff.FailuresBeforeBackoff + 2, "the candidate beside it is attempted every pass"); + + _clock.Advance(SweepFailureBackoff.BackoffWindow + TimeSpan.FromSeconds(1)); + await RunAsync(backlog, batchSize: 50, failing); + + _attempts[0].ShouldBe(SweepFailureBackoff.FailuresBeforeBackoff + 1, "attempted once when the window ends"); + } + + [Fact] + public async Task A_candidate_that_succeeds_between_failures_is_never_set_aside() + { + // Failures in a row, not failures in total: a candidate that fails twice, is repaired, and fails twice again has not + // failed three passes running. + var backlog = Backlog(1); + var outcomes = new Queue(Enumerable.Repeat(false, SweepFailureBackoff.FailuresBeforeBackoff - 1).Append(true).Concat(Enumerable.Repeat(false, SweepFailureBackoff.FailuresBeforeBackoff - 1))); + var passes = outcomes.Count; + + for (var pass = 0; pass < passes; pass++) + { + var succeeds = outcomes.Dequeue(); + + await new SweepPass(Sweep, _backoff, _logger).RunAsync(50, Loader(backlog), (c, _) => { Count(c); return succeeds ? Task.FromResult(true) : throw new InvalidOperationException("fails"); }, CancellationToken.None); + } + + _attempts[0].ShouldBe(passes, "attempted every pass, never set aside"); + _backoff.IsBackedOff(Sweep, backlog[0].Id).ShouldBeFalse(); + } + + [Fact] + public async Task A_cancellation_is_not_a_failure() + { + var backlog = Backlog(2); + + await Should.ThrowAsync(() => new SweepPass(Sweep, _backoff, _logger).RunAsync(50, Loader(backlog), (_, _) => throw new OperationCanceledException(), CancellationToken.None)); + + _backoff.IsBackedOff(Sweep, backlog[0].Id).ShouldBeFalse(); + _logger.Warnings.ShouldBeEmpty(); + } + + // ── Plumbing ────────────────────────────────────────────────────────────────── + + private sealed record Candidate(int Index) : ISweepCandidate + { + public Guid Id { get; } = new(Index + 1, 0, 0, new byte[8]); + } + + private static List Backlog(int count) => Enumerable.Range(0, count).Select(i => new Candidate(i)).ToList(); + + private void SetAside(Candidate candidate) + { + for (var failure = 0; failure < SweepFailureBackoff.FailuresBeforeBackoff; failure++) _backoff.RecordFailure(Sweep, candidate.Id); + } + + private async Task RunAsync(List backlog, int batchSize, HashSet failing) => + await new SweepPass(Sweep, _backoff, _logger).RunAsync(batchSize, Loader(backlog), (c, _) => { Count(c); return failing.Contains(c.Index) ? throw new InvalidOperationException($"candidate {c.Index} cannot be done") : Task.FromResult(true); }, CancellationToken.None); + + /// The loader a service writes: the candidates past the cursor, in order, one page at a time. + private LoadSweepPage Loader(List backlog) => (after, take, _) => + { + _logger.Reads++; + + var past = after is Guid cursor ? backlog.SkipWhile(c => c.Id != cursor).Skip(1) : backlog; + + return Task.FromResult(past.Take(take).ToList()); + }; + + private void Count(Candidate candidate) => _attempts[candidate.Index] = _attempts.GetValueOrDefault(candidate.Index) + 1; + + private List Attempted() => _attempts.Keys.OrderBy(i => i).ToList(); + + private sealed class RecordingLogger : ILogger + { + public int Reads { get; set; } + public List Warnings { get; } = new(); + public List Errors { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) Warnings.Add(formatter(state, exception)); + if (logLevel == LogLevel.Error) Errors.Add(formatter(state, exception)); + } + } +} diff --git a/backend/tests/CodeSpace.UnitTests/Jobs/DisconnectedCredentialBindingRepairDispatchTests.cs b/backend/tests/CodeSpace.UnitTests/Jobs/DisconnectedCredentialBindingRepairDispatchTests.cs index 402ef43fb..57f969e4a 100644 --- a/backend/tests/CodeSpace.UnitTests/Jobs/DisconnectedCredentialBindingRepairDispatchTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Jobs/DisconnectedCredentialBindingRepairDispatchTests.cs @@ -3,6 +3,7 @@ using CodeSpace.Core.Persistence.Entities; using CodeSpace.Core.Services.Credentials; using CodeSpace.Messages.Commands.Credentials; +using CodeSpace.Messages.Dtos.Credentials; using CodeSpace.Messages.Mediation; using MediatR; using Shouldly; @@ -88,6 +89,12 @@ private sealed class RecordingSuccession : ICredentialSuccessionService public Task CarryForwardAsync(Credential successor, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task RefusalAsync(Credential from, Credential to, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task MoveBindingsAsync(Credential from, Credential to, CancellationToken cancellationToken) => throw new NotSupportedException(); + + public Task FindOnlyOtherConnectionAsync(Credential from, Guid callerId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task RepairStrandedAsync(int batchSize, CancellationToken cancellationToken) { BatchSizes.Add(batchSize); diff --git a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx index eca86ddce..2d93123d0 100644 --- a/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx +++ b/frontend/src/_imported/ai-code-space/connect-remote-modal.tsx @@ -161,7 +161,11 @@ export function ConnectRemoteModal({ onClose }: ConnectRemoteModalProps) { let usageMsg = "This credential will be revoked. You can reconnect at any time."; try { const usage = await oauthApi.getCredentialUsage(credential.id); - if (usage.activeRepositoryCount > 0) { + const moved = usage.willMoveRepositoryCount ?? 0; + if (moved > 0 && usage.willMoveToDisplayName) { + // Your only other connection on this provider: the repositories carry on through it, never stop working. + usageMsg = `This credential will be revoked. ${moved} repositor${moved === 1 ? "y" : "ies"} bound through it will move to your other connection, ${usage.willMoveToDisplayName}, and keep working.`; + } else if (usage.activeRepositoryCount > 0) { const n = usage.activeRepositoryCount; usageMsg = `This credential will be revoked. ${n} repositor${n === 1 ? "y" : "ies"} bound through this credential will be marked as needing a new credential (event webhooks keep working — you can re-link or unbind later).`; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b591ba952..69cd49199 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -67,7 +67,14 @@ export interface RevokeCredentialResponse { export interface CredentialUsage { credentialId: string; + /** Working repositories a Disconnect would turn to "needs a new credential". Zero when they would follow another + * connection instead (see willMoveRepositoryCount). */ activeRepositoryCount: number; + /** Repositories a Disconnect would carry — in the same unit of work, so they never stop working — to the person's one + * other live connection. Absent from an API that predates the move; read it as zero. */ + willMoveRepositoryCount?: number; + /** Display name of the connection they would move to; null or absent when nothing would move. */ + willMoveToDisplayName?: string | null; } /** Pre-delete preview for a provider instance — drives the confirm dialog's wording diff --git a/frontend/src/components/connect-remote-modal.test.tsx b/frontend/src/components/connect-remote-modal.test.tsx index d12be9dc2..6ecf1429c 100644 --- a/frontend/src/components/connect-remote-modal.test.tsx +++ b/frontend/src/components/connect-remote-modal.test.tsx @@ -32,11 +32,12 @@ describe("connect remote modal credential controls", () => { { id: "team", teamId: "t1", providerInstanceId: "pi1", ownerUserId: null, ownerUserName: null, ownership: "TeamService", authType: "GroupAccessToken", displayName: "Acme group token", status: "Active", createdDate: "2026-01-01T00:00:00Z" }, ]; - function renderAs(t: MeTeam, held = credentials) { + function renderAs(t: MeTeam, held = credentials, specific: Record = {}) { localStorage.setItem("codespace.jwt", "test-jwt"); localStorage.setItem("codespace.activeTeamId", t.id); - const routes: Record = { "/api/users/me": me(t), "/capabilities": { warnings: [] }, "/api/provider-instances": [instance], "/api/credentials": held }; + // `specific` routes come first: a route is matched by substring, in order, and "/api/credentials" is a prefix of every credential URL. + const routes: Record = { ...specific, "/api/users/me": me(t), "/capabilities": { warnings: [] }, "/api/provider-instances": [instance], "/api/credentials": held }; vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input : input.toString(); @@ -100,6 +101,29 @@ describe("connect remote modal credential controls", () => { await waitFor(() => expect(screen.getByRole("button", { name: /Add team token/ })).toBeTruthy()); }); + /** + * The confirm dialog quotes the usage preview. With a Disconnect that carries the repositories to the person's one other + * connection, a warning that they "will be marked as needing a new credential" is about something that will not happen; + * and an API that predates the move sends no such fields, so the old wording stays for it. + */ + it.each([ + ["moves them", { credentialId: "own", activeRepositoryCount: 0, willMoveRepositoryCount: 2, willMoveToDisplayName: "Maya's OAuth" }, ["2 repositories bound through it will move to your other connection, Maya's OAuth, and keep working."], ["needing a new credential"]], + ["moves the only one", { credentialId: "own", activeRepositoryCount: 0, willMoveRepositoryCount: 1, willMoveToDisplayName: "Maya's OAuth" }, ["1 repository bound through it will move to your other connection"], ["needing a new credential"]], + ["would break them", { credentialId: "own", activeRepositoryCount: 3, willMoveRepositoryCount: 0, willMoveToDisplayName: null }, ["3 repositories bound through this credential will be marked as needing a new credential"], ["will move to"]], + ["comes from an API that predates the move", { credentialId: "own", activeRepositoryCount: 1 }, ["1 repository bound through this credential will be marked as needing a new credential"], ["will move to"]], + ["has nothing bound", { credentialId: "own", activeRepositoryCount: 0, willMoveRepositoryCount: 0, willMoveToDisplayName: null }, ["This credential will be revoked. You can reconnect at any time."], ["needing a new credential", "will move to"]], + ] as const)("tells the disconnect confirm what the disconnect will do when it %s", async (_case, usage, expected, absent) => { + renderAs(team("Member", ["workflows.write"]), credentials, { "/api/credentials/own/usage": usage, "/api/credentials/own/capabilities": { credentialId: "own", grantedScopes: [], capabilities: [] } }); + + await waitFor(() => expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy()); + + fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); + + const dialog = await screen.findByRole("alertdialog"); + for (const text of expected) expect(dialog.textContent).toContain(text); + for (const text of absent) expect(dialog.textContent).not.toContain(text); + }); + it("gives an admin revoke on a team token", async () => { renderAs(team("Admin", ["repos.manage", "credentials.manage", "members.manage"]));