diff --git a/app/adapters/trustpub-gitlab-config.js b/app/adapters/trustpub-gitlab-config.js new file mode 100644 index 00000000000..1d229cbb6e2 --- /dev/null +++ b/app/adapters/trustpub-gitlab-config.js @@ -0,0 +1,7 @@ +import ApplicationAdapter from './application'; + +export default class TrustpubGitLabConfigAdapter extends ApplicationAdapter { + pathForType() { + return 'trusted_publishing/gitlab_configs'; + } +} diff --git a/app/controllers/crate/settings/new-trusted-publisher.js b/app/controllers/crate/settings/new-trusted-publisher.js index de23bd217d6..b7f3506b928 100644 --- a/app/controllers/crate/settings/new-trusted-publisher.js +++ b/app/controllers/crate/settings/new-trusted-publisher.js @@ -25,7 +25,7 @@ export default class NewTrustedPublisherController extends Controller { } get publishers() { - return ['GitHub']; + return ['GitHub', 'GitLab']; } get repository() { @@ -37,6 +37,8 @@ export default class NewTrustedPublisherController extends Controller { get verificationUrl() { if (this.publisher === 'GitHub' && this.namespace && this.project && this.workflow) { return `https://raw.githubusercontent.com/${this.namespace}/${this.project}/HEAD/.github/workflows/${this.workflow}`; + } else if (this.publisher === 'GitLab' && this.namespace && this.project && this.workflow) { + return `https://gitlab.com/${this.namespace}/${this.project}/-/raw/HEAD/${this.workflow}`; } } @@ -65,13 +67,24 @@ export default class NewTrustedPublisherController extends Controller { saveConfigTask = task(async () => { if (!this.validate()) return; - let config = this.store.createRecord('trustpub-github-config', { - crate: this.crate, - repository_owner: this.namespace, - repository_name: this.project, - workflow_filename: this.workflow, - environment: this.environment || null, - }); + let config; + if (this.publisher === 'GitHub') { + config = this.store.createRecord('trustpub-github-config', { + crate: this.crate, + repository_owner: this.namespace, + repository_name: this.project, + workflow_filename: this.workflow, + environment: this.environment || null, + }); + } else if (this.publisher === 'GitLab') { + config = this.store.createRecord('trustpub-gitlab-config', { + crate: this.crate, + namespace: this.namespace, + project: this.project, + workflow_filepath: this.workflow, + environment: this.environment || null, + }); + } try { // Save the new config on the backend @@ -106,6 +119,10 @@ export default class NewTrustedPublisherController extends Controller { return !this.namespaceInvalid && !this.projectInvalid && !this.workflowInvalid; } + @action publisherChanged(event) { + this.publisher = event.target.value; + } + @action resetNamespaceValidation() { this.namespaceInvalid = false; } diff --git a/app/models/trustpub-gitlab-config.js b/app/models/trustpub-gitlab-config.js new file mode 100644 index 00000000000..76b987f743f --- /dev/null +++ b/app/models/trustpub-gitlab-config.js @@ -0,0 +1,11 @@ +import Model, { attr, belongsTo } from '@ember-data/model'; + +export default class TrustpubGitLabConfig extends Model { + @belongsTo('crate', { async: true, inverse: null }) crate; + @attr namespace; + @attr namespace_id; + @attr project; + @attr workflow_filepath; + @attr environment; + @attr('date') created_at; +} diff --git a/app/routes/crate/settings/index.js b/app/routes/crate/settings/index.js index 4c88ff0930f..5e2ca1bc792 100644 --- a/app/routes/crate/settings/index.js +++ b/app/routes/crate/settings/index.js @@ -8,14 +8,16 @@ export default class SettingsIndexRoute extends Route { let crate = this.modelFor('crate'); let githubConfigs = await this.store.query('trustpub-github-config', { crate: crate.name }); + let gitlabConfigs = await this.store.query('trustpub-gitlab-config', { crate: crate.name }); - return { crate, githubConfigs }; + return { crate, githubConfigs, gitlabConfigs }; } - setupController(controller, { crate, githubConfigs }) { + setupController(controller, { crate, githubConfigs, gitlabConfigs }) { super.setupController(...arguments); controller.set('crate', crate); controller.set('githubConfigs', githubConfigs); + controller.set('gitlabConfigs', gitlabConfigs); } } diff --git a/app/routes/crate/settings/new-trusted-publisher.js b/app/routes/crate/settings/new-trusted-publisher.js index 58e87443477..4fdea450d8a 100644 --- a/app/routes/crate/settings/new-trusted-publisher.js +++ b/app/routes/crate/settings/new-trusted-publisher.js @@ -27,6 +27,27 @@ export default class NewTrustedPublisherRoute extends Route { } catch { // ignore malformed URLs } + } else if (repository && repository.startsWith('https://gitlab.com/')) { + controller.publisher = 'GitLab'; + try { + let url = new URL(repository); + let pathParts = url.pathname.slice(1).split('/'); + + // Find the repository path end (indicated by /-/ for trees/blobs/etc) + let repoEndIndex = pathParts.indexOf('-'); + if (repoEndIndex !== -1) { + pathParts = pathParts.slice(0, repoEndIndex); + } + + if (pathParts.length >= 2) { + // For GitLab, support nested groups: https://gitlab.com/a/b/c + // namespace = "a/b", project = "c" + controller.namespace = pathParts.slice(0, -1).join('/'); + controller.project = pathParts.at(-1).replace(/.git$/, ''); + } + } catch { + // ignore malformed URLs + } } } } diff --git a/app/serializers/trustpub-gitlab-config.js b/app/serializers/trustpub-gitlab-config.js new file mode 100644 index 00000000000..0789419b932 --- /dev/null +++ b/app/serializers/trustpub-gitlab-config.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from './application'; + +export default class TrustpubGitLabConfigSerializer extends ApplicationSerializer { + modelNameFromPayloadKey() { + return 'trustpub-gitlab-config'; + } + + payloadKeyFromModelName() { + return 'gitlab_config'; + } +} diff --git a/app/templates/crate/settings/index.gjs b/app/templates/crate/settings/index.gjs index e14a078abe1..0095058b929 100644 --- a/app/templates/crate/settings/index.gjs +++ b/app/templates/crate/settings/index.gjs @@ -6,6 +6,7 @@ import perform from 'ember-concurrency/helpers/perform'; import preventDefault from 'ember-event-helpers/helpers/prevent-default'; import pageTitle from 'ember-page-title/helpers/page-title'; import not from 'ember-truth-helpers/helpers/not'; +import or from 'ember-truth-helpers/helpers/or'; import CrateHeader from 'crates-io/components/crate-header'; import Tooltip from 'crates-io/components/tooltip'; @@ -165,11 +166,61 @@ import UserAvatar from 'crates-io/components/user-avatar'; >Remove - {{else}} + {{/each}} + {{#each @controller.gitlabConfigs as |config|}} + + GitLab + + Repository: + {{config.namespace}}/{{config.project}} + + · Namespace ID: + {{#if config.namespace_id}} + {{config.namespace_id}} + + This is the namespace ID for + {{config.namespace}} + from the first publish using this configuration. If + {{config.namespace}} + was recreated on GitLab, this configuration will need to be recreated as well. + + {{else}} + (not yet set) + + The namespace ID will be captured from the first publish using this configuration. + + {{/if}} +
+ Workflow: + {{config.workflow_filepath}}
+ {{#if config.environment}} + Environment: + {{config.environment}} + {{/if}} + + + + + + {{/each}} + {{#unless (or @controller.githubConfigs.length @controller.gitlabConfigs.length)}} No trusted publishers configured for this crate. - {{/each}} + {{/unless}} diff --git a/app/templates/crate/settings/new-trusted-publisher.gjs b/app/templates/crate/settings/new-trusted-publisher.gjs index 45d00ada0d7..02163f58e84 100644 --- a/app/templates/crate/settings/new-trusted-publisher.gjs +++ b/app/templates/crate/settings/new-trusted-publisher.gjs @@ -24,6 +24,8 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; disabled={{@controller.saveConfigTask.isRunning}} class='publisher-select base-input' data-test-publisher + {{on 'change' @controller.publisherChanged}} + {{on 'change' (perform @controller.verifyWorkflowTask)}} > {{#each @controller.publishers as |publisher|}} @@ -32,7 +34,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; {{/let}}
- crates.io currently only supports GitHub, but we are planning to support other platforms in the future. + Select the CI/CD platform where your publishing workflow is configured.
@@ -207,6 +209,164 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; {{/let}} + {{else if (eq @controller.publisher 'GitLab')}} +
+ {{#let (uniqueId) as |id|}} + + + + + {{#if @controller.namespaceInvalid}} +
+ Please enter a namespace. +
+ {{else}} +
+ The GitLab group name or GitLab username that owns the project. +
+ {{/if}} + {{/let}} +
+ +
+ {{#let (uniqueId) as |id|}} + + + + + {{#if @controller.projectInvalid}} +
+ Please enter a project name. +
+ {{else}} +
+ The name of the GitLab project that contains the publishing workflow. +
+ {{/if}} + {{/let}} +
+ +
+ {{#let (uniqueId) as |id|}} + + + + + {{#if @controller.workflowInvalid}} +
+ Please enter a workflow filepath. +
+ {{else}} +
+ The filepath to the GitLab CI configuration file, relative to the + {{#if @controller.repository}}{{@controller.repository}} + {{/if}} + repository{{unless @controller.repository ' configured above'}} + root. For example: + .gitlab-ci.yml + or + ci/publish.yml. +
+ {{/if}} + + {{#if (not @controller.verificationUrl)}} +
+ The workflow filepath will be verified once all necessary fields are filled. +
+ {{else if (eq @controller.verifyWorkflowTask.last.value 'success')}} +
+ ✓ Workflow file found at + + {{@controller.verificationUrl}} + +
+ {{else if (eq @controller.verifyWorkflowTask.last.value 'not-found')}} +
+ ⚠ Workflow file not found at + + {{@controller.verificationUrl}} + +
+ {{else if (eq @controller.verifyWorkflowTask.last.value 'error')}} +
+ ⚠ Could not verify workflow file at + + {{@controller.verificationUrl}} + + (network error) +
+ {{else}} +
+ Verifying... +
+ {{/if}} + {{/let}} +
+ +
+ {{#let (uniqueId) as |id|}} + + + + +
+ The name of the + GitLab environment + that the above workflow uses for publishing. This should be configured in the project settings. A dedicated + publishing environment is not required, but is + strongly recommended, especially if your project has maintainers with merge access who + should not have crates.io publishing access. +
+ {{/let}} +
{{/if}}
diff --git a/e2e/routes/crate/settings.spec.ts b/e2e/routes/crate/settings.spec.ts index c3d3b6b7c0e..45621852985 100644 --- a/e2e/routes/crate/settings.spec.ts +++ b/e2e/routes/crate/settings.spec.ts @@ -59,6 +59,55 @@ test.describe('Route | crate.settings', { tag: '@routes' }, () => { }); test.describe('Trusted Publishing', () => { + test('mixed GitHub and GitLab configs', async ({ msw, page, percy }) => { + const { crate } = await prepare(msw); + + // Create GitHub config + msw.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + // Create GitLab config + msw.db.trustpubGitlabConfig.create({ + crate, + namespace: 'johndoe', + namespace_id: '1234', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + environment: 'production', + }); + + await page.goto('/crates/foo/settings'); + await expect(page).toHaveURL('/crates/foo/settings'); + + await percy.snapshot(); + + // Check that both GitHub and GitLab configs are displayed + await expect(page.locator('[data-test-trusted-publishing]')).toBeVisible(); + await expect(page.locator('[data-test-add-trusted-publisher-button]')).toBeVisible(); + await expect(page.locator('[data-test-github-config]')).toHaveCount(1); + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(1); + + // Verify GitHub config + await expect(page.locator('[data-test-github-config="1"] td:nth-child(1)')).toHaveText('GitHub'); + let details = page.locator('[data-test-github-config="1"] td:nth-child(2)'); + await expect(details).toContainText('Repository: rust-lang/crates.io'); + await expect(details).toContainText('Workflow: ci.yml'); + + // Verify GitLab config + await expect(page.locator('[data-test-gitlab-config="1"] td:nth-child(1)')).toHaveText('GitLab'); + details = page.locator('[data-test-gitlab-config="1"] td:nth-child(2)'); + await expect(details).toContainText('Repository: johndoe/crates.io'); + await expect(details).toContainText('Namespace ID: 1234'); + await expect(details).toContainText('Workflow: .gitlab-ci.yml'); + await expect(details).toContainText('Environment: production'); + + await expect(page.locator('[data-test-no-config]')).not.toBeVisible(); + }); + test.describe('GitHub', () => { test('happy path', async ({ msw, page, percy }) => { const { crate } = await prepare(msw); @@ -99,8 +148,6 @@ test.describe('Route | crate.settings', { tag: '@routes' }, () => { await expect(page.locator('[data-test-github-config="2"] [data-test-remove-config-button]')).toBeVisible(); await expect(page.locator('[data-test-no-config]')).not.toBeVisible(); - await percy.snapshot(); - // Click the remove button await page.click('[data-test-github-config="2"] [data-test-remove-config-button]'); @@ -143,5 +190,92 @@ test.describe('Route | crate.settings', { tag: '@routes' }, () => { ); }); }); + + test.describe('GitLab', () => { + test('happy path', async ({ msw, page, percy }) => { + const { crate } = await prepare(msw); + + // Create two GitLab configs for the crate + msw.db.trustpubGitlabConfig.create({ + crate, + namespace: 'rust-lang', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + }); + + msw.db.trustpubGitlabConfig.create({ + crate, + namespace: 'johndoe', + namespace_id: '1234', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + environment: 'release', + }); + + await page.goto('/crates/foo/settings'); + await expect(page).toHaveURL('/crates/foo/settings'); + + await expect(page.locator('[data-test-trusted-publishing]')).toBeVisible(); + await expect(page.locator('[data-test-add-trusted-publisher-button]')).toBeVisible(); + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(2); + await expect(page.locator('[data-test-gitlab-config="1"] td:nth-child(1)')).toHaveText('GitLab'); + let details = page.locator('[data-test-gitlab-config="1"] td:nth-child(2)'); + await expect(details).toContainText('Repository: rust-lang/crates.io'); + await expect(details).toContainText('Namespace ID: (not yet set)'); + await expect(details).toContainText('Workflow: .gitlab-ci.yml'); + await expect(details).not.toContainText('Environment'); + await expect(page.locator('[data-test-gitlab-config="1"] [data-test-remove-config-button]')).toBeVisible(); + await expect(page.locator('[data-test-gitlab-config="2"] td:nth-child(1)')).toHaveText('GitLab'); + details = page.locator('[data-test-gitlab-config="2"] td:nth-child(2)'); + await expect(details).toContainText('Repository: johndoe/crates.io'); + await expect(details).toContainText('Namespace ID: 1234'); + await expect(details).toContainText('Workflow: .gitlab-ci.yml'); + await expect(details).toContainText('Environment: release'); + await expect(page.locator('[data-test-gitlab-config="2"] [data-test-remove-config-button]')).toBeVisible(); + await expect(page.locator('[data-test-no-config]')).not.toBeVisible(); + + // Click the remove button + await page.click('[data-test-gitlab-config="2"] [data-test-remove-config-button]'); + + // Check that the config is no longer displayed + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(1); + details = page.locator('[data-test-gitlab-config="1"] td:nth-child(2)'); + await expect(details).toContainText('Repository: rust-lang/crates.io'); + await expect(page.locator('[data-test-notification-message]')).toHaveText( + 'Trusted Publishing configuration removed successfully', + ); + }); + + test('deletion failure', async ({ msw, page, percy }) => { + let { crate } = await prepare(msw); + + // Create a GitLab config for the crate + let config = msw.db.trustpubGitlabConfig.create({ + crate, + namespace: 'rust-lang', + namespace_id: '1234', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + environment: 'release', + }); + + // Mock the server to return an error when trying to delete the config + await msw.worker.use( + http.delete(`/api/v1/trusted_publishing/gitlab_configs/${config.id}`, () => { + return HttpResponse.json({ errors: [{ detail: 'Server error' }] }, { status: 500 }); + }), + ); + + await page.goto(`/crates/${crate.name}/settings`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings`); + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(1); + + await page.click('[data-test-remove-config-button]'); + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(1); + await expect(page.locator('[data-test-notification-message]')).toHaveText( + 'Failed to remove Trusted Publishing configuration: Server error', + ); + }); + }); }); }); diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts index 00bd619d6f9..6db89074225 100644 --- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts +++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts @@ -53,29 +53,57 @@ test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' } test.describe('prefill', () => { const testCases = [ { - name: 'simple https', + name: 'GitHub: simple https', url: 'https://github.com/rust-lang/crates.io', publisher: 'GitHub', owner: 'rust-lang', repo: 'crates.io', }, { - name: 'with .git suffix', + name: 'GitHub: with .git suffix', url: 'https://github.com/rust-lang/crates.io.git', publisher: 'GitHub', owner: 'rust-lang', repo: 'crates.io', }, { - name: 'with extra path segments', + name: 'GitHub: with extra path segments', url: 'https://github.com/Byron/google-apis-rs/tree/main/gen/privateca1', publisher: 'GitHub', owner: 'Byron', repo: 'google-apis-rs', }, { - name: 'non-github url', + name: 'GitLab: simple https', url: 'https://gitlab.com/rust-lang/crates.io', + publisher: 'GitLab', + owner: 'rust-lang', + repo: 'crates.io', + }, + { + name: 'GitLab: with .git suffix', + url: 'https://gitlab.com/rust-lang/crates.io.git', + publisher: 'GitLab', + owner: 'rust-lang', + repo: 'crates.io', + }, + { + name: 'GitLab: with extra path segments', + url: 'https://gitlab.com/Byron/google-apis-rs/-/tree/main/gen/privateca1', + publisher: 'GitLab', + owner: 'Byron', + repo: 'google-apis-rs', + }, + { + name: 'GitLab: nested groups', + url: 'https://gitlab.com/a/b/c', + publisher: 'GitLab', + owner: 'a/b', + repo: 'c', + }, + { + name: 'non-github url', + url: 'https://example.com/rust-lang/crates.io', publisher: 'GitHub', owner: '', repo: '', @@ -330,6 +358,237 @@ test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' } }); test.describe('GitLab', () => { - // Placeholder for GitLab tests when they are implemented + test('happy path', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + msw.db.trustpubGitlabConfig.create({ + crate, + namespace: 'johndoe', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + }); + + await page.goto(`/crates/${crate.name}/settings`); + await page.click('[data-test-add-trusted-publisher-button]'); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Check that the form is displayed correctly + await expect(page.locator('[data-test-publisher]')).toBeVisible(); + await expect(page.locator('[data-test-namespace]')).toBeVisible(); + await expect(page.locator('[data-test-project]')).toBeVisible(); + await expect(page.locator('[data-test-workflow]')).toBeVisible(); + + // Select GitLab from the publisher dropdown + await page.selectOption('[data-test-publisher]', 'GitLab'); + + // Check that GitLab fields are displayed + await expect(page.locator('[data-test-namespace]')).toBeVisible(); + await expect(page.locator('[data-test-project]')).toBeVisible(); + await expect(page.locator('[data-test-workflow]')).toBeVisible(); + await expect(page.locator('[data-test-environment]')).toBeVisible(); + await expect(page.locator('[data-test-add]')).toBeVisible(); + await expect(page.locator('[data-test-cancel]')).toBeVisible(); + + // Fill in the form + await page.fill('[data-test-namespace]', 'rust-lang'); + await page.fill('[data-test-project]', 'crates.io'); + await page.fill('[data-test-workflow]', '.gitlab-ci.yml'); + await page.fill('[data-test-environment]', 'production'); + + // Submit the form + await page.click('[data-test-add]'); + + // Check that we're redirected back to the crate settings page + await expect(page).toHaveURL(`/crates/${crate.name}/settings`); + + // Check that the config was created + let config = msw.db.trustpubGitlabConfig.findFirst({ + where: { + namespace: { equals: 'rust-lang' }, + project: { equals: 'crates.io' }, + workflow_filepath: { equals: '.gitlab-ci.yml' }, + environment: { equals: 'production' }, + }, + }); + expect(config, 'Config was created').toBeDefined(); + + // Check that the success notification is displayed + await expect(page.locator('[data-test-notification-message]')).toHaveText( + 'Trusted Publishing configuration added successfully', + ); + + // Check that the config is displayed on the crate settings page + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(2); + await expect(page.locator('[data-test-gitlab-config="2"] td:nth-child(1)')).toHaveText('GitLab'); + let details = page.locator('[data-test-gitlab-config="2"] td:nth-child(2)'); + await expect(details).toContainText('Repository: rust-lang/crates.io'); + await expect(details).toContainText('Workflow: .gitlab-ci.yml'); + await expect(details).toContainText('Environment: production'); + }); + + test('validation errors', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await page.selectOption('[data-test-publisher]', 'GitLab'); + + // Submit the form without filling in required fields + await page.click('[data-test-add]'); + + // Check that validation errors are displayed + await expect(page.locator('[data-test-namespace-group] [data-test-error]')).toBeVisible(); + await expect(page.locator('[data-test-project-group] [data-test-error]')).toBeVisible(); + await expect(page.locator('[data-test-workflow-group] [data-test-error]')).toBeVisible(); + + // Fill in the required fields + await page.fill('[data-test-namespace]', 'rust-lang'); + await page.fill('[data-test-project]', 'crates.io'); + await page.fill('[data-test-workflow]', '.gitlab-ci.yml'); + + // Submit the form + await page.click('[data-test-add]'); + + // Check that we're redirected back to the crate settings page + await expect(page).toHaveURL(`/crates/${crate.name}/settings`); + }); + + test('loading and error state', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + // Mock the server to return an error + let deferred = defer(); + msw.worker.use(http.post('/api/v1/trusted_publishing/gitlab_configs', () => deferred.promise)); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await page.selectOption('[data-test-publisher]', 'GitLab'); + + // Fill in the form + await page.fill('[data-test-namespace]', 'rust-lang'); + await page.fill('[data-test-project]', 'crates.io'); + await page.fill('[data-test-workflow]', '.gitlab-ci.yml'); + + // Submit the form + await page.click('[data-test-add]'); + await expect(page.locator('[data-test-add] [data-test-spinner]')).toBeVisible(); + await expect(page.locator('[data-test-publisher]')).toBeDisabled(); + await expect(page.locator('[data-test-namespace]')).toBeDisabled(); + await expect(page.locator('[data-test-project]')).toBeDisabled(); + await expect(page.locator('[data-test-workflow]')).toBeDisabled(); + await expect(page.locator('[data-test-environment]')).toBeDisabled(); + await expect(page.locator('[data-test-add]')).toBeDisabled(); + + // Resolve the deferred with an error + deferred.resolve(HttpResponse.json({ errors: [{ detail: 'Server error' }] }, { status: 500 })); + + // Check that the error notification is displayed + await expect(page.locator('[data-test-notification-message]')).toHaveText( + 'An error has occurred while adding the Trusted Publishing configuration: Server error', + ); + + await expect(page.locator('[data-test-publisher]')).toBeEnabled(); + await expect(page.locator('[data-test-namespace]')).toBeEnabled(); + await expect(page.locator('[data-test-project]')).toBeEnabled(); + await expect(page.locator('[data-test-workflow]')).toBeEnabled(); + await expect(page.locator('[data-test-environment]')).toBeEnabled(); + await expect(page.locator('[data-test-add]')).toBeEnabled(); + + await page.click('[data-test-cancel]'); + await expect(page).toHaveURL(`/crates/${crate.name}/settings`); + await expect(page.locator('[data-test-gitlab-config]')).toHaveCount(0); + }); + + test.describe('workflow verification', () => { + test('success case (200 OK)', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await page.selectOption('[data-test-publisher]', 'GitLab'); + + await msw.worker.use( + http.head('https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml', () => { + return new HttpResponse(null, { status: 200 }); + }), + ); + + await expect(page.locator('[data-test-workflow-verification="initial"]')).toHaveText( + 'The workflow filepath will be verified once all necessary fields are filled.', + ); + + await page.fill('[data-test-namespace]', 'rust-lang'); + await page.fill('[data-test-project]', 'crates.io'); + await page.fill('[data-test-workflow]', '.gitlab-ci.yml'); + + await expect(page.locator('[data-test-workflow-verification="success"]')).toBeVisible(); + + await expect(page.locator('[data-test-workflow-verification="success"]')).toHaveText( + '✓ Workflow file found at https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml', + ); + }); + + test('not found case (404)', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await page.selectOption('[data-test-publisher]', 'GitLab'); + + await msw.worker.use( + http.head('https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/missing.yml', () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + await page.fill('[data-test-namespace]', 'rust-lang'); + await page.fill('[data-test-project]', 'crates.io'); + await page.fill('[data-test-workflow]', 'missing.yml'); + + await expect(page.locator('[data-test-workflow-verification="not-found"]')).toBeVisible(); + + await expect(page.locator('[data-test-workflow-verification="not-found"]')).toHaveText( + '⚠ Workflow file not found at https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/missing.yml', + ); + + // Verify form can still be submitted + await page.click('[data-test-add]'); + await expect(page).toHaveURL(`/crates/${crate.name}/settings`); + }); + + test('server error (5xx)', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await page.selectOption('[data-test-publisher]', 'GitLab'); + + await msw.worker.use( + http.head('https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await page.fill('[data-test-namespace]', 'rust-lang'); + await page.fill('[data-test-project]', 'crates.io'); + await page.fill('[data-test-workflow]', '.gitlab-ci.yml'); + + await expect(page.locator('[data-test-workflow-verification="error"]')).toBeVisible(); + + await expect(page.locator('[data-test-workflow-verification="error"]')).toHaveText( + '⚠ Could not verify workflow file at https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml (network error)', + ); + }); + }); }); }); diff --git a/packages/crates-io-msw/handlers/gitlab.js b/packages/crates-io-msw/handlers/gitlab.js new file mode 100644 index 00000000000..b0af61132f0 --- /dev/null +++ b/packages/crates-io-msw/handlers/gitlab.js @@ -0,0 +1,7 @@ +import { http, HttpResponse } from 'msw'; + +export default [ + http.head('https://gitlab.com/:owner/:project/-/raw/HEAD/:workflow_path', () => { + return new HttpResponse(null, { status: 404 }); + }), +]; diff --git a/packages/crates-io-msw/index.js b/packages/crates-io-msw/index.js index c5fd0ec3171..83e7b40fb43 100644 --- a/packages/crates-io-msw/index.js +++ b/packages/crates-io-msw/index.js @@ -3,6 +3,7 @@ import categoryHandlers from './handlers/categories.js'; import cratesHandlers from './handlers/crates.js'; import docsRsHandlers from './handlers/docs-rs.js'; import githubHandlers from './handlers/github.js'; +import gitlabHandlers from './handlers/gitlab.js'; import inviteHandlers from './handlers/invites.js'; import keywordHandlers from './handlers/keywords.js'; import metadataHandlers from './handlers/metadata.js'; @@ -35,6 +36,7 @@ export const handlers = [ ...cratesHandlers, ...docsRsHandlers, ...githubHandlers, + ...gitlabHandlers, ...inviteHandlers, ...keywordHandlers, ...metadataHandlers, diff --git a/tests/routes/crate/settings-test.js b/tests/routes/crate/settings-test.js index 50ce9ff5458..858c71ebdc0 100644 --- a/tests/routes/crate/settings-test.js +++ b/tests/routes/crate/settings-test.js @@ -63,6 +63,53 @@ module('Route | crate.settings', hooks => { }); module('Trusted Publishing', function () { + test('mixed GitHub and GitLab configs', async function (assert) { + const { crate, user } = prepare(this); + this.authenticateAs(user); + + // Create GitHub configs + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + // Create GitLab configs + this.db.trustpubGitlabConfig.create({ + crate, + namespace: 'johndoe', + namespace_id: '1234', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + environment: 'production', + }); + + await visit(`/crates/${crate.name}/settings`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + + await percySnapshot(assert); + + // Check that both GitHub and GitLab configs are displayed + assert.dom('[data-test-trusted-publishing]').exists(); + assert.dom('[data-test-github-config]').exists({ count: 1 }); + assert.dom('[data-test-gitlab-config]').exists({ count: 1 }); + + // Verify GitHub config + assert.dom('[data-test-github-config="1"] td:nth-child(1)').hasText('GitHub'); + assert.dom('[data-test-github-config="1"] td:nth-child(2)').includesText('Repository: rust-lang/crates.io'); + assert.dom('[data-test-github-config="1"] td:nth-child(2)').includesText('Workflow: ci.yml'); + + // Verify GitLab config + assert.dom('[data-test-gitlab-config="1"] td:nth-child(1)').hasText('GitLab'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Repository: johndoe/crates.io'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Namespace ID: 1234'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Workflow: .gitlab-ci.yml'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Environment: production'); + + assert.dom('[data-test-no-config]').doesNotExist(); + }); + module('GitHub', function () { test('happy path', async function (assert) { const { crate, user } = prepare(this); @@ -87,8 +134,6 @@ module('Route | crate.settings', hooks => { await visit(`/crates/${crate.name}/settings`); assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); - await percySnapshot(assert); - // Check that the GitHub config is displayed assert.dom('[data-test-trusted-publishing]').exists(); assert.dom('[data-test-github-config]').exists({ count: 2 }); @@ -144,5 +189,89 @@ module('Route | crate.settings', hooks => { .hasText('Failed to remove Trusted Publishing configuration: Server error'); }); }); + + module('GitLab', function () { + test('happy path', async function (assert) { + const { crate, user } = prepare(this); + this.authenticateAs(user); + + // Create two GitLab configs for the crate + this.db.trustpubGitlabConfig.create({ + crate, + namespace: 'rust-lang', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + }); + + this.db.trustpubGitlabConfig.create({ + crate, + namespace: 'johndoe', + namespace_id: '1234', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + environment: 'release', + }); + + await visit(`/crates/${crate.name}/settings`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + + // Check that the GitLab config is displayed + assert.dom('[data-test-trusted-publishing]').exists(); + assert.dom('[data-test-gitlab-config]').exists({ count: 2 }); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(1)').hasText('GitLab'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Repository: rust-lang/crates.io'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Namespace ID: (not yet set)'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Workflow: .gitlab-ci.yml'); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').doesNotIncludeText('Environment'); + assert.dom('[data-test-gitlab-config="1"] [data-test-remove-config-button]').exists(); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(1)').hasText('GitLab'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Repository: johndoe/crates.io'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Namespace ID: 1234'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Workflow: .gitlab-ci.yml'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Environment: release'); + assert.dom('[data-test-gitlab-config="2"] [data-test-remove-config-button]').exists(); + assert.dom('[data-test-no-config]').doesNotExist(); + + // Click the remove button + await click('[data-test-gitlab-config="2"] [data-test-remove-config-button]'); + + // Check that the config is no longer displayed + assert.dom('[data-test-gitlab-config]').exists({ count: 1 }); + assert.dom('[data-test-gitlab-config="1"] td:nth-child(2)').includesText('Repository: rust-lang/crates.io'); + assert.dom('[data-test-notification-message]').hasText('Trusted Publishing configuration removed successfully'); + }); + + test('deletion failure', async function (assert) { + let { crate, user } = prepare(this); + this.authenticateAs(user); + + // Create a GitLab config for the crate + let config = this.db.trustpubGitlabConfig.create({ + crate, + namespace: 'rust-lang', + namespace_id: '1234', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + environment: 'release', + }); + + // Mock the server to return an error when trying to delete the config + this.worker.use( + http.delete(`/api/v1/trusted_publishing/gitlab_configs/${config.id}`, () => { + return HttpResponse.json({ errors: [{ detail: 'Server error' }] }, { status: 500 }); + }), + ); + + await visit(`/crates/${crate.name}/settings`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + assert.dom('[data-test-gitlab-config]').exists({ count: 1 }); + + await click('[data-test-remove-config-button]'); + assert.dom('[data-test-gitlab-config]').exists({ count: 1 }); + assert + .dom('[data-test-notification-message]') + .hasText('Failed to remove Trusted Publishing configuration: Server error'); + }); + }); }); }); diff --git a/tests/routes/crate/settings/new-trusted-publisher-test.js b/tests/routes/crate/settings/new-trusted-publisher-test.js index 97d0331bb20..f562c23bff6 100644 --- a/tests/routes/crate/settings/new-trusted-publisher-test.js +++ b/tests/routes/crate/settings/new-trusted-publisher-test.js @@ -63,29 +63,57 @@ module('Route | crate.settings.new-trusted-publisher', hooks => { module('prefill', function () { let testCases = [ { - name: 'simple https', + name: 'GitHub: simple https', url: 'https://github.com/rust-lang/crates.io', publisher: 'GitHub', owner: 'rust-lang', repo: 'crates.io', }, { - name: 'with .git suffix', + name: 'GitHub: with .git suffix', url: 'https://github.com/rust-lang/crates.io.git', publisher: 'GitHub', owner: 'rust-lang', repo: 'crates.io', }, { - name: 'with extra path segments', + name: 'GitHub: with extra path segments', url: 'https://github.com/Byron/google-apis-rs/tree/main/gen/privateca1', publisher: 'GitHub', owner: 'Byron', repo: 'google-apis-rs', }, { - name: 'non-github url', + name: 'GitLab: simple https', url: 'https://gitlab.com/rust-lang/crates.io', + publisher: 'GitLab', + owner: 'rust-lang', + repo: 'crates.io', + }, + { + name: 'GitLab: with .git suffix', + url: 'https://gitlab.com/rust-lang/crates.io.git', + publisher: 'GitLab', + owner: 'rust-lang', + repo: 'crates.io', + }, + { + name: 'GitLab: with extra path segments', + url: 'https://gitlab.com/Byron/google-apis-rs/-/tree/main/gen/privateca1', + publisher: 'GitLab', + owner: 'Byron', + repo: 'google-apis-rs', + }, + { + name: 'GitLab: nested groups', + url: 'https://gitlab.com/a/b/c', + publisher: 'GitLab', + owner: 'a/b', + repo: 'c', + }, + { + name: 'non-github url', + url: 'https://example.com/rust-lang/crates.io', publisher: 'GitHub', owner: '', repo: '', @@ -343,6 +371,233 @@ module('Route | crate.settings.new-trusted-publisher', hooks => { }); module('GitLab', function () { - // Placeholder for GitLab tests when they are implemented + test('happy path', async function (assert) { + let { crate } = prepare(this); + + this.db.trustpubGitlabConfig.create({ + crate, + namespace: 'johndoe', + project: 'crates.io', + workflow_filepath: '.gitlab-ci.yml', + }); + + await visit(`/crates/${crate.name}/settings`); + await click('[data-test-add-trusted-publisher-button]'); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Check that the form is displayed correctly + assert.dom('[data-test-publisher]').exists(); + assert.dom('[data-test-namespace]').exists(); + assert.dom('[data-test-project]').exists(); + assert.dom('[data-test-workflow]').exists(); + + // Select GitLab from the publisher dropdown + await fillIn('[data-test-publisher]', 'GitLab'); + + // Check that GitLab fields are displayed + assert.dom('[data-test-namespace]').exists(); + assert.dom('[data-test-project]').exists(); + assert.dom('[data-test-workflow]').exists(); + assert.dom('[data-test-environment]').exists(); + assert.dom('[data-test-add]').exists(); + assert.dom('[data-test-cancel]').exists(); + + // Fill in the form + await fillIn('[data-test-namespace]', 'rust-lang'); + await fillIn('[data-test-project]', 'crates.io'); + await fillIn('[data-test-workflow]', '.gitlab-ci.yml'); + await fillIn('[data-test-environment]', 'production'); + + // Submit the form + await click('[data-test-add]'); + + // Check that we're redirected back to the crate settings page + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + + // Check that the config was created + let config = this.db.trustpubGitlabConfig.findFirst({ + where: { + namespace: { equals: 'rust-lang' }, + project: { equals: 'crates.io' }, + workflow_filepath: { equals: '.gitlab-ci.yml' }, + environment: { equals: 'production' }, + }, + }); + assert.ok(config, 'Config was created'); + + // Check that the success notification is displayed + assert.dom('[data-test-notification-message]').hasText('Trusted Publishing configuration added successfully'); + + // Check that the config is displayed on the crate settings page + assert.dom('[data-test-gitlab-config]').exists({ count: 2 }); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(1)').hasText('GitLab'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Repository: rust-lang/crates.io'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Workflow: .gitlab-ci.yml'); + assert.dom('[data-test-gitlab-config="2"] td:nth-child(2)').includesText('Environment: production'); + }); + + test('validation errors', async function (assert) { + let { crate } = prepare(this); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await fillIn('[data-test-publisher]', 'GitLab'); + + // Submit the form without filling in required fields + await click('[data-test-add]'); + + // Check that validation errors are displayed + assert.dom('[data-test-namespace-group] [data-test-error]').exists(); + assert.dom('[data-test-project-group] [data-test-error]').exists(); + assert.dom('[data-test-workflow-group] [data-test-error]').exists(); + + // Fill in the required fields + await fillIn('[data-test-namespace]', 'rust-lang'); + await fillIn('[data-test-project]', 'crates.io'); + await fillIn('[data-test-workflow]', '.gitlab-ci.yml'); + + // Submit the form + await click('[data-test-add]'); + + // Check that we're redirected back to the crate settings page + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + }); + + test('loading and error state', async function (assert) { + let { crate } = prepare(this); + + // Mock the server to return an error + let deferred = defer(); + this.worker.use(http.post('/api/v1/trusted_publishing/gitlab_configs', () => deferred.promise)); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await fillIn('[data-test-publisher]', 'GitLab'); + + // Fill in the form + await fillIn('[data-test-namespace]', 'rust-lang'); + await fillIn('[data-test-project]', 'crates.io'); + await fillIn('[data-test-workflow]', '.gitlab-ci.yml'); + + // Submit the form + let clickPromise = click('[data-test-add]'); + await waitFor('[data-test-add] [data-test-spinner]'); + assert.dom('[data-test-publisher]').isDisabled(); + assert.dom('[data-test-namespace]').isDisabled(); + assert.dom('[data-test-project]').isDisabled(); + assert.dom('[data-test-workflow]').isDisabled(); + assert.dom('[data-test-environment]').isDisabled(); + assert.dom('[data-test-add]').isDisabled(); + + // Resolve the deferred with an error + deferred.resolve(HttpResponse.json({ errors: [{ detail: 'Server error' }] }, { status: 500 })); + await clickPromise; + + // Check that the error notification is displayed + assert + .dom('[data-test-notification-message]') + .hasText('An error has occurred while adding the Trusted Publishing configuration: Server error'); + + assert.dom('[data-test-publisher]').isEnabled(); + assert.dom('[data-test-namespace]').isEnabled(); + assert.dom('[data-test-project]').isEnabled(); + assert.dom('[data-test-workflow]').isEnabled(); + assert.dom('[data-test-environment]').isEnabled(); + assert.dom('[data-test-add]').isEnabled(); + + await click('[data-test-cancel]'); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + assert.dom('[data-test-gitlab-config]').exists({ count: 0 }); + }); + + module('workflow verification', function () { + test('success case (200 OK)', async function (assert) { + let { crate } = prepare(this); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await fillIn('[data-test-publisher]', 'GitLab'); + + this.worker.use( + http.head('https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml', () => { + return new HttpResponse(null, { status: 200 }); + }), + ); + + assert + .dom('[data-test-workflow-verification="initial"]') + .hasText('The workflow filepath will be verified once all necessary fields are filled.'); + + await fillIn('[data-test-namespace]', 'rust-lang'); + await fillIn('[data-test-project]', 'crates.io'); + await fillIn('[data-test-workflow]', '.gitlab-ci.yml'); + + await waitFor('[data-test-workflow-verification="success"]'); + + let expected = '✓ Workflow file found at https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml'; + assert.dom('[data-test-workflow-verification="success"]').hasText(expected); + }); + + test('not found case (404)', async function (assert) { + let { crate } = prepare(this); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await fillIn('[data-test-publisher]', 'GitLab'); + + this.worker.use( + http.head('https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/missing.yml', () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + await fillIn('[data-test-namespace]', 'rust-lang'); + await fillIn('[data-test-project]', 'crates.io'); + await fillIn('[data-test-workflow]', 'missing.yml'); + + await waitFor('[data-test-workflow-verification="not-found"]'); + + let expected = '⚠ Workflow file not found at https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/missing.yml'; + assert.dom('[data-test-workflow-verification="not-found"]').hasText(expected); + + // Verify form can still be submitted + await click('[data-test-add]'); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + }); + + test('server error (5xx)', async function (assert) { + let { crate } = prepare(this); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Select GitLab from the publisher dropdown + await fillIn('[data-test-publisher]', 'GitLab'); + + this.worker.use( + http.head('https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await fillIn('[data-test-namespace]', 'rust-lang'); + await fillIn('[data-test-project]', 'crates.io'); + await fillIn('[data-test-workflow]', '.gitlab-ci.yml'); + + await waitFor('[data-test-workflow-verification="error"]'); + + let expected = + '⚠ Could not verify workflow file at https://gitlab.com/rust-lang/crates.io/-/raw/HEAD/.gitlab-ci.yml (network error)'; + assert.dom('[data-test-workflow-verification="error"]').hasText(expected); + }); + }); }); });