From 9565dc756bab52cc61b66c0869225f5385dfc787 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Thu, 19 Jun 2025 12:28:18 +0200 Subject: [PATCH 1/6] trustpub: Add `trustpub-github-config` Ember Data model, adapter and serializer --- app/adapters/trustpub-github-config.js | 7 + app/models/trustpub-github-config.js | 10 + app/serializers/trustpub-github-config.js | 11 + tests/models/trustpub-github-config-test.js | 230 ++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 app/adapters/trustpub-github-config.js create mode 100644 app/models/trustpub-github-config.js create mode 100644 app/serializers/trustpub-github-config.js create mode 100644 tests/models/trustpub-github-config-test.js 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/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/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/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; + }); + }); + }); +}); From 6dae308572b9bf8a4e40c9d42b637c8439e6d3c4 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 20 Jun 2025 09:34:58 +0200 Subject: [PATCH 2/6] crate.settings: Use `beforeModel()` hook for owner check --- app/routes/crate/settings.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/routes/crate/settings.js b/app/routes/crate/settings.js index e53e3480914..1dc98b86b2b 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', { From 018a8093a4ba395ff5c8c045eb4c16882b6942a5 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 20 Jun 2025 09:48:05 +0200 Subject: [PATCH 3/6] crate.settings: Add "Trusted Publishing" section This section lists the configured Trusted Publishing configurations for this crate. Until we publicly ship this feature the section is hidden, unless the crate already has corresponding configurations configured. --- app/routes/crate/settings.js | 14 ++++++-- app/styles/crate/settings.module.css | 20 +++++++++++ app/templates/crate/settings.hbs | 32 +++++++++++++++++ e2e/routes/crate/settings.spec.ts | 49 ++++++++++++++++++++++++++ tests/routes/crate/settings-test.js | 51 ++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 2 deletions(-) diff --git a/app/routes/crate/settings.js b/app/routes/crate/settings.js index 1dc98b86b2b..bc4e056e04b 100644 --- a/app/routes/crate/settings.js +++ b/app/routes/crate/settings.js @@ -5,6 +5,7 @@ import AuthenticatedRoute from '../-authenticated-route'; export default class SettingsRoute extends AuthenticatedRoute { @service router; @service session; + @service store; async beforeModel(transition) { await super.beforeModel(...arguments); @@ -20,9 +21,18 @@ export default class SettingsRoute extends AuthenticatedRoute { } } - setupController(controller) { - super.setupController(...arguments); + 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/styles/crate/settings.module.css b/app/styles/crate/settings.module.css index 699bbff540d..ca3a7169772 100644 --- a/app/styles/crate/settings.module.css +++ b/app/styles/crate/settings.module.css @@ -43,6 +43,26 @@ } } +.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; + } +} + .email-column { width: 25%; color: var(--main-color-light); diff --git a/app/templates/crate/settings.hbs b/app/templates/crate/settings.hbs index dd82a412946..1661c645395 100644 --- a/app/templates/crate/settings.hbs +++ b/app/templates/crate/settings.hbs @@ -54,6 +54,38 @@ {{/each}} +{{! The "Trusted Publishing" section is hidden for now until we make this feature publicly available. }} +{{#if this.githubConfigs}} +

Trusted Publishing

+ + + + + + + + + + {{#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/e2e/routes/crate/settings.spec.ts b/e2e/routes/crate/settings.spec.ts index 1710e1310c8..496751ae3b4 100644 --- a/e2e/routes/crate/settings.spec.ts +++ b/e2e/routes/crate/settings.spec.ts @@ -43,10 +43,59 @@ 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-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="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-no-config]')).not.toBeVisible(); + }); + }); }); diff --git a/tests/routes/crate/settings-test.js b/tests/routes/crate/settings-test.js index 77e6e7d52df..201689d8f56 100644 --- a/tests/routes/crate/settings-test.js +++ b/tests/routes/crate/settings-test.js @@ -1,6 +1,8 @@ import { currentURL } from '@ember/test-helpers'; import { module, test } from 'qunit'; +import percySnapshot from '@percy/ember'; + import { setupApplicationTest } from 'crates-io/tests/helpers'; import { visit } from '../../helpers/visit-ignoring-abort'; @@ -46,10 +48,59 @@ 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="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-no-config]').doesNotExist(); + }); + }); }); From 6de6225da3015c5e46af0665effc86361ed6fa8c Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 20 Jun 2025 10:25:55 +0200 Subject: [PATCH 4/6] crate.settings: Add "Remove" button to Trusted Publishing configs --- app/controllers/crate/settings.js | 17 +++++++++++ app/styles/crate/settings.module.css | 4 +++ app/templates/crate/settings.hbs | 6 +++- e2e/routes/crate/settings.spec.ts | 45 ++++++++++++++++++++++++++++ tests/routes/crate/settings-test.js | 44 ++++++++++++++++++++++++++- 5 files changed, 114 insertions(+), 2 deletions(-) diff --git a/app/controllers/crate/settings.js b/app/controllers/crate/settings.js index 1fd26ab7200..6d30528bdd4 100644 --- a/app/controllers/crate/settings.js +++ b/app/controllers/crate/settings.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/styles/crate/settings.module.css b/app/styles/crate/settings.module.css index ca3a7169772..02bbda0938c 100644 --- a/app/styles/crate/settings.module.css +++ b/app/styles/crate/settings.module.css @@ -61,6 +61,10 @@ font-size: 0.85em; line-height: 1.5; } + + .actions { + text-align: right; + } } .email-column { diff --git a/app/templates/crate/settings.hbs b/app/templates/crate/settings.hbs index 1661c645395..44c12db96a2 100644 --- a/app/templates/crate/settings.hbs +++ b/app/templates/crate/settings.hbs @@ -63,6 +63,7 @@ Publisher Details + @@ -76,10 +77,13 @@ Environment: {{config.environment}} {{/if}} + + + {{else}} - No trusted publishers configured for this crate. + No trusted publishers configured for this crate. {{/each}} diff --git a/e2e/routes/crate/settings.spec.ts b/e2e/routes/crate/settings.spec.ts index 496751ae3b4..4a084e44d14 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) { @@ -90,12 +92,55 @@ test.describe('Route | crate.settings', { tag: '@routes' }, () => { 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/tests/routes/crate/settings-test.js b/tests/routes/crate/settings-test.js index 201689d8f56..bf428e2006f 100644 --- a/tests/routes/crate/settings-test.js +++ b/tests/routes/crate/settings-test.js @@ -1,7 +1,8 @@ -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'; @@ -96,11 +97,52 @@ module('Route | crate.settings', hooks => { 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'); }); }); }); From e876f22cf6ce8c56fd1a86532b32a0b6a941b46b Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 20 Jun 2025 13:33:11 +0200 Subject: [PATCH 5/6] crate.settings: Extract `crate.settings.index` route --- .../crate/{settings.js => settings/index.js} | 0 app/router.js | 2 +- app/routes/crate/settings.js | 16 -------------- app/routes/crate/settings/index.js | 21 +++++++++++++++++++ .../index.module.css} | 0 .../{settings.hbs => settings/index.hbs} | 0 6 files changed, 22 insertions(+), 17 deletions(-) rename app/controllers/crate/{settings.js => settings/index.js} (100%) create mode 100644 app/routes/crate/settings/index.js rename app/styles/crate/{settings.module.css => settings/index.module.css} (100%) rename app/templates/crate/{settings.hbs => settings/index.hbs} (100%) diff --git a/app/controllers/crate/settings.js b/app/controllers/crate/settings/index.js similarity index 100% rename from app/controllers/crate/settings.js rename to app/controllers/crate/settings/index.js diff --git a/app/router.js b/app/router.js index f6bc3248d15..997009a81b6 100644 --- a/app/router.js +++ b/app/router.js @@ -19,7 +19,7 @@ Router.map(function () { this.route('reverse-dependencies', { path: 'reverse_dependencies' }); this.route('owners'); - this.route('settings'); + this.route('settings', function () {}); this.route('delete'); // Well-known routes diff --git a/app/routes/crate/settings.js b/app/routes/crate/settings.js index bc4e056e04b..73bd61c6275 100644 --- a/app/routes/crate/settings.js +++ b/app/routes/crate/settings.js @@ -5,7 +5,6 @@ import AuthenticatedRoute from '../-authenticated-route'; export default class SettingsRoute extends AuthenticatedRoute { @service router; @service session; - @service store; async beforeModel(transition) { await super.beforeModel(...arguments); @@ -20,19 +19,4 @@ export default class SettingsRoute extends AuthenticatedRoute { }); } } - - 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/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/styles/crate/settings.module.css b/app/styles/crate/settings/index.module.css similarity index 100% rename from app/styles/crate/settings.module.css rename to app/styles/crate/settings/index.module.css diff --git a/app/templates/crate/settings.hbs b/app/templates/crate/settings/index.hbs similarity index 100% rename from app/templates/crate/settings.hbs rename to app/templates/crate/settings/index.hbs From b00a70f072b13e5695cfd89cfddf59800cd31a40 Mon Sep 17 00:00:00 2001 From: Tobias Bieniek Date: Fri, 20 Jun 2025 13:33:51 +0200 Subject: [PATCH 6/6] crate.settings: Add "Add a new Trusted Publisher" page --- .../crate/settings/new-trusted-publisher.js | 85 ++++++++ app/router.js | 4 +- .../crate/settings/new-trusted-publisher.js | 8 + app/styles/crate/settings/index.module.css | 2 +- .../settings/new-trusted-publisher.module.css | 42 ++++ app/templates/crate/settings/index.hbs | 7 +- .../crate/settings/new-trusted-publisher.hbs | 160 +++++++++++++++ e2e/routes/crate/settings.spec.ts | 1 + .../settings/new-trusted-publisher.spec.ts | 185 +++++++++++++++++ .../settings/new-trusted-publisher-test.js | 193 ++++++++++++++++++ 10 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 app/controllers/crate/settings/new-trusted-publisher.js create mode 100644 app/routes/crate/settings/new-trusted-publisher.js create mode 100644 app/styles/crate/settings/new-trusted-publisher.module.css create mode 100644 app/templates/crate/settings/new-trusted-publisher.hbs create mode 100644 e2e/routes/crate/settings/new-trusted-publisher.spec.ts create mode 100644 tests/routes/crate/settings/new-trusted-publisher-test.js 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/router.js b/app/router.js index 997009a81b6..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', function () {}); + this.route('settings', function () { + this.route('new-trusted-publisher'); + }); this.route('delete'); // Well-known routes 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/styles/crate/settings/index.module.css b/app/styles/crate/settings/index.module.css index 02bbda0938c..6c8ec0daeac 100644 --- a/app/styles/crate/settings/index.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; 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/index.hbs b/app/templates/crate/settings/index.hbs index 44c12db96a2..f4dea175696 100644 --- a/app/templates/crate/settings/index.hbs +++ b/app/templates/crate/settings/index.hbs @@ -56,7 +56,12 @@ {{! The "Trusted Publishing" section is hidden for now until we make this feature publicly available. }} {{#if this.githubConfigs}} -

Trusted Publishing

+
+

Trusted Publishing

+ + Add + +
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 4a084e44d14..93329f6f72f 100644 --- a/e2e/routes/crate/settings.spec.ts +++ b/e2e/routes/crate/settings.spec.ts @@ -86,6 +86,7 @@ test.describe('Route | crate.settings', { tag: '@routes' }, () => { 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)'); 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/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`); + }); +});