mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
Tests first for task 2.2; the implementation comes separately. - AuthService contract (auth/testing/auth-service.contract.ts), run on the in-memory fake (unit) and on PocketBaseAuthService against a test PocketBase (integration) - integration: register persists the user, login maps id/email/name, restore with a valid, broken or missing token, signals follow the auth store, two users in separate injectors, NetworkError and kept session when PocketBase is unreachable - unit (jsdom): pb_auth cookie is written on sign-in and removed on sign-out with Path=/ and SameSite=Lax, loaded into the auth store before restore() at startup, untouched on the server platform - contract stubs that throw "Not implemented": User, AuthService, PocketBaseAuthService, provideAuth, InMemoryAuthService (signInAs, failNextWith) - docs/testing.md: note on the AuthService contract Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
6.5 KiB
TypeScript
154 lines
6.5 KiB
TypeScript
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
|
|
import { TestBed } from '@angular/core/testing';
|
|
import type PocketBase from 'pocketbase';
|
|
import { inject } from 'vitest';
|
|
import { asSuperuser, asUser, createUser } from '../../../../tests/support/clients';
|
|
import { NetworkError } from '../../shared/errors/domain-errors';
|
|
import { PB_URL } from '../../shared/pocketbase/pb-url';
|
|
import { POCKETBASE, providePocketBase } from '../../shared/pocketbase/pocketbase-client';
|
|
import type { AuthService } from '../domain/auth-service';
|
|
import { describeAuthServiceContract } from '../testing/auth-service.contract';
|
|
import { PocketBaseAuthService } from './pocketbase-auth-service';
|
|
|
|
// Сервис из отдельного инжектора приложения, как у отдельного SSR-запроса или вкладки.
|
|
function createService(pbUrl: string = inject('pbUrl')): { auth: AuthService; pb: PocketBase } {
|
|
const injector = createEnvironmentInjector(
|
|
[providePocketBase(), { provide: PB_URL, useValue: pbUrl }, PocketBaseAuthService],
|
|
TestBed.inject(EnvironmentInjector),
|
|
);
|
|
return { auth: injector.get(PocketBaseAuthService), pb: injector.get(POCKETBASE) };
|
|
}
|
|
|
|
/** Токен и запись настоящего входа, как их кладёт в auth store cookie. */
|
|
async function sessionOf(user: Awaited<ReturnType<typeof createUser>>) {
|
|
const { authStore } = await asUser(user);
|
|
return { token: authStore.token, record: authStore.record };
|
|
}
|
|
|
|
/** JWT с той же шапкой и данными, но чужой подписью: локально выглядит валидным, сервер отвергает. */
|
|
function withBrokenSignature(token: string): string {
|
|
const [header, payload, signature] = token.split('.');
|
|
return `${header}.${payload}.${'x'.repeat(signature.length)}`;
|
|
}
|
|
|
|
describeAuthServiceContract(() => createService().auth);
|
|
|
|
describe('PocketBaseAuthService против тестового PocketBase', () => {
|
|
it('register сохраняет пользователя в users', async () => {
|
|
const { auth } = createService();
|
|
const email = `user-${crypto.randomUUID()}@test.local`;
|
|
|
|
const user = await auth.register({ email, password: crypto.randomUUID(), name: 'Иван' });
|
|
|
|
const record = await (
|
|
await asSuperuser()
|
|
)
|
|
.collection('users')
|
|
.getFirstListItem(`email = "${email}"`);
|
|
expect(record.id).toBe(user.id);
|
|
expect(record['name']).toBe('Иван');
|
|
});
|
|
|
|
it('login пользователя, созданного в PocketBase, отдаёт id, email и имя', async () => {
|
|
const created = await createUser({ name: 'Иван' });
|
|
const { auth } = createService();
|
|
|
|
await auth.login(created.email, created.password);
|
|
|
|
expect(auth.user()).toEqual({ id: created.id, email: created.email, name: 'Иван' });
|
|
});
|
|
|
|
it('restore с валидным токеном обновляет токен и данные пользователя', async () => {
|
|
const created = await createUser({ name: 'Иван' });
|
|
const { token, record } = await sessionOf(created);
|
|
const { auth, pb } = createService();
|
|
pb.authStore.save(token, record);
|
|
await (await asSuperuser()).collection('users').update(created.id, { name: 'Пётр' });
|
|
// JWT выпущенный в ту же секунду совпадает с прежним: ждём смены секунды в exp.
|
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
|
|
await auth.restore();
|
|
|
|
expect(auth.user()).toEqual({ id: created.id, email: created.email, name: 'Пётр' });
|
|
expect(auth.isAuthenticated()).toBe(true);
|
|
expect(pb.authStore.token).not.toBe('');
|
|
expect(pb.authStore.token).not.toBe(token);
|
|
});
|
|
|
|
it('restore с испорченным токеном завершает сессию', async () => {
|
|
const { token, record } = await sessionOf(await createUser());
|
|
const { auth, pb } = createService();
|
|
pb.authStore.save(withBrokenSignature(token), record);
|
|
expect(auth.user()).not.toBeNull();
|
|
|
|
await auth.restore();
|
|
|
|
expect(auth.user()).toBeNull();
|
|
expect(auth.isAuthenticated()).toBe(false);
|
|
expect(pb.authStore.token).toBe('');
|
|
});
|
|
|
|
it('restore без токена ничего не делает', async () => {
|
|
const { auth } = createService();
|
|
|
|
await expect(auth.restore()).resolves.toBeUndefined();
|
|
|
|
expect(auth.user()).toBeNull();
|
|
});
|
|
|
|
it('user() следует за любым изменением auth store', async () => {
|
|
const created = await createUser();
|
|
const { auth, pb } = createService();
|
|
await auth.login(created.email, created.password);
|
|
|
|
pb.authStore.clear();
|
|
|
|
expect(auth.user()).toBeNull();
|
|
expect(auth.isAuthenticated()).toBe(false);
|
|
});
|
|
|
|
it('сессии двух пользователей в разных инжекторах независимы', async () => {
|
|
const [userA, userB] = await Promise.all([createUser(), createUser()]);
|
|
const a = createService().auth;
|
|
const b = createService().auth;
|
|
|
|
await Promise.all([
|
|
a.login(userA.email, userA.password),
|
|
b.login(userB.email, userB.password),
|
|
]);
|
|
|
|
expect(a.user()?.id).toBe(userA.id);
|
|
expect(b.user()?.id).toBe(userB.id);
|
|
|
|
a.logout();
|
|
|
|
expect(a.user()).toBeNull();
|
|
expect(b.user()?.id).toBe(userB.id);
|
|
});
|
|
|
|
describe('PocketBase недоступен', () => {
|
|
const unreachableUrl = 'http://127.0.0.1:1';
|
|
|
|
it('login → NetworkError', async () => {
|
|
const { auth } = createService(unreachableUrl);
|
|
|
|
await expect(auth.login('user@test.local', 'password-1')).rejects.toBeInstanceOf(
|
|
NetworkError,
|
|
);
|
|
expect(auth.user()).toBeNull();
|
|
});
|
|
|
|
it('restore сохраняет сессию', async () => {
|
|
const created = await createUser();
|
|
const { token, record } = await sessionOf(created);
|
|
const { auth, pb } = createService(unreachableUrl);
|
|
pb.authStore.save(token, record);
|
|
|
|
await expect(auth.restore()).resolves.toBeUndefined();
|
|
|
|
expect(auth.user()?.id).toBe(created.id);
|
|
expect(pb.authStore.token).toBe(token);
|
|
});
|
|
});
|
|
});
|