Files
transcriber/internal/adapter/repo/pocketbase/identity_test.go
T
av 7f33c957e5 вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с
  адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни
  корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе
- учётная запись заводится первым обращением: EnsureUser в пакете хранилища,
  шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users
- cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт
  унаследованный DL3066 — пользователь образа назван числом
2026-08-22 20:24:22 +03:00

317 lines
14 KiB
Go

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))
}