package pocketbase import ( "strings" "sync" "testing" "unicode/utf8" "github.com/pocketbase/pocketbase/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase/migrations" ) // Проверки узнавания: как учётная запись находится и как заводится. // Заведение идемпотентно: второе обращение попадает в ту же запись и не // переписывает её. // // Не переписывает — половина требования, и она отдельная: перепись на каждом // запросе означала бы запись в базу на каждый запрос, а правка имени у // провайдера меняла бы карточку человека молча, посреди его работы. func TestEnsureUserIsIdempotent(t *testing.T) { app := newTestStorage(t) first, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Алиса", Email: "alice@example.test"}) require.NoError(t, err) second, _, err := EnsureUser(app, Identity{Login: "alice", Name: "Другое имя", Email: "other@example.test"}) require.NoError(t, err) assert.Equal(t, first.Id, second.Id, "второе обращение завело вторую запись") assert.Equal(t, "Алиса", second.GetString("name"), "имя переписано вторым обращением") assert.Equal(t, "alice@example.test", second.Email(), "почта переписана вторым обращением") assert.Equal(t, 1, countUsers(t, app)) } // Одновременные первые обращения одним логином дают одну учётную запись. // // Проверка стоит потому, что норма без неё держалась бы на одном уникальном // индексе: забытый в шаге схемы, он дал бы зелёную приёмку и две учётные записи // на одного человека — а архив разъехался бы между ними молча и склеить его было // бы нечем. func TestEnsureUserSurvivesConcurrentFirstRequests(t *testing.T) { app := newTestStorage(t) const racers = 8 var wg sync.WaitGroup ids := make([]string, racers) errs := make([]error, racers) start := make(chan struct{}) for i := range racers { wg.Add(1) go func() { defer wg.Done() <-start record, _, err := EnsureUser(app, Identity{Login: "racer", Name: "Гонщик"}) errs[i] = err if record != nil { ids[i] = record.Id } }() } close(start) wg.Wait() for i, err := range errs { require.NoError(t, err, "обращение %d отказало", i) } for i, id := range ids { assert.Equal(t, ids[0], id, "обращение %d попало в другую учётную запись", i) } assert.Equal(t, 1, countUsers(t, app), "гонка завела больше одной учётной записи") } // Занятая почта не мешает завести запись: она достаётся первому, а второй // заводится без неё. // // Общий почтовый ящик — обычное дело в семье, а Authelia вправе отдать один // адрес группе. Без разреза двух отказов уникальности второй человек не завёлся // бы никогда: повторный поиск по логину снова ничего не находит. func TestEnsureUserWithTakenEmail(t *testing.T) { app := newTestStorage(t) first, _, err := EnsureUser(app, Identity{Login: "one", Email: "family@example.test"}) require.NoError(t, err) second, _, err := EnsureUser(app, Identity{Login: "two", Email: "family@example.test"}) require.NoError(t, err) assert.NotEqual(t, first.Id, second.Id) assert.Equal(t, "family@example.test", first.Email(), "почта досталась первому") assert.Empty(t, second.Email(), "второму почта не досталась, но запись завелась") assert.Equal(t, 2, countUsers(t, app)) } // Вырожденный логин никого не узнаёт и ничего не заводит. func TestEnsureUserRejectsDegenerateLogin(t *testing.T) { app := newTestStorage(t) values := map[string]string{ "пустой": "", "одни пробелы": " \t ", "управляющий знак": "ali\x00ce", "длиннее предела": strings.Repeat("a", MaxProviderLoginLength+1), } for name, value := range values { t.Run(name, func(t *testing.T) { record, _, err := EnsureUser(app, Identity{Login: value}) assert.Nil(t, record) require.ErrorIs(t, err, ErrLoginNotAcceptable) assert.Equal(t, 0, countUsers(t, app)) }) } } // Обрамляющие пробелы срезаются: заголовок с ведущим пробелом и без него // называет одного человека, и две записи о нём разошлись бы молча. func TestEnsureUserTrimsSurroundingSpaces(t *testing.T) { app := newTestStorage(t) first, _, err := EnsureUser(app, Identity{Login: "alice"}) require.NoError(t, err) second, _, err := EnsureUser(app, Identity{Login: " alice "}) require.NoError(t, err) assert.Equal(t, first.Id, second.Id) assert.Equal(t, 1, countUsers(t, app)) } // Сравнение точное: приведение регистра завело бы правило, которого у // провайдера нет, — и склеило бы двух разных людей. func TestEnsureUserComparesExactly(t *testing.T) { app := newTestStorage(t) lower, _, err := EnsureUser(app, Identity{Login: "alice"}) require.NoError(t, err) upper, _, err := EnsureUser(app, Identity{Login: "Alice"}) require.NoError(t, err) assert.NotEqual(t, lower.Id, upper.Id) assert.Equal(t, 2, countUsers(t, app)) } // Значение, похожее на условие отбора, ищется как значение, а не как часть // запроса: оно уходит хранилищу параметром. func TestEnsureUserDoesNotLetLoginChangeTheQuery(t *testing.T) { app := newTestStorage(t) victim, _, err := EnsureUser(app, Identity{Login: "victim"}) require.NoError(t, err) attacker, _, err := EnsureUser(app, Identity{Login: `x" || provider_login = "victim`}) require.NoError(t, err) assert.NotEqual(t, victim.Id, attacker.Id, "значение изменило сам запрос и вернуло чужую учётную запись") assert.Equal(t, 2, countUsers(t, app)) } func countUsers(t *testing.T, app core.App) int { t.Helper() records, err := app.FindAllRecords(migrations.UsersCollection) require.NoError(t, err) return len(records) } // Признак заведения отличает первое обращение от всех следующих. // // По нему слой узнавания пишет строку журнала, и без него владелец не отличит // «никто не заходил» от «завелось двадцать»: убрать заведённую запись потом // нечем. func TestEnsureUserReportsWhetherItCreated(t *testing.T) { app := newTestStorage(t) _, created, err := EnsureUser(app, Identity{Login: "alice"}) require.NoError(t, err) assert.True(t, created, "первое обращение не назвалось заведением") _, created, err = EnsureUser(app, Identity{Login: "alice"}) require.NoError(t, err) assert.False(t, created, "второе обращение назвалось заведением") } // Негодное имя не отменяет заведения: оно обрезается по пределу колонки. // // Прежде имя уходило в колонку как есть, и человек с длинным именем у // провайдера получал отказ сервиса на **каждом** запросе — учётная запись не // заводилась никогда, а починить у себя он ничего не мог. func TestEnsureUserAcceptsDegenerateName(t *testing.T) { app := newTestStorage(t) long := strings.Repeat("я", MaxDisplayNameLength+50) record, created, err := EnsureUser(app, Identity{Login: "bob", Name: long}) require.NoError(t, err, "негодное имя отменило заведение записи") require.True(t, created) name := record.GetString("name") assert.Equal(t, MaxDisplayNameLength, utf8.RuneCountInString(name), "имя не обрезано по пределу") assert.NotEmpty(t, name) } // Управляющие знаки из имени убираются: значение приезжает заголовком. func TestEnsureUserStripsControlsFromName(t *testing.T) { app := newTestStorage(t) record, _, err := EnsureUser(app, Identity{Login: "carol", Name: "Ка\x00ро\nл"}) require.NoError(t, err) assert.Equal(t, "Карол", record.GetString("name")) } // Негодная почта отбрасывается **явно**, а не через ветвь «почта занята». // // Иначе опечатка в контуре неотличима от общего семейного ящика, и владелец // ищет второго человека там, где сломан прокси. func TestEnsureUserDropsMalformedEmail(t *testing.T) { app := newTestStorage(t) record, created, err := EnsureUser(app, Identity{Login: "dave", Email: "не-адрес"}) require.NoError(t, err, "негодная почта отменила заведение записи") require.True(t, created) assert.Empty(t, record.Email()) } // Предел логина считается в знаках, а не в байтах: колонка считает знаки. // // Прежде кириллический логин длиннее половины предела отвергался навсегда, // хотя колонка приняла бы его. func TestEnsureUserCountsLoginInRunes(t *testing.T) { app := newTestStorage(t) login := strings.Repeat("я", MaxProviderLoginLength) record, _, err := EnsureUser(app, Identity{Login: login}) require.NoError(t, err, "логин ровно на пределе отвергнут: предел считается в байтах") assert.Equal(t, login, record.GetString(migrations.ProviderLoginField)) _, _, err = EnsureUser(app, Identity{Login: strings.Repeat("я", MaxProviderLoginLength+1)}) assert.ErrorIs(t, err, ErrLoginNotAcceptable, "логин сверх предела принят") } // Ключ учётной записи не меняется после заведения — ни правкой в панели, ни // прямым сохранением. // // Правила доступа коллекции закрывают только путь снаружи; панель работает // суперпользователем. Переписанный ключ отдал бы весь архив следующему, кто // придёт с этим именем, и вернуть его было бы нечем. func TestProviderLoginIsImmutable(t *testing.T) { app := newTestStorage(t) BindPanelRules(app) record, _, err := EnsureUser(app, Identity{Login: "victim-owner"}) require.NoError(t, err) record.Set(migrations.ProviderLoginField, "someone-else") err = app.Save(record) require.Error(t, err, "ключ учётной записи переписан прямым сохранением") stored, err := app.FindRecordById(migrations.UsersCollection, record.Id) require.NoError(t, err) assert.Equal(t, "victim-owner", stored.GetString(migrations.ProviderLoginField)) } // Правка прочих полей учётной записи при этом проходит: хук сторожит один ключ, // а не запирает коллекцию целиком. func TestUserRecordStaysEditableExceptTheKey(t *testing.T) { app := newTestStorage(t) BindPanelRules(app) record, _, err := EnsureUser(app, Identity{Login: "editable", Name: "Прежнее"}) require.NoError(t, err) record.Set("name", "Новое") require.NoError(t, app.Save(record), "правка имени в панели отвергнута") stored, err := app.FindRecordById(migrations.UsersCollection, record.Id) require.NoError(t, err) assert.Equal(t, "Новое", stored.GetString("name")) } // Две учётные записи без ключа уживаются: индекс частичный, как и соседний // индекс почты. // // Сплошной индекс ронял бы накатку шага на всякой базе, где записей больше // одной, — то есть у разработчика, ходившего прежним рецептом входа. func TestEmptyProviderLoginDoesNotCollide(t *testing.T) { app := newTestStorage(t) users, err := app.FindCollectionByNameOrId(migrations.UsersCollection) require.NoError(t, err) for _, name := range []string{"Первый", "Второй"} { record := core.NewRecord(users) record.Set("name", name) record.SetRandomPassword() require.NoError(t, app.Save(record), "вторая запись без ключа отвергнута индексом") } assert.Equal(t, 2, countUsers(t, app)) }