diff --git a/client/package-lock.json b/client/package-lock.json index 1559d82d..641194ab 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -34,7 +34,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.2", - "@seliseblocks/genesis-os": "^4.3.4", + "@seliseblocks/genesis-os": "^4.3.7", "@tanstack/react-query": "^5.62.11", "@tanstack/react-query-devtools": "^5.62.11", "@tanstack/react-table": "^8.20.5", @@ -3263,9 +3263,9 @@ "license": "MIT" }, "node_modules/@seliseblocks/genesis-os": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@seliseblocks/genesis-os/-/genesis-os-4.3.4.tgz", - "integrity": "sha512-cmxM+Mr5GNP0kVy1es9Yowt1qUCtxCzQHuaqKEYmX3e/8A0pwZJ3SUZwvas+/aPnIB2ewkbFipskFH+wmudK6Q==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@seliseblocks/genesis-os/-/genesis-os-4.3.7.tgz", + "integrity": "sha512-Tw+/N6BaJCRPnG1vpvC/ijmBfeC9PEjGHVMeBj8dGiwem/BNHE6dWWGMmagR9Uvodlbe76QhSjnSJwWZWhHqyg==", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/client/package.json b/client/package.json index 2f5f7e4c..5329b770 100644 --- a/client/package.json +++ b/client/package.json @@ -50,7 +50,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.2", - "@seliseblocks/genesis-os": "^4.3.4", + "@seliseblocks/genesis-os": "^4.3.7", "@tanstack/react-query": "^5.62.11", "@tanstack/react-query-devtools": "^5.62.11", "@tanstack/react-table": "^8.20.5", diff --git a/server/Api/Controllers/GithubController.cs b/server/Api/Controllers/GithubController.cs index 560ac760..298e2657 100644 --- a/server/Api/Controllers/GithubController.cs +++ b/server/Api/Controllers/GithubController.cs @@ -9,6 +9,7 @@ using Devops.DomainService.Shared.Interfaces; using Devops.DomainService.VersionControlSystems.Interfaces; using Devops.DomainService.VersionControlSystems.Models.Request; +using Devops.DomainService.VersionControlSystems.Models.Response; using Devops.DomainService.VersionControlSystems.Services; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; @@ -106,14 +107,47 @@ public async Task GithubBranchExists([FromQuery] string repoId) }); } - [HttpGet("clone")] - [ProtectedEndPoint("blocks-release::github::clone")] - public async Task Clone([FromQuery] string repo) + /// + /// The calling user's git credential for HTTPS push/fetch — what + /// `blocks git push` and Studio's post-run push authenticate with. + /// 404 when GitHub isn't connected, so the CLI can distinguish "connect + /// GitHub" from a genuine failure. Never logged; the body is the token. + /// + [HttpGet("credential")] + [ProtectedEndPoint("blocks-release::github::credential")] + public async Task GetCredential() { - var result = await _githubService.Clone(repo); - if (result) - return Ok("Successfully cloned repo"); - return BadRequest("Failed to clone repo"); + var credential = await _githubService.GetPushCredential(); + if (credential is null) + { + return NotFound(new BaseApiResponse + { + IsSuccess = false, + Message = "GitHub is not connected for this user, or the connection is no longer valid.", + StatusCode = HttpStatusCode.NotFound, + }); + } + + return Ok(credential); + } + + /// Creates a GitHub repository for a project that has none yet — see . + [HttpPost("repos")] + [ProtectedEndPoint("blocks-release::github::create-repo")] + public async Task CreateRepo([FromBody] CreateRepositoryRequest request) + { + if (request is null || string.IsNullOrWhiteSpace(request.Name)) + { + return BadRequest(new BaseApiResponse { IsSuccess = false, Message = "A repository name is required.", StatusCode = HttpStatusCode.BadRequest }); + } + + var (repo, error) = await _githubService.CreateRepository(request); + if (repo is null) + { + return BadRequest(new BaseApiResponse { IsSuccess = false, Message = error, StatusCode = HttpStatusCode.BadRequest }); + } + + return Ok(new BaseApiResponse { IsSuccess = true, Data = repo, StatusCode = HttpStatusCode.OK }); } [HttpPost("webhook")] diff --git a/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs b/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs index f3500aab..1acc44b5 100644 --- a/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs +++ b/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs @@ -17,5 +17,18 @@ public interface IVersionControlService public Task SearchUserRepositories(SearchRepositoryListRequest repoSearchQuery); public Task> GetBranches(string repo); public Task<(bool, string)> GetRepoBranchByName(string repo, string branch); - public Task Clone(string repo); + + /// + /// The calling Blocks user's git credential, or null when they have + /// not connected GitHub or the stored token no longer validates. See + /// for what the caller owes it. + /// + public Task GetPushCredential(); + + /// + /// Creates a repository on GitHub for the calling user (or one of their + /// recorded organisations). Returns the repository, or an error message + /// naming why GitHub refused — a name collision is the common one. + /// + public Task<(GithubRepositoryResponse? repo, string? error)> CreateRepository(CreateRepositoryRequest request); } \ No newline at end of file diff --git a/server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs b/server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs new file mode 100644 index 00000000..a15a0892 --- /dev/null +++ b/server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs @@ -0,0 +1,25 @@ +namespace Devops.DomainService.VersionControlSystems.Models.Request; + +/// +/// What a caller needs to say to get a brand-new GitHub repository for a +/// project that has none yet — the `blocks git init` case, where the code +/// exists locally (a Studio workspace, or a `blocks new web` scaffold) and +/// there is nowhere to push it. +/// +public class CreateRepositoryRequest +{ + /// Repository name only — no owner. GitHub derives the slug. + public string Name { get; set; } + + public string? Description { get; set; } + + /// Defaults to private: generated app source is the owner's, not the world's. + public bool Private { get; set; } = true; + + /// + /// Create under this organisation instead of the user's own account. + /// Must be one of the orgs recorded on the stored token, or the call is + /// refused before GitHub is contacted. + /// + public string? Organization { get; set; } +} diff --git a/server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs b/server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs new file mode 100644 index 00000000..c8ad7801 --- /dev/null +++ b/server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs @@ -0,0 +1,31 @@ +namespace Devops.DomainService.VersionControlSystems.Models.Response; + +/// +/// The credential a git client uses to authenticate an HTTPS push or fetch +/// against GitHub on behalf of the calling Blocks user. +/// +/// This is the stored OAuth access token — the decision taken for Studio's +/// background runs was to push as the app owner, and an OAuth-app token is +/// the only credential this integration holds. Two consequences the caller +/// must respect: the token does not expire on its own, and its repo +/// scope reaches every repository the owner can write to, not just the one +/// being pushed. It is therefore handed to git through an askpass/credential +/// helper for the lifetime of one process only, and never written to +/// .git/config, a remote URL, or any file. A GitHub App with +/// installation tokens would remove both caveats; this shape leaves room for +/// that by carrying already. +/// +/// +public class GitPushCredentialResponse +{ + /// Basic-auth username. GitHub accepts any value when the password is a token; this is the conventional one. + public string Username { get; set; } = "x-access-token"; + + public string Token { get; set; } + + /// GitHub login of the account the token belongs to — for `user.name` and for telling the owner whose account is pushing. + public string Login { get; set; } + + /// Null for an OAuth-app token, which never expires until revoked. + public DateTime? ExpiresAt { get; set; } +} diff --git a/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs b/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs index 552cccd1..33ec5277 100644 --- a/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs +++ b/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs @@ -293,10 +293,96 @@ public async Task> GetBranches(string repo) return (false, null); } - public async Task Clone(string repo) + public async Task GetPushCredential() { + var token = await _tokenRepository.getToken(); + if (token is null || string.IsNullOrWhiteSpace(token.AccessToken)) + { + return null; + } - return true; + // Validated on every call, not trusted from storage: a revoked token + // handed to a git client fails inside `git push` with a message the + // owner can't act on. Failing here instead lets the CLI say + // "reconnect GitHub" rather than "authentication failed". + if (!await ValidateAccessToken(token)) + { + return null; + } + + return new GitPushCredentialResponse + { + Token = token.AccessToken, + Login = token.UserName, + ExpiresAt = null, + }; } + public async Task<(GithubRepositoryResponse? repo, string? error)> CreateRepository(CreateRepositoryRequest request) + { + if (request is null || string.IsNullOrWhiteSpace(request.Name)) + { + return (null, "A repository name is required."); + } + + var token = await _tokenRepository.getToken(); + if (token is null) + { + return (null, "GitHub is not connected for this user."); + } + + // An org the token doesn't list is refused here rather than by + // GitHub, whose 404 for "no access to this org" is indistinguishable + // from "org doesn't exist". + string url; + if (!string.IsNullOrWhiteSpace(request.Organization)) + { + var known = token.Organizations?.Any(o => string.Equals(o.OrgUserName, request.Organization, StringComparison.OrdinalIgnoreCase)) ?? false; + if (!known) + { + return (null, $"Organisation '{request.Organization}' is not one this GitHub account belongs to."); + } + + url = $"{CloudBuildConstants.GITHUB_API_BASE_URI}/orgs/{request.Organization}/repos"; + } + else + { + url = $"{CloudBuildConstants.GITHUB_API_BASE_URI}/user/repos"; + } + + var headers = new Dictionary + { + { "Accept", "application/vnd.github.v3+json" }, + { "User-Agent", "BlocksDevOps" }, + { "Authorization", $"Bearer {token.AccessToken}" }, + }; + + // auto_init deliberately false: the caller already has the code and + // a first commit. A GitHub-made README would give the remote a + // history the local one doesn't share, and the very first push + // would be rejected as non-fast-forward. + var payload = new + { + name = request.Name, + description = request.Description ?? string.Empty, + @private = request.Private, + auto_init = false, + }; + + var (repo, response) = await _httpHelperServices.MakeHttpRequest( + CloudBuildConstants.GITHUB_API_BASE_URI, url, HttpMethod.Post, payload, headers, null); + + if (response.StatusCode == HttpStatusCode.Created && repo is not null) + { + return (repo, null); + } + + return response.StatusCode switch + { + HttpStatusCode.UnprocessableEntity => (null, $"GitHub refused to create '{request.Name}' — a repository with that name probably already exists."), + HttpStatusCode.Unauthorized => (null, "GitHub rejected the stored token. Reconnect GitHub and try again."), + HttpStatusCode.Forbidden => (null, "The connected GitHub account is not allowed to create repositories here."), + _ => (null, $"GitHub returned {(int)response.StatusCode} while creating the repository."), + }; + } } \ No newline at end of file diff --git a/server/XUnitTest/Api/Controllers/GithubControllerTests.cs b/server/XUnitTest/Api/Controllers/GithubControllerTests.cs index 5bb08156..7cdf6d60 100644 --- a/server/XUnitTest/Api/Controllers/GithubControllerTests.cs +++ b/server/XUnitTest/Api/Controllers/GithubControllerTests.cs @@ -108,20 +108,54 @@ public async Task GithubBranchExists_RepoFound_ReturnsBranchResult() body.IsSuccess.Should().BeTrue(); } - // ---- Clone ---- + // ---- GetCredential ---- [Fact] - public async Task Clone_Success_ReturnsOk() + public async Task GetCredential_Connected_ReturnsOkWithCredential() { - _github.Setup(g => g.Clone("repo")).ReturnsAsync(true); - (await CreateController().Clone("repo")).Should().BeOfType(); + _github.Setup(g => g.GetPushCredential()).ReturnsAsync(new GitPushCredentialResponse { Token = "tok", Login = "octo" }); + var result = await CreateController().GetCredential(); + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Token.Should().Be("tok"); } [Fact] - public async Task Clone_Failure_ReturnsBadRequest() + public async Task GetCredential_NotConnected_ReturnsNotFound_SoTheCliCanSayReconnect() { - _github.Setup(g => g.Clone("repo")).ReturnsAsync(false); - (await CreateController().Clone("repo")).Should().BeOfType(); + _github.Setup(g => g.GetPushCredential()).ReturnsAsync((GitPushCredentialResponse)null); + (await CreateController().GetCredential()).Should().BeOfType(); + } + + // ---- CreateRepo ---- + + [Fact] + public async Task CreateRepo_NoName_ReturnsBadRequest_WithoutCallingGithub() + { + (await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "" })).Should().BeOfType(); + _github.Verify(g => g.CreateRepository(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateRepo_Created_ReturnsOkWithRepo() + { + _github.Setup(g => g.CreateRepository(It.IsAny())) + .ReturnsAsync((new GithubRepositoryResponse { fullName = "octo/app" }, (string)null)); + var result = await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "app" }); + var body = result.Should().BeOfType().Which.Value.Should().BeOfType().Which; + body.IsSuccess.Should().BeTrue(); + body.Data.Should().BeOfType().Which.fullName.Should().Be("octo/app"); + } + + [Fact] + public async Task CreateRepo_GithubRefused_ReturnsBadRequestWithTheReason() + { + _github.Setup(g => g.CreateRepository(It.IsAny())) + .ReturnsAsync(((GithubRepositoryResponse)null, "already exists")); + var result = await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "app" }); + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Message.Should().Contain("already exists"); } // ---- CreateWebhook ---- diff --git a/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs b/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs index 4c2c15a4..f0625e4a 100644 --- a/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs +++ b/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs @@ -349,13 +349,113 @@ public async Task GetRepoBranchByName_OtherStatus_ReturnsFalseNull() msg.Should().BeNull(); } - // ---- Clone ---- + // ---- GetPushCredential ---- + + private void ValidationReturns(HttpStatusCode code) => + _http.Setup(h => h.MakeHttpRequest(It.IsAny(), It.IsAny(), HttpMethod.Get, null, It.IsAny>(), null)) + .ReturnsAsync(((object)null, Resp(code))); [Fact] - public async Task Clone_ReturnsTrue() + public async Task GetPushCredential_NoToken_ReturnsNull() { - var result = await CreateService().Clone("org/repo"); - result.Should().BeTrue(); + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync((RepositoryToken)null); + (await CreateService().GetPushCredential()).Should().BeNull(); + } + + [Fact] + public async Task GetPushCredential_RevokedToken_ReturnsNull_NotAStaleCredential() + { + // A revoked token handed to git fails inside `git push`; failing + // here is what lets the CLI say "reconnect GitHub" instead. + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + ValidationReturns(HttpStatusCode.Unauthorized); + (await CreateService().GetPushCredential()).Should().BeNull(); + } + + [Fact] + public async Task GetPushCredential_ValidToken_ReturnsTokenAndLogin() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + ValidationReturns(HttpStatusCode.OK); + + var credential = await CreateService().GetPushCredential(); + + credential.Should().NotBeNull(); + credential.Token.Should().Be("tok"); + credential.Login.Should().Be("octo"); + credential.Username.Should().Be("x-access-token"); + credential.ExpiresAt.Should().BeNull("an OAuth-app token has no expiry of its own"); + } + + // ---- CreateRepository ---- + + private void CreateReturns(HttpStatusCode code, GithubRepositoryResponse body = null) => + _http.Setup(h => h.MakeHttpRequest(It.IsAny(), It.IsAny(), HttpMethod.Post, It.IsAny(), It.IsAny>(), null)) + .ReturnsAsync((body, Resp(code))); + + [Fact] + public async Task CreateRepository_NoName_FailsBeforeGithub() + { + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = " " }); + repo.Should().BeNull(); + error.Should().Contain("name is required"); + _http.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateRepository_NoToken_SaysNotConnected() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync((RepositoryToken)null); + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app" }); + repo.Should().BeNull(); + error.Should().Contain("not connected"); + } + + [Fact] + public async Task CreateRepository_Created_ReturnsRepo_PostedToUserRepos() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + CreateReturns(HttpStatusCode.Created, new GithubRepositoryResponse { fullName = "octo/app", url = "https://github.com/octo/app" }); + + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app" }); + + error.Should().BeNull(); + repo.fullName.Should().Be("octo/app"); + _http.Verify(h => h.MakeHttpRequest(It.IsAny(), It.Is(u => u.EndsWith("/user/repos")), HttpMethod.Post, It.IsAny(), It.IsAny>(), null), Times.Once); + } + + [Fact] + public async Task CreateRepository_KnownOrg_PostsToOrgRepos() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + CreateReturns(HttpStatusCode.Created, new GithubRepositoryResponse { fullName = "myorg/app" }); + + var (repo, _) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app", Organization = "myorg" }); + + repo.Should().NotBeNull(); + _http.Verify(h => h.MakeHttpRequest(It.IsAny(), It.Is(u => u.EndsWith("/orgs/myorg/repos")), HttpMethod.Post, It.IsAny(), It.IsAny>(), null), Times.Once); + } + + [Fact] + public async Task CreateRepository_UnknownOrg_RefusedBeforeGithub() + { + // GitHub's 404 for "no access" and "doesn't exist" are the same; + // refusing here gives the owner a message they can act on. + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app", Organization = "someone-else" }); + repo.Should().BeNull(); + error.Should().Contain("someone-else"); + _http.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateRepository_NameTaken_ExplainsTheCollision() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + CreateReturns(HttpStatusCode.UnprocessableEntity); + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app" }); + repo.Should().BeNull(); + error.Should().Contain("already exists"); } } }