From 115b5fb036aebef958deddcdebff26d8442e038d Mon Sep 17 00:00:00 2001 From: kamask Date: Thu, 5 Nov 2026 18:24:33 +0300 Subject: [PATCH] feat(users): configure users collection and validate email format Migration sets access rules, password min 8, name max 100, password-only auth, and a case-insensitive unique email index. A hook rejects non-ASCII and malformed emails on every save. API tests cover registration, login, and isolation between two users and a guest. Co-Authored-By: Claude Sonnet 5.5 --- docs/data-model.md | 152 +++++------ pocketbase/pb_hooks/users_email.pb.js | 17 ++ .../1793884947_configure_users.js | 40 +++ tests/api/users.api.spec.ts | 251 ++++++++++++++++++ 4 files changed, 385 insertions(+), 75 deletions(-) create mode 100644 pocketbase/pb_hooks/users_email.pb.js create mode 100644 pocketbase/pb_migrations/1793884947_configure_users.js create mode 100644 tests/api/users.api.spec.ts diff --git a/docs/data-model.md b/docs/data-model.md index a44ad47..a0777ba 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -8,21 +8,23 @@ ## users (auth-коллекция, уже существует в PocketBase) -| Поле | Тип | Ограничения | -| --- | --- | --- | -| `email` | email | обязательно, уникально | -| `password` | password | минимум 8 символов | -| `name` | text | необязательно, до 100 символов | -| `verified` | bool | системное; гость не может выставить `true` | +| Поле | Тип | Ограничения | +| ---------- | -------- | ------------------------------------------------------------------- | +| `email` | email | обязательно, уникально без учёта регистра (индекс `COLLATE NOCASE`) | +| `password` | password | минимум 8 символов | +| `name` | text | необязательно, до 100 символов | +| `verified` | bool | системное; гость не может выставить `true` | + +Формат email дополнительно проверяет хук `pb_hooks/users_email.pb.js` при любом сохранении записи: только печатные символы ASCII без пробелов, минимум один символ до `@`, один после `@`, точка и минимум два символа после неё. Поэтому кириллические адреса отклоняются (400, ошибка в поле `email`), а индекс `COLLATE NOCASE` покрывает все допустимые адреса. Способ входа — только email + пароль (OAuth2, OTP, MFA выключены). -| Правило | Значение | Смысл | -| --- | --- | --- | -| list / view | `id = @request.auth.id` | пользователь видит только себя | -| create | `""` (всем) | открытая регистрация | -| update | `id = @request.auth.id` | меняет только себя | -| delete | `null` (только суперпользователь) | удаление аккаунта в MVP не делаем | +| Правило | Значение | Смысл | +| ----------- | --------------------------------- | --------------------------------- | +| list / view | `id = @request.auth.id` | пользователь видит только себя | +| create | `""` (всем) | открытая регистрация | +| update | `id = @request.auth.id` | меняет только себя | +| delete | `null` (только суперпользователь) | удаление аккаунта в MVP не делаем | Поле `avatar`, созданное PocketBase по умолчанию, можно оставить, в MVP оно не используется. @@ -30,11 +32,11 @@ Метки заменяют и категории, и источники заявок: «Установка Windows», «YouDo», «Профи.ру», «Сарафан», «Гарантия» — это всё метки. Набор у каждого пользователя свой. -| Поле | Тип | Ограничения | -| --- | --- | --- | -| `owner` | relation → users | обязательно, одна, `cascadeDelete: true` | -| `name` | text | обязательно, 1–50 символов | -| `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно | +| Поле | Тип | Ограничения | +| ------- | ---------------- | -------------------------------------------------------------------------------------------------- | +| `owner` | relation → users | обязательно, одна, `cascadeDelete: true` | +| `name` | text | обязательно, 1–50 символов | +| `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно | Индекс: уникальный `(owner, name COLLATE NOCASE)` — у одного пользователя нет двух меток с одинаковым названием без учёта регистра; у разных пользователей названия могут совпадать. @@ -42,16 +44,16 @@ ## clients — клиенты -| Поле | Тип | Ограничения | -| --- | --- | --- | -| `owner` | relation → users | обязательно, `cascadeDelete: true` | -| `name` | text | обязательно, 1–200 символов (как клиента зовут или как он подписан на площадке) | -| `phones` | json | массив нормализованных номеров (`["79991234567"]`), до 5 штук, можно пустой | -| `email` | email | необязательно | -| `contacts` | text | свободный текст до 2000 символов: мессенджеры и прочие контакты | -| `external_urls` | json | необязательно; массив ссылок на профили клиента на любых площадках (YouDo, Профи.ру, Avito и др.), до 5 штук, каждая — валидный `http(s)` URL | -| `address` | text | необязательно, до 500 символов | -| `note` | text | внутренняя заметка, до 5000 символов | +| Поле | Тип | Ограничения | +| --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `owner` | relation → users | обязательно, `cascadeDelete: true` | +| `name` | text | обязательно, 1–200 символов (как клиента зовут или как он подписан на площадке) | +| `phones` | json | массив нормализованных номеров (`["79991234567"]`), до 5 штук, можно пустой | +| `email` | email | необязательно | +| `contacts` | text | свободный текст до 2000 символов: мессенджеры и прочие контакты | +| `external_urls` | json | необязательно; массив ссылок на профили клиента на любых площадках (YouDo, Профи.ру, Avito и др.), до 5 штук, каждая — валидный `http(s)` URL | +| `address` | text | необязательно, до 500 символов | +| `note` | text | внутренняя заметка, до 5000 символов | Нормализация телефона (делает фронтенд, функция в `clients/domain`): оставить только цифры; `8XXXXXXXXXX` → `7XXXXXXXXXX`; 10 цифр, начинающиеся на `9`, → добавить `7`. Остальные номера хранятся как есть (только цифры). @@ -59,33 +61,33 @@ ## orders — заявки -| Поле | Тип | Ограничения | -| --- | --- | --- | -| `owner` | relation → users | обязательно, `cascadeDelete: true` | -| `client` | relation → clients | обязательно, одна, без каскада | -| `title` | text | обязательно, 1–200 символов | -| `description` | text | описание проблемы со слов клиента, до 10000 | -| `status` | select | обязательно: `new`, `scheduled`, `in_progress`, `done`, `failed` | -| `scheduled_at` | date | дата и время визита, необязательно | -| `address` | text | адрес выезда, до 500 (по умолчанию в форме подставляется адрес клиента) | -| `price` | number | целые рубли, ≥ 0, необязательно | -| `result` | text | что сделано, до 10000; **виден по публичной ссылке** | -| `note` | text | примечания мастера, до 10000; **видны по публичной ссылке** | -| `review` | text | отзыв клиента (например, с площадки), до 5000; **виден по публичной ссылке** | -| `external_url` | url | необязательно; ссылка на страницу заказа на любой площадке (YouDo, Профи.ру, Avito и др.) | -| `tags` | relation → tags | несколько, до 20, необязательно | -| `attachments` | file | несколько, до 20 файлов, до 50 МБ каждый, **protected**; изображения (`image/jpeg`, `image/png`, `image/webp`, `image/heic`) и аудио (`audio/mpeg`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/ogg`, `audio/wav`, `audio/webm`, `audio/amr`); миниатюры `200x200` | -| `share_token` | text | необязательно; если задан — 43 символа `[A-Za-z0-9_-]`; уникален среди непустых | +| Поле | Тип | Ограничения | +| -------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `owner` | relation → users | обязательно, `cascadeDelete: true` | +| `client` | relation → clients | обязательно, одна, без каскада | +| `title` | text | обязательно, 1–200 символов | +| `description` | text | описание проблемы со слов клиента, до 10000 | +| `status` | select | обязательно: `new`, `scheduled`, `in_progress`, `done`, `failed` | +| `scheduled_at` | date | дата и время визита, необязательно | +| `address` | text | адрес выезда, до 500 (по умолчанию в форме подставляется адрес клиента) | +| `price` | number | целые рубли, ≥ 0, необязательно | +| `result` | text | что сделано, до 10000; **виден по публичной ссылке** | +| `note` | text | примечания мастера, до 10000; **видны по публичной ссылке** | +| `review` | text | отзыв клиента (например, с площадки), до 5000; **виден по публичной ссылке** | +| `external_url` | url | необязательно; ссылка на страницу заказа на любой площадке (YouDo, Профи.ру, Avito и др.) | +| `tags` | relation → tags | несколько, до 20, необязательно | +| `attachments` | file | несколько, до 20 файлов, до 50 МБ каждый, **protected**; изображения (`image/jpeg`, `image/png`, `image/webp`, `image/heic`) и аудио (`audio/mpeg`, `audio/mp4`, `audio/x-m4a`, `audio/aac`, `audio/ogg`, `audio/wav`, `audio/webm`, `audio/amr`); миниатюры `200x200` | +| `share_token` | text | необязательно; если задан — 43 символа `[A-Za-z0-9_-]`; уникален среди непустых | Статусы и подписи в UI: -| Значение | Подпись | -| --- | --- | -| `new` | Новая | -| `scheduled` | Запланирована | -| `in_progress` | В работе | -| `done` | Выполнена | -| `failed` | Не выполнена | +| Значение | Подпись | +| ------------- | ------------- | +| `new` | Новая | +| `scheduled` | Запланирована | +| `in_progress` | В работе | +| `done` | Выполнена | +| `failed` | Не выполнена | Переходы между статусами не ограничены: мастер может выставить любой. @@ -93,28 +95,28 @@ ### Соответствие полям YouDo (для справки) -| YouDo | Pchelpcrm | -| --- | --- | -| title, description | `title`, `description` | -| price | `price` | -| address, privateInfo | `address` | -| customerName | `clients.name` | -| customerUrl | `clients.external_urls` | -| taskUrl | `external_url` | -| category | метка | -| review | `review` | -| taskDate | `created` / `scheduled_at` | +| YouDo | Pchelpcrm | +| -------------------- | -------------------------- | +| title, description | `title`, `description` | +| price | `price` | +| address, privateInfo | `address` | +| customerName | `clients.name` | +| customerUrl | `clients.external_urls` | +| taskUrl | `external_url` | +| category | метка | +| review | `review` | +| taskDate | `created` / `scheduled_at` | ## Правила доступа для tags, clients, orders Общий шаблон (для `orders` добавляются проверки связей): -| Правило | Значение | -| --- | --- | -| list / view | `owner = @request.auth.id` | -| create | `@request.auth.id != "" && owner = @request.auth.id` | -| update | `owner = @request.auth.id && (@request.body.owner:isset = false \|\| @request.body.owner = @request.auth.id)` | -| delete | `owner = @request.auth.id` | +| Правило | Значение | +| ----------- | ------------------------------------------------------------------------------------------------------------- | +| list / view | `owner = @request.auth.id` | +| create | `@request.auth.id != "" && owner = @request.auth.id` | +| update | `owner = @request.auth.id && (@request.body.owner:isset = false \|\| @request.body.owner = @request.auth.id)` | +| delete | `owner = @request.auth.id` | Дополнительно для `orders` (create и update): @@ -127,13 +129,13 @@ API-тесты проверяют именно это поведение. Если фактический код в PocketBase 0.40 окажется другим, тесты фиксируют фактический, а главное требование неизменно: **чужие данные никогда не попадают в ответ и не меняются**. -| Операция | Правило не выполнено | Правило `null` (не суперпользователь) | -| --- | --- | --- | -| list | 200, чужие записи отфильтрованы | 403 | -| view | 404 | 403 | -| create | 400 | 403 | -| update | 404 | 403 | -| delete | 404 | 403 | +| Операция | Правило не выполнено | Правило `null` (не суперпользователь) | +| -------- | ------------------------------- | ------------------------------------- | +| list | 200, чужие записи отфильтрованы | 403 | +| view | 404 | 403 | +| create | 400 | 403 | +| update | 404 | 403 | +| delete | 404 | 403 | ## Публичный маршрут diff --git a/pocketbase/pb_hooks/users_email.pb.js b/pocketbase/pb_hooks/users_email.pb.js new file mode 100644 index 0000000..e69b63e --- /dev/null +++ b/pocketbase/pb_hooks/users_email.pb.js @@ -0,0 +1,17 @@ +/// + +// Встроенная проверка PocketBase пропускает кириллицу и домен верхнего уровня из одной буквы. +// onRecordValidate срабатывает при любом сохранении: регистрация, смена email, суперпользователь. +onRecordValidate((e) => { + // Обработчики выполняются изолированно, поэтому регулярка объявлена внутри. + // Только печатные символы ASCII без пробела: что-то@что-то.минимум-два-символа. + const email = /^[!-?A-~]+@[!-?A-~]+\.[!-?A-~]{2,}$/; + + if (!email.test(e.record.email())) { + throw new BadRequestError('Некорректный email.', { + email: new ValidationError('validation_invalid_email', 'Некорректный email.'), + }); + } + + e.next(); +}, 'users'); diff --git a/pocketbase/pb_migrations/1793884947_configure_users.js b/pocketbase/pb_migrations/1793884947_configure_users.js new file mode 100644 index 0000000..f0e7dc4 --- /dev/null +++ b/pocketbase/pb_migrations/1793884947_configure_users.js @@ -0,0 +1,40 @@ +/// + +function configureUsers(app, { deleteRule, nameMax, emailColumn }) { + const users = app.findCollectionByNameOrId('users'); + + users.listRule = 'id = @request.auth.id'; + users.viewRule = 'id = @request.auth.id'; + users.createRule = ''; + users.updateRule = 'id = @request.auth.id'; + users.deleteRule = deleteRule; + + // Встроенный индекс PocketBase учитывает регистр: без замены A@x.ru и a@x.ru — разные аккаунты. + users.addIndex('idx_email__pb_users_auth_', true, emailColumn, "`email` != ''"); + + users.fields.getByName('password').min = 8; + users.fields.getByName('name').max = nameMax; + + users.passwordAuth.enabled = true; + users.passwordAuth.identityFields = ['email']; + users.oauth2.enabled = false; + users.otp.enabled = false; + users.mfa.enabled = false; + + app.save(users); +} + +migrate( + (app) => + configureUsers(app, { + deleteRule: null, + nameMax: 100, + emailColumn: '`email` COLLATE NOCASE', + }), + (app) => + configureUsers(app, { + deleteRule: 'id = @request.auth.id', + nameMax: 255, + emailColumn: '`email`', + }), +); diff --git a/tests/api/users.api.spec.ts b/tests/api/users.api.spec.ts new file mode 100644 index 0000000..42a72eb --- /dev/null +++ b/tests/api/users.api.spec.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest'; +import { asGuest, asSuperuser, asUser, createUser } from '../support/clients'; + +const newEmail = () => `user-${crypto.randomUUID()}@test.local`; + +function register(overrides: Record = {}) { + const data = { email: newEmail(), password: crypto.randomUUID(), ...overrides }; + return asGuest() + .collection('users') + .create({ ...data, passwordConfirm: data.password }); +} + +const invalidField = (field: string) => ({ + status: 400, + response: { data: { [field]: expect.anything() } }, +}); + +describe('users: регистрация', () => { + it('регистрирует гостя с валидными данными, неподтверждённым и с именем', async () => { + const record = await register({ name: 'Иван' }); + + expect(record['verified']).toBe(false); + expect(record['name']).toBe('Иван'); + }); + + it('регистрирует гостя без имени', async () => { + await expect(register()).resolves.toMatchObject({ verified: false }); + }); + + it('принимает пароль из 8 символов и имя из 100 символов', async () => { + const name = 'я'.repeat(100); + + const record = await register({ password: 'a1b2c3d4', name }); + + expect(record['name']).toBe(name); + }); + + it('не даёт гостю зарегистрироваться подтверждённым', async () => { + const email = newEmail(); + + // Поведение 6 допускает оба исхода: поле проигнорировано или запрос отклонён. + await register({ email, verified: true }).catch((error: unknown) => { + expect(error).toMatchObject({ status: 400 }); + }); + + const su = await asSuperuser(); + const { items } = await su + .collection('users') + .getList(1, 1, { filter: su.filter('email = {:email}', { email }) }); + expect(items.map((record) => record['verified'])).not.toContain(true); + }); + + it('отклоняет повторный email с ошибкой в поле email', async () => { + const email = newEmail(); + await register({ email }); + + await expect(register({ email })).rejects.toMatchObject(invalidField('email')); + }); + + it('отклоняет тот же email в другом регистре с ошибкой в поле email', async () => { + const email = newEmail(); + await register({ email }); + + await expect(register({ email: email.toUpperCase() })).rejects.toMatchObject( + invalidField('email'), + ); + }); + + it('отклоняет пароль из 7 символов с ошибкой в поле password', async () => { + await expect(register({ password: 'a1b2c3d' })).rejects.toMatchObject( + invalidField('password'), + ); + }); + + it('отклоняет невалидный email с ошибкой в поле email', async () => { + await expect(register({ email: 'not-an-email' })).rejects.toMatchObject( + invalidField('email'), + ); + }); + + it.each([ + 'иван@test.local', + 'user@домен.рф', + 'user@test.рф', + 'a@b.c', + 'a@b', + '@test.local', + 'user@.local', + ])('отклоняет email %s с ошибкой в поле email', async (email) => { + await expect(register({ email })).rejects.toMatchObject(invalidField('email')); + }); + + it.each(['a@b.cd', 'first.last+tag@sub.example.com'])('принимает email %s', async (email) => { + await expect(register({ email })).resolves.toMatchObject({ id: expect.any(String) }); + }); + + it('не даёт сменить email на не-ASCII даже суперпользователю', async () => { + const a = await createUser(); + const su = await asSuperuser(); + + await expect( + su + .collection('users') + .update(a.id, { email: `иван-${crypto.randomUUID()}@test.local` }), + ).rejects.toMatchObject(invalidField('email')); + + const record = await su.collection('users').getOne(a.id); + expect(record['email']).toBe(a.email); + }); + + it('отклоняет имя из 101 символа с ошибкой в поле name', async () => { + await expect(register({ name: 'я'.repeat(101) })).rejects.toMatchObject( + invalidField('name'), + ); + }); +}); + +describe('users: вход', () => { + it('с верным паролем возвращает токен и запись пользователя', async () => { + const user = await createUser(); + + const auth = await asGuest() + .collection('users') + .authWithPassword(user.email, user.password); + + expect(auth.token).not.toBe(''); + expect(auth.record.id).toBe(user.id); + }); + + it('с неверным паролем отвечает 400', async () => { + const user = await createUser(); + + await expect( + asGuest().collection('users').authWithPassword(user.email, 'wrong-password'), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('из способов входа включён только пароль по email', async () => { + const methods = await asGuest().collection('users').listAuthMethods(); + + expect(methods.password.enabled).toBe(true); + expect(methods.password.identityFields).toEqual(['email']); + expect(methods.oauth2.enabled).toBe(false); + expect(methods.otp.enabled).toBe(false); + expect(methods.mfa.enabled).toBe(false); + }); +}); + +describe('users: доступ', () => { + it('показывает пользователю в list только его самого', async () => { + const a = await createUser(); + await createUser(); + + const { items, totalItems } = await (await asUser(a)).collection('users').getList(); + + expect(items.map((record) => record.id)).toEqual([a.id]); + expect(totalItems).toBe(1); + }); + + it('отдаёт пользователю свою запись и не отдаёт чужую', async () => { + const a = await createUser(); + const b = await createUser(); + const pb = await asUser(a); + + await expect(pb.collection('users').getOne(a.id)).resolves.toMatchObject({ id: a.id }); + await expect(pb.collection('users').getOne(b.id)).rejects.toMatchObject({ status: 404 }); + }); + + it('не показывает пользователей гостю', async () => { + const a = await createUser(); + const guest = asGuest(); + + const { items, totalItems } = await guest.collection('users').getList(); + + expect(items).toEqual([]); + expect(totalItems).toBe(0); + await expect(guest.collection('users').getOne(a.id)).rejects.toMatchObject({ + status: 404, + }); + }); + + it('не даёт гостю изменить и удалить пользователя', async () => { + const a = await createUser({ name: 'А' }); + const guest = asGuest(); + + await expect( + guest.collection('users').update(a.id, { name: 'Взлом' }), + ).rejects.toMatchObject({ status: 404 }); + await expect(guest.collection('users').delete(a.id)).rejects.toMatchObject({ status: 403 }); + + const record = await (await asSuperuser()).collection('users').getOne(a.id); + expect(record['name']).toBe('А'); + }); + + it('не находит чужую запись через filter по email', async () => { + const a = await createUser(); + const b = await createUser(); + + for (const pb of [await asUser(a), asGuest()]) { + const { items, totalItems } = await pb + .collection('users') + .getList(1, 50, { filter: pb.filter('email = {:email}', { email: b.email }) }); + + expect(items).toEqual([]); + expect(totalItems).toBe(0); + } + }); + + it('даёт изменить своё имя и не даёт изменить чужое', async () => { + const a = await createUser({ name: 'А' }); + const b = await createUser({ name: 'Б' }); + const pb = await asUser(a); + + await expect(pb.collection('users').update(a.id, { name: 'Новое' })).resolves.toMatchObject( + { name: 'Новое' }, + ); + await expect(pb.collection('users').update(b.id, { name: 'Взлом' })).rejects.toMatchObject({ + status: 404, + }); + + const recordB = await (await asSuperuser()).collection('users').getOne(b.id); + expect(recordB['name']).toBe('Б'); + }); + + it('не даёт пользователю подтвердить себя через update', async () => { + const a = await createUser(); + const pb = await asUser(a); + + // Важен итог, а не ответ: поле может быть проигнорировано или запрос отклонён. + await pb + .collection('users') + .update(a.id, { verified: true }) + .catch(() => undefined); + + const record = await (await asSuperuser()).collection('users').getOne(a.id); + expect(record['verified']).toBe(false); + }); + + it('не даёт пользователю удалить ни свою, ни чужую запись', async () => { + const a = await createUser(); + const b = await createUser(); + const pb = await asUser(a); + + await expect(pb.collection('users').delete(a.id)).rejects.toMatchObject({ status: 403 }); + await expect(pb.collection('users').delete(b.id)).rejects.toMatchObject({ status: 403 }); + + const su = await asSuperuser(); + await expect(su.collection('users').getOne(a.id)).resolves.toMatchObject({ id: a.id }); + await expect(su.collection('users').getOne(b.id)).resolves.toMatchObject({ id: b.id }); + }); +});