diff --git a/app/components/owners-list.hbs b/app/components/owners-list.hbs index a4eac17b56b..c70170064f9 100644 --- a/app/components/owners-list.hbs +++ b/app/components/owners-list.hbs @@ -7,12 +7,12 @@
  • {{/each}} diff --git a/app/components/pending-owner-invite-row.hbs b/app/components/pending-owner-invite-row.hbs index bad2d9e790b..01992cf1cf7 100644 --- a/app/components/pending-owner-invite-row.hbs +++ b/app/components/pending-owner-invite-row.hbs @@ -19,8 +19,8 @@
    Invited by: - - {{@invite.inviter.login}} + + {{@invite.inviter.username}}
    diff --git a/app/components/user-avatar.js b/app/components/user-avatar.js index 37012f4738d..c65ba3daf0c 100644 --- a/app/components/user-avatar.js +++ b/app/components/user-avatar.js @@ -13,8 +13,8 @@ export default class UserAvatar extends Component { get alt() { return this.args.user.name === null - ? `(${this.args.user.login})` - : `${this.args.user.name} (${this.args.user.login})`; + ? `(${this.args.user.username})` + : `${this.args.user.name} (${this.args.user.username})`; } get title() { diff --git a/app/components/user-link.hbs b/app/components/user-link.hbs index 13f128379cc..58865fd2dd1 100644 --- a/app/components/user-link.hbs +++ b/app/components/user-link.hbs @@ -1 +1 @@ -{{yield}} \ No newline at end of file +{{yield}} \ No newline at end of file diff --git a/app/components/version-list/row.hbs b/app/components/version-list/row.hbs index e6630036423..c1eaead5a18 100644 --- a/app/components/version-list/row.hbs +++ b/app/components/version-list/row.hbs @@ -40,9 +40,9 @@ {{#if @version.published_by}} by - + - {{or @version.published_by.name @version.published_by.login}} + {{or @version.published_by.name @version.published_by.username}} {{/if}} diff --git a/app/controllers/crate/settings.js b/app/controllers/crate/settings.js index 72d38bdce9a..e55bb789cdd 100644 --- a/app/controllers/crate/settings.js +++ b/app/controllers/crate/settings.js @@ -32,19 +32,19 @@ export default class CrateSettingsController extends Controller { removeOwnerTask = task(async owner => { try { - await this.crate.removeOwner(owner.get('login')); + await this.crate.removeOwner(owner.get('username')); if (owner.kind === 'team') { this.notifications.success(`Team ${owner.get('display_name')} removed as crate owner`); let owner_team = await this.crate.owner_team; removeOwner(owner_team, owner); } else { - this.notifications.success(`User ${owner.get('login')} removed as crate owner`); + this.notifications.success(`User ${owner.get('username')} removed as crate owner`); let owner_user = await this.crate.owner_user; removeOwner(owner_user, owner); } } catch (error) { - let subject = owner.kind === 'team' ? `team ${owner.get('display_name')}` : `user ${owner.get('login')}`; + let subject = owner.kind === 'team' ? `team ${owner.get('display_name')}` : `user ${owner.get('username')}`; let message = `Failed to remove the ${subject} as crate owner`; let detail = error.errors?.[0]?.detail; diff --git a/app/models/team.js b/app/models/team.js index 2d69e1a108e..07104f40661 100644 --- a/app/models/team.js +++ b/app/models/team.js @@ -4,15 +4,16 @@ export default class Team extends Model { @attr email; @attr name; @attr login; + @attr username; @attr api_token; @attr avatar; @attr url; @attr kind; get org_name() { - let login = this.login; - let login_split = login.split(':'); - return login_split[1]; + let username = this.username; + let username_split = username.split(':'); + return username_split[1]; } get display_name() { diff --git a/app/models/user.js b/app/models/user.js index 9e96f47adcf..22dbff658e0 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -13,6 +13,7 @@ export default class User extends Model { @attr name; @attr is_admin; @attr login; + @attr username; @attr avatar; @attr url; @attr kind; diff --git a/app/services/session.js b/app/services/session.js index baf5a0af7bb..37f66289039 100644 --- a/app/services/session.js +++ b/app/services/session.js @@ -194,7 +194,7 @@ export default class SessionService extends Service { } let currentUser = this.store.push(this.store.normalize('user', response.user)); - debug(`User found: ${currentUser.login}`); + debug(`User found: ${currentUser.username}`); let ownedCrates = response.owned_crates.map(c => this.store.push(this.store.normalize('owned-crate', c))); let { id } = currentUser; diff --git a/app/templates/crate/settings.hbs b/app/templates/crate/settings.hbs index 5487e88876c..4d6be016528 100644 --- a/app/templates/crate/settings.hbs +++ b/app/templates/crate/settings.hbs @@ -18,11 +18,11 @@
    {{#each this.crate.owner_team as |team|}} -
    - +
    + - + {{team.display_name}}
    @@ -32,15 +32,15 @@
    {{/each}} {{#each this.crate.owner_user as |user|}} -
    - +
    + - + {{#if user.name}} {{user.name}} {{else}} - {{user.login}} + {{user.username}} {{/if}}
    diff --git a/app/templates/settings/profile.hbs b/app/templates/settings/profile.hbs index c4ab8eb42e1..16f3ca2e1b3 100644 --- a/app/templates/settings/profile.hbs +++ b/app/templates/settings/profile.hbs @@ -13,7 +13,7 @@
    Name
    {{ this.model.user.name }}
    GitHub Account
    -
    {{ this.model.user.login }}
    +
    {{ this.model.user.username }}
    diff --git a/app/templates/user.hbs b/app/templates/user.hbs index 10b8c202970..c70ae7d95aa 100644 --- a/app/templates/user.hbs +++ b/app/templates/user.hbs @@ -1,7 +1,7 @@

    - {{ this.model.user.login }} + {{ this.model.user.username }}

    {{svg-jar "github" alt="GitHub profile"}} diff --git a/crates/crates_io_database/src/models/mod.rs b/crates/crates_io_database/src/models/mod.rs index 2b6f2698adc..7b75204056a 100644 --- a/crates/crates_io_database/src/models/mod.rs +++ b/crates/crates_io_database/src/models/mod.rs @@ -14,7 +14,7 @@ pub use self::krate::{Crate, CrateName, NewCrate, RecentCrateDownloads}; pub use self::owner::{CrateOwner, Owner, OwnerKind}; pub use self::team::{NewTeam, Team}; pub use self::token::ApiToken; -pub use self::user::{NewUser, User}; +pub use self::user::{AccountProvider, LinkedAccount, NewLinkedAccount, NewUser, User}; pub use self::version::{NewVersion, TopVersions, Version}; pub mod helpers; diff --git a/crates/crates_io_database/src/models/user.rs b/crates/crates_io_database/src/models/user.rs index 9d060361ca5..8b85a1dad32 100644 --- a/crates/crates_io_database/src/models/user.rs +++ b/crates/crates_io_database/src/models/user.rs @@ -8,8 +8,10 @@ use diesel_async::{AsyncPgConnection, RunQueryDsl}; use secrecy::SecretString; use crate::models::{Crate, CrateOwner, Email, Owner, OwnerKind}; -use crate::schema::{crate_owners, emails, users}; -use crates_io_diesel_helpers::lower; +use crate::schema::{crate_owners, emails, linked_accounts, users}; +use crates_io_diesel_helpers::{lower, pg_enum}; + +use std::fmt::{Display, Formatter}; /// The model representing a row in the `users` database table. #[derive(Clone, Debug, Queryable, Identifiable, Selectable)] @@ -25,6 +27,7 @@ pub struct User { pub account_lock_until: Option>, pub is_admin: bool, pub publish_notifications: bool, + pub username: Option, } impl User { @@ -76,6 +79,17 @@ impl User { .await .optional() } + + /// Queries for the linked accounts belonging to a particular user + pub async fn linked_accounts( + &self, + conn: &mut AsyncPgConnection, + ) -> QueryResult> { + LinkedAccount::belonging_to(self) + .select(LinkedAccount::as_select()) + .load(conn) + .await + } } /// Represents a new user record insertable to the `users` table @@ -85,6 +99,7 @@ pub struct NewUser<'a> { pub gh_id: i32, pub gh_login: &'a str, pub name: Option<&'a str>, + pub username: Option<&'a str>, pub gh_avatar: Option<&'a str>, pub gh_access_token: &'a str, } @@ -114,6 +129,7 @@ impl NewUser<'_> { .do_update() .set(( users::gh_login.eq(excluded(users::gh_login)), + users::username.eq(excluded(users::username)), users::name.eq(excluded(users::name)), users::gh_avatar.eq(excluded(users::gh_avatar)), users::gh_access_token.eq(excluded(users::gh_access_token)), @@ -122,3 +138,94 @@ impl NewUser<'_> { .await } } + +// Supported OAuth providers. Currently only GitHub. +pg_enum! { + pub enum AccountProvider { + Github = 0, + } +} + +impl Display for AccountProvider { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Github => write!(f, "GitHub"), + } + } +} + +impl AccountProvider { + pub fn url(&self, login: &str) -> String { + match self { + Self::Github => format!("https://github.com/{login}"), + } + } +} + +/// Represents an OAuth account record linked to a user record. +#[derive(Associations, Identifiable, Selectable, Queryable, Debug, Clone)] +#[diesel( + table_name = linked_accounts, + check_for_backend(diesel::pg::Pg), + primary_key(provider, account_id), + belongs_to(User), +)] +pub struct LinkedAccount { + pub user_id: i32, + pub provider: AccountProvider, + pub account_id: i32, // corresponds to user.gh_id + #[diesel(deserialize_as = String)] + pub access_token: SecretString, // corresponds to user.gh_access_token + pub login: String, // corresponds to user.gh_login + pub avatar: Option, // corresponds to user.gh_avatar +} + +/// Represents a new linked account record insertable to the `linked_accounts` table +#[derive(Insertable, Debug, Builder)] +#[diesel( + table_name = linked_accounts, + check_for_backend(diesel::pg::Pg), + primary_key(provider, account_id), + belongs_to(User), +)] +pub struct NewLinkedAccount<'a> { + pub user_id: i32, + pub provider: AccountProvider, + pub account_id: i32, // corresponds to user.gh_id + pub access_token: &'a str, // corresponds to user.gh_access_token + pub login: &'a str, // corresponds to user.gh_login + pub avatar: Option<&'a str>, // corresponds to user.gh_avatar +} + +impl NewLinkedAccount<'_> { + /// Inserts the linked account into the database, or updates an existing one. + /// + /// This is to be used for logging in when there is no currently logged-in user, as opposed to + /// adding another linked account to a currently-logged-in user. The logic for adding another + /// linked account (when that ability gets added) will need to ensure that a particular + /// (provider, account_id) combo (ex: GitHub account with GitHub ID 1234) is only associated + /// with one crates.io account, so that we know what crates.io account to log in when we get an + /// oAuth request from GitHub ID 1234. In other words, we should NOT be updating the user_id on + /// an existing (provider, account_id) row when starting from a currently-logged-in crates.io \ + /// user because that would mean that oAuth account has already been associated with a + /// different crates.io account. + /// + /// This function should be called if there is no current user and should update, say, the + /// access token, login, or avatar if those have changed. + pub async fn insert_or_update( + &self, + conn: &mut AsyncPgConnection, + ) -> QueryResult { + diesel::insert_into(linked_accounts::table) + .values(self) + .on_conflict((linked_accounts::provider, linked_accounts::account_id)) + .do_update() + .set(( + linked_accounts::access_token.eq(excluded(linked_accounts::access_token)), + linked_accounts::login.eq(excluded(linked_accounts::login)), + linked_accounts::avatar.eq(excluded(linked_accounts::avatar)), + )) + .get_result(conn) + .await + } +} diff --git a/crates/crates_io_database/src/schema.rs b/crates/crates_io_database/src/schema.rs index 9b05b2434e1..ccf182b95e6 100644 --- a/crates/crates_io_database/src/schema.rs +++ b/crates/crates_io_database/src/schema.rs @@ -593,6 +593,50 @@ diesel::table! { } } +diesel::table! { + /// Representation of the `linked_accounts` table. + /// + /// (Automatically generated by Diesel.) + linked_accounts (provider, account_id) { + /// The `user_id` column of the `linked_accounts` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + user_id -> Int4, + /// The `provider` column of the `linked_accounts` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + provider -> Int4, + /// The `account_id` column of the `linked_accounts` table. + /// + /// Its SQL type is `Int4`. + /// + /// (Automatically generated by Diesel.) + account_id -> Int4, + /// The `access_token` column of the `linked_accounts` table. + /// + /// Its SQL type is `Varchar`. + /// + /// (Automatically generated by Diesel.) + access_token -> Varchar, + /// The `login` column of the `linked_accounts` table. + /// + /// Its SQL type is `Varchar`. + /// + /// (Automatically generated by Diesel.) + login -> Varchar, + /// The `avatar` column of the `linked_accounts` table. + /// + /// Its SQL type is `Nullable`. + /// + /// (Automatically generated by Diesel.) + avatar -> Nullable, + } +} + diesel::table! { /// Representation of the `metadata` table. /// @@ -826,6 +870,12 @@ diesel::table! { is_admin -> Bool, /// Whether or not the user wants to receive notifications when a package they own is published publish_notifications -> Bool, + /// The `username` column of the `users` table. + /// + /// Its SQL type is `Nullable`. + /// + /// (Automatically generated by Diesel.) + username -> Nullable, } } @@ -1066,6 +1116,7 @@ diesel::joinable!(dependencies -> versions (version_id)); diesel::joinable!(emails -> users (user_id)); diesel::joinable!(follows -> crates (crate_id)); diesel::joinable!(follows -> users (user_id)); +diesel::joinable!(linked_accounts -> users (user_id)); diesel::joinable!(publish_limit_buckets -> users (user_id)); diesel::joinable!(publish_rate_overrides -> users (user_id)); diesel::joinable!(readme_renderings -> versions (version_id)); @@ -1094,6 +1145,7 @@ diesel::allow_tables_to_appear_in_same_query!( emails, follows, keywords, + linked_accounts, metadata, processed_log_files, publish_limit_buckets, diff --git a/crates/crates_io_database_dump/src/dump-db.toml b/crates/crates_io_database_dump/src/dump-db.toml index e42d45c59dd..9213d7ad5c1 100644 --- a/crates/crates_io_database_dump/src/dump-db.toml +++ b/crates/crates_io_database_dump/src/dump-db.toml @@ -154,6 +154,16 @@ keyword = "public" crates_cnt = "public" created_at = "public" +[linked_accounts.columns] +user_id = "public" +provider = "public" +account_id = "public" +access_token = "private" +login = "public" +avatar = "public" +[linked_accounts.column_defaults] +access_token = "''" + [metadata.columns] total_downloads = "public" @@ -206,6 +216,7 @@ account_lock_reason = "private" account_lock_until = "private" is_admin = "private" publish_notifications = "private" +username = "public" [users.column_defaults] gh_access_token = "''" diff --git a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap index 1d801f192d7..41039804ec0 100644 --- a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap +++ b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@export.sql.snap @@ -1,6 +1,7 @@ --- source: crates/crates_io_database_dump/src/lib.rs expression: content +snapshot_kind: text --- BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY; @@ -8,10 +9,11 @@ BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY; \copy "crate_downloads" ("crate_id", "downloads") TO 'data/crate_downloads.csv' WITH CSV HEADER \copy "crates" ("created_at", "description", "documentation", "homepage", "id", "max_features", "max_upload_size", "name", "readme", "repository", "updated_at") TO 'data/crates.csv' WITH CSV HEADER \copy "keywords" ("crates_cnt", "created_at", "id", "keyword") TO 'data/keywords.csv' WITH CSV HEADER + \copy "linked_accounts" ("account_id", "avatar", "login", "provider", "user_id") TO 'data/linked_accounts.csv' WITH CSV HEADER \copy "metadata" ("total_downloads") TO 'data/metadata.csv' WITH CSV HEADER \copy "reserved_crate_names" ("name") TO 'data/reserved_crate_names.csv' WITH CSV HEADER \copy "teams" ("avatar", "github_id", "id", "login", "name", "org_id") TO 'data/teams.csv' WITH CSV HEADER - \copy (SELECT "gh_avatar", "gh_id", "gh_login", "id", "name" FROM "users" WHERE id in ( SELECT owner_id AS user_id FROM crate_owners WHERE NOT deleted AND owner_kind = 0 UNION SELECT published_by as user_id FROM versions )) TO 'data/users.csv' WITH CSV HEADER + \copy (SELECT "gh_avatar", "gh_id", "gh_login", "id", "name", "username" FROM "users" WHERE id in ( SELECT owner_id AS user_id FROM crate_owners WHERE NOT deleted AND owner_kind = 0 UNION SELECT published_by as user_id FROM versions )) TO 'data/users.csv' WITH CSV HEADER \copy "crates_categories" ("category_id", "crate_id") TO 'data/crates_categories.csv' WITH CSV HEADER \copy "crates_keywords" ("crate_id", "keyword_id") TO 'data/crates_keywords.csv' WITH CSV HEADER diff --git a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap index f5315ad6929..20a067d5905 100644 --- a/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap +++ b/crates/crates_io_database_dump/src/snapshots/crates_io_database_dump__tests__sql_scripts@import.sql.snap @@ -1,6 +1,7 @@ --- source: crates/crates_io_database_dump/src/lib.rs expression: content +snapshot_kind: text --- BEGIN; -- Disable triggers on each table. @@ -9,6 +10,7 @@ BEGIN; ALTER TABLE "crate_downloads" DISABLE TRIGGER ALL; ALTER TABLE "crates" DISABLE TRIGGER ALL; ALTER TABLE "keywords" DISABLE TRIGGER ALL; + ALTER TABLE "linked_accounts" DISABLE TRIGGER ALL; ALTER TABLE "metadata" DISABLE TRIGGER ALL; ALTER TABLE "reserved_crate_names" DISABLE TRIGGER ALL; ALTER TABLE "teams" DISABLE TRIGGER ALL; @@ -23,6 +25,7 @@ BEGIN; -- Set defaults for non-nullable columns not included in the dump. + ALTER TABLE "linked_accounts" ALTER COLUMN "access_token" SET DEFAULT ''; ALTER TABLE "users" ALTER COLUMN "gh_access_token" SET DEFAULT ''; -- Truncate all tables. @@ -31,6 +34,7 @@ BEGIN; TRUNCATE "crate_downloads" RESTART IDENTITY CASCADE; TRUNCATE "crates" RESTART IDENTITY CASCADE; TRUNCATE "keywords" RESTART IDENTITY CASCADE; + TRUNCATE "linked_accounts" RESTART IDENTITY CASCADE; TRUNCATE "metadata" RESTART IDENTITY CASCADE; TRUNCATE "reserved_crate_names" RESTART IDENTITY CASCADE; TRUNCATE "teams" RESTART IDENTITY CASCADE; @@ -52,10 +56,11 @@ BEGIN; \copy "crate_downloads" ("crate_id", "downloads") FROM 'data/crate_downloads.csv' WITH CSV HEADER \copy "crates" ("created_at", "description", "documentation", "homepage", "id", "max_features", "max_upload_size", "name", "readme", "repository", "updated_at") FROM 'data/crates.csv' WITH CSV HEADER \copy "keywords" ("crates_cnt", "created_at", "id", "keyword") FROM 'data/keywords.csv' WITH CSV HEADER + \copy "linked_accounts" ("account_id", "avatar", "login", "provider", "user_id") FROM 'data/linked_accounts.csv' WITH CSV HEADER \copy "metadata" ("total_downloads") FROM 'data/metadata.csv' WITH CSV HEADER \copy "reserved_crate_names" ("name") FROM 'data/reserved_crate_names.csv' WITH CSV HEADER \copy "teams" ("avatar", "github_id", "id", "login", "name", "org_id") FROM 'data/teams.csv' WITH CSV HEADER - \copy "users" ("gh_avatar", "gh_id", "gh_login", "id", "name") FROM 'data/users.csv' WITH CSV HEADER + \copy "users" ("gh_avatar", "gh_id", "gh_login", "id", "name", "username") FROM 'data/users.csv' WITH CSV HEADER \copy "crates_categories" ("category_id", "crate_id") FROM 'data/crates_categories.csv' WITH CSV HEADER \copy "crates_keywords" ("crate_id", "keyword_id") FROM 'data/crates_keywords.csv' WITH CSV HEADER \copy "crate_owners" ("crate_id", "created_at", "created_by", "owner_id", "owner_kind") FROM 'data/crate_owners.csv' WITH CSV HEADER @@ -66,6 +71,7 @@ BEGIN; -- Drop the defaults again. + ALTER TABLE "linked_accounts" ALTER COLUMN "access_token" DROP DEFAULT; ALTER TABLE "users" ALTER COLUMN "gh_access_token" DROP DEFAULT; -- Reenable triggers on each table. @@ -74,6 +80,7 @@ BEGIN; ALTER TABLE "crate_downloads" ENABLE TRIGGER ALL; ALTER TABLE "crates" ENABLE TRIGGER ALL; ALTER TABLE "keywords" ENABLE TRIGGER ALL; + ALTER TABLE "linked_accounts" ENABLE TRIGGER ALL; ALTER TABLE "metadata" ENABLE TRIGGER ALL; ALTER TABLE "reserved_crate_names" ENABLE TRIGGER ALL; ALTER TABLE "teams" ENABLE TRIGGER ALL; diff --git a/e2e/acceptance/api-token.spec.ts b/e2e/acceptance/api-token.spec.ts index c7a3f2827df..c89819e76c4 100644 --- a/e2e/acceptance/api-token.spec.ts +++ b/e2e/acceptance/api-token.spec.ts @@ -5,6 +5,7 @@ test.describe('Acceptance | api-tokens', { tag: '@acceptance' }, () => { test.beforeEach(async ({ msw }) => { let user = msw.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/e2e/acceptance/crate.spec.ts b/e2e/acceptance/crate.spec.ts index bf09fe3628b..d7a77404742 100644 --- a/e2e/acceptance/crate.spec.ts +++ b/e2e/acceptance/crate.spec.ts @@ -213,7 +213,7 @@ test.describe('Acceptance | crate page', { tag: '@acceptance' }, () => { test.skip('crates can be yanked by owner', async ({ page, msw }) => { loadFixtures(msw.db); - let user = msw.db.user.findFirst({ where: { login: { equals: 'thehydroimpulse' } } }); + let user = msw.db.user.findFirst({ where: { username: { equals: 'thehydroimpulse' } } }); await msw.authenticateAs(user); await page.goto('/crates/nanomsg/0.5.0'); @@ -241,7 +241,7 @@ test.describe('Acceptance | crate page', { tag: '@acceptance' }, () => { test('navigating to the owners page when not an owner', async ({ page, msw }) => { loadFixtures(msw.db); - let user = msw.db.user.findFirst({ where: { login: { equals: 'iain8' } } }); + let user = msw.db.user.findFirst({ where: { username: { equals: 'iain8' } } }); await msw.authenticateAs(user); await page.goto('/crates/nanomsg'); @@ -252,7 +252,7 @@ test.describe('Acceptance | crate page', { tag: '@acceptance' }, () => { test('navigating to the settings page', async ({ page, msw }) => { loadFixtures(msw.db); - let user = msw.db.user.findFirst({ where: { login: { equals: 'thehydroimpulse' } } }); + let user = msw.db.user.findFirst({ where: { username: { equals: 'thehydroimpulse' } } }); await msw.authenticateAs(user); await page.goto('/crates/nanomsg'); diff --git a/e2e/acceptance/dashboard.spec.ts b/e2e/acceptance/dashboard.spec.ts index 1baefd56928..1ce2bffe78f 100644 --- a/e2e/acceptance/dashboard.spec.ts +++ b/e2e/acceptance/dashboard.spec.ts @@ -12,6 +12,7 @@ test.describe('Acceptance | Dashboard', { tag: '@acceptance' }, () => { test('shows the dashboard when logged in', async ({ page, msw, percy }) => { let user = msw.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/e2e/acceptance/login.spec.ts b/e2e/acceptance/login.spec.ts index aaeb9704747..ab9a9bc2b41 100644 --- a/e2e/acceptance/login.spec.ts +++ b/e2e/acceptance/login.spec.ts @@ -28,6 +28,7 @@ test.describe('Acceptance | Login', { tag: '@acceptance' }, () => { user: { id: 42, login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.name', avatar: 'https://avatars2.githubusercontent.com/u/12345?v=4', diff --git a/e2e/acceptance/settings/add-owner.spec.ts b/e2e/acceptance/settings/add-owner.spec.ts index 43f53cbdf0f..0368096bb10 100644 --- a/e2e/acceptance/settings/add-owner.spec.ts +++ b/e2e/acceptance/settings/add-owner.spec.ts @@ -29,7 +29,7 @@ test.describe('Acceptance | Settings | Add Owner', { tag: '@acceptance' }, () => await page.click('[data-test-save-button]'); await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( - 'Error sending invite: could not find user with login `spookyghostboo`', + 'Error sending invite: could not find user with username `spookyghostboo`', ); await expect(page.locator('[data-test-owners] [data-test-owner-team]')).toHaveCount(2); await expect(page.locator('[data-test-owners] [data-test-owner-user]')).toHaveCount(2); diff --git a/e2e/acceptance/settings/remove-owner.spec.ts b/e2e/acceptance/settings/remove-owner.spec.ts index f767ed711c4..093ad53b7fc 100644 --- a/e2e/acceptance/settings/remove-owner.spec.ts +++ b/e2e/acceptance/settings/remove-owner.spec.ts @@ -37,10 +37,10 @@ test.describe('Acceptance | Settings | Remove Owner', { tag: '@acceptance' }, () await msw.worker.use(http.delete('/api/v1/crates/nanomsg/owners', () => error)); await page.goto(`/crates/${crate.name}/settings`); - await page.click(`[data-test-owner-user="${user2.login}"] [data-test-remove-owner-button]`); + await page.click(`[data-test-owner-user="${user2.username}"] [data-test-remove-owner-button]`); await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( - `Failed to remove the user ${user2.login} as crate owner: nope`, + `Failed to remove the user ${user2.username} as crate owner: nope`, ); await expect(page.locator('[data-test-owner-user]')).toHaveCount(2); }); @@ -62,7 +62,7 @@ test.describe('Acceptance | Settings | Remove Owner', { tag: '@acceptance' }, () await msw.worker.use(http.delete('/api/v1/crates/nanomsg/owners', () => error)); await page.goto(`/crates/${crate.name}/settings`); - await page.click(`[data-test-owner-team="${team1.login}"] [data-test-remove-owner-button]`); + await page.click(`[data-test-owner-team="${team1.username}"] [data-test-remove-owner-button]`); await expect(page.locator('[data-test-notification-message="error"]')).toHaveText( `Failed to remove the team ${team1.org}/${team1.name} as crate owner: nope`, diff --git a/e2e/acceptance/sudo.spec.ts b/e2e/acceptance/sudo.spec.ts index 3970bbd60da..23a0439ff58 100644 --- a/e2e/acceptance/sudo.spec.ts +++ b/e2e/acceptance/sudo.spec.ts @@ -5,6 +5,7 @@ test.describe('Acceptance | sudo', { tag: '@acceptance' }, () => { async function prepare(msw, { isAdmin = false } = {}) { let user = msw.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/e2e/routes/settings/tokens/index.spec.ts b/e2e/routes/settings/tokens/index.spec.ts index 63661616294..1bb42528f50 100644 --- a/e2e/routes/settings/tokens/index.spec.ts +++ b/e2e/routes/settings/tokens/index.spec.ts @@ -4,6 +4,7 @@ test.describe('/settings/tokens', { tag: '@routes' }, () => { test('reloads all tokens from the server', async ({ page, msw }) => { let user = msw.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/e2e/routes/settings/tokens/new.spec.ts b/e2e/routes/settings/tokens/new.spec.ts index 87b1a8bfb8e..be46a7513cc 100644 --- a/e2e/routes/settings/tokens/new.spec.ts +++ b/e2e/routes/settings/tokens/new.spec.ts @@ -6,6 +6,7 @@ test.describe('/settings/tokens/new', { tag: '@routes' }, () => { async function prepare(msw) { let user = msw.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/migrations/2025-01-29-205705_linked_accounts_table/down.sql b/migrations/2025-01-29-205705_linked_accounts_table/down.sql new file mode 100644 index 00000000000..bef2a0aa5ea --- /dev/null +++ b/migrations/2025-01-29-205705_linked_accounts_table/down.sql @@ -0,0 +1 @@ +DROP TABLE linked_accounts; diff --git a/migrations/2025-01-29-205705_linked_accounts_table/up.sql b/migrations/2025-01-29-205705_linked_accounts_table/up.sql new file mode 100644 index 00000000000..aa2d79a9fcc --- /dev/null +++ b/migrations/2025-01-29-205705_linked_accounts_table/up.sql @@ -0,0 +1,9 @@ +CREATE TABLE linked_accounts ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + provider INTEGER NOT NULL, + account_id INTEGER NOT NULL, + access_token VARCHAR NOT NULL, + login VARCHAR NOT NULL, + avatar VARCHAR, + PRIMARY KEY (provider, account_id) +); diff --git a/migrations/2025-02-19-013433_add_username_to_user/down.sql b/migrations/2025-02-19-013433_add_username_to_user/down.sql new file mode 100644 index 00000000000..228952f6bf6 --- /dev/null +++ b/migrations/2025-02-19-013433_add_username_to_user/down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS lower_username; + +ALTER TABLE users +DROP COLUMN username; diff --git a/migrations/2025-02-19-013433_add_username_to_user/up.sql b/migrations/2025-02-19-013433_add_username_to_user/up.sql new file mode 100644 index 00000000000..661c0587e8d --- /dev/null +++ b/migrations/2025-02-19-013433_add_username_to_user/up.sql @@ -0,0 +1,6 @@ +ALTER TABLE users +-- The column needs to be nullable for this migration to be fast; can be changed to non-nullable +-- after backfill of all records. +ADD COLUMN username VARCHAR; + +CREATE INDEX lower_username ON users (lower(username)); diff --git a/packages/crates-io-msw/fixtures/teams.js b/packages/crates-io-msw/fixtures/teams.js index 0c753788449..dbd2ca25462 100644 --- a/packages/crates-io-msw/fixtures/teams.js +++ b/packages/crates-io-msw/fixtures/teams.js @@ -3,6 +3,7 @@ export default [ avatar: 'https://avatars.githubusercontent.com/u/565790?v=3', id: 1, login: 'github:org:thehydroimpulse', + username: 'github:org:thehydroimpulse', name: 'thehydroimpulseteam', url: 'https://github.com/org_test', }, @@ -10,6 +11,7 @@ export default [ avatar: 'https://avatars.githubusercontent.com/u/9447137?v=3', id: 303, login: 'github:org:blabaere', + username: 'github:org:blabaere', name: 'Team Benoît Labaere', url: 'https://github.com/blabaere', }, diff --git a/packages/crates-io-msw/fixtures/users.js b/packages/crates-io-msw/fixtures/users.js index 00243bdce19..d6628f4b192 100644 --- a/packages/crates-io-msw/fixtures/users.js +++ b/packages/crates-io-msw/fixtures/users.js @@ -4,6 +4,7 @@ export default [ email: null, id: 303, login: 'blabaere', + username: 'blabaere', name: 'Benoît Labaere', url: 'https://github.com/blabaere', }, @@ -12,6 +13,7 @@ export default [ email: 'dnfagnan@gmail.com', id: 2, login: 'thehydroimpulse', + username: 'thehydroimpulse', name: 'Daniel Fagnan', url: 'https://github.com/thehydroimpulse', }, @@ -20,6 +22,7 @@ export default [ email: 'iain@fastmail.com', id: 10_982, login: 'iain8', + username: 'iain8', name: 'Iain Buchanan', url: 'https://github.com/iain8', }, diff --git a/packages/crates-io-msw/handlers/crates/add-owners.js b/packages/crates-io-msw/handlers/crates/add-owners.js index 227d27ef6a8..77f9bcdda4d 100644 --- a/packages/crates-io-msw/handlers/crates/add-owners.js +++ b/packages/crates-io-msw/handlers/crates/add-owners.js @@ -20,25 +20,25 @@ export default http.put('/api/v1/crates/:name/owners', async ({ request, params let users = []; let teams = []; let msgs = []; - for (let login of body.owners) { - if (login.includes(':')) { - let team = db.team.findFirst({ where: { login: { equals: login } } }); + for (let username of body.owners) { + if (username.includes(':')) { + let team = db.team.findFirst({ where: { username: { equals: username } } }); if (!team) { - let errorMessage = `could not find team with login \`${login}\``; + let errorMessage = `could not find team with username \`${username}\``; return HttpResponse.json({ errors: [{ detail: errorMessage }] }, { status: 404 }); } teams.push(team); - msgs.push(`team ${login} has been added as an owner of crate ${crate.name}`); + msgs.push(`team ${username} has been added as an owner of crate ${crate.name}`); } else { - let user = db.user.findFirst({ where: { login: { equals: login } } }); + let user = db.user.findFirst({ where: { username: { equals: username } } }); if (!user) { - let errorMessage = `could not find user with login \`${login}\``; + let errorMessage = `could not find user with username \`${username}\``; return HttpResponse.json({ errors: [{ detail: errorMessage }] }, { status: 404 }); } users.push(user); - msgs.push(`user ${login} has been invited to be an owner of crate ${crate.name}`); + msgs.push(`user ${username} has been invited to be an owner of crate ${crate.name}`); } } diff --git a/packages/crates-io-msw/handlers/crates/add-owners.test.js b/packages/crates-io-msw/handlers/crates/add-owners.test.js index cdaae3e7ae6..e936b6402df 100644 --- a/packages/crates-io-msw/handlers/crates/add-owners.test.js +++ b/packages/crates-io-msw/handlers/crates/add-owners.test.js @@ -30,7 +30,7 @@ test('can add new owner', async function () { let user2 = db.user.create(); - let body = JSON.stringify({ owners: [user2.login] }); + let body = JSON.stringify({ owners: [user2.username] }); let response = await fetch('/api/v1/crates/foo/owners', { method: 'PUT', body }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { @@ -57,7 +57,7 @@ test('can add team owner', async function () { let team = db.team.create(); - let body = JSON.stringify({ owners: [team.login] }); + let body = JSON.stringify({ owners: [team.username] }); let response = await fetch('/api/v1/crates/foo/owners', { method: 'PUT', body }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { @@ -87,7 +87,7 @@ test('can add multiple owners', async function () { let user2 = db.user.create(); let user3 = db.user.create(); - let body = JSON.stringify({ owners: [user2.login, team.login, user3.login] }); + let body = JSON.stringify({ owners: [user2.username, team.username, user3.username] }); let response = await fetch('/api/v1/crates/foo/owners', { method: 'PUT', body }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { diff --git a/packages/crates-io-msw/handlers/crates/remove-owners.js b/packages/crates-io-msw/handlers/crates/remove-owners.js index a221967d4fb..2a9d8f9c7b2 100644 --- a/packages/crates-io-msw/handlers/crates/remove-owners.js +++ b/packages/crates-io-msw/handlers/crates/remove-owners.js @@ -19,7 +19,9 @@ export default http.delete('/api/v1/crates/:name/owners', async ({ request, para for (let owner of body.owners) { let ownership = db.crateOwnership.findFirst({ - where: owner.includes(':') ? { team: { login: { equals: owner } } } : { user: { login: { equals: owner } } }, + where: owner.includes(':') + ? { team: { username: { equals: owner } } } + : { user: { username: { equals: owner } } }, }); if (!ownership) return notFound(); db.crateOwnership.delete({ where: { id: { equals: ownership.id } } }); diff --git a/packages/crates-io-msw/handlers/crates/remove-owners.test.js b/packages/crates-io-msw/handlers/crates/remove-owners.test.js index d1c84998227..ac5811aef76 100644 --- a/packages/crates-io-msw/handlers/crates/remove-owners.test.js +++ b/packages/crates-io-msw/handlers/crates/remove-owners.test.js @@ -31,7 +31,7 @@ test('can remove a user owner', async function () { let user2 = db.user.create(); db.crateOwnership.create({ crate, user: user2 }); - let body = JSON.stringify({ owners: [user2.login] }); + let body = JSON.stringify({ owners: [user2.username] }); let response = await fetch('/api/v1/crates/foo/owners', { method: 'DELETE', body }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { ok: true, msg: 'owners successfully removed' }); @@ -54,7 +54,7 @@ test('can remove a team owner', async function () { let team = db.team.create(); db.crateOwnership.create({ crate, team }); - let body = JSON.stringify({ owners: [team.login] }); + let body = JSON.stringify({ owners: [team.username] }); let response = await fetch('/api/v1/crates/foo/owners', { method: 'DELETE', body }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { ok: true, msg: 'owners successfully removed' }); @@ -81,7 +81,7 @@ test('can remove multiple owners', async function () { let user2 = db.user.create(); db.crateOwnership.create({ crate, user: user2 }); - let body = JSON.stringify({ owners: [user2.login, team.login] }); + let body = JSON.stringify({ owners: [user2.username, team.username] }); let response = await fetch('/api/v1/crates/foo/owners', { method: 'DELETE', body }); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { ok: true, msg: 'owners successfully removed' }); diff --git a/packages/crates-io-msw/handlers/crates/team-owners.test.js b/packages/crates-io-msw/handlers/crates/team-owners.test.js index 25a9981aff5..ea8cb1570fb 100644 --- a/packages/crates-io-msw/handlers/crates/team-owners.test.js +++ b/packages/crates-io-msw/handlers/crates/team-owners.test.js @@ -32,6 +32,7 @@ test('returns the list of teams that own the specified crate', async function () avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', kind: 'team', login: 'github:rust-lang:maintainers', + username: 'github:rust-lang:maintainers', name: 'maintainers', url: 'https://github.com/rust-lang', }, diff --git a/packages/crates-io-msw/handlers/crates/user-owners.test.js b/packages/crates-io-msw/handlers/crates/user-owners.test.js index e5426153674..1cbfcc28ab2 100644 --- a/packages/crates-io-msw/handlers/crates/user-owners.test.js +++ b/packages/crates-io-msw/handlers/crates/user-owners.test.js @@ -32,6 +32,7 @@ test('returns the list of users that own the specified crate', async function () avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', kind: 'user', login: 'john-doe', + username: 'john-doe', name: 'John Doe', url: 'https://github.com/john-doe', }, diff --git a/packages/crates-io-msw/handlers/invites/legacy-list.test.js b/packages/crates-io-msw/handlers/invites/legacy-list.test.js index 91e7b4c768e..80ca10e7d0a 100644 --- a/packages/crates-io-msw/handlers/invites/legacy-list.test.js +++ b/packages/crates-io-msw/handlers/invites/legacy-list.test.js @@ -63,6 +63,7 @@ test('returns the list of invitations for the authenticated user', async functio avatar: user.avatar, id: Number(user.id), login: user.login, + username: user.username, name: user.name, url: user.url, }, @@ -70,6 +71,7 @@ test('returns the list of invitations for the authenticated user', async functio avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', id: Number(inviter.id), login: 'janed', + username: 'janed', name: 'janed', url: 'https://github.com/janed', }, @@ -77,6 +79,7 @@ test('returns the list of invitations for the authenticated user', async functio avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', id: Number(inviter2.id), login: 'wycats', + username: 'wycats', name: 'wycats', url: 'https://github.com/wycats', }, diff --git a/packages/crates-io-msw/handlers/invites/list.test.js b/packages/crates-io-msw/handlers/invites/list.test.js index 551bcd05903..9f5df26ecb5 100644 --- a/packages/crates-io-msw/handlers/invites/list.test.js +++ b/packages/crates-io-msw/handlers/invites/list.test.js @@ -56,6 +56,7 @@ test('happy path (invitee_id)', async function () { login: user.login, name: user.name, url: user.url, + username: user.username, }, { avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', @@ -63,6 +64,7 @@ test('happy path (invitee_id)', async function () { login: 'janed', name: 'janed', url: 'https://github.com/janed', + username: 'janed', }, { avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', @@ -70,6 +72,7 @@ test('happy path (invitee_id)', async function () { login: 'wycats', name: 'wycats', url: 'https://github.com/wycats', + username: 'wycats', }, ], meta: { @@ -164,6 +167,7 @@ test('happy path (crate_name)', async function () { login: user.login, name: user.name, url: user.url, + username: user.username, }, { avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', @@ -171,6 +175,7 @@ test('happy path (crate_name)', async function () { login: 'wycats', name: 'wycats', url: 'https://github.com/wycats', + username: 'wycats', }, ], meta: { diff --git a/packages/crates-io-msw/handlers/teams/get.js b/packages/crates-io-msw/handlers/teams/get.js index 8fb593aa11f..8e1a70a71d0 100644 --- a/packages/crates-io-msw/handlers/teams/get.js +++ b/packages/crates-io-msw/handlers/teams/get.js @@ -5,8 +5,8 @@ import { serializeTeam } from '../../serializers/team.js'; import { notFound } from '../../utils/handlers.js'; export default http.get('/api/v1/teams/:team_id', ({ params }) => { - let login = params.team_id; - let team = db.team.findFirst({ where: { login: { equals: login } } }); + let username = params.team_id; + let team = db.team.findFirst({ where: { username: { equals: username } } }); if (!team) { return notFound(); } diff --git a/packages/crates-io-msw/handlers/teams/get.test.js b/packages/crates-io-msw/handlers/teams/get.test.js index 7e164e26f74..35ae9f68d71 100644 --- a/packages/crates-io-msw/handlers/teams/get.test.js +++ b/packages/crates-io-msw/handlers/teams/get.test.js @@ -11,13 +11,14 @@ test('returns 404 for unknown teams', async function () { test('returns a team object for known teams', async function () { let team = db.team.create({ name: 'maintainers' }); - let response = await fetch(`/api/v1/teams/${team.login}`); + let response = await fetch(`/api/v1/teams/${team.username}`); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { team: { id: 1, avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', login: 'github:rust-lang:maintainers', + username: 'github:rust-lang:maintainers', name: 'maintainers', url: 'https://github.com/rust-lang', }, diff --git a/packages/crates-io-msw/handlers/users/get.js b/packages/crates-io-msw/handlers/users/get.js index ee299d708d1..ef4a68caf7e 100644 --- a/packages/crates-io-msw/handlers/users/get.js +++ b/packages/crates-io-msw/handlers/users/get.js @@ -5,8 +5,8 @@ import { serializeUser } from '../../serializers/user.js'; import { notFound } from '../../utils/handlers.js'; export default http.get('/api/v1/users/:user_id', ({ params }) => { - let login = params.user_id; - let user = db.user.findFirst({ where: { login: { equals: login } } }); + let username = params.user_id; + let user = db.user.findFirst({ where: { username: { equals: username } } }); if (!user) { return notFound(); } diff --git a/packages/crates-io-msw/handlers/users/get.test.js b/packages/crates-io-msw/handlers/users/get.test.js index f5314186740..e5e7476e264 100644 --- a/packages/crates-io-msw/handlers/users/get.test.js +++ b/packages/crates-io-msw/handlers/users/get.test.js @@ -11,13 +11,14 @@ test('returns 404 for unknown users', async function () { test('returns a user object for known users', async function () { let user = db.user.create({ name: 'John Doe' }); - let response = await fetch(`/api/v1/users/${user.login}`); + let response = await fetch(`/api/v1/users/${user.username}`); assert.strictEqual(response.status, 200); assert.deepEqual(await response.json(), { user: { id: 1, avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', login: 'john-doe', + username: 'john-doe', name: 'John Doe', url: 'https://github.com/john-doe', }, diff --git a/packages/crates-io-msw/handlers/users/me.test.js b/packages/crates-io-msw/handlers/users/me.test.js index fba39926019..f480abf57d7 100644 --- a/packages/crates-io-msw/handlers/users/me.test.js +++ b/packages/crates-io-msw/handlers/users/me.test.js @@ -17,6 +17,7 @@ test('returns the `user` resource including the private fields', async function email_verified: true, is_admin: false, login: 'user-1', + username: 'user-1', name: 'User 1', publish_notifications: true, url: 'https://github.com/user-1', diff --git a/packages/crates-io-msw/handlers/versions/list.test.js b/packages/crates-io-msw/handlers/versions/list.test.js index 263d0f0d007..372e015cbea 100644 --- a/packages/crates-io-msw/handlers/versions/list.test.js +++ b/packages/crates-io-msw/handlers/versions/list.test.js @@ -69,6 +69,7 @@ test('returns all versions belonging to the specified crate', async function () id: 1, avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4', login: 'user-1', + username: 'user-1', name: 'User 1', url: 'https://github.com/user-1', }, diff --git a/packages/crates-io-msw/models/api-token.test.js b/packages/crates-io-msw/models/api-token.test.js index 135b78352b9..867503fdc99 100644 --- a/packages/crates-io-msw/models/api-token.test.js +++ b/packages/crates-io-msw/models/api-token.test.js @@ -34,6 +34,7 @@ test('happy path', ({ expect }) => { "name": "User 1", "publishNotifications": true, "url": "https://github.com/user-1", + "username": "user-1", Symbol(type): "user", Symbol(primaryKey): "id", }, diff --git a/packages/crates-io-msw/models/crate-owner-invitation.test.js b/packages/crates-io-msw/models/crate-owner-invitation.test.js index 32c10b97d52..4e9725b7f1f 100644 --- a/packages/crates-io-msw/models/crate-owner-invitation.test.js +++ b/packages/crates-io-msw/models/crate-owner-invitation.test.js @@ -66,6 +66,7 @@ test('happy path', ({ expect }) => { "name": "User 2", "publishNotifications": true, "url": "https://github.com/user-2", + "username": "user-2", Symbol(type): "user", Symbol(primaryKey): "id", }, @@ -81,6 +82,7 @@ test('happy path', ({ expect }) => { "name": "User 1", "publishNotifications": true, "url": "https://github.com/user-1", + "username": "user-1", Symbol(type): "user", Symbol(primaryKey): "id", }, diff --git a/packages/crates-io-msw/models/crate-ownership.test.js b/packages/crates-io-msw/models/crate-ownership.test.js index 00f6b389824..3ef87c2d345 100644 --- a/packages/crates-io-msw/models/crate-ownership.test.js +++ b/packages/crates-io-msw/models/crate-ownership.test.js @@ -58,6 +58,7 @@ test('can set `team`', ({ expect }) => { "name": "team-1", "org": "rust-lang", "url": "https://github.com/rust-lang", + "username": "github:rust-lang:team-1", Symbol(type): "team", Symbol(primaryKey): "id", }, @@ -107,6 +108,7 @@ test('can set `user`', ({ expect }) => { "name": "User 1", "publishNotifications": true, "url": "https://github.com/user-1", + "username": "user-1", Symbol(type): "user", Symbol(primaryKey): "id", }, diff --git a/packages/crates-io-msw/models/msw-session.test.js b/packages/crates-io-msw/models/msw-session.test.js index 5a6874566c9..d01a15f300c 100644 --- a/packages/crates-io-msw/models/msw-session.test.js +++ b/packages/crates-io-msw/models/msw-session.test.js @@ -24,6 +24,7 @@ test('happy path', ({ expect }) => { "name": "User 1", "publishNotifications": true, "url": "https://github.com/user-1", + "username": "user-1", Symbol(type): "user", Symbol(primaryKey): "id", }, diff --git a/packages/crates-io-msw/models/team.js b/packages/crates-io-msw/models/team.js index 87df22a6f80..e7eea285e24 100644 --- a/packages/crates-io-msw/models/team.js +++ b/packages/crates-io-msw/models/team.js @@ -10,6 +10,7 @@ export default { name: String, org: String, login: String, + username: String, url: String, avatar: String, @@ -18,6 +19,7 @@ export default { applyDefault(attrs, 'name', () => `team-${attrs.id}`); applyDefault(attrs, 'org', () => ORGS[(attrs.id - 1) % ORGS.length]); applyDefault(attrs, 'login', () => `github:${attrs.org}:${attrs.name}`); + applyDefault(attrs, 'username', () => `github:${attrs.org}:${attrs.name}`); applyDefault(attrs, 'url', () => `https://github.com/${attrs.org}`); applyDefault(attrs, 'avatar', () => 'https://avatars1.githubusercontent.com/u/14631425?v=4'); }, diff --git a/packages/crates-io-msw/models/team.test.js b/packages/crates-io-msw/models/team.test.js index c3aecafd0f1..c5c39ed7bb2 100644 --- a/packages/crates-io-msw/models/team.test.js +++ b/packages/crates-io-msw/models/team.test.js @@ -12,6 +12,7 @@ test('default are applied', ({ expect }) => { "name": "team-1", "org": "rust-lang", "url": "https://github.com/rust-lang", + "username": "github:rust-lang:team-1", Symbol(type): "team", Symbol(primaryKey): "id", } @@ -28,6 +29,7 @@ test('attributes can be set', ({ expect }) => { "name": "axum", "org": "tokio-rs", "url": "https://github.com/tokio-rs", + "username": "github:tokio-rs:axum", Symbol(type): "team", Symbol(primaryKey): "id", } diff --git a/packages/crates-io-msw/models/user.js b/packages/crates-io-msw/models/user.js index 2d3ce6f22f4..9e2999fb91c 100644 --- a/packages/crates-io-msw/models/user.js +++ b/packages/crates-io-msw/models/user.js @@ -8,6 +8,7 @@ export default { name: nullable(String), login: String, + username: String, url: String, avatar: String, email: nullable(String), @@ -22,7 +23,8 @@ export default { applyDefault(attrs, 'id', () => counter); applyDefault(attrs, 'name', () => `User ${attrs.id}`); applyDefault(attrs, 'login', () => (attrs.name ? dasherize(attrs.name) : `user-${attrs.id}`)); - applyDefault(attrs, 'email', () => `${attrs.login}@crates.io`); + applyDefault(attrs, 'username', () => (attrs.name ? dasherize(attrs.name) : `user-${attrs.id}`)); + applyDefault(attrs, 'email', () => `${attrs.username}@crates.io`); applyDefault(attrs, 'url', () => `https://github.com/${attrs.login}`); applyDefault(attrs, 'avatar', () => 'https://avatars1.githubusercontent.com/u/14631425?v=4'); applyDefault(attrs, 'emailVerificationToken', () => null); diff --git a/packages/crates-io-msw/models/user.test.js b/packages/crates-io-msw/models/user.test.js index e3db559e569..e15bba659d6 100644 --- a/packages/crates-io-msw/models/user.test.js +++ b/packages/crates-io-msw/models/user.test.js @@ -17,6 +17,7 @@ test('default are applied', ({ expect }) => { "name": "User 1", "publishNotifications": true, "url": "https://github.com/user-1", + "username": "user-1", Symbol(type): "user", Symbol(primaryKey): "id", } @@ -38,6 +39,7 @@ test('name can be set', ({ expect }) => { "name": "John Doe", "publishNotifications": true, "url": "https://github.com/john-doe", + "username": "john-doe", Symbol(type): "user", Symbol(primaryKey): "id", } diff --git a/script/backfill-linked-accounts.sql b/script/backfill-linked-accounts.sql new file mode 100644 index 00000000000..f2413cd110e --- /dev/null +++ b/script/backfill-linked-accounts.sql @@ -0,0 +1,7 @@ +INSERT INTO linked_accounts (user_id, provider, account_id, access_token, login, avatar) +SELECT id, 0, gh_id, gh_access_token, gh_login, gh_avatar +FROM users +LEFT JOIN linked_accounts +ON users.id = linked_accounts.user_id +WHERE gh_id != -1 +AND linked_accounts.user_id IS NULL; diff --git a/script/backfill-usernames.sql b/script/backfill-usernames.sql new file mode 100644 index 00000000000..59372812ce3 --- /dev/null +++ b/script/backfill-usernames.sql @@ -0,0 +1,2 @@ +UPDATE users +SET username = gh_login; diff --git a/src/controllers/crate_owner_invitation.rs b/src/controllers/crate_owner_invitation.rs index 24547c0e650..6fd88928e14 100644 --- a/src/controllers/crate_owner_invitation.rs +++ b/src/controllers/crate_owner_invitation.rs @@ -63,7 +63,7 @@ pub async fn list_crate_owner_invitations_for_user( .iter() .find(|u| u.id == private.inviter_id) .ok_or_else(|| internal(format!("missing user {}", private.inviter_id)))? - .login + .username .clone(), invitee_id: private.invitee_id, inviter_id: private.inviter_id, diff --git a/src/controllers/krate/owners.rs b/src/controllers/krate/owners.rs index e3482ba6e59..0bebd0cd3cb 100644 --- a/src/controllers/krate/owners.rs +++ b/src/controllers/krate/owners.rs @@ -328,7 +328,7 @@ async fn invite_user_owner( let user = User::find_by_login(conn, login) .await .optional()? - .ok_or_else(|| bad_request(format_args!("could not find user with login `{login}`")))?; + .ok_or_else(|| bad_request(format_args!("could not find user with username `{login}`")))?; // Users are invited and must accept before being added let expires_at = Utc::now() + app.config.ownership_invitations_expiration; @@ -498,7 +498,7 @@ impl From for BoxedAppError { match error { OwnerRemoveError::Diesel(error) => error.into(), OwnerRemoveError::NotFound { login } => { - bad_request(format!("could not find owner with login `{login}`")) + bad_request(format!("could not find owner with username `{login}`")) } } } diff --git a/src/controllers/session.rs b/src/controllers/session.rs index 44266c356a3..8dd53424e04 100644 --- a/src/controllers/session.rs +++ b/src/controllers/session.rs @@ -10,7 +10,7 @@ use crate::app::AppState; use crate::controllers::user::update::UserConfirmEmail; use crate::email::Emails; use crate::middleware::log_request::RequestLogExt; -use crate::models::{NewEmail, NewUser, User}; +use crate::models::{AccountProvider, NewEmail, NewLinkedAccount, NewUser, User}; use crate::schema::users; use crate::util::diesel::is_read_only_error; use crate::util::errors::{AppResult, bad_request, server_error}; @@ -132,6 +132,7 @@ pub async fn save_user_to_database( let new_user = NewUser::builder() .gh_id(user.id) .gh_login(&user.login) + .username(&user.login) .maybe_name(user.name.as_deref()) .maybe_gh_avatar(user.avatar_url.as_deref()) .gh_access_token(access_token) @@ -162,6 +163,21 @@ async fn create_or_update_user( async move { let user = new_user.insert_or_update(conn).await?; + // To assist in eventually someday allowing OAuth with more than GitHub, also + // write the GitHub info to the `linked_accounts` table. Nothing currently reads + // from this table. Only log errors but don't fail login if this writing fails. + let new_linked_account = NewLinkedAccount::builder() + .user_id(user.id) + .provider(AccountProvider::Github) + .account_id(user.gh_id) + .access_token(new_user.gh_access_token) + .login(&user.gh_login) + .maybe_avatar(user.gh_avatar.as_deref()) + .build(); + if let Err(e) = new_linked_account.insert_or_update(conn).await { + info!("Error inserting or updating linked_accounts record: {e}"); + } + // To send the user an account verification email if let Some(user_email) = email { let new_email = NewEmail::builder() diff --git a/src/controllers/user/other.rs b/src/controllers/user/other.rs index b0ac93b29ba..f903a32b395 100644 --- a/src/controllers/user/other.rs +++ b/src/controllers/user/other.rs @@ -16,12 +16,12 @@ pub struct GetResponse { pub user: EncodablePublicUser, } -/// Find user by login. +/// Find user by username. #[utoipa::path( get, path = "/api/v1/users/{user}", params( - ("user" = String, Path, description = "Login name of the user"), + ("user" = String, Path, description = "Crates.io username of the user"), ), tag = "users", responses((status = 200, description = "Successful Response", body = inline(GetResponse))), @@ -41,7 +41,11 @@ pub async fn find_user( .first(&mut conn) .await?; - Ok(Json(GetResponse { user: user.into() })) + let linked_accounts = user.linked_accounts(&mut conn).await?; + + Ok(Json(GetResponse { + user: EncodablePublicUser::with_linked_accounts(user, &linked_accounts), + })) } #[derive(Debug, Serialize, utoipa::ToSchema)] diff --git a/src/index.rs b/src/index.rs index 0e52eb91962..08eacf2266a 100644 --- a/src/index.rs +++ b/src/index.rs @@ -153,6 +153,7 @@ mod tests { let user_id = diesel::insert_into(users::table) .values(( users::name.eq("user1"), + users::username.eq("user1"), users::gh_login.eq("user1"), users::gh_id.eq(42), users::gh_access_token.eq("some random token"), diff --git a/src/rate_limiter.rs b/src/rate_limiter.rs index 5416a9ffaa4..4b4da12db2c 100644 --- a/src/rate_limiter.rs +++ b/src/rate_limiter.rs @@ -706,6 +706,7 @@ mod tests { NewUser::builder() .gh_id(0) .gh_login(gh_login) + .username(gh_login) .gh_access_token("some random token") .build() .insert(conn) diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap index cb7a074036a..e2f3ea8bce8 100644 --- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap +++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot.snap @@ -1,6 +1,7 @@ --- source: src/openapi.rs expression: response.json() +snapshot_kind: text --- { "components": { @@ -117,7 +118,7 @@ expression: response.json() "type": "boolean" }, "login": { - "description": "The user's login name.", + "description": "The user's GitHub login.", "example": "ghost", "type": "string" }, @@ -141,11 +142,17 @@ expression: response.json() "string", "null" ] + }, + "username": { + "description": "The user's crates.io username.", + "example": "ghost", + "type": "string" } }, "required": [ "id", "login", + "username", "email_verified", "email_verification_sent", "is_admin", @@ -707,7 +714,7 @@ expression: response.json() "type": "string" }, "login": { - "description": "The login name of the team or user.", + "description": "The GitHub login of the team or user.", "example": "ghost", "type": "string" }, @@ -726,11 +733,17 @@ expression: response.json() "string", "null" ] + }, + "username": { + "description": "The crates.io username of the team or user.", + "example": "ghost", + "type": "string" } }, "required": [ "id", "login", + "username", "kind" ], "type": "object" @@ -852,8 +865,19 @@ expression: response.json() "format": "int32", "type": "integer" }, + "linked_accounts": { + "description": "The accounts linked to this crates.io account.", + "example": [], + "items": { + "$ref": "#/components/schemas/LinkedAccount" + }, + "type": [ + "array", + "null" + ] + }, "login": { - "description": "The user's login name.", + "description": "The user's GitHub login name.", "example": "ghost", "type": "string" }, @@ -869,11 +893,17 @@ expression: response.json() "description": "The user's GitHub profile URL.", "example": "https://github.com/ghost", "type": "string" + }, + "username": { + "description": "The user's crates.io username.", + "example": "ghost", + "type": "string" } }, "required": [ "id", "login", + "username", "url" ], "type": "object" @@ -4148,7 +4178,7 @@ expression: response.json() "operationId": "find_user", "parameters": [ { - "description": "Login name of the user", + "description": "Crates.io username of the user", "in": "path", "name": "user", "required": true, @@ -4177,7 +4207,7 @@ expression: response.json() "description": "Successful Response" } }, - "summary": "Find user by login.", + "summary": "Find user by username.", "tags": [ "users" ] diff --git a/src/tests/dump_db.rs b/src/tests/dump_db.rs index c7d0e549ac8..bce2be568e8 100644 --- a/src/tests/dump_db.rs +++ b/src/tests/dump_db.rs @@ -52,6 +52,7 @@ async fn test_dump_db_job() -> anyhow::Result<()> { "YYYY-MM-DD-HHMMSS/data/crate_downloads.csv", "YYYY-MM-DD-HHMMSS/data/crates.csv", "YYYY-MM-DD-HHMMSS/data/keywords.csv", + "YYYY-MM-DD-HHMMSS/data/linked_accounts.csv", "YYYY-MM-DD-HHMMSS/data/metadata.csv", "YYYY-MM-DD-HHMMSS/data/reserved_crate_names.csv", "YYYY-MM-DD-HHMMSS/data/teams.csv", @@ -84,6 +85,7 @@ async fn test_dump_db_job() -> anyhow::Result<()> { "data/crate_downloads.csv", "data/crates.csv", "data/keywords.csv", + "data/linked_accounts.csv", "data/metadata.csv", "data/reserved_crate_names.csv", "data/teams.csv", diff --git a/src/tests/issues/issue1205.rs b/src/tests/issues/issue1205.rs index 1229746dc0e..01260cd8209 100644 --- a/src/tests/issues/issue1205.rs +++ b/src/tests/issues/issue1205.rs @@ -46,7 +46,7 @@ async fn test_issue_1205() -> anyhow::Result<()> { .remove_named_owner(CRATE_NAME, "github:rustaudio:owners") .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find owner with login `github:rustaudio:owners`"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find owner with username `github:rustaudio:owners`"}]}"#); Ok(()) } diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__edition__edition_is_saved-2.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__edition__edition_is_saved-2.snap index 02666658d2f..ce18851935a 100644 --- a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__edition__edition_is_saved-2.snap +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__edition__edition_is_saved-2.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/publish/edition.rs expression: response.json() +snapshot_kind: text --- { "version": { @@ -13,7 +14,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" } } ], @@ -44,7 +46,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo/1.0.0/readme", "repository": null, diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__links__crate_with_links_field-2.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__links__crate_with_links_field-2.snap index e42da14510a..3c046059c4c 100644 --- a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__links__crate_with_links_field-2.snap +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__links__crate_with_links_field-2.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/publish/links.rs expression: response.json() +snapshot_kind: text --- { "version": { @@ -13,7 +14,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" } } ], @@ -44,7 +46,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo/1.0.0/readme", "repository": null, diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__boolean_readme-2.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__boolean_readme-2.snap index 6f61d116c53..6b939f6d35c 100644 --- a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__boolean_readme-2.snap +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__boolean_readme-2.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/publish/manifest.rs expression: response.json() +snapshot_kind: text --- { "version": { @@ -13,7 +14,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" } } ], @@ -44,7 +46,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo/1.0.0/readme", "repository": null, diff --git a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__lib_and_bin_crate-2.snap b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__lib_and_bin_crate-2.snap index 328af1f7ec4..070885d4fca 100644 --- a/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__lib_and_bin_crate-2.snap +++ b/src/tests/krate/publish/snapshots/crates_io__tests__krate__publish__manifest__lib_and_bin_crate-2.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/publish/manifest.rs expression: response.json() +snapshot_kind: text --- { "version": { @@ -13,7 +14,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" } } ], @@ -47,7 +49,8 @@ expression: response.json() "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo/1.0.0/readme", "repository": null, diff --git a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-2.snap b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-2.snap index 9e8b8ef7e78..6bd08f4220f 100644 --- a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-2.snap +++ b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-2.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/yanking.rs expression: json +snapshot_kind: text --- { "version": { @@ -26,6 +27,7 @@ expression: json "published_by": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -36,6 +38,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -47,6 +50,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" diff --git a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-3.snap b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-3.snap index 896ce3ae55b..3cc88906683 100644 --- a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-3.snap +++ b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-3.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/yanking.rs expression: json +snapshot_kind: text --- { "version": { @@ -26,6 +27,7 @@ expression: json "published_by": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -36,6 +38,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -47,6 +50,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -58,6 +62,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" diff --git a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-4.snap b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-4.snap index 896ce3ae55b..3cc88906683 100644 --- a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-4.snap +++ b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-4.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/yanking.rs expression: json +snapshot_kind: text --- { "version": { @@ -26,6 +27,7 @@ expression: json "published_by": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -36,6 +38,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -47,6 +50,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -58,6 +62,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" diff --git a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-5.snap b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-5.snap index e6938557e70..2a931ebc0d9 100644 --- a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-5.snap +++ b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-5.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/yanking.rs expression: json +snapshot_kind: text --- { "version": { @@ -26,6 +27,7 @@ expression: json "published_by": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -36,6 +38,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -47,6 +50,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -58,6 +62,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -69,6 +74,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" diff --git a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-6.snap b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-6.snap index e6938557e70..2a931ebc0d9 100644 --- a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-6.snap +++ b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank-6.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/yanking.rs expression: json +snapshot_kind: text --- { "version": { @@ -26,6 +27,7 @@ expression: json "published_by": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -36,6 +38,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -47,6 +50,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -58,6 +62,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -69,6 +74,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" diff --git a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank.snap b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank.snap index 9e8b8ef7e78..6bd08f4220f 100644 --- a/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank.snap +++ b/src/tests/krate/snapshots/crates_io__tests__krate__yanking__patch_version_yank_unyank.snap @@ -1,6 +1,7 @@ --- source: src/tests/krate/yanking.rs expression: json +snapshot_kind: text --- { "version": { @@ -26,6 +27,7 @@ expression: json "published_by": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -36,6 +38,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" @@ -47,6 +50,7 @@ expression: json "user": { "id": 1, "login": "foo", + "username": "foo", "name": null, "avatar": null, "url": "https://github.com/foo" diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 9d0ee1983a3..5b642f63278 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -95,6 +95,7 @@ fn new_user(login: &str) -> NewUser<'_> { NewUser::builder() .gh_id(next_gh_id()) .gh_login(login) + .username(login) .gh_access_token("some random token") .build() } diff --git a/src/tests/owners.rs b/src/tests/owners.rs index c0c7f892a3d..51870813cc1 100644 --- a/src/tests/owners.rs +++ b/src/tests/owners.rs @@ -772,6 +772,7 @@ async fn inactive_users_dont_get_invitations() { NewUser::builder() .gh_id(-1) .gh_login(invited_gh_login) + .username(invited_gh_login) .gh_access_token("some random token") .build() .insert(&mut conn) diff --git a/src/tests/routes/crates/owners/add.rs b/src/tests/routes/crates/owners/add.rs index f2aa0a8f02c..79649abebdc 100644 --- a/src/tests/routes/crates/owners/add.rs +++ b/src/tests/routes/crates/owners/add.rs @@ -305,7 +305,7 @@ async fn test_unknown_user() { let response = cookie.add_named_owner("foo", "unknown").await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find user with login `unknown`"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find user with username `unknown`"}]}"#); } #[tokio::test(flavor = "multi_thread")] @@ -370,7 +370,7 @@ async fn no_invite_emails_for_txn_rollback() { let response = token.add_named_owners("crate_name", &usernames).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find user with login `bananas`"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find user with username `bananas`"}]}"#); // No emails should have been sent. assert_eq!(app.emails().await.len(), 0); diff --git a/src/tests/routes/crates/owners/remove.rs b/src/tests/routes/crates/owners/remove.rs index 669248aef07..9ef01b23a5a 100644 --- a/src/tests/routes/crates/owners/remove.rs +++ b/src/tests/routes/crates/owners/remove.rs @@ -61,7 +61,7 @@ async fn test_unknown_user() { let response = cookie.remove_named_owner("foo", "unknown").await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find owner with login `unknown`"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find owner with username `unknown`"}]}"#); } #[tokio::test(flavor = "multi_thread")] @@ -77,7 +77,7 @@ async fn test_unknown_team() { .remove_named_owner("foo", "github:unknown:unknown") .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find owner with login `github:unknown:unknown`"}]}"#); + assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"could not find owner with username `github:unknown:unknown`"}]}"#); } #[tokio::test(flavor = "multi_thread")] diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__include_default_version.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__include_default_version.snap index c7b079c37a4..a06971a8993 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__include_default_version.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__include_default_version.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/read.rs expression: response.json() +snapshot_kind: text --- { "categories": null, @@ -66,7 +67,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_default_version/0.5.1/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show.snap index 75c2cd698be..a4a002164e6 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/read.rs expression: response.json() +snapshot_kind: text --- { "categories": [], @@ -79,7 +80,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_show/0.5.1/readme", "repository": null, @@ -117,7 +119,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_show/0.5.0/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show_all_yanked.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show_all_yanked.snap index 30269ad6920..b20d9bf3bf0 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show_all_yanked.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__read__show_all_yanked.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/read.rs expression: response.json() +snapshot_kind: text --- { "categories": [], @@ -78,7 +79,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_show/0.5.0/readme", "repository": null, @@ -116,7 +118,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_show/1.0.0/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__prerelease_versions_not_included_in_reverse_dependencies.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__prerelease_versions_not_included_in_reverse_dependencies.snap index 27e4773acf2..0b71ee66752 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__prerelease_versions_not_included_in_reverse_dependencies.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__prerelease_versions_not_included_in_reverse_dependencies.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/reverse_dependencies.rs expression: response.json() +snapshot_kind: text --- { "dependencies": [ @@ -50,7 +51,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/c3/1.0.0/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies.snap index 7ca7deae51e..7e06a811058 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/reverse_dependencies.rs expression: response.json() +snapshot_kind: text --- { "dependencies": [ @@ -50,7 +51,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/c2/1.1.0/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_includes_published_by_user_when_present.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_includes_published_by_user_when_present.snap index 94dde205c95..dd548de43c3 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_includes_published_by_user_when_present.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_includes_published_by_user_when_present.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/reverse_dependencies.rs expression: response.json() +snapshot_kind: text --- { "dependencies": [ @@ -62,7 +63,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/c3/3.0.0/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_query_supports_u64_version_number_parts.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_query_supports_u64_version_number_parts.snap index 6850cb4856b..8c363689879 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_query_supports_u64_version_number_parts.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_query_supports_u64_version_number_parts.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/reverse_dependencies.rs expression: response.json() +snapshot_kind: text --- { "dependencies": [ @@ -50,7 +51,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/c2/1.0.18446744073709551615/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_when_old_version_doesnt_depend_but_new_does.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_when_old_version_doesnt_depend_but_new_does.snap index 6e17a2fbc8f..ab9cafea9d9 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_when_old_version_doesnt_depend_but_new_does.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__reverse_dependencies_when_old_version_doesnt_depend_but_new_does.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/reverse_dependencies.rs expression: response.json() +snapshot_kind: text --- { "dependencies": [ @@ -50,7 +51,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/c2/2.0.0/readme", "repository": null, diff --git a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__yanked_versions_not_included_in_reverse_dependencies.snap b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__yanked_versions_not_included_in_reverse_dependencies.snap index 6e17a2fbc8f..ab9cafea9d9 100644 --- a/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__yanked_versions_not_included_in_reverse_dependencies.snap +++ b/src/tests/routes/crates/snapshots/crates_io__tests__routes__crates__reverse_dependencies__yanked_versions_not_included_in_reverse_dependencies.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/reverse_dependencies.rs expression: response.json() +snapshot_kind: text --- { "dependencies": [ @@ -50,7 +51,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/c2/2.0.0/readme", "repository": null, diff --git a/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__list__versions.snap b/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__list__versions.snap index d0546ef9c91..6a78599d2d5 100644 --- a/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__list__versions.snap +++ b/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__list__versions.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/versions/list.rs expression: response.json() +snapshot_kind: text --- { "meta": { @@ -69,7 +70,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_versions/0.5.1/readme", "repository": null, @@ -107,7 +109,8 @@ expression: response.json() "id": 1, "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_versions/0.5.0/readme", "repository": null, diff --git a/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__read__show_by_crate_name_and_version.snap b/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__read__show_by_crate_name_and_version.snap index 6940293e5ca..96f047cf709 100644 --- a/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__read__show_by_crate_name_and_version.snap +++ b/src/tests/routes/crates/versions/snapshots/crates_io__tests__routes__crates__versions__read__show_by_crate_name_and_version.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/crates/versions/read.rs expression: json +snapshot_kind: text --- { "version": { @@ -32,7 +33,8 @@ expression: json "id": "[id]", "login": "foo", "name": null, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" }, "readme_path": "/api/v1/crates/foo_vers_show/2.0.0/readme", "repository": null, diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-2.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-2.snap index 5564b16de5e..0275f95e5ff 100644 --- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-2.snap +++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-2.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/me/get.rs expression: response.json() +snapshot_kind: text --- { "owned_crates": [], @@ -14,6 +15,7 @@ expression: response.json() "login": "foo", "name": null, "publish_notifications": true, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" } } diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-3.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-3.snap index b0ffc3e7fc8..5d511698d04 100644 --- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-3.snap +++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-3.snap @@ -1,6 +1,7 @@ --- source: src/tests/routes/me/get.rs expression: response.json() +snapshot_kind: text --- { "owned_crates": [ @@ -20,6 +21,7 @@ expression: response.json() "login": "foo", "name": null, "publish_notifications": true, - "url": "https://github.com/foo" + "url": "https://github.com/foo", + "username": "foo" } } diff --git a/src/tests/routes/me/updates.rs b/src/tests/routes/me/updates.rs index f81f56e525f..8f15a33467b 100644 --- a/src/tests/routes/me/updates.rs +++ b/src/tests/routes/me/updates.rs @@ -78,8 +78,8 @@ async fn following() { .find(|v| v.krate == "bar_fighters") .unwrap(); assert_eq!( - bar_version.published_by.as_ref().unwrap().login, - user_model.gh_login + &bar_version.published_by.as_ref().unwrap().username, + user_model.username.as_ref().unwrap() ); let r: R = user diff --git a/src/tests/routes/users/read.rs b/src/tests/routes/users/read.rs index cd697fc53e7..84aba7a33a7 100644 --- a/src/tests/routes/users/read.rs +++ b/src/tests/routes/users/read.rs @@ -16,10 +16,19 @@ async fn show() { let json: UserShowPublicResponse = anon.get("/api/v1/users/foo").await.good(); assert_eq!(json.user.login, "foo"); + assert_eq!(json.user.username, "foo"); let json: UserShowPublicResponse = anon.get("/api/v1/users/bAr").await.good(); assert_eq!(json.user.login, "Bar"); + assert_eq!(json.user.username, "Bar"); assert_eq!(json.user.url, "https://github.com/Bar"); + + let accounts = json.user.linked_accounts.unwrap(); + assert_eq!(accounts.len(), 1); + let account = &accounts[0]; + assert_eq!(account.provider, "GitHub"); + assert_eq!(account.login, "Bar"); + assert_eq!(account.url, "https://github.com/Bar"); } #[tokio::test(flavor = "multi_thread")] @@ -38,6 +47,7 @@ async fn show_latest_user_case_insensitively() { let user1 = NewUser::builder() .gh_id(1) .gh_login("foobar") + .username("foobar") .name("I was first then deleted my github account") .gh_access_token("bar") .build(); @@ -45,6 +55,7 @@ async fn show_latest_user_case_insensitively() { let user2 = NewUser::builder() .gh_id(2) .gh_login("FOOBAR") + .username("FOOBAR") .name("I was second, I took the foobar username on github") .gh_access_token("bar") .build(); diff --git a/src/tests/team.rs b/src/tests/team.rs index 34561a20cc0..eb4303f9467 100644 --- a/src/tests/team.rs +++ b/src/tests/team.rs @@ -121,6 +121,7 @@ async fn add_renamed_team() -> anyhow::Result<()> { let json = anon.crate_owner_teams("foo_renamed_team").await.good(); assert_eq!(json.teams.len(), 1); assert_eq!(json.teams[0].login, "github:test-org:core"); + assert_eq!(json.teams[0].username, "github:test-org:core"); Ok(()) } @@ -151,6 +152,7 @@ async fn add_team_mixed_case() -> anyhow::Result<()> { let json = anon.crate_owner_teams("foo_mixed_case").await.good(); assert_eq!(json.teams.len(), 1); assert_eq!(json.teams[0].login, "github:test-org:core"); + assert_eq!(json.teams[0].username, "github:test-org:core"); Ok(()) } diff --git a/src/tests/user.rs b/src/tests/user.rs index 33e98796849..e3b662ac036 100644 --- a/src/tests/user.rs +++ b/src/tests/user.rs @@ -1,5 +1,6 @@ use crate::controllers::session; -use crate::models::{ApiToken, Email, User}; +use crate::models::{AccountProvider, ApiToken, Email, LinkedAccount, User}; +use crate::schema::linked_accounts; use crate::tests::TestApp; use crate::tests::util::github::next_gh_id; use crate::tests::util::{MockCookieUser, RequestHelper}; @@ -45,6 +46,7 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()> let user = assert_ok!(User::find(&mut conn, api_token.user_id).await); assert_eq!(user.gh_login, "bar"); + assert_eq!(user.username.unwrap(), "bar"); assert_eq!(user.gh_access_token.expose_secret(), "bar_token"); Ok(()) @@ -265,3 +267,47 @@ async fn test_existing_user_email() -> anyhow::Result<()> { Ok(()) } + +// To assist in eventually someday allowing OAuth with more than GitHub, verify that we're starting +// to also write the GitHub info to the `linked_accounts` table. Nothing currently reads from this +// table other than this test. +#[tokio::test(flavor = "multi_thread")] +async fn also_write_to_linked_accounts() -> anyhow::Result<()> { + let (app, _) = TestApp::init().empty().await; + let mut conn = app.db_conn().await; + + // Simulate logging in via GitHub. Don't use app.db_new_user because it inserts a user record + // directly into the database and we want to test the OAuth flow here. + let email = "potahto@example.com"; + + let emails = &app.as_inner().emails; + + let gh_user = GithubUser { + id: next_gh_id(), + login: "arbitrary_username".to_string(), + name: None, + email: Some(email.to_string()), + avatar_url: None, + }; + + let u = + session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?; + + let linked_accounts = linked_accounts::table + .filter(linked_accounts::provider.eq(AccountProvider::Github)) + .filter(linked_accounts::account_id.eq(u.gh_id)) + .load::(&mut conn) + .await + .unwrap(); + + assert_eq!(linked_accounts.len(), 1); + let linked_account = &linked_accounts[0]; + assert_eq!(linked_account.user_id, u.id); + assert_eq!(linked_account.login, u.gh_login); + assert_eq!( + linked_account.access_token.expose_secret(), + u.gh_access_token.expose_secret() + ); + + Ok(()) +} diff --git a/src/tests/util/test_app.rs b/src/tests/util/test_app.rs index 2e155b3332b..b0b43865b4d 100644 --- a/src/tests/util/test_app.rs +++ b/src/tests/util/test_app.rs @@ -3,8 +3,8 @@ use crate::config::{ self, Base, CdnLogQueueConfig, CdnLogStorageConfig, DatabasePools, DbPoolConfig, }; use crate::middleware::cargo_compat::StatusCodeConfig; -use crate::models::NewEmail; use crate::models::token::{CrateScope, EndpointScope}; +use crate::models::{AccountProvider, NewEmail, NewLinkedAccount}; use crate::rate_limiter::{LimitedAction, RateLimiterConfig}; use crate::storage::StorageConfig; use crate::tests::util::chaosproxy::ChaosProxy; @@ -114,8 +114,8 @@ impl TestApp { self.0.test_database.async_connect().await } - /// Create a new user with a verified email address in the database - /// (`@example.com`) and return a mock user session. + /// Create a new user with a verified email address (`@example.com`) + /// and a linked GitHub account in the database and return a mock user session. /// /// This method updates the database directly pub async fn db_new_user(&self, username: &str) -> MockCookieUser { @@ -123,10 +123,19 @@ impl TestApp { let email = format!("{username}@example.com"); - let user = crate::tests::new_user(username) - .insert(&mut conn) - .await - .unwrap(); + let new_user = crate::tests::new_user(username); + let user = new_user.insert(&mut conn).await.unwrap(); + + let linked_account = NewLinkedAccount::builder() + .user_id(user.id) + .provider(AccountProvider::Github) + .account_id(user.gh_id) + .access_token(&new_user.gh_access_token) + .login(&user.gh_login) + .maybe_avatar(user.gh_avatar.as_deref()) + .build(); + + linked_account.insert_or_update(&mut conn).await.unwrap(); let new_email = NewEmail::builder() .user_id(user.id) diff --git a/src/typosquat/test_util.rs b/src/typosquat/test_util.rs index 69875e1261b..ede066dfdf7 100644 --- a/src/typosquat/test_util.rs +++ b/src/typosquat/test_util.rs @@ -41,6 +41,7 @@ pub mod faker { NewUser::builder() .gh_id(next_gh_id()) .gh_login(login) + .username(login) .gh_access_token("token") .build() .insert(conn) diff --git a/src/views.rs b/src/views.rs index c3b3be8443e..824c563c36e 100644 --- a/src/views.rs +++ b/src/views.rs @@ -2,8 +2,8 @@ use chrono::{DateTime, Utc}; use crate::external_urls::remove_blocked_urls; use crate::models::{ - ApiToken, Category, Crate, Dependency, DependencyKind, Keyword, Owner, ReverseDependency, Team, - TopVersions, User, Version, VersionDownload, VersionOwnerAction, + ApiToken, Category, Crate, Dependency, DependencyKind, Keyword, LinkedAccount, Owner, + ReverseDependency, Team, TopVersions, User, Version, VersionDownload, VersionOwnerAction, }; use crates_io_github as github; @@ -521,10 +521,16 @@ pub struct EncodableOwner { #[schema(example = 42)] pub id: i32, - /// The login name of the team or user. + // `login` and `username` should contain the same value for now. + // `login` is deprecated; can be removed when all frontends have migrated to `username`. + /// The GitHub login of the team or user. #[schema(example = "ghost")] pub login: String, + /// The crates.io username of the team or user. + #[schema(example = "ghost")] + pub username: String, + /// The kind of the owner (`user` or `team`). #[schema(example = "user")] pub kind: String, @@ -548,14 +554,17 @@ impl From for EncodableOwner { Owner::User(User { id, name, + username, gh_login, gh_avatar, .. }) => { let url = format!("https://github.com/{gh_login}"); + let username = username.unwrap_or(gh_login); Self { id, - login: gh_login, + login: username.clone(), + username, avatar: gh_avatar, url: Some(url), name, @@ -572,7 +581,8 @@ impl From for EncodableOwner { let url = github::team_url(&login); Self { id, - login, + login: login.clone(), + username: login, url: Some(url), avatar, name, @@ -672,10 +682,16 @@ pub struct EncodablePrivateUser { #[schema(example = 42)] pub id: i32, - /// The user's login name. + // `login` and `username` should contain the same value for now. + // `login` is deprecated; can be removed when all frontends have migrated to `username`. + /// The user's GitHub login. #[schema(example = "ghost")] pub login: String, + /// The user's crates.io username. + #[schema(example = "ghost")] + pub username: String, + /// Whether the user's email address has been verified. #[schema(example = true)] pub email_verified: bool, @@ -720,6 +736,7 @@ impl EncodablePrivateUser { let User { id, name, + username, gh_login, gh_avatar, is_admin, @@ -727,14 +744,16 @@ impl EncodablePrivateUser { .. } = user; let url = format!("https://github.com/{gh_login}"); + let username = username.unwrap_or(gh_login); EncodablePrivateUser { id, + login: username.clone(), + username, email, email_verified, email_verification_sent, avatar: gh_avatar, - login: gh_login, name, url: Some(url), is_admin, @@ -750,10 +769,16 @@ pub struct EncodablePublicUser { #[schema(example = 42)] pub id: i32, - /// The user's login name. + // `login` and `username` should contain the same value for now. + // `login` is deprecated; can be removed when all frontends have migrated to `username`. + /// The user's GitHub login name. #[schema(example = "ghost")] pub login: String, + /// The user's crates.io username. + #[schema(example = "ghost")] + pub username: String, + /// The user's display name, if set. #[schema(example = "Kate Morgan")] pub name: Option, @@ -765,24 +790,107 @@ pub struct EncodablePublicUser { /// The user's GitHub profile URL. #[schema(example = "https://github.com/ghost")] pub url: String, + + /// The accounts linked to this crates.io account. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(no_recursion, example = json!([]))] + pub linked_accounts: Option>, } -/// Converts a `User` model into an `EncodablePublicUser` for JSON serialization. +/// Converts a `User` model into an `EncodablePublicUser` for JSON serialization. Does not include +/// linked accounts. impl From for EncodablePublicUser { fn from(user: User) -> Self { let User { id, name, + username, gh_login, gh_avatar, .. } = user; let url = format!("https://github.com/{gh_login}"); + let username = username.unwrap_or(gh_login); + EncodablePublicUser { id, + login: username.clone(), + username, + name, avatar: gh_avatar, - login: gh_login, + url, + linked_accounts: None, + } + } +} + +impl EncodablePublicUser { + pub fn with_linked_accounts(user: User, linked_accounts: &[LinkedAccount]) -> Self { + let User { + id, + name, + username, + gh_login, + gh_avatar, + .. + } = user; + let url = format!("https://github.com/{gh_login}"); + let username = username.unwrap_or(gh_login); + + let linked_accounts = if linked_accounts.is_empty() { + None + } else { + Some(linked_accounts.iter().map(Into::into).collect()) + }; + + EncodablePublicUser { + id, + login: username.clone(), + username, name, + avatar: gh_avatar, + url, + linked_accounts, + } + } +} + +#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, utoipa::ToSchema)] +#[schema(as = LinkedAccount)] +pub struct EncodableLinkedAccount { + /// The service providing this linked account. + #[schema(example = "GitHub")] + pub provider: String, + + /// The linked account's login name. + #[schema(example = "ghost")] + pub login: String, + + /// The linked account's avatar URL, if set. + #[schema(example = "https://avatars2.githubusercontent.com/u/1234567?v=4")] + pub avatar: Option, + + /// The linked account's profile URL on the provided service. + #[schema(example = "https://github.com/ghost")] + pub url: String, +} + +/// Converts a `LinkedAccount` model into an `EncodableLinkedAccount` for JSON serialization. +impl From<&LinkedAccount> for EncodableLinkedAccount { + fn from(linked_account: &LinkedAccount) -> Self { + let LinkedAccount { + provider, + login, + avatar, + .. + } = linked_account; + + let url = provider.url(login); + + Self { + provider: provider.to_string(), + login: login.clone(), + avatar: avatar.clone(), url, } } @@ -1108,9 +1216,11 @@ mod tests { user: EncodablePublicUser { id: 0, login: String::new(), + username: String::new(), name: None, avatar: None, url: String::new(), + linked_accounts: None, }, time: NaiveDate::from_ymd_opt(2017, 1, 6) .unwrap() diff --git a/src/worker/jobs/downloads/update_metadata.rs b/src/worker/jobs/downloads/update_metadata.rs index a2d676c594c..5cc20a90c64 100644 --- a/src/worker/jobs/downloads/update_metadata.rs +++ b/src/worker/jobs/downloads/update_metadata.rs @@ -115,6 +115,7 @@ mod tests { NewUser::builder() .gh_id(2) .gh_login("login") + .username("login") .gh_access_token("access_token") .build() .insert(conn) diff --git a/src/worker/jobs/expiry_notification.rs b/src/worker/jobs/expiry_notification.rs index 4119eab29aa..1c4add805ae 100644 --- a/src/worker/jobs/expiry_notification.rs +++ b/src/worker/jobs/expiry_notification.rs @@ -186,6 +186,7 @@ mod tests { let user = NewUser::builder() .gh_id(0) .gh_login("a") + .username("a") .gh_access_token("token") .build() .insert(&mut conn) diff --git a/tests/acceptance/api-token-test.js b/tests/acceptance/api-token-test.js index 1c18fcac5ac..c6d8c092c93 100644 --- a/tests/acceptance/api-token-test.js +++ b/tests/acceptance/api-token-test.js @@ -14,6 +14,7 @@ module('Acceptance | api-tokens', function (hooks) { function prepare(context) { let user = context.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/tests/acceptance/crate-test.js b/tests/acceptance/crate-test.js index d3e03704ba9..52127b3f428 100644 --- a/tests/acceptance/crate-test.js +++ b/tests/acceptance/crate-test.js @@ -215,7 +215,7 @@ module('Acceptance | crate page', function (hooks) { skip('crates can be yanked by owner', async function (assert) { loadFixtures(this.db); - let user = this.db.user.findFirst({ where: { login: { equals: 'thehydroimpulse' } } }); + let user = this.db.user.findFirst({ where: { username: { equals: 'thehydroimpulse' } } }); this.authenticateAs(user); await visit('/crates/nanomsg/0.5.0'); @@ -242,7 +242,7 @@ module('Acceptance | crate page', function (hooks) { test('navigating to the owners page when not an owner', async function (assert) { loadFixtures(this.db); - let user = this.db.user.findFirst({ where: { login: { equals: 'iain8' } } }); + let user = this.db.user.findFirst({ where: { username: { equals: 'iain8' } } }); this.authenticateAs(user); await visit('/crates/nanomsg'); @@ -253,7 +253,7 @@ module('Acceptance | crate page', function (hooks) { test('navigating to the settings page', async function (assert) { loadFixtures(this.db); - let user = this.db.user.findFirst({ where: { login: { equals: 'thehydroimpulse' } } }); + let user = this.db.user.findFirst({ where: { username: { equals: 'thehydroimpulse' } } }); this.authenticateAs(user); await visit('/crates/nanomsg'); diff --git a/tests/acceptance/dashboard-test.js b/tests/acceptance/dashboard-test.js index 0bda0593957..e0a31b381c6 100644 --- a/tests/acceptance/dashboard-test.js +++ b/tests/acceptance/dashboard-test.js @@ -21,6 +21,7 @@ module('Acceptance | Dashboard', function (hooks) { test('shows the dashboard when logged in', async function (assert) { let user = this.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/tests/acceptance/login-test.js b/tests/acceptance/login-test.js index 7d000efe358..f3a3e2481ce 100644 --- a/tests/acceptance/login-test.js +++ b/tests/acceptance/login-test.js @@ -45,6 +45,7 @@ module('Acceptance | Login', function (hooks) { user: { id: 42, login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.name', avatar: 'https://avatars2.githubusercontent.com/u/12345?v=4', diff --git a/tests/acceptance/settings/add-owner-test.js b/tests/acceptance/settings/add-owner-test.js index 50ae4130701..1461a8ee73e 100644 --- a/tests/acceptance/settings/add-owner-test.js +++ b/tests/acceptance/settings/add-owner-test.js @@ -43,7 +43,7 @@ module('Acceptance | Settings | Add Owner', function (hooks) { assert .dom('[data-test-notification-message="error"]') - .hasText('Error sending invite: could not find user with login `spookyghostboo`'); + .hasText('Error sending invite: could not find user with username `spookyghostboo`'); assert.dom('[data-test-owners] [data-test-owner-team]').exists({ count: 2 }); assert.dom('[data-test-owners] [data-test-owner-user]').exists({ count: 2 }); }); diff --git a/tests/acceptance/settings/remove-owner-test.js b/tests/acceptance/settings/remove-owner-test.js index 5bcb55973ed..5814eeabfe7 100644 --- a/tests/acceptance/settings/remove-owner-test.js +++ b/tests/acceptance/settings/remove-owner-test.js @@ -48,11 +48,11 @@ module('Acceptance | Settings | Remove Owner', function (hooks) { ); await visit(`/crates/${crate.name}/settings`); - await click(`[data-test-owner-user="${user2.login}"] [data-test-remove-owner-button]`); + await click(`[data-test-owner-user="${user2.username}"] [data-test-remove-owner-button]`); assert .dom('[data-test-notification-message="error"]') - .hasText(`Failed to remove the user ${user2.login} as crate owner: nope`); + .hasText(`Failed to remove the user ${user2.username} as crate owner: nope`); assert.dom('[data-test-owner-user]').exists({ count: 2 }); }); @@ -76,7 +76,7 @@ module('Acceptance | Settings | Remove Owner', function (hooks) { ); await visit(`/crates/${crate.name}/settings`); - await click(`[data-test-owner-team="${team1.login}"] [data-test-remove-owner-button]`); + await click(`[data-test-owner-team="${team1.username}"] [data-test-remove-owner-button]`); assert .dom('[data-test-notification-message="error"]') diff --git a/tests/acceptance/sudo-test.js b/tests/acceptance/sudo-test.js index 274fc7ae412..9bca55687a1 100644 --- a/tests/acceptance/sudo-test.js +++ b/tests/acceptance/sudo-test.js @@ -13,6 +13,7 @@ module('Acceptance | sudo', function (hooks) { function prepare(context, isAdmin) { const user = context.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/tests/components/owners-list-test.js b/tests/components/owners-list-test.js index 5e44df4cd74..fdcbd6c64dd 100644 --- a/tests/components/owners-list-test.js +++ b/tests/components/owners-list-test.js @@ -26,8 +26,8 @@ module('Component | OwnersList', function (hooks) { assert.dom('ul > li').exists({ count: 1 }); assert.dom('[data-test-owner-link]').exists({ count: 1 }); - let logins = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); - assert.deepEqual(logins, ['user-1']); + let usernames = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); + assert.deepEqual(usernames, ['user-1']); assert.dom('[data-test-owner-link="user-1"]').hasText('User 1'); assert.dom('[data-test-owner-link="user-1"]').hasAttribute('href', '/users/user-1'); @@ -37,7 +37,7 @@ module('Component | OwnersList', function (hooks) { let crate = this.db.crate.create(); this.db.version.create({ crate }); - let user = this.db.user.create({ name: null, login: 'anonymous' }); + let user = this.db.user.create({ name: null, username: 'anonymous' }); this.db.crateOwnership.create({ crate, user }); let store = this.owner.lookup('service:store'); @@ -49,8 +49,8 @@ module('Component | OwnersList', function (hooks) { assert.dom('ul > li').exists({ count: 1 }); assert.dom('[data-test-owner-link]').exists({ count: 1 }); - let logins = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); - assert.deepEqual(logins, ['anonymous']); + let usernames = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); + assert.deepEqual(usernames, ['anonymous']); assert.dom('[data-test-owner-link="anonymous"]').hasText('anonymous'); assert.dom('[data-test-owner-link="anonymous"]').hasAttribute('href', '/users/anonymous'); @@ -74,8 +74,8 @@ module('Component | OwnersList', function (hooks) { assert.dom('ul > li').exists({ count: 5 }); assert.dom('[data-test-owner-link]').exists({ count: 5 }); - let logins = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); - assert.deepEqual(logins, ['user-1', 'user-2', 'user-3', 'user-4', 'user-5']); + let usernames = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); + assert.deepEqual(usernames, ['user-1', 'user-2', 'user-3', 'user-4', 'user-5']); }); test('six users', async function (assert) { @@ -96,8 +96,8 @@ module('Component | OwnersList', function (hooks) { assert.dom('ul > li').exists({ count: 6 }); assert.dom('[data-test-owner-link]').exists({ count: 6 }); - let logins = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); - assert.deepEqual(logins, ['user-1', 'user-2', 'user-3', 'user-4', 'user-5', 'user-6']); + let usernames = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); + assert.deepEqual(usernames, ['user-1', 'user-2', 'user-3', 'user-4', 'user-5', 'user-6']); }); test('teams mixed with users', async function (assert) { @@ -122,8 +122,8 @@ module('Component | OwnersList', function (hooks) { assert.dom('ul > li').exists({ count: 5 }); assert.dom('[data-test-owner-link]').exists({ count: 5 }); - let logins = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); - assert.deepEqual(logins, ['github:crates-io:team-1', 'github:crates-io:team-2', 'user-1', 'user-2', 'user-3']); + let usernames = [...this.element.querySelectorAll('[data-test-owner-link]')].map(it => it.dataset.testOwnerLink); + assert.deepEqual(usernames, ['github:crates-io:team-1', 'github:crates-io:team-2', 'user-1', 'user-2', 'user-3']); assert.dom('[data-test-owner-link="github:crates-io:team-1"]').hasText('crates-io/team-1'); assert diff --git a/tests/models/crate-test.js b/tests/models/crate-test.js index bb2e0a8dfde..d677293abc5 100644 --- a/tests/models/crate-test.js +++ b/tests/models/crate-test.js @@ -25,7 +25,7 @@ module('Model | Crate', function (hooks) { let crateRecord = await this.store.findRecord('crate', crate.name); - let result = await crateRecord.inviteOwner(user2.login); + let result = await crateRecord.inviteOwner(user2.username); assert.deepEqual(result, { ok: true, msg: 'user user-2 has been invited to be an owner of crate crate-1' }); }); @@ -39,7 +39,7 @@ module('Model | Crate', function (hooks) { let crateRecord = await this.store.findRecord('crate', crate.name); await assert.rejects(crateRecord.inviteOwner('unknown'), function (error) { - assert.deepEqual(error.errors, [{ detail: 'could not find user with login `unknown`' }]); + assert.deepEqual(error.errors, [{ detail: 'could not find user with username `unknown`' }]); return true; }); }); @@ -58,7 +58,7 @@ module('Model | Crate', function (hooks) { let crateRecord = await this.store.findRecord('crate', crate.name); - let result = await crateRecord.removeOwner(user2.login); + let result = await crateRecord.removeOwner(user2.username); assert.deepEqual(result, { ok: true, msg: 'owners successfully removed' }); }); diff --git a/tests/routes/settings/tokens/index-test.js b/tests/routes/settings/tokens/index-test.js index 042442a7d14..29032c96272 100644 --- a/tests/routes/settings/tokens/index-test.js +++ b/tests/routes/settings/tokens/index-test.js @@ -11,6 +11,7 @@ module('/settings/tokens', function (hooks) { function prepare(context) { let user = context.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4', diff --git a/tests/routes/settings/tokens/new-test.js b/tests/routes/settings/tokens/new-test.js index f1126c0ac34..5183517b044 100644 --- a/tests/routes/settings/tokens/new-test.js +++ b/tests/routes/settings/tokens/new-test.js @@ -15,6 +15,7 @@ module('/settings/tokens/new', function (hooks) { function prepare(context) { let user = context.db.user.create({ login: 'johnnydee', + username: 'johnnydee', name: 'John Doe', email: 'john@doe.com', avatar: 'https://avatars2.githubusercontent.com/u/1234567?v=4',