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}}
+
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
+
+
\ 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`);
+ });
+});