diff --git a/.deadcode-out b/.deadcode-out index 97093ce93b..6d2c35e374 100644 --- a/.deadcode-out +++ b/.deadcode-out @@ -23,6 +23,7 @@ forgejo.org/models/db DumpTables GetTableNames extendBeansForCascade + IsErrNameActivityPubInvalid forgejo.org/models/dbfs file.renameTo diff --git a/models/db/name.go b/models/db/name.go index 29b60b2373..d456f49d9c 100644 --- a/models/db/name.go +++ b/models/db/name.go @@ -80,6 +80,31 @@ func (err ErrNameCharsNotAllowed) Unwrap() error { return util.ErrInvalidArgument } +// ErrNameActivityPubInvalid represents an error for usernames which cannot +// belong to ActivityPub accounts. +type ErrNameActivityPubInvalid struct { + Name string +} + +// Similarly to IsErrNameCharsNotAllowed, IsErrNameActivityPubInvalid checks if +// an error is an ErrNameActivityPubInvalid. +func IsErrNameActivityPubInvalid(err error) bool { + _, ok := err.(ErrNameActivityPubInvalid) + return ok +} + +func (err ErrNameActivityPubInvalid) Error() string { + return fmt.Sprintf( + "name is invalid [%s]: not acceptable for users from federated activitypub instances (e.g. @username@domain.example)", + err.Name, + ) +} + +// Unwrap unwraps this as a ErrInvalidArgument err +func (err ErrNameActivityPubInvalid) Unwrap() error { + return util.ErrInvalidArgument +} + // IsUsableName checks if name is reserved or pattern of name is not allowed // based on given reserved names and patterns. // Names are exact match, patterns can be prefix or suffix match with placeholder '*'. diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index 4e957f30f3..17592ef330 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -1566,9 +1566,9 @@ - id: 42 - lower_name: federated-example.net - name: federated-example.net - full_name: federated + lower_name: "@federated@example.net" + name: "@federated@example.net" + full_name: "@federated@example.net" email: f73240e82-c061-41ef-b7d6-4376cb6f2e1c@example.com keep_email_private: false email_notifications_preference: enabled @@ -1576,7 +1576,7 @@ passwd_hash_algo: "" must_change_password: false login_source: 0 - login_name: federated-example.net + login_name: "@federated@example.net" type: 6 salt: "" max_repo_creation: -1 diff --git a/models/forgejo_migrations/v14a_ap-change-fedi-handle-structure.go b/models/forgejo_migrations/v14a_ap-change-fedi-handle-structure.go new file mode 100644 index 0000000000..c59f778205 --- /dev/null +++ b/models/forgejo_migrations/v14a_ap-change-fedi-handle-structure.go @@ -0,0 +1,150 @@ +// Copyright 2025 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "context" + "fmt" + "strings" + + "forgejo.org/models/db" + "forgejo.org/models/forgefed" + user_model "forgejo.org/models/user" + "forgejo.org/modules/log" + "forgejo.org/modules/validation" + user_service "forgejo.org/services/user" + + "xorm.io/xorm" +) + +func init() { + registerMigration(&Migration{ + Description: "use structure @PreferredUsername@host.tld:port for actors", + Upgrade: changeActivityPubUsernameFormat, + }) +} + +func changeActivityPubUsernameFormat(x *xorm.Engine) error { + // Normally, the db.WithTx statement ensures that the database transaction (aka. all changes made + // by this migration) will only be committed if the SQL operations inside of the iteration + // (db.Iterate) don't return an error. + // + // This migration was originally authored with those cases in mind, but it was later agreed that + // migrations concerning Forgejo's federation-related components should not return any errors at + // this point in time, as federation is not considered to be stable at the moment. For more + // information, check the relevant discussion here: + // https://codeberg.org/forgejo-contrib/federation/issues/67 + // + // Nevertheless, this structure involves some useful boilerplate that can be used for future + // migrations at a later point and has been kept as-is. + return db.WithTx(db.DefaultContext, func(ctx context.Context) error { + // The transaction is committed only if modifying all federated users is possible. + return db.Iterate(ctx, nil, func(ctx context.Context, federatedUser *user_model.FederatedUser) error { + // localUser represents the "local" representation of an ActivityPub (federated) user + localUser := &user_model.User{} + has, err := db.GetEngine(ctx).ID(federatedUser.UserID).Get(localUser) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while getting local user (ID: %d), ignoring...: %e", federatedUser.UserID, err) + return nil + } + + if !has { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: User missing for federated user: %v", federatedUser) + err := user_model.DeleteFederatedUser(ctx, federatedUser.UserID) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while deleting federated user (%s), ignoring...: %e", federatedUser, err) + return nil + } + } + + if validation.IsValidActivityPubUsername(localUser.Name) { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: FederatedUser was already migrated: %v", federatedUser) + } else { + // Copied from models/forgefed/federationhost_repository.go (forgefed.GetFederationHost), + // minus some validation code for FederationHost which we do not otherwise manipulate here. + federationHost := new(forgefed.FederationHost) + has, err := db.GetEngine(ctx).ID(federatedUser.FederationHostID).Get(federationHost) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while looking up federation host info (for %v), ignoring...: %e", federatedUser, err) + return nil + } else if !has { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Federation host for federated user missing, deleting: %v", federatedUser) + err := user_model.DeleteFederatedUser(ctx, federatedUser.UserID) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while deleting federated user (%v), ignoring...: %e", federatedUser, err) + return nil + } + + err = user_service.DeleteUser(ctx, localUser, true) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while deleting user (%s), ignoring...: %v", localUser.LogString(), err) + } + + return nil + } + + // Take part of the username before the first dash, reconstruct the rest + // of it using whatever we have in FederationHost. Before this migration, + // usernames of ActivityPub accounts have an expected format of + // "username-subdomain-domain-tld-port". We don't know how many subdomains + // there are, but that doesn't matter. We can always get the username unless + // if the username of an ActivityPub account was manually changed by an admin, + // in which case they should either delete the account or change it back. + s := strings.Split(localUser.Name, "-") + if len(s) == 0 { + log.Warn( + "Migration[v14a_ap-change-fedi-handle-structure]: Username %s belonging to federatedUser %v does not contain any dashes, can't construct new username", + localUser.Name, + federatedUser, + ) + return nil + } + + // Were a running Forgejo instance to create a new federated account, would the port + // have been marked as "supplemented" (thus leading to its omission)? + var newUsername string + if (federationHost.HostPort == 443 && federationHost.HostSchema == "https") || (federationHost.HostPort == 80 && federationHost.HostSchema == "http") { + newUsername = fmt.Sprintf("@%s@%s", s[0], federationHost.HostFqdn) + } else { + newUsername = fmt.Sprintf("@%s@%s:%d", s[0], federationHost.HostFqdn, federationHost.HostPort) + } + + // Implicitly assumes that there won't be a lower name unique constraint violation. + // Potentially a bit paranoid, but why not? + userThatShouldntExist := &user_model.User{} + lowernameTaken, err := db.GetEngine(ctx).Where("lower_name = ?", strings.ToLower(newUsername)).Table("user").Get(userThatShouldntExist) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred, skipping migration of %s: %e", localUser.LogString(), err) + return nil + } + + if lowernameTaken { + log.Warn( + "Migration[v14a_ap-change-fedi-handle-structure]: New username %s for %s already taken by %s, deleting the former...", + newUsername, + localUser.LogString(), + userThatShouldntExist.LogString(), + ) + err := user_model.DeleteFederatedUser(ctx, localUser.ID) + if err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while deleting federated user (%s), ignoring...: %e", localUser.LogString(), err) + } + return nil + } + + // Safe to assume that the following operations should just work now. + log.Info("Migration[v14a_ap-change-fedi-handle-structure]: Updating username of %s to %s", localUser.LogString(), newUsername) + if _, err := db.GetEngine(ctx).ID(localUser.ID).Cols("lower_name", "name").Update(&user_model.User{ + LowerName: strings.ToLower(newUsername), + Name: newUsername, + }); err != nil { + log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred when updating federated user's username (%s), ignoring...: %e", localUser.LogString(), err) + return nil + } + } + + return nil + }) + }) +} diff --git a/models/user/user.go b/models/user/user.go index 0fd8061a89..cc5b4abec3 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -698,6 +698,14 @@ func IsUsableUsername(name string) error { return db.IsUsableName(reservedUsernames, reservedUserPatterns, name) } +// IsActivityPubUsername returns an error if a fediverse handle (referred to as a username) cannot exist +func IsActivityPubUsername(name string) error { + if !validation.IsValidActivityPubUsername(name) { + return db.ErrNameActivityPubInvalid{Name: name} + } + return db.IsUsableName(reservedUsernames, reservedUserPatterns, name) +} + // CreateUserOverwriteOptions are an optional options who overwrite system defaults on user creation type CreateUserOverwriteOptions struct { KeepEmailPrivate optional.Option[bool] @@ -708,6 +716,7 @@ type CreateUserOverwriteOptions struct { Theme *string IsRestricted optional.Option[bool] IsActive optional.Option[bool] + IsActivityPub optional.Option[bool] } // CreateUser creates record of a new user. @@ -722,12 +731,26 @@ func AdminCreateUser(ctx context.Context, u *User, overwriteDefault ...*CreateUs // createUser creates record of a new user. func createUser(ctx context.Context, u *User, createdByAdmin bool, overwriteDefault ...*CreateUserOverwriteOptions) (err error) { - if err = IsUsableUsername(u.Name); err != nil { + overwriteDefaultPresent := len(overwriteDefault) != 0 && overwriteDefault[0] != nil + + // If a username is invalid as-is, check whether the username is meant + // for an ActivityPub account. Username constraints that belong to "foreign" + // ActivityPub servers, whose implementations we cannot control, are expected + // to be much less restrictive than those of Forgejo itself. + if overwriteDefaultPresent && overwriteDefault[0].IsActivityPub.Has() { + if err = IsActivityPubUsername(u.Name); err != nil { + return err + } + } else if err := IsUsableUsername(u.Name); err != nil { return err } // Check if the new username can be claimed. // Skip this check if done by an admin. + // + // Note: This skip should not currently cover usernames that could belong to + // fediverse accounts. This "defensive programming" is in place to prevent future + // breakage until the ActivityPub component matures more. if !createdByAdmin { if ok, expireTime, err := CanClaimUsername(ctx, u.Name, -1); err != nil { return err @@ -754,7 +777,7 @@ func createUser(ctx context.Context, u *User, createdByAdmin bool, overwriteDefa } // overwrite defaults if set - if len(overwriteDefault) != 0 && overwriteDefault[0] != nil { + if overwriteDefaultPresent { overwrite := overwriteDefault[0] if overwrite.KeepEmailPrivate.Has() { u.KeepEmailPrivate = overwrite.KeepEmailPrivate.Value() diff --git a/models/user/user_repository.go b/models/user/user_repository.go index f1d06abe17..9692bd7304 100644 --- a/models/user/user_repository.go +++ b/models/user/user_repository.go @@ -23,8 +23,9 @@ func CreateFederatedUser(ctx context.Context, user *User, federatedUser *Federat return err } overwrite := CreateUserOverwriteOptions{ - IsActive: optional.Some(false), - IsRestricted: optional.Some(false), + IsActive: optional.Some(false), + IsRestricted: optional.Some(false), + IsActivityPub: optional.Some(true), } // Begin transaction diff --git a/models/user/user_test.go b/models/user/user_test.go index 330a3fd563..4db253df18 100644 --- a/models/user/user_test.go +++ b/models/user/user_test.go @@ -440,6 +440,63 @@ func TestCreateUserClaimingUsername(t *testing.T) { }) } +// Attempts to create a username with a fediverse-format handle, which should +// fail (without the override IsActivityPub, which is set by CreateFederatedUser) +func TestCreateUserPlainWithFediverseHandle(t *testing.T) { + require.NoError(t, unittest.PrepareTestDatabase()) + + _, err := db.GetEngine(db.DefaultContext).NoAutoTime().Insert(&user_model.Redirect{RedirectUserID: 1, LowerName: "redirecting", CreatedUnix: timeutil.TimeStampNow()}) + require.NoError(t, err) + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + + user.Name = "@example@example.tld" + user.LowerName = strings.ToLower(user.Name) + user.ID = 0 + user.Email = "unique@example.com" + + t.Run("Normal creation (without ActivityPub override)", func(t *testing.T) { + err = user_model.CreateUser(db.DefaultContext, user) + require.Error(t, err) + assert.True(t, db.IsErrNameCharsNotAllowed(err)) + }) + + t.Run("Creation as admin (without ActivityPub override)", func(t *testing.T) { + err = user_model.AdminCreateUser(db.DefaultContext, user) + require.Error(t, err) + assert.True(t, db.IsErrNameCharsNotAllowed(err)) + }) + + // Logic borrowed from CreateFederatedUser (which invokes CreateUser), but + // we "lend" this here to verify CreateUser's paths. + overwrite := user_model.CreateUserOverwriteOptions{ + IsActive: optional.Some(false), + IsRestricted: optional.Some(false), + IsActivityPub: optional.Some(true), + } + + t.Run("Normal creation (with ActivityPub override, invalid format)", func(t *testing.T) { + user.Name = "invalid-format-for-an-activitypub-account" + user.LowerName = strings.ToLower(user.Name) + + err = user_model.CreateUser(db.DefaultContext, user, &overwrite) + require.Error(t, err) + assert.True(t, db.IsErrNameActivityPubInvalid(err)) + }) + + t.Run("Normal creation (with ActivityPub override)", func(t *testing.T) { + user.Name = "@valid@example.tld" + user.LowerName = strings.ToLower(user.Name) + + err = user_model.CreateUser(db.DefaultContext, user, &overwrite) + require.NoError(t, err) + }) + + // Note: We don't expect that admins are able to access any front-facing + // function that sets the overwrite (i.e. CreateFederatedUser), hence it + // has been omitted for now. +} + func TestGetUserIDsByNames(t *testing.T) { require.NoError(t, unittest.PrepareTestDatabase()) diff --git a/modules/forgefed/actor_person.go b/modules/forgefed/actor_person.go index 7c43b0d7ce..72768f4815 100644 --- a/modules/forgefed/actor_person.go +++ b/modules/forgefed/actor_person.go @@ -71,12 +71,13 @@ func (id PersonID) AsLoginName() string { return result } +// HostSuffix returns the host part of a handle, i.e. @host.tld (if port is supplemented) or @host.tld:1234 func (id PersonID) HostSuffix() string { var result string if !id.IsPortSupplemented { - result = fmt.Sprintf("-%s-%d", strings.ToLower(id.Host), id.HostPort) + result = fmt.Sprintf("@%s:%d", strings.ToLower(id.Host), id.HostPort) } else { - result = fmt.Sprintf("-%s", strings.ToLower(id.Host)) + result = fmt.Sprintf("@%s", strings.ToLower(id.Host)) } return result } diff --git a/modules/forgefed/actor_person_test.go b/modules/forgefed/actor_person_test.go index a5f3ee47b1..82bd9fabc3 100644 --- a/modules/forgefed/actor_person_test.go +++ b/modules/forgefed/actor_person_test.go @@ -246,8 +246,19 @@ func TestForgePersonValidation(t *testing.T) { func TestAsloginName(t *testing.T) { sut, _ := forgefed.NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo") - assert.Equal(t, "12345-codeberg.org", sut.AsLoginName()) + assert.Equal(t, "12345@codeberg.org", sut.AsLoginName()) sut, _ = forgefed.NewPersonID("https://codeberg.org:443/api/v1/activitypub/user-id/12345", "forgejo") - assert.Equal(t, "12345-codeberg.org-443", sut.AsLoginName()) + assert.Equal(t, "12345@codeberg.org:443", sut.AsLoginName()) +} + +func TestHostSuffix(t *testing.T) { + sut, _ := forgefed.NewPersonID("https://codeberg.org/api/v1/activitypub/user-id/12345", "forgejo") + sut.Host = "forgejo.example.tld" + sut.HostPort = 80 + + // sut.IsPortSupplemented is true by default at time of writing. + assert.Equal(t, "@forgejo.example.tld", sut.HostSuffix()) + sut.IsPortSupplemented = false + assert.Equal(t, "@forgejo.example.tld:80", sut.HostSuffix()) } diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go index 4b28dead03..848fb70af5 100644 --- a/modules/validation/helpers.go +++ b/modules/validation/helpers.go @@ -102,6 +102,21 @@ var ( // No consecutive or trailing non-alphanumeric chars, catches both cases invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`) + + // This is intended to accept any character, in any language, with accent symbols, + // as well as an arbitrary amount of subdomains and an optional port number defined + // through `:12345`. + // + // This is intended to cover username cases from distant servers in the fediverse, which + // can have much laxer requirements than those of Forgejo. It is not intended to check for + // invalid, non-standard compliant domains. + // + // For instance, the following should work: + // @user.όνομαß_21__@subdomain1.subdomain2.example.tld:65536 + // @42@42.example.tld + // @user@example.tld:99999 (presumed to be an impossible case) + // @-@-.tld (also impossible) + validFediverseUsernamePattern = regexp.MustCompile(`^(@[\p{L}\p{M}0-9_\.\-]{1,})(@[\p{L}\p{M}0-9_\.\-]{1,})(:[1-9][0-9]{0,4})?$`) ) // IsValidUsername checks if username is valid @@ -114,3 +129,11 @@ func IsValidUsername(name string) bool { return validUsernamePatternWithoutDots.MatchString(name) && !invalidUsernamePattern.MatchString(name) } + +// IsValidActivityPubUsername checks whether the username can be a valid ActivityPub handle. +// +// Username refers to the Forgejo user account's username for consistency, and not +// e.g. "username" in @username@example.tld. +func IsValidActivityPubUsername(name string) bool { + return validFediverseUsernamePattern.MatchString(name) +} diff --git a/modules/validation/helpers_test.go b/modules/validation/helpers_test.go index 7e32184691..6bca3664e1 100644 --- a/modules/validation/helpers_test.go +++ b/modules/validation/helpers_test.go @@ -1,4 +1,5 @@ // Copyright 2018 The Gitea Authors. All rights reserved. +// Copyright 2025 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package validation @@ -213,3 +214,109 @@ func TestIsValidUsernameBanDots(t *testing.T) { }) } } + +func TestIsValidActivityPubUsername(t *testing.T) { + cases := []struct { + description string + username string + valid bool + }{ + { + description: "Username without domain", + username: "@user", + valid: false, + }, + { + description: "Username with domain", + username: "@user@example.tld", + valid: true, + }, + { + description: "Numeric username with subdomain", + username: "@42@42.example.tld", + valid: true, + }, + { + description: "Username with two subdomains", + username: "@user@forgejo.activitypub.example.tld", + valid: true, + }, + { + description: "Username with domain and without port", + username: "@user@social.example.tld:", + valid: false, + }, + { + description: "Username with domain and invalid port 0", + username: "@user@social.example.tld:0", + valid: false, + }, + { + // We do not validate the port and assume that federationHost.HostPort + // cannot present such invalid ports. That also makes the previous case + // (port: 0) redundant, but it doesn't hurt. + description: "Username with domain and valid port", + username: "@user@social.example.tld:65536", + valid: true, + }, + { + description: "Username with Latin letters and special symbols", + username: "@$username$@example.tld", + valid: false, + }, + { + description: "Strictly numeric handle, domain, TLD", + username: "@0123456789@0123456789.0123456789.123", + valid: true, + }, + { + description: "Handle with Latin characters and dashes", + username: "@0-O@O-O.tld", + valid: true, + }, + // This is an impossible case, but we assume that this will never happen + // to begin with. + { + description: "Handle that only has dashes", + username: "@-@-.-", + valid: true, + }, + { + description: "Username with a mix of Latin and non-Latin letters containing accents", + username: "@usernäme.όνομαß_21__@example.tld", + valid: true, + }, + // Note: Our regex should accept any character, in any language and with accent symbols. + // The list is neither exhaustive, nor does it represent all possible cases. + // I chose some TLDs from https://en.wikipedia.org/wiki/Country_code_top-level_domain, + // although only one test case should suffice in theory. Nevertheless, to play it safe, + // I included four from different geographic regions whose scripts were legible using my + // IDE's default font to play it safe. + { + description: "Username, domain and ccTLD in Greek", + username: "@ευ@ευ.ευ", + valid: true, + }, + { + description: "Username, domain and ccTLD in Georgian (Mkhedruli)", + username: "@გე@გე.გე", + valid: true, + }, + { + description: "Username, domain and ccTLD of Malaysia (Arabic Jawi)", + username: "@مليسيا@ລمليسيا.مليسيا", + valid: true, + }, + { + description: "Username, domain and ccTLD of China (Simplified)", + username: "@中国@中国.中国", + valid: true, + }, + } + + for _, testCase := range cases { + t.Run(testCase.description, func(t *testing.T) { + assert.Equal(t, testCase.valid, IsValidActivityPubUsername(testCase.username)) + }) + } +} diff --git a/services/federation/federation_service.go b/services/federation/federation_service.go index 69b2379cce..d0336881a8 100644 --- a/services/federation/federation_service.go +++ b/services/federation/federation_service.go @@ -173,7 +173,7 @@ func fetchUserFromAP(ctx context.Context, personID fm.PersonID, federationHostID email := fmt.Sprintf("f%v@%v", uuid.New().String(), localFqdn.Hostname()) loginName := personID.AsLoginName() - name := fmt.Sprintf("%v%v", person.PreferredUsername.String(), personID.HostSuffix()) + name := fmt.Sprintf("@%v%v", person.PreferredUsername.String(), personID.HostSuffix()) fullName := person.Name.String() if len(person.Name) == 0 { diff --git a/tests/integration/admin_user_test.go b/tests/integration/admin_user_test.go index 02d5898826..611a0e89c6 100644 --- a/tests/integration/admin_user_test.go +++ b/tests/integration/admin_user_test.go @@ -171,11 +171,11 @@ func TestSourceId(t *testing.T) { defer tests.PrepareTestEnv(t)() testUser23 := &user_model.User{ - Name: "ausersourceid23", - LoginName: "ausersourceid23", + Name: "@ausersourceid23@example.net", + LoginName: "@ausersourceid23@example.net", Email: "ausersourceid23@example.com", Passwd: "ausersourceid23password", - Type: user_model.UserTypeIndividual, + Type: user_model.UserTypeRemoteUser, LoginType: auth_model.Plain, LoginSource: 23, } @@ -184,13 +184,17 @@ func TestSourceId(t *testing.T) { session := loginUser(t, "user1") token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeReadAdmin) - // Our new user start with 'a' so it should be the first one + // Historic background: The user was previously called "ausersourceid23", but the + // test started failing on PostgreSQL specifically because of another federated user + // in a fixture called @federated@example.net - this did not apply to other database + // engines. Said user's username began with an 'a' so that it comes up on top, so, we + // simply made another federated user that starts with '@a' here as an easy way out. req := NewRequest(t, "GET", "/api/v1/admin/users?limit=1").AddTokenAuth(token) resp := session.MakeRequest(t, req, http.StatusOK) var users []api.User DecodeJSON(t, resp, &users) assert.Len(t, users, 1) - assert.Equal(t, "ausersourceid23", users[0].UserName) + assert.Equal(t, "@ausersourceid23@example.net", users[0].UserName) // Now our new user should not be in the list, because we filter by source_id 0 req = NewRequest(t, "GET", "/api/v1/admin/users?limit=1&source_id=0").AddTokenAuth(token) @@ -204,7 +208,7 @@ func TestSourceId(t *testing.T) { resp = session.MakeRequest(t, req, http.StatusOK) DecodeJSON(t, resp, &users) assert.Len(t, users, 1) - assert.Equal(t, "ausersourceid23", users[0].UserName) + assert.Equal(t, "@ausersourceid23@example.net", users[0].UserName) } func TestAdminViewUsersSorted(t *testing.T) {