From 519bfee48c44c564a5788db836d7dbcc6fe64213 Mon Sep 17 00:00:00 2001 From: "Mars.P" Date: Sun, 18 Oct 2026 13:42:13 +0800 Subject: [PATCH] Move repository bindings when a credential is disconnected Connecting a new credential and then disconnecting the old one stranded every repository bound through the old one: the connect found nothing to carry forward (the owner's other credential was already live), and the repair sweep only follows a credential connected after the revocation. The repository stayed Error on a revoked row for good, and the only way back was a per-repository Re-link that never touched group hooks. Disconnect now takes an optional MoveBindingsToCredentialId and moves the credential's repositories and group hooks in the same unit of work. Left out, the bindings follow the owner's only other live connection, but only when the owner is the one disconnecting a credential that is still live: never for an Admin cutting off a teammate's token, never on a retry of a disconnect already made, never for a team token or when two connections compete. Connect-then-disconnect thus ends where disconnect-then-connect always did, and no one else's grant is chosen for them. A refused target fails before the provider is asked to revoke anything. Unlinking a provider identity ends its credential the same way and follows the same rule. GET /credentials/{id}/usage previews which of the two outcomes a Disconnect will be, and the confirm dialog quotes it. RelinkCredentialBindingsCommand moves everything a dead credential left, a departed member's retired token included, in one action. It needs repos.manage and credentials.manage, takes only the caller's own connection or a team token, and asks the target to read every repository first, so one it cannot read refuses the whole move and names it. Only an answer about the target counts (401, 403, 404, insufficient scope, a refused OAuth grant); an outage or rate limit is a retryable refusal that blames no one, and one relink verifies at most 100 repositories. GetCredentialDependentsQuery and GetRelinkSuggestionsQuery say what is waiting and where it could go; for a credential that is still connected the suggestions are exactly the targets a Disconnect accepts. The departed-token retirement and the repair of stranded bindings read their backlog through one shared pass: in pages behind an id cursor, so candidates that can never succeed no longer hold the whole batch and starve the rest; bounded in attempts and candidates examined as well as in results; and a candidate that fails several passes in a row is set aside for an hour rather than attempted and warned about every tick. The disconnected-repository error and CredentialDisconnectedException name only the remedies a person can perform today. --- .../Controllers/CredentialsController.cs | 43 +- .../RelinkCredentialBindingsCommandHandler.cs | 15 + .../RevokeCredentialCommandHandler.cs | 2 +- .../GetCredentialDependentsQueryHandler.cs | 16 + .../GetRelinkSuggestionsQueryHandler.cs | 16 + .../CredentialRelinkService.Context.cs | 16 + .../CredentialRelinkService.Dependents.cs | 120 +++ .../CredentialRelinkService.Probe.cs | 103 +++ .../CredentialRelinkService.Suggestions.cs | 42 + .../Credentials/CredentialRelinkService.cs | 123 +++ .../CredentialRevocationService.cs | 57 +- .../Credentials/CredentialService.Move.cs | 57 ++ .../Services/Credentials/CredentialService.cs | 62 +- .../CredentialSuccessionService.Move.cs | 71 ++ .../CredentialSuccessionService.cs | 77 +- .../Credentials/ICredentialRelinkService.cs | 26 + .../ICredentialRevocationService.cs | 4 +- .../Credentials/ICredentialService.cs | 14 +- .../ICredentialSuccessionService.cs | 38 +- .../Credentials/RelinkSuggestionRanking.cs | 69 ++ .../RelinkVerificationUnavailableException.cs | 21 + .../Credentials/SweepFailureBackoff.cs | 78 ++ .../Services/Credentials/SweepPass.cs | 142 +++ .../Identity/IUserProviderIdentityService.cs | 2 +- .../Identity/UserProviderIdentityService.cs | 17 + .../RelinkCredentialBindingsCommand.cs | 48 + ...irDisconnectedCredentialBindingsCommand.cs | 2 +- ...RetireDepartedMembersCredentialsCommand.cs | 2 +- .../Credentials/RevokeCredentialCommand.cs | 31 +- .../Identity/UnlinkProviderIdentityCommand.cs | 6 +- .../Dtos/Credentials/BindingsMoved.cs | 13 + .../Dtos/Credentials/CredentialDependents.cs | 76 ++ .../Dtos/Credentials/RelinkSuggestion.cs | 35 + .../CredentialDisconnectedException.cs | 8 +- .../GetCredentialDependentsQuery.cs | 20 + .../Credentials/GetCredentialUsageQuery.cs | 16 +- .../Credentials/GetRelinkSuggestionsQuery.cs | 19 + .../CredentialBindingsHttpE2ETests.cs | 427 +++++++++ .../Credentials/CredentialRelinkFlowTests.cs | 854 ++++++++++++++++++ .../OAuth/BindingMoveKit.cs | 295 ++++++ .../OAuth/CredentialBindingMoveFlowTests.cs | 541 +++++++++++ .../OAuth/RepairSweepFairnessFlowTests.cs | 191 ++++ .../OAuth/RetireSweepFairnessFlowTests.cs | 183 ++++ .../ResourceAccessMarkerInventoryTests.cs | 4 + .../TeamWritePermissionAdoptionTests.cs | 1 + .../Credentials/CredentialResolverTests.cs | 29 + .../RelinkSuggestionRankingTests.cs | 164 ++++ .../Credentials/SweepFailureBackoffTests.cs | 86 ++ .../Credentials/SweepPassTests.cs | 214 +++++ ...tedCredentialBindingRepairDispatchTests.cs | 7 + .../ai-code-space/connect-remote-modal.tsx | 6 +- frontend/src/api/types.ts | 7 + .../components/connect-remote-modal.test.tsx | 28 +- 53 files changed, 4437 insertions(+), 107 deletions(-) create mode 100644 backend/src/CodeSpace.Core/Handlers/CommandHandlers/Credentials/RelinkCredentialBindingsCommandHandler.cs create mode 100644 backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetCredentialDependentsQueryHandler.cs create mode 100644 backend/src/CodeSpace.Core/Handlers/QueryHandlers/Credentials/GetRelinkSuggestionsQueryHandler.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Context.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Dependents.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Probe.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.Suggestions.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialRelinkService.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialService.Move.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/CredentialSuccessionService.Move.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/ICredentialRelinkService.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/RelinkSuggestionRanking.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/RelinkVerificationUnavailableException.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/SweepFailureBackoff.cs create mode 100644 backend/src/CodeSpace.Core/Services/Credentials/SweepPass.cs create mode 100644 backend/src/CodeSpace.Messages/Commands/Credentials/RelinkCredentialBindingsCommand.cs create mode 100644 backend/src/CodeSpace.Messages/Dtos/Credentials/BindingsMoved.cs create mode 100644 backend/src/CodeSpace.Messages/Dtos/Credentials/CredentialDependents.cs create mode 100644 backend/src/CodeSpace.Messages/Dtos/Credentials/RelinkSuggestion.cs create mode 100644 backend/src/CodeSpace.Messages/Queries/Credentials/GetCredentialDependentsQuery.cs create mode 100644 backend/src/CodeSpace.Messages/Queries/Credentials/GetRelinkSuggestionsQuery.cs create mode 100644 backend/tests/CodeSpace.E2ETests/Credentials/CredentialBindingsHttpE2ETests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/Credentials/CredentialRelinkFlowTests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/OAuth/BindingMoveKit.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/OAuth/CredentialBindingMoveFlowTests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/OAuth/RepairSweepFairnessFlowTests.cs create mode 100644 backend/tests/CodeSpace.IntegrationTests/OAuth/RetireSweepFairnessFlowTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Credentials/RelinkSuggestionRankingTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Credentials/SweepFailureBackoffTests.cs create mode 100644 backend/tests/CodeSpace.UnitTests/Credentials/SweepPassTests.cs 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"]));