test(auth): add failing tests for auth service and pb_auth cookie

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>
This commit is contained in:
kamask
2026-11-08 22:41:32 +03:00
co-authored by Claude Opus 5.5
parent 115b5fb036
commit 74d3c660f9
11 changed files with 527 additions and 0 deletions
@@ -0,0 +1,153 @@
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);
});
});
});