mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
eb4c1c6604
commit
115b5fb036
+12
-10
@@ -9,16 +9,18 @@
|
||||
## users (auth-коллекция, уже существует в PocketBase)
|
||||
|
||||
| Поле | Тип | Ограничения |
|
||||
| --- | --- | --- |
|
||||
| `email` | email | обязательно, уникально |
|
||||
| ---------- | -------- | ------------------------------------------------------------------- |
|
||||
| `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` | меняет только себя |
|
||||
@@ -31,7 +33,7 @@
|
||||
Метки заменяют и категории, и источники заявок: «Установка Windows», «YouDo», «Профи.ру», «Сарафан», «Гарантия» — это всё метки. Набор у каждого пользователя свой.
|
||||
|
||||
| Поле | Тип | Ограничения |
|
||||
| --- | --- | --- |
|
||||
| ------- | ---------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `owner` | relation → users | обязательно, одна, `cascadeDelete: true` |
|
||||
| `name` | text | обязательно, 1–50 символов |
|
||||
| `color` | select | одно из: `gray`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `purple`, `pink`; обязательно |
|
||||
@@ -43,7 +45,7 @@
|
||||
## clients — клиенты
|
||||
|
||||
| Поле | Тип | Ограничения |
|
||||
| --- | --- | --- |
|
||||
| --------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `owner` | relation → users | обязательно, `cascadeDelete: true` |
|
||||
| `name` | text | обязательно, 1–200 символов (как клиента зовут или как он подписан на площадке) |
|
||||
| `phones` | json | массив нормализованных номеров (`["79991234567"]`), до 5 штук, можно пустой |
|
||||
@@ -60,7 +62,7 @@
|
||||
## orders — заявки
|
||||
|
||||
| Поле | Тип | Ограничения |
|
||||
| --- | --- | --- |
|
||||
| -------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `owner` | relation → users | обязательно, `cascadeDelete: true` |
|
||||
| `client` | relation → clients | обязательно, одна, без каскада |
|
||||
| `title` | text | обязательно, 1–200 символов |
|
||||
@@ -80,7 +82,7 @@
|
||||
Статусы и подписи в UI:
|
||||
|
||||
| Значение | Подпись |
|
||||
| --- | --- |
|
||||
| ------------- | ------------- |
|
||||
| `new` | Новая |
|
||||
| `scheduled` | Запланирована |
|
||||
| `in_progress` | В работе |
|
||||
@@ -94,7 +96,7 @@
|
||||
### Соответствие полям YouDo (для справки)
|
||||
|
||||
| YouDo | Pchelpcrm |
|
||||
| --- | --- |
|
||||
| -------------------- | -------------------------- |
|
||||
| title, description | `title`, `description` |
|
||||
| price | `price` |
|
||||
| address, privateInfo | `address` |
|
||||
@@ -110,7 +112,7 @@
|
||||
Общий шаблон (для `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)` |
|
||||
@@ -128,7 +130,7 @@
|
||||
API-тесты проверяют именно это поведение. Если фактический код в PocketBase 0.40 окажется другим, тесты фиксируют фактический, а главное требование неизменно: **чужие данные никогда не попадают в ответ и не меняются**.
|
||||
|
||||
| Операция | Правило не выполнено | Правило `null` (не суперпользователь) |
|
||||
| --- | --- | --- |
|
||||
| -------- | ------------------------------- | ------------------------------------- |
|
||||
| list | 200, чужие записи отфильтрованы | 403 |
|
||||
| view | 404 | 403 |
|
||||
| create | 400 | 403 |
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Встроенная проверка 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');
|
||||
@@ -0,0 +1,40 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
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`',
|
||||
}),
|
||||
);
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user