diff --git a/lib/resolve-config.js b/lib/resolve-config.js index d26dbf37..ca36e5fc 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -23,6 +23,7 @@ export default ( CI_PROJECT_URL, CI_PROJECT_PATH, CI_API_V4_URL, + CI_API_GRAPHQL_URL, GL_TOKEN, GITLAB_TOKEN, GL_URL, @@ -59,6 +60,11 @@ export default ( : service === "gitlab" && CI_API_V4_URL ? CI_API_V4_URL : urlJoin(defaultedGitlabUrl, isNil(userGitlabApiPathPrefix) ? "/api/v4" : userGitlabApiPathPrefix), + gitlabGraphQlApiUrl: userGitlabUrl + ? urlJoin(userGitlabUrl, "/graphql") + : service === "gitlab" && CI_API_GRAPHQL_URL + ? CI_API_GRAPHQL_URL + : urlJoin(defaultedGitlabUrl, "/graphql"), assets: assets ? castArray(assets) : assets, milestones: milestones ? castArray(milestones) : milestones, successComment, diff --git a/lib/verify.js b/lib/verify.js index fdfc3b7f..fd8a408e 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -30,7 +30,10 @@ export default async (pluginConfig, context) => { options: { repositoryUrl }, logger, } = context; - const { gitlabToken, gitlabUrl, gitlabApiUrl, proxy, ...options } = resolveConfig(pluginConfig, context); + const { gitlabToken, gitlabUrl, gitlabApiUrl, gitlabGraphQlApiUrl, proxy, ...options } = resolveConfig( + pluginConfig, + context + ); const { projectPath, projectApiUrl } = getProjectContext(context, gitlabUrl, gitlabApiUrl, repositoryUrl); debug("apiUrl: %o", gitlabApiUrl); @@ -54,28 +57,59 @@ export default async (pluginConfig, context) => { } if (gitlabToken && projectPath) { - let projectAccess; - let groupAccess; - logger.log("Verify GitLab authentication (%s)", gitlabApiUrl); try { - ({ - permissions: { project_access: projectAccess, group_access: groupAccess }, - } = await got + // First, get basic project information to ensure the project exists + await got .get(projectApiUrl, { headers: { "PRIVATE-TOKEN": gitlabToken }, ...proxy, }) - .json()); - if ( - context.options.dryRun && - !((projectAccess && projectAccess.access_level >= 10) || (groupAccess && groupAccess.access_level >= 10)) - ) { + .json(); + + // Use GraphQL to check user permissions + debug("Checking permissions via GraphQL"); + const query = ` + query { + project(fullPath: "${projectPath}") { + userPermissions { + pushToRepository + readRepository + } + } + } + `; + + const graphqlResponse = await got + .post(gitlabGraphQlApiUrl, { + headers: { + "Private-Token": gitlabToken, + "Content-Type": "application/json", + Accept: "application/graphql-response+json", + }, + json: { query }, + ...proxy, + }) + .json(); + + if (graphqlResponse.errors) { + debug("GraphQL query returned errors: %O", graphqlResponse.errors); + throw new Error(`GraphQL query failed: ${graphqlResponse.errors.map((e) => e.message).join(", ")}`); + } + + const permissions = graphqlResponse.data?.project?.userPermissions; + if (!permissions) { + debug("No permissions data returned from GraphQL query"); + throw new Error("Unable to determine permissions from GraphQL response"); + } + + debug("GraphQL permissions: %O", permissions); + + // Check permissions based on GraphQL response + if (context.options.dryRun && !permissions.readRepository) { errors.push(getError("EGLNOPULLPERMISSION", { projectPath })); - } else if ( - !((projectAccess && projectAccess.access_level >= 30) || (groupAccess && groupAccess.access_level >= 30)) - ) { + } else if (!permissions.pushToRepository) { errors.push(getError("EGLNOPUSHPERMISSION", { projectPath })); } } catch (error) { diff --git a/test/resolve-config.test.js b/test/resolve-config.test.js index 5f800e78..ebf62cc4 100644 --- a/test/resolve-config.test.js +++ b/test/resolve-config.test.js @@ -7,6 +7,7 @@ const defaultOptions = { gitlabToken: undefined, gitlabUrl: "https://gitlab.com", gitlabApiUrl: urlJoin("https://gitlab.com", "/api/v4"), + gitlabGraphQlApiUrl: urlJoin("https://gitlab.com", "/graphql"), assets: undefined, milestones: undefined, successComment: undefined, @@ -41,6 +42,7 @@ test("Returns user config", (t) => { gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), assets, labels: false, retryLimit, @@ -54,6 +56,7 @@ test("Returns user config", (t) => { gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), assets, proxy, } @@ -77,6 +80,7 @@ test("Returns user config via environment variables", (t) => { gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), assets, milestones, } @@ -96,6 +100,7 @@ test("Returns user config via alternative environment variables", (t) => { gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), assets, milestones: undefined, successComment: undefined, @@ -223,6 +228,7 @@ test("Returns user config via alternative environment variables with mismatching gitlabToken: "TOKEN", gitlabUrl: "http://host.com", gitlabApiUrl: "http://host.com/api/prefix", + gitlabGraphQlApiUrl: "http://host.com/graphql", assets: ["file.js"], } ); @@ -245,6 +251,7 @@ test("Returns user config via alternative environment variables with mismatching gitlabToken: "TOKEN", gitlabUrl: "https://host.com", gitlabApiUrl: "https://host.com/api/prefix", + gitlabGraphQlApiUrl: "https://host.com/graphql", assets: ["file.js"], } ); @@ -387,6 +394,7 @@ test("Returns default config via GitLab CI/CD environment variables", (t) => { gitlabToken, gitlabUrl: "http://ci-host.com", gitlabApiUrl: CI_API_V4_URL, + gitlabGraphQlApiUrl: "http://ci-host.com/graphql", } ); }); @@ -415,6 +423,7 @@ test("Returns user config over GitLab CI/CD environment variables", (t) => { gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), assets, failTitle: "The automated release unfortunately failed!", labels: "bot,release-failed", @@ -450,6 +459,7 @@ test("Returns user config via environment variables over GitLab CI/CD environmen gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), } ); }); @@ -482,6 +492,7 @@ test("Returns user config via alternative environment variables over GitLab CI/C gitlabToken, gitlabUrl, gitlabApiUrl: urlJoin(gitlabUrl, gitlabApiPathPrefix), + gitlabGraphQlApiUrl: urlJoin(gitlabUrl, "/graphql"), } ); }); diff --git a/test/verify.test.js b/test/verify.test.js index 48f3004f..53a4bd48 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -18,74 +18,6 @@ test.afterEach.always(() => { nock.cleanAll(); }); -test.serial("Verify token and repository access (project_access 30)", async (t) => { - const owner = "test_user"; - const repo = "test_repo"; - const env = { GL_TOKEN: "gitlab_token" }; - const gitlab = authenticate(env) - .get(`/projects/${owner}%2F${repo}`) - .reply(200, { permissions: { project_access: { access_level: 30 } } }); - - await t.notThrowsAsync( - verify( - {}, - { env, options: { repositoryUrl: `git+https://gitalb.com/${owner}/${repo}.git` }, logger: t.context.logger } - ) - ); - t.true(gitlab.isDone()); -}); - -test.serial("Verify token and repository access (project_access 40)", async (t) => { - const owner = "test_user"; - const repo = "test_repo"; - const env = { GL_TOKEN: "gitlab_token" }; - const gitlab = authenticate(env) - .get(`/projects/${owner}%2F${repo}`) - .reply(200, { permissions: { project_access: { access_level: 40 } } }); - - await t.notThrowsAsync( - verify( - {}, - { env, options: { repositoryUrl: `git+https://gitalb.com/${owner}/${repo}.git` }, logger: t.context.logger } - ) - ); - t.true(gitlab.isDone()); -}); - -test.serial("Verify token and repository access (group_access 30)", async (t) => { - const owner = "test_user"; - const repo = "test_repo"; - const env = { GL_TOKEN: "gitlab_token" }; - const gitlab = authenticate(env) - .get(`/projects/${owner}%2F${repo}`) - .reply(200, { permissions: { project_access: { access_level: 10 }, group_access: { access_level: 30 } } }); - - await t.notThrowsAsync( - verify( - {}, - { env, options: { repositoryUrl: `git+https://gitalb.com/${owner}/${repo}.git` }, logger: t.context.logger } - ) - ); - t.true(gitlab.isDone()); -}); - -test.serial("Verify token and repository access (group_access 40)", async (t) => { - const owner = "test_user"; - const repo = "test_repo"; - const env = { GL_TOKEN: "gitlab_token" }; - const gitlab = authenticate(env) - .get(`/projects/${owner}%2F${repo}`) - .reply(200, { permissions: { project_access: { access_level: 10 }, group_access: { access_level: 40 } } }); - - await t.notThrowsAsync( - verify( - {}, - { env, options: { repositoryUrl: `git+https://gitalb.com/${owner}/${repo}.git` }, logger: t.context.logger } - ) - ); - t.true(gitlab.isDone()); -}); - test.serial("Verify token and repository access and custom URL with prefix", async (t) => { const owner = "test_user"; const repo = "test_repo"; @@ -498,53 +430,6 @@ test.serial("Throw AggregateError if multiple verification fails", async (t) => t.is(noTokenError.code, "ENOGLTOKEN"); }); -test.serial("Throw SemanticReleaseError if token doesn't have the push permission on the repository", async (t) => { - const owner = "test_user"; - const repo = "test_repo"; - const env = { GITLAB_TOKEN: "gitlab_token" }; - const gitlab = authenticate(env) - .get(`/projects/${owner}%2F${repo}`) - .reply(200, { permissions: { project_access: { access_level: 10 }, group_access: { access_level: 20 } } }); - - const { - errors: [error, ...errors], - } = await t.throwsAsync( - verify({}, { env, options: { repositoryUrl: `https://gitlab.com:${owner}/${repo}.git` }, logger: t.context.logger }) - ); - - t.is(errors.length, 0); - t.is(error.name, "SemanticReleaseError"); - t.is(error.code, "EGLNOPUSHPERMISSION"); - t.true(gitlab.isDone()); -}); - -test.serial("Throw SemanticReleaseError if token doesn't have the pull permission on the repository", async (t) => { - const owner = "test_user"; - const repo = "test_repo"; - const env = { GITLAB_TOKEN: "gitlab_token" }; - const gitlab = authenticate(env) - .get(`/projects/${owner}%2F${repo}`) - .reply(200, { permissions: { project_access: { access_level: 5 }, group_access: { access_level: 5 } } }); - - const { - errors: [error, ...errors], - } = await t.throwsAsync( - verify( - {}, - { - env, - options: { repositoryUrl: `https://gitlab.com:${owner}/${repo}.git`, dryRun: true }, - logger: t.context.logger, - } - ) - ); - - t.is(errors.length, 0); - t.is(error.name, "SemanticReleaseError"); - t.is(error.code, "EGLNOPULLPERMISSION"); - t.true(gitlab.isDone()); -}); - test.serial("Throw SemanticReleaseError if the repository doesn't exist", async (t) => { const owner = "test_user"; const repo = "test_repo"; @@ -988,3 +873,285 @@ test.serial( t.true(gitlab.isDone()); } ); + +test.serial( + "Throw SemanticReleaseError when GraphQL returns only read permissions", + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GL_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, {}); + + const gitlabGraphQl = nock("https://gitlab.com", { reqheaders: { "Private-Token": "gitlab_token" } }) + .post("/graphql", { + query: ` + query { + project(fullPath: "${owner}/${repo}") { + userPermissions { + pushToRepository + readRepository + } + } + } + `, + }) + .reply(200, { + data: { + project: { + userPermissions: { + pushToRepository: false, + readRepository: true, + }, + }, + }, + }); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + {}, + { env, options: { repositoryUrl: `https://gitlab.com:${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGLNOPUSHPERMISSION"); + t.true(gitlab.isDone()); + t.true(gitlabGraphQl.isDone()); + } +); + +test.serial( + "Throw SemanticReleaseError when GraphQL returns neither read nor write permissions", + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GL_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, {}); + + const gitlabGraphQl = nock("https://gitlab.com", { reqheaders: { "Private-Token": "gitlab_token" } }) + .post("/graphql", { + query: ` + query { + project(fullPath: "${owner}/${repo}") { + userPermissions { + pushToRepository + readRepository + } + } + } + `, + }) + .reply(200, { + data: { + project: { + userPermissions: { + pushToRepository: false, + readRepository: false, + }, + }, + }, + }); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + {}, + { env, options: { repositoryUrl: `https://gitlab.com:${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGLNOPUSHPERMISSION"); + t.true(gitlab.isDone()); + t.true(gitlabGraphQl.isDone()); + } +); + +test.serial( + "Throw SemanticReleaseError when GraphQL returns insufficient read permissions in dry run mode", + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GL_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, {}); + + const gitlabGraphQl = nock("https://gitlab.com", { reqheaders: { "Private-Token": "gitlab_token" } }) + .post("/graphql", { + query: ` + query { + project(fullPath: "${owner}/${repo}") { + userPermissions { + pushToRepository + readRepository + } + } + } + `, + }) + .reply(200, { + data: { + project: { + userPermissions: { + pushToRepository: false, + readRepository: false, + }, + }, + }, + }); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify( + {}, + { + env, + options: { + repositoryUrl: `https://gitlab.com:${owner}/${repo}.git`, + dryRun: true + }, + logger: t.context.logger + } + ) + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGLNOPULLPERMISSION"); + t.true(gitlab.isDone()); + t.true(gitlabGraphQl.isDone()); + } +); + +test.serial( + "Verify token and repository access when GraphQL returns sufficient permissions", + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GL_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, {}); + + const gitlabGraphQl = nock("https://gitlab.com", { reqheaders: { "Private-Token": "gitlab_token" } }) + .post("/graphql", { + query: ` + query { + project(fullPath: "${owner}/${repo}") { + userPermissions { + pushToRepository + readRepository + } + } + } + `, + }) + .reply(200, { + data: { + project: { + userPermissions: { + pushToRepository: true, + readRepository: true, + }, + }, + }, + }); + + await t.notThrowsAsync( + verify( + {}, + { env, options: { repositoryUrl: `https://gitlab.com/${owner}/${repo}.git` }, logger: t.context.logger } + ) + ); + t.true(gitlab.isDone()); + t.true(gitlabGraphQl.isDone()); + } +); + +test.serial( + "Verify token and repository access when GraphQL returns sufficient permissions in dry run mode", + async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GL_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, {}); + + const gitlabGraphQl = nock("https://gitlab.com", { reqheaders: { "Private-Token": "gitlab_token" } }) + .post("/graphql", { + query: ` + query { + project(fullPath: "${owner}/${repo}") { + userPermissions { + pushToRepository + readRepository + } + } + } + `, + }) + .reply(200, { + data: { + project: { + userPermissions: { + pushToRepository: false, + readRepository: true, + }, + }, + }, + }); + + await t.notThrowsAsync( + verify( + {}, + { + env, + options: { + repositoryUrl: `https://gitlab.com/${owner}/${repo}.git`, + dryRun: true + }, + logger: t.context.logger + } + ) + ); + t.true(gitlab.isDone()); + t.true(gitlabGraphQl.isDone()); + } +); + +test.serial("Throw SemanticReleaseError when GraphQL fails", async (t) => { + const owner = "test_user"; + const repo = "test_repo"; + const env = { GL_TOKEN: "gitlab_token" }; + const gitlab = authenticate(env) + .get(`/projects/${owner}%2F${repo}`) + .reply(200, {}); + + const gitlabGraphQl = nock("https://gitlab.com", { reqheaders: { "Private-Token": "gitlab_token" } }) + .post("/graphql") + .reply(500, { error: "Internal server error" }); + + const { + errors: [error, ...errors], + } = await t.throwsAsync( + verify({}, { env, options: { repositoryUrl: `https://gitlab.com:${owner}/${repo}.git` }, logger: t.context.logger }) + ); + + t.is(errors.length, 0); + t.is(error.name, "SemanticReleaseError"); + t.is(error.code, "EGLNOPUSHPERMISSION"); + t.true(gitlab.isDone()); + t.true(gitlabGraphQl.isDone()); +});