diff --git a/app/adapters/trustpub-github-config.js b/app/adapters/trustpub-github-config.js new file mode 100644 index 00000000000..996186f778f --- /dev/null +++ b/app/adapters/trustpub-github-config.js @@ -0,0 +1,7 @@ +import ApplicationAdapter from './application'; + +export default class TrustpubGitHubConfigAdapter extends ApplicationAdapter { + pathForType() { + return 'trusted_publishing/github_configs'; + } +} diff --git a/app/controllers/crate/settings.js b/app/controllers/crate/settings/index.js similarity index 82% rename from app/controllers/crate/settings.js rename to app/controllers/crate/settings/index.js index 1fd26ab7200..6d30528bdd4 100644 --- a/app/controllers/crate/settings.js +++ b/app/controllers/crate/settings/index.js @@ -7,6 +7,7 @@ import { task } from 'ember-concurrency'; export default class CrateSettingsController extends Controller { @service notifications; + @service store; crate = null; username = ''; @@ -64,6 +65,22 @@ export default class CrateSettingsController extends Controller { this.notifications.error(message); } }); + + removeConfigTask = task(async config => { + try { + await config.destroyRecord(); + this.notifications.success('Trusted Publishing configuration removed successfully'); + } catch (error) { + let message = 'Failed to remove Trusted Publishing configuration'; + + let detail = error.errors?.[0]?.detail; + if (detail && !detail.startsWith('{')) { + message += `: ${detail}`; + } + + this.notifications.error(message); + } + }); } function removeOwner(owners, target) { diff --git a/app/controllers/crate/settings/new-trusted-publisher.js b/app/controllers/crate/settings/new-trusted-publisher.js new file mode 100644 index 00000000000..ff2eef1b3c1 --- /dev/null +++ b/app/controllers/crate/settings/new-trusted-publisher.js @@ -0,0 +1,85 @@ +import Controller from '@ember/controller'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; + +import { task } from 'ember-concurrency'; + +export default class NewTrustedPublisherController extends Controller { + @service notifications; + @service store; + @service router; + + @tracked publisher = 'GitHub'; + @tracked repositoryOwner = ''; + @tracked repositoryName = ''; + @tracked workflowFilename = ''; + @tracked environment = ''; + @tracked repositoryOwnerInvalid = false; + @tracked repositoryNameInvalid = false; + @tracked workflowFilenameInvalid = false; + + get crate() { + return this.model.crate; + } + + get publishers() { + return ['GitHub']; + } + + saveConfigTask = task(async () => { + if (!this.validate()) return; + + let config = this.store.createRecord('trustpub-github-config', { + crate: this.crate, + repository_owner: this.repositoryOwner, + repository_name: this.repositoryName, + workflow_filename: this.workflowFilename, + environment: this.environment || null, + }); + + try { + // Save the new config on the backend + await config.save(); + + this.repositoryOwner = ''; + this.repositoryName = ''; + this.workflowFilename = ''; + this.environment = ''; + + // Navigate back to the crate settings page + this.notifications.success('Trusted Publishing configuration added successfully'); + this.router.transitionTo('crate.settings', this.crate.id); + } catch (error) { + // Notify the user + let message = 'An error has occurred while adding the Trusted Publishing configuration'; + + let detail = error.errors?.[0]?.detail; + if (detail && !detail.startsWith('{')) { + message += `: ${detail}`; + } + + this.notifications.error(message); + } + }); + + validate() { + this.repositoryOwnerInvalid = !this.repositoryOwner; + this.repositoryNameInvalid = !this.repositoryName; + this.workflowFilenameInvalid = !this.workflowFilename; + + return !this.repositoryOwnerInvalid && !this.repositoryNameInvalid && !this.workflowFilenameInvalid; + } + + @action resetRepositoryOwnerValidation() { + this.repositoryOwnerInvalid = false; + } + + @action resetRepositoryNameValidation() { + this.repositoryNameInvalid = false; + } + + @action resetWorkflowFilenameValidation() { + this.workflowFilenameInvalid = false; + } +} diff --git a/app/models/trustpub-github-config.js b/app/models/trustpub-github-config.js new file mode 100644 index 00000000000..296a9567e81 --- /dev/null +++ b/app/models/trustpub-github-config.js @@ -0,0 +1,10 @@ +import Model, { attr, belongsTo } from '@ember-data/model'; + +export default class TrustpubGitHubConfig extends Model { + @belongsTo('crate', { async: true, inverse: null }) crate; + @attr repository_owner; + @attr repository_name; + @attr workflow_filename; + @attr environment; + @attr('date') created_at; +} diff --git a/app/router.js b/app/router.js index f6bc3248d15..e683b17aebc 100644 --- a/app/router.js +++ b/app/router.js @@ -19,7 +19,9 @@ Router.map(function () { this.route('reverse-dependencies', { path: 'reverse_dependencies' }); this.route('owners'); - this.route('settings'); + this.route('settings', function () { + this.route('new-trusted-publisher'); + }); this.route('delete'); // Well-known routes diff --git a/app/routes/crate/settings.js b/app/routes/crate/settings.js index e53e3480914..73bd61c6275 100644 --- a/app/routes/crate/settings.js +++ b/app/routes/crate/settings.js @@ -6,9 +6,11 @@ export default class SettingsRoute extends AuthenticatedRoute { @service router; @service session; - async afterModel(crate, transition) { + async beforeModel(transition) { + await super.beforeModel(...arguments); + let user = this.session.currentUser; - let owners = await crate.owner_user; + let owners = await this.modelFor('crate').owner_user; let isOwner = owners.some(owner => owner.id === user.id); if (!isOwner) { this.router.replaceWith('catch-all', { @@ -17,10 +19,4 @@ export default class SettingsRoute extends AuthenticatedRoute { }); } } - - setupController(controller) { - super.setupController(...arguments); - let crate = this.modelFor('crate'); - controller.set('crate', crate); - } } diff --git a/app/routes/crate/settings/index.js b/app/routes/crate/settings/index.js new file mode 100644 index 00000000000..4c88ff0930f --- /dev/null +++ b/app/routes/crate/settings/index.js @@ -0,0 +1,21 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class SettingsIndexRoute extends Route { + @service store; + + async model() { + let crate = this.modelFor('crate'); + + let githubConfigs = await this.store.query('trustpub-github-config', { crate: crate.name }); + + return { crate, githubConfigs }; + } + + setupController(controller, { crate, githubConfigs }) { + super.setupController(...arguments); + + controller.set('crate', crate); + controller.set('githubConfigs', githubConfigs); + } +} diff --git a/app/routes/crate/settings/new-trusted-publisher.js b/app/routes/crate/settings/new-trusted-publisher.js new file mode 100644 index 00000000000..259cad18732 --- /dev/null +++ b/app/routes/crate/settings/new-trusted-publisher.js @@ -0,0 +1,8 @@ +import Route from '@ember/routing/route'; + +export default class NewTrustedPublisherRoute extends Route { + async model() { + let crate = this.modelFor('crate'); + return { crate }; + } +} diff --git a/app/serializers/trustpub-github-config.js b/app/serializers/trustpub-github-config.js new file mode 100644 index 00000000000..041032d3147 --- /dev/null +++ b/app/serializers/trustpub-github-config.js @@ -0,0 +1,11 @@ +import ApplicationSerializer from './application'; + +export default class TrustpubGitHubConfigSerializer extends ApplicationSerializer { + modelNameFromPayloadKey() { + return 'trustpub-github-config'; + } + + payloadKeyFromModelName() { + return 'github_config'; + } +} diff --git a/app/styles/crate/settings.module.css b/app/styles/crate/settings/index.module.css similarity index 66% rename from app/styles/crate/settings.module.css rename to app/styles/crate/settings/index.module.css index 699bbff540d..6c8ec0daeac 100644 --- a/app/styles/crate/settings.module.css +++ b/app/styles/crate/settings/index.module.css @@ -1,4 +1,4 @@ -.owners-header { +.owners-header, .trusted-publishing-header { display: flex; justify-content: space-between; align-items: center; @@ -43,6 +43,30 @@ } } +.trustpub { + background-color: light-dark(white, #141413); + border-radius: var(--space-3xs); + box-shadow: 0 1px 3px light-dark(hsla(51, 90%, 42%, .35), #232321); + + tbody > tr > td { + border-top: 1px solid light-dark(hsla(51, 90%, 42%, .25), #232321); + } + + th, td { + text-align: left; + padding: var(--space-s) var(--space-m); + } + + .details { + font-size: 0.85em; + line-height: 1.5; + } + + .actions { + text-align: right; + } +} + .email-column { width: 25%; color: var(--main-color-light); diff --git a/app/styles/crate/settings/new-trusted-publisher.module.css b/app/styles/crate/settings/new-trusted-publisher.module.css new file mode 100644 index 00000000000..8ca8bd50eae --- /dev/null +++ b/app/styles/crate/settings/new-trusted-publisher.module.css @@ -0,0 +1,42 @@ +.form-group, .buttons { + margin: var(--space-m) 0; +} + +.publisher-select { + max-width: 440px; + width: 100%; + padding-right: var(--space-m); + background-image: url("/assets/dropdown.svg"); + background-repeat: no-repeat; + background-position: calc(100% - var(--space-2xs)) center; + background-size: 10px; + appearance: none; +} + +.note { + margin-top: var(--space-2xs); + font-size: 0.85em; +} + +.input { + max-width: 440px; + width: 100%; +} + +.buttons { + display: flex; + gap: var(--space-2xs); + flex-wrap: wrap; +} + +.add-button { + border-radius: 4px; + + .spinner { + margin-left: var(--space-2xs); + } +} + +.cancel-button { + border-radius: 4px; +} diff --git a/app/templates/crate/settings.hbs b/app/templates/crate/settings/index.hbs similarity index 61% rename from app/templates/crate/settings.hbs rename to app/templates/crate/settings/index.hbs index dd82a412946..f4dea175696 100644 --- a/app/templates/crate/settings.hbs +++ b/app/templates/crate/settings/index.hbs @@ -54,6 +54,47 @@ {{/each}} +{{! The "Trusted Publishing" section is hidden for now until we make this feature publicly available. }} +{{#if this.githubConfigs}} +
+

Trusted Publishing

+ + Add + +
+ + + + + + + + + + + {{#each this.githubConfigs as |config|}} + + + + + + {{else}} + + + + {{/each}} + +
PublisherDetails
GitHub + Repository: {{config.repository_owner}}/{{config.repository_name}}
+ Workflow: {{config.workflow_filename}}
+ {{#if config.environment}} + Environment: {{config.environment}} + {{/if}} +
+ +
No trusted publishers configured for this crate.
+{{/if}} +

Danger Zone

diff --git a/app/templates/crate/settings/new-trusted-publisher.hbs b/app/templates/crate/settings/new-trusted-publisher.hbs new file mode 100644 index 00000000000..3bb1b667631 --- /dev/null +++ b/app/templates/crate/settings/new-trusted-publisher.hbs @@ -0,0 +1,160 @@ +

Add a new Trusted Publisher

+ +
+
+ {{#let (unique-id) as |id|}} + + + + {{/let}} + +
+ crates.io currently only supports GitHub, but we are planning to support other platforms in the future. +
+
+ + {{#if (eq this.publisher "GitHub")}} +
+ {{#let (unique-id) as |id|}} + + + + + {{#if this.repositoryOwnerInvalid}} +
+ Please enter a repository owner. +
+ {{else}} +
+ The GitHub organization name or GitHub username that owns the repository. +
+ {{/if}} + {{/let}} +
+ +
+ {{#let (unique-id) as |id|}} + + + + + {{#if this.repositoryNameInvalid}} +
+ Please enter a repository name. +
+ {{else}} +
+ The name of the GitHub repository that contains the publishing workflow. +
+ {{/if}} + {{/let}} +
+ +
+ {{#let (unique-id) as |id|}} + + + + + {{#if this.workflowFilenameInvalid}} +
+ Please enter a workflow filename. +
+ {{else}} +
+ The filename of the publishing workflow. This file should be present in the .github/workflows/ directory of the repository configured above. +
+ {{/if}} + {{/let}} +
+ +
+ {{#let (unique-id) as |id|}} + + + + +
+ The name of the GitHub Actions environment that the above workflow uses for publishing. This should be configured in the repository settings. A dedicated publishing environment is not required, but is strongly recommended, especially if your repository has maintainers with commit access who should not have crates.io publishing access. +
+ {{/let}} +
+ {{/if}} + +
+ + + + Cancel + +
+
\ No newline at end of file diff --git a/e2e/routes/crate/settings.spec.ts b/e2e/routes/crate/settings.spec.ts index 1710e1310c8..93329f6f72f 100644 --- a/e2e/routes/crate/settings.spec.ts +++ b/e2e/routes/crate/settings.spec.ts @@ -1,4 +1,6 @@ import { expect, test } from '@/e2e/helper'; +import { click } from '@ember/test-helpers'; +import { http, HttpResponse } from 'msw'; test.describe('Route | crate.settings', { tag: '@routes' }, () => { async function prepare(msw) { @@ -43,10 +45,103 @@ test.describe('Route | crate.settings', { tag: '@routes' }, () => { await page.goto('/crates/foo/settings'); await expect(page).toHaveURL('/crates/foo/settings'); + await expect(page.locator('[data-test-owners]')).toBeVisible(); await expect(page.locator('[data-test-add-owner-button]')).toBeVisible(); await expect(page.locator(`[data-test-owner-user="${user.login}"]`)).toBeVisible(); await expect(page.locator('[data-test-remove-owner-button]')).toBeVisible(); + + // Disabled for now, until we make this feature publicly available + // await expect(page.locator('[data-test-trusted-publishing]')).toBeVisible(); + // await expect(page.locator('[data-test-no-config]')).toBeVisible(); + // await expect(page.locator('[data-test-github-config]')).not.toBeVisible(); + await expect(page.locator('[data-test-trusted-publishing]')).not.toBeVisible(); + await expect(page.locator('[data-test-delete-button]')).toBeVisible(); }); + + test.describe('Trusted Publishing', () => { + test('happy path', async ({ msw, page, percy }) => { + const { crate } = await prepare(msw); + + // Create two GitHub configs for the crate + msw.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + msw.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'johndoe', + repository_name: 'crates.io', + workflow_filename: 'release.yml', + environment: 'release', + }); + + await page.goto('/crates/foo/settings'); + await expect(page).toHaveURL('/crates/foo/settings'); + + await percy.snapshot(); + + 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(2); + 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'); + await expect(details).not.toContainText('Environment'); + await expect(page.locator('[data-test-github-config="1"] [data-test-remove-config-button]')).toBeVisible(); + await expect(page.locator('[data-test-github-config="2"] td:nth-child(1)')).toHaveText('GitHub'); + details = page.locator('[data-test-github-config="2"] td:nth-child(2)'); + await expect(details).toContainText('Repository: johndoe/crates.io'); + await expect(details).toContainText('Workflow: release.yml'); + await expect(details).toContainText('Environment: release'); + 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(); + + // Click the remove button + await page.click('[data-test-github-config="2"] [data-test-remove-config-button]'); + + // Check that the config is no longer displayed + await expect(page.locator('[data-test-github-config]')).toHaveCount(1); + details = page.locator('[data-test-github-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 GitHub config for the crate + let config = msw.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: '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/github_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-github-config]')).toHaveCount(1); + + await page.click('[data-test-remove-config-button]'); + await expect(page.locator('[data-test-github-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 new file mode 100644 index 00000000000..5d5497d3df4 --- /dev/null +++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts @@ -0,0 +1,185 @@ +import { expect, test } from '@/e2e/helper'; +import { http, HttpResponse } from 'msw'; +import { defer } from '@/e2e/deferred'; + +test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' }, () => { + async function prepare(msw) { + let user = msw.db.user.create(); + + let crate = msw.db.crate.create({ name: 'foo' }); + msw.db.version.create({ crate }); + msw.db.crateOwnership.create({ crate, user }); + + await msw.authenticateAs(user); + + return { crate, user }; + } + + test('unauthenticated', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + msw.db.mswSession.deleteMany({}); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page.locator('[data-test-title]')).toHaveText('This page requires authentication'); + await expect(page.locator('[data-test-login]')).toBeVisible(); + }); + + test('not an owner', async ({ msw, page }) => { + let { crate } = await prepare(msw); + + msw.db.crateOwnership.deleteMany({}); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page.locator('[data-test-title]')).toHaveText('This page is only accessible by crate owners'); + await expect(page.locator('[data-test-go-back]')).toBeVisible(); + }); + + test('happy path', async ({ msw, page, percy }) => { + let { crate } = await prepare(msw); + + msw.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'johndoe', + repository_name: 'crates.io', + workflow_filename: 'release.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`); + + await percy.snapshot(); + + // Check that the form is displayed correctly + await expect(page.locator('[data-test-publisher]')).toBeVisible(); + await expect(page.locator('[data-test-repository-owner]')).toBeVisible(); + await expect(page.locator('[data-test-repository-name]')).toBeVisible(); + await expect(page.locator('[data-test-workflow-filename]')).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-repository-owner]', 'rust-lang'); + await page.fill('[data-test-repository-name]', 'crates.io'); + await page.fill('[data-test-workflow-filename]', 'ci.yml'); + await page.fill('[data-test-environment]', 'release'); + + // 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.trustpubGithubConfig.findFirst({ + where: { + repository_owner: { equals: 'rust-lang' }, + repository_name: { equals: 'crates.io' }, + workflow_filename: { equals: 'ci.yml' }, + environment: { equals: 'release' }, + }, + }); + 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-github-config]')).toHaveCount(2); + await expect(page.locator('[data-test-github-config="2"] td:nth-child(1)')).toHaveText('GitHub'); + let details = page.locator('[data-test-github-config="2"] td:nth-child(2)'); + await expect(details).toContainText('Repository: rust-lang/crates.io'); + await expect(details).toContainText('Workflow: ci.yml'); + await expect(details).toContainText('Environment: release'); + }); + + 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`); + + // 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-repository-owner-group] [data-test-error]')).toBeVisible(); + await expect(page.locator('[data-test-repository-name-group] [data-test-error]')).toBeVisible(); + await expect(page.locator('[data-test-workflow-filename-group] [data-test-error]')).toBeVisible(); + + // Fill in the required fields + 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'); + + // 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/github_configs', () => deferred.promise)); + + await page.goto(`/crates/${crate.name}/settings/new-trusted-publisher`); + await expect(page).toHaveURL(`/crates/${crate.name}/settings/new-trusted-publisher`); + + // Fill in the form + 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'); + + // 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-repository-owner]')).toBeDisabled(); + await expect(page.locator('[data-test-repository-name]')).toBeDisabled(); + await expect(page.locator('[data-test-workflow-filename]')).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-repository-owner]')).toBeEnabled(); + await expect(page.locator('[data-test-repository-name]')).toBeEnabled(); + await expect(page.locator('[data-test-workflow-filename]')).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-github-config]')).toHaveCount(0); + }); + + test('cancel button', 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`); + + // Click the cancel button + await page.click('[data-test-cancel]'); + + // Check that we're redirected back to the crate settings page + await expect(page).toHaveURL(`/crates/${crate.name}/settings`); + }); +}); diff --git a/tests/models/trustpub-github-config-test.js b/tests/models/trustpub-github-config-test.js new file mode 100644 index 00000000000..249c0ee2c41 --- /dev/null +++ b/tests/models/trustpub-github-config-test.js @@ -0,0 +1,230 @@ +import { module, test } from 'qunit'; + +import { db } from '@crates-io/msw'; + +import { setupTest } from 'crates-io/tests/helpers'; +import setupMsw from 'crates-io/tests/helpers/setup-msw'; + +module('Model | TrustpubGitHubConfig', function (hooks) { + setupTest(hooks); + setupMsw(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + }); + + module('query()', function () { + test('fetches GitHub configs for a crate', async function (assert) { + let user = this.db.user.create(); + this.authenticateAs(user); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + this.db.crateOwnership.create({ crate, user }); + + let config = this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + let configs = await this.store.query('trustpub-github-config', { crate: crate.name }); + assert.strictEqual(configs.length, 1); + assert.strictEqual(parseInt(configs[0].id, 10), config.id); + assert.strictEqual(configs[0].repository_owner, 'rust-lang'); + assert.strictEqual(configs[0].repository_name, 'crates.io'); + assert.strictEqual(configs[0].workflow_filename, 'ci.yml'); + assert.true(configs[0].created_at instanceof Date); + }); + + test('returns an error if the user is not authenticated', async function (assert) { + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + + await assert.rejects(this.store.query('trustpub-github-config', { crate: crate.name }), function (error) { + assert.deepEqual(error.errors, [{ detail: 'must be logged in to perform that action' }]); + return true; + }); + }); + + test('returns an error if the user is not an owner of the crate', async function (assert) { + let user = this.db.user.create(); + this.authenticateAs(user); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + + await assert.rejects(this.store.query('trustpub-github-config', { crate: crate.name }), function (error) { + assert.deepEqual(error.errors, [{ detail: 'You are not an owner of this crate' }]); + return true; + }); + }); + }); + + module('createRecord()', function () { + test('creates a new GitHub config', async function (assert) { + let user = this.db.user.create({ emailVerified: true }); + this.authenticateAs(user); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + this.db.crateOwnership.create({ crate, user }); + + let config = this.store.createRecord('trustpub-github-config', { + crate: await this.store.findRecord('crate', crate.name), + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + await config.save(); + assert.strictEqual(config.id, '1'); + assert.strictEqual(config.repository_owner, 'rust-lang'); + assert.strictEqual(config.repository_name, 'crates.io'); + assert.strictEqual(config.workflow_filename, 'ci.yml'); + }); + + test('returns an error if the user is not authenticated', async function (assert) { + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + + let config = this.store.createRecord('trustpub-github-config', { + crate: await this.store.findRecord('crate', crate.name), + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + await assert.rejects(config.save(), function (error) { + assert.deepEqual(error.errors, [{ detail: 'must be logged in to perform that action' }]); + return true; + }); + }); + + test('returns an error if the user is not an owner of the crate', async function (assert) { + let user = this.db.user.create({ emailVerified: true }); + this.authenticateAs(user); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + + let config = this.store.createRecord('trustpub-github-config', { + crate: await this.store.findRecord('crate', crate.name), + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + await assert.rejects(config.save(), function (error) { + assert.deepEqual(error.errors, [{ detail: 'You are not an owner of this crate' }]); + return true; + }); + }); + + test('returns an error if the user does not have a verified email', async function (assert) { + let user = this.db.user.create({ emailVerified: false }); + this.authenticateAs(user); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + this.db.crateOwnership.create({ crate, user }); + + let config = this.store.createRecord('trustpub-github-config', { + crate: await this.store.findRecord('crate', crate.name), + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + await assert.rejects(config.save(), function (error) { + let detail = 'You must verify your email address to create a Trusted Publishing config'; + assert.deepEqual(error.errors, [{ detail }]); + return true; + }); + }); + }); + + module('deleteRecord()', function () { + test('deletes a GitHub config', async function (assert) { + let user = this.db.user.create(); + this.authenticateAs(user); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + this.db.crateOwnership.create({ crate, user }); + + // Create a config in the database that will be queried later + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + let configs = await this.store.query('trustpub-github-config', { crate: crate.name }); + assert.strictEqual(configs.length, 1); + + await configs[0].destroyRecord(); + + configs = await this.store.query('trustpub-github-config', { crate: crate.name }); + assert.strictEqual(configs.length, 0); + }); + + test('returns an error if the user is not authenticated', async function (assert) { + let user = this.db.user.create(); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + this.db.crateOwnership.create({ crate, user }); + + // Create a config in the database that will be queried later + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + this.authenticateAs(user); + let configs = await this.store.query('trustpub-github-config', { crate: crate.name }); + assert.strictEqual(configs.length, 1); + + db.mswSession.deleteMany({}); + + await assert.rejects(configs[0].destroyRecord(), function (error) { + assert.deepEqual(error.errors, [{ detail: 'must be logged in to perform that action' }]); + return true; + }); + }); + + test('returns an error if the user is not an owner of the crate', async function (assert) { + let user1 = this.db.user.create(); + let user2 = this.db.user.create(); + + let crate = this.db.crate.create(); + this.db.version.create({ crate }); + this.db.crateOwnership.create({ crate, user: user1 }); + + // Create a config in the database that will be queried later + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + this.authenticateAs(user1); + let configs = await this.store.query('trustpub-github-config', { crate: crate.name }); + assert.strictEqual(configs.length, 1); + + db.mswSession.deleteMany({}); + this.authenticateAs(user2); + + await assert.rejects(configs[0].destroyRecord(), function (error) { + assert.deepEqual(error.errors, [{ detail: 'You are not an owner of this crate' }]); + return true; + }); + }); + }); +}); diff --git a/tests/routes/crate/settings-test.js b/tests/routes/crate/settings-test.js index 77e6e7d52df..bf428e2006f 100644 --- a/tests/routes/crate/settings-test.js +++ b/tests/routes/crate/settings-test.js @@ -1,6 +1,9 @@ -import { currentURL } from '@ember/test-helpers'; +import { click, currentURL } from '@ember/test-helpers'; import { module, test } from 'qunit'; +import percySnapshot from '@percy/ember'; +import { http, HttpResponse } from 'msw'; + import { setupApplicationTest } from 'crates-io/tests/helpers'; import { visit } from '../../helpers/visit-ignoring-abort'; @@ -46,10 +49,100 @@ module('Route | crate.settings', hooks => { await visit(`/crates/${crate.name}/settings`); assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + assert.dom('[data-test-add-owner-button]').exists(); assert.dom('[data-test-owners]').exists(); assert.dom(`[data-test-owner-user="${user.login}"]`).exists(); assert.dom('[data-test-remove-owner-button]').exists(); + + // Disabled for now, until we make this feature publicly available + // assert.dom('[data-test-trusted-publishing]').exists(); + // assert.dom('[data-test-no-config]').exists(); + // assert.dom('[data-test-github-config]').doesNotExist(); + assert.dom('[data-test-trusted-publishing]').doesNotExist(); + assert.dom('[data-test-delete-button]').exists(); }); + + module('Trusted Publishing', function () { + test('happy path', async function (assert) { + const { crate, user } = prepare(this); + this.authenticateAs(user); + + // Create two GitHub configs for the crate + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: 'ci.yml', + }); + + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'johndoe', + repository_name: 'crates.io', + workflow_filename: 'release.yml', + environment: 'release', + }); + + 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 }); + 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'); + assert.dom('[data-test-github-config="1"] td:nth-child(2)').doesNotIncludeText('Environment'); + assert.dom('[data-test-github-config="1"] [data-test-remove-config-button]').exists(); + assert.dom('[data-test-github-config="2"] td:nth-child(1)').hasText('GitHub'); + assert.dom('[data-test-github-config="2"] td:nth-child(2)').includesText('Repository: johndoe/crates.io'); + assert.dom('[data-test-github-config="2"] td:nth-child(2)').includesText('Workflow: release.yml'); + assert.dom('[data-test-github-config="2"] td:nth-child(2)').includesText('Environment: release'); + assert.dom('[data-test-github-config="2"] [data-test-remove-config-button]').exists(); + assert.dom('[data-test-no-config]').doesNotExist(); + + // Click the remove button + await click('[data-test-github-config="2"] [data-test-remove-config-button]'); + + // Check that the config is no longer displayed + assert.dom('[data-test-github-config]').exists({ count: 1 }); + assert.dom('[data-test-github-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 GitHub config for the crate + let config = this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'rust-lang', + repository_name: 'crates.io', + workflow_filename: '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/github_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-github-config]').exists({ count: 1 }); + + await click('[data-test-remove-config-button]'); + assert.dom('[data-test-github-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 new file mode 100644 index 00000000000..415c79f122c --- /dev/null +++ b/tests/routes/crate/settings/new-trusted-publisher-test.js @@ -0,0 +1,193 @@ +import { click, currentURL, fillIn, waitFor } from '@ember/test-helpers'; +import { module, test } from 'qunit'; + +import { defer } from 'rsvp'; + +import percySnapshot from '@percy/ember'; +import { http, HttpResponse } from 'msw'; + +import { setupApplicationTest } from 'crates-io/tests/helpers'; + +import { visit } from '../../../helpers/visit-ignoring-abort'; + +module('Route | crate.settings.new-trusted-publisher', hooks => { + setupApplicationTest(hooks); + + function prepare(context) { + let user = context.db.user.create(); + + let crate = context.db.crate.create({ name: 'foo' }); + context.db.version.create({ crate }); + context.db.crateOwnership.create({ crate, user }); + + context.authenticateAs(user); + + return { crate, user }; + } + + test('unauthenticated', async function (assert) { + let { crate } = prepare(this); + + this.db.mswSession.deleteMany({}); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + assert.dom('[data-test-title]').hasText('This page requires authentication'); + assert.dom('[data-test-login]').exists(); + }); + + test('not an owner', async function (assert) { + let { crate } = prepare(this); + + this.db.crateOwnership.deleteMany({}); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + assert.dom('[data-test-title]').hasText('This page is only accessible by crate owners'); + assert.dom('[data-test-go-back]').exists(); + }); + + test('happy path', async function (assert) { + let { crate } = prepare(this); + + this.db.trustpubGithubConfig.create({ + crate, + repository_owner: 'johndoe', + repository_name: 'crates.io', + workflow_filename: 'release.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`); + + await percySnapshot(assert); + + // Check that the form is displayed correctly + assert.dom('[data-test-publisher]').exists(); + assert.dom('[data-test-repository-owner]').exists(); + assert.dom('[data-test-repository-name]').exists(); + assert.dom('[data-test-workflow-filename]').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-repository-owner]', 'rust-lang'); + await fillIn('[data-test-repository-name]', 'crates.io'); + await fillIn('[data-test-workflow-filename]', 'ci.yml'); + await fillIn('[data-test-environment]', 'release'); + + // 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.trustpubGithubConfig.findFirst({ + where: { + repository_owner: { equals: 'rust-lang' }, + repository_name: { equals: 'crates.io' }, + workflow_filename: { equals: 'ci.yml' }, + environment: { equals: 'release' }, + }, + }); + 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-github-config]').exists({ count: 2 }); + assert.dom('[data-test-github-config="2"] td:nth-child(1)').hasText('GitHub'); + assert.dom('[data-test-github-config="2"] td:nth-child(2)').includesText('Repository: rust-lang/crates.io'); + assert.dom('[data-test-github-config="2"] td:nth-child(2)').includesText('Workflow: ci.yml'); + assert.dom('[data-test-github-config="2"] td:nth-child(2)').includesText('Environment: release'); + }); + + 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`); + + // Submit the form without filling in required fields + await click('[data-test-add]'); + + // Check that validation errors are displayed + assert.dom('[data-test-repository-owner-group] [data-test-error]').exists(); + assert.dom('[data-test-repository-name-group] [data-test-error]').exists(); + assert.dom('[data-test-workflow-filename-group] [data-test-error]').exists(); + + // Fill in the required fields + await fillIn('[data-test-repository-owner]', 'rust-lang'); + await fillIn('[data-test-repository-name]', 'crates.io'); + await fillIn('[data-test-workflow-filename]', '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/github_configs', () => deferred.promise)); + + await visit(`/crates/${crate.name}/settings/new-trusted-publisher`); + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings/new-trusted-publisher`); + + // Fill in the form + await fillIn('[data-test-repository-owner]', 'rust-lang'); + await fillIn('[data-test-repository-name]', 'crates.io'); + await fillIn('[data-test-workflow-filename]', '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-repository-owner]').isDisabled(); + assert.dom('[data-test-repository-name]').isDisabled(); + assert.dom('[data-test-workflow-filename]').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-repository-owner]').isEnabled(); + assert.dom('[data-test-repository-name]').isEnabled(); + assert.dom('[data-test-workflow-filename]').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-github-config]').exists({ count: 0 }); + }); + + test('cancel button', 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`); + + // Click the cancel button + await click('[data-test-cancel]'); + + // Check that we're redirected back to the crate settings page + assert.strictEqual(currentURL(), `/crates/${crate.name}/settings`); + }); +});