diff --git a/app/controllers/crate/settings/new-trusted-publisher.js b/app/controllers/crate/settings/new-trusted-publisher.js index 2231c9b0534..f236f6f0443 100644 --- a/app/controllers/crate/settings/new-trusted-publisher.js +++ b/app/controllers/crate/settings/new-trusted-publisher.js @@ -2,8 +2,9 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; +import Ember from 'ember'; -import { task } from 'ember-concurrency'; +import { rawTimeout, restartableTask, task } from 'ember-concurrency'; export default class NewTrustedPublisherController extends Controller { @service notifications; @@ -33,6 +34,34 @@ export default class NewTrustedPublisherController extends Controller { } } + get verificationUrl() { + if (this.repositoryOwner && this.repositoryName && this.workflowFilename) { + return `https://raw.githubusercontent.com/${this.repositoryOwner}/${this.repositoryName}/HEAD/.github/workflows/${this.workflowFilename}`; + } + } + + verifyWorkflowTask = restartableTask(async () => { + let timeout = Ember.testing ? 0 : 500; + await rawTimeout(timeout); + + let { verificationUrl } = this; + if (!verificationUrl) return null; + + try { + let response = await fetch(verificationUrl, { method: 'HEAD' }); + + if (response.ok) { + return 'success'; + } else if (response.status === 404) { + return 'not-found'; + } else { + return 'error'; + } + } catch { + return 'error'; + } + }); + saveConfigTask = task(async () => { if (!this.validate()) return; diff --git a/app/templates/crate/settings/new-trusted-publisher.css b/app/templates/crate/settings/new-trusted-publisher.css index 020fddccf53..fb54ba322ce 100644 --- a/app/templates/crate/settings/new-trusted-publisher.css +++ b/app/templates/crate/settings/new-trusted-publisher.css @@ -45,3 +45,20 @@ .cancel-button { border-radius: 4px; } + +.workflow-verification { + margin-top: var(--space-2xs); + font-size: 0.85em; + + :global(a), :global(a):hover { + color: inherit; + } +} + +.workflow-verification--success { + color: var(--green800); +} + +.workflow-verification--warning { + color: var(--yellow700); +} diff --git a/app/templates/crate/settings/new-trusted-publisher.gjs b/app/templates/crate/settings/new-trusted-publisher.gjs index 18f92053cb7..b6e85c97f5b 100644 --- a/app/templates/crate/settings/new-trusted-publisher.gjs +++ b/app/templates/crate/settings/new-trusted-publisher.gjs @@ -7,6 +7,7 @@ import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus'; import perform from 'ember-concurrency/helpers/perform'; import preventDefault from 'ember-event-helpers/helpers/prevent-default'; import eq from 'ember-truth-helpers/helpers/eq'; +import not from 'ember-truth-helpers/helpers/not'; import LoadingSpinner from 'crates-io/components/loading-spinner'; @@ -51,6 +52,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; data-test-repository-owner {{autoFocus}} {{on 'input' @controller.resetRepositoryOwnerValidation}} + {{on 'input' (perform @controller.verifyWorkflowTask)}} /> {{#if @controller.repositoryOwnerInvalid}} @@ -79,6 +81,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; class='input base-input' data-test-repository-name {{on 'input' @controller.resetRepositoryNameValidation}} + {{on 'input' (perform @controller.verifyWorkflowTask)}} /> {{#if @controller.repositoryNameInvalid}} @@ -107,6 +110,7 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; class='input base-input' data-test-workflow-filename {{on 'input' @controller.resetWorkflowFilenameValidation}} + {{on 'input' (perform @controller.verifyWorkflowTask)}} /> {{#if @controller.workflowFilenameInvalid}} @@ -140,6 +144,41 @@ import LoadingSpinner from 'crates-io/components/loading-spinner'; publish.yml. {{/if}} + + {{#if (not @controller.verificationUrl)}} +
+ The workflow filename 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}} diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts index d69fcf81b0d..78c90e30dc7 100644 --- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts +++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts @@ -245,4 +245,77 @@ test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' } }); } }); + + 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`); + + await msw.worker.use( + http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => { + return new HttpResponse(null, { status: 200 }); + }), + ); + + await expect(page.locator('[data-test-workflow-verification="initial"]')).toHaveText( + 'The workflow filename will be verified once all necessary fields are filled.', + ); + + await page.fill('[data-test-repository-owner]', 'rust-lang'); + await page.fill('[data-test-repository-name]', 'crates.io'); + await page.fill('[data-test-workflow-filename]', 'ci.yml'); + + await expect(page.locator('[data-test-workflow-verification="success"]')).toHaveText( + '✓ Workflow file found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/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`); + + await msw.worker.use( + http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/missing.yml', () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + await page.fill('[data-test-repository-owner]', 'rust-lang'); + await page.fill('[data-test-repository-name]', 'crates.io'); + await page.fill('[data-test-workflow-filename]', 'missing.yml'); + + await expect(page.locator('[data-test-workflow-verification="not-found"]')).toHaveText( + '⚠ Workflow file not found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/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`); + + await msw.worker.use( + http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await page.fill('[data-test-repository-owner]', 'rust-lang'); + await page.fill('[data-test-repository-name]', 'crates.io'); + await page.fill('[data-test-workflow-filename]', 'ci.yml'); + + await expect(page.locator('[data-test-workflow-verification="error"]')).toHaveText( + '⚠ Could not verify workflow file at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml (network error)', + ); + }); + }); }); diff --git a/packages/crates-io-msw/handlers/github.js b/packages/crates-io-msw/handlers/github.js new file mode 100644 index 00000000000..0901e5a6a42 --- /dev/null +++ b/packages/crates-io-msw/handlers/github.js @@ -0,0 +1,7 @@ +import { http, HttpResponse } from 'msw'; + +export default [ + http.head('https://raw.githubusercontent.com/:owner/:projec/HEAD/.github/workflows/:workflow_filename', () => { + return new HttpResponse(null, { status: 404 }); + }), +]; diff --git a/packages/crates-io-msw/index.js b/packages/crates-io-msw/index.js index 5973d4bb81a..4c4d65a6e6d 100644 --- a/packages/crates-io-msw/index.js +++ b/packages/crates-io-msw/index.js @@ -2,6 +2,7 @@ import apiTokenHandlers from './handlers/api-tokens.js'; 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 inviteHandlers from './handlers/invites.js'; import keywordHandlers from './handlers/keywords.js'; import metadataHandlers from './handlers/metadata.js'; @@ -32,6 +33,7 @@ export const handlers = [ ...categoryHandlers, ...cratesHandlers, ...docsRsHandlers, + ...githubHandlers, ...inviteHandlers, ...keywordHandlers, ...metadataHandlers, diff --git a/tests/routes/crate/settings/new-trusted-publisher-test.js b/tests/routes/crate/settings/new-trusted-publisher-test.js index 157231163ae..7c7adbfe8f2 100644 --- a/tests/routes/crate/settings/new-trusted-publisher-test.js +++ b/tests/routes/crate/settings/new-trusted-publisher-test.js @@ -252,4 +252,83 @@ module('Route | crate.settings.new-trusted-publisher', hooks => { }); } }); + + 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`); + + this.worker.use( + http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => { + return new HttpResponse(null, { status: 200 }); + }), + ); + + assert + .dom('[data-test-workflow-verification="initial"]') + .hasText('The workflow filename will be verified once all necessary fields are filled.'); + + await fillIn('[data-test-repository-owner]', 'rust-lang'); + await fillIn('[data-test-repository-name]', 'crates.io'); + await fillIn('[data-test-workflow-filename]', 'ci.yml'); + + await waitFor('[data-test-workflow-verification="success"]'); + + let expected = + '✓ Workflow file found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/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`); + + this.worker.use( + http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/missing.yml', () => { + return new HttpResponse(null, { status: 404 }); + }), + ); + + await fillIn('[data-test-repository-owner]', 'rust-lang'); + await fillIn('[data-test-repository-name]', 'crates.io'); + await fillIn('[data-test-workflow-filename]', 'missing.yml'); + + await waitFor('[data-test-workflow-verification="not-found"]'); + + let expected = + '⚠ Workflow file not found at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/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`); + + this.worker.use( + http.head('https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml', () => { + return new HttpResponse(null, { status: 500 }); + }), + ); + + await fillIn('[data-test-repository-owner]', 'rust-lang'); + await fillIn('[data-test-repository-name]', 'crates.io'); + await fillIn('[data-test-workflow-filename]', 'ci.yml'); + + await waitFor('[data-test-workflow-verification="error"]'); + + let expected = + '⚠ Could not verify workflow file at https://raw.githubusercontent.com/rust-lang/crates.io/HEAD/.github/workflows/ci.yml (network error)'; + assert.dom('[data-test-workflow-verification="error"]').hasText(expected); + }); + }); });