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
+2
View File
@@ -76,6 +76,8 @@ export function describeTagRepositoryContract(
и вызывается дважды: в `in-memory-tag-repository.spec.ts` (быстро, в `pnpm test`) и в `pocketbase-tag-repository.integration.spec.ts` (на тестовом PocketBase, в `pnpm test:integration`).
Так же устроен контракт сервиса `AuthService`: `auth/testing/auth-service.contract.ts`. Файл контракта не `*.spec.ts`, поэтому `describe`/`it`/`expect` импортируются из `vitest` явно.
## Тестовый PocketBase
API-, integration- и e2e-тесты работают с **отдельным временным экземпляром** PocketBase, а не с `pocketbase/pb_data`:
+132
View File
@@ -0,0 +1,132 @@
import { ApplicationInitStatus, PLATFORM_ID, type Provider } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { BaseAuthStore, type RecordModel } from 'pocketbase';
import { PB_URL } from '../../shared/pocketbase/pb-url';
import { POCKETBASE, providePocketBase } from '../../shared/pocketbase/pocketbase-client';
import { provideAuth } from '../provide-auth';
import { PocketBaseAuthService } from './pocketbase-auth-service';
const record: RecordModel = {
id: 'u1',
collectionId: '_pb_users_auth_',
collectionName: 'users',
email: 'user@test.local',
name: 'Иван',
};
/** JWT без настоящей подписи, но с exp через час: по exp SDK считает токен живым и ставит срок cookie. */
function fakeJwt(): string {
const encode = (part: object) =>
btoa(JSON.stringify(part)).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
const exp = Math.floor(Date.now() / 1000) + 3600;
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode({ id: record.id, type: 'auth', exp })}.signature`;
}
function hasAuthCookie(): boolean {
return document.cookie.split('; ').some((cookie) => cookie.startsWith('pb_auth='));
}
/** Кладёт cookie сессии так, как её оставил бы прошлый визит. */
function putAuthCookie(token: string): void {
const store = new BaseAuthStore();
store.save(token, record);
document.cookie = store.exportToCookie({ httpOnly: false, secure: false });
}
/** Поднимает приложение с provideAuth() и дожидается инициализаторов; сети нет. */
async function startApp(providers: Provider[] = []) {
TestBed.configureTestingModule({
providers: [
providePocketBase(),
{ provide: PB_URL, useValue: 'http://pb.test:1234' },
provideAuth(),
...providers,
],
});
await TestBed.inject(ApplicationInitStatus).donePromise;
return TestBed.inject(POCKETBASE);
}
afterEach(() => {
document.cookie = 'pb_auth=; Path=/; Max-Age=0';
vi.restoreAllMocks();
});
describe('cookie pb_auth в браузере', () => {
it('после входа cookie содержит сессию из auth store', async () => {
const pb = await startApp();
const token = fakeJwt();
pb.authStore.save(token, record);
expect(hasAuthCookie()).toBe(true);
const restored = new BaseAuthStore();
restored.loadFromCookie(document.cookie);
expect(restored.token).toBe(token);
expect(restored.record?.id).toBe(record.id);
});
it('после выхода cookie нет', async () => {
const pb = await startApp();
pb.authStore.save(fakeJwt(), record);
pb.authStore.clear();
expect(hasAuthCookie()).toBe(false);
});
it('пишется с Path=/ и SameSite=Lax, без HttpOnly и без Secure на http', async () => {
const pb = await startApp();
const writes = vi.spyOn(document, 'cookie', 'set');
pb.authStore.save(fakeJwt(), record);
const written = writes.mock.calls.map(([value]) => String(value));
const authCookie = written.find((value) => value.startsWith('pb_auth='));
expect(authCookie).toBeDefined();
expect(authCookie).toMatch(/;\s*Path=\/(;|$)/i);
expect(authCookie).toMatch(/;\s*SameSite=Lax(;|$)/i);
expect(authCookie).not.toMatch(/;\s*HttpOnly/i);
expect(authCookie).not.toMatch(/;\s*Secure/i);
});
it('при старте токен из cookie попадает в auth store до вызова restore()', async () => {
const token = fakeJwt();
putAuthCookie(token);
let tokenAtRestore: string | undefined;
const restore = vi
.spyOn(PocketBaseAuthService.prototype, 'restore')
.mockImplementation(async () => {
tokenAtRestore = TestBed.inject(POCKETBASE).authStore.token;
});
const pb = await startApp();
expect(pb.authStore.token).toBe(token);
expect(pb.authStore.record?.id).toBe(record.id);
expect(restore).toHaveBeenCalledTimes(1);
expect(tokenAtRestore).toBe(token);
});
});
describe('cookie pb_auth на сервере', () => {
it('не читается при старте и restore() не вызывается', async () => {
putAuthCookie(fakeJwt());
const restore = vi
.spyOn(PocketBaseAuthService.prototype, 'restore')
.mockResolvedValue(undefined);
const pb = await startApp([{ provide: PLATFORM_ID, useValue: 'server' }]);
expect(pb.authStore.token).toBe('');
expect(restore).not.toHaveBeenCalled();
});
it('не пишется при изменении auth store', async () => {
const pb = await startApp([{ provide: PLATFORM_ID, useValue: 'server' }]);
pb.authStore.save(fakeJwt(), record);
expect(hasAuthCookie()).toBe(false);
});
});
@@ -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);
});
});
});
@@ -0,0 +1,33 @@
import { Service, computed } from '@angular/core';
import type { User } from '../../users';
import { AuthService } from '../domain/auth-service';
// Заглушка контракта для тестов; реализация — задача 2.2.
/* eslint-disable @typescript-eslint/no-unused-vars -- параметры заглушки понадобятся реализации */
// Регистрируется только через provideAuth() под токеном AuthService: авторегистрация дала бы второй экземпляр.
@Service({ autoProvided: false })
export class PocketBaseAuthService extends AuthService {
readonly user = computed<User | null>(() => {
throw new Error('Not implemented');
});
readonly isAuthenticated = computed<boolean>(() => {
throw new Error('Not implemented');
});
login(_email: string, _password: string): Promise<User> {
throw new Error('Not implemented');
}
register(_data: { email: string; password: string; name?: string }): Promise<User> {
throw new Error('Not implemented');
}
logout(): void {
throw new Error('Not implemented');
}
restore(): Promise<void> {
throw new Error('Not implemented');
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { Signal } from '@angular/core';
import type { User } from '../../users';
/** Сессия пользователя: вход, регистрация, выход, текущий пользователь. */
export abstract class AuthService {
abstract readonly user: Signal<User | null>;
abstract readonly isAuthenticated: Signal<boolean>;
abstract login(email: string, password: string): Promise<User>;
abstract register(data: { email: string; password: string; name?: string }): Promise<User>;
abstract logout(): void;
abstract restore(): Promise<void>;
}
+6
View File
@@ -0,0 +1,6 @@
import type { EnvironmentProviders } from '@angular/core';
// Заглушка контракта для тестов; реализация — задача 2.2.
export function provideAuth(): EnvironmentProviders {
throw new Error('Not implemented');
}
@@ -0,0 +1,107 @@
// Контракт AuthService: одни и те же кейсы для PocketBase-реализации и фейка в памяти.
// Явный импорт из vitest: файл не *.spec.ts и попадает в программу tsconfig.app.json без глобалов vitest.
import { describe, expect, it } from 'vitest';
import { InvalidCredentialsError, ValidationError } from '../../shared/errors/domain-errors';
import type { AuthService } from '../domain/auth-service';
function newCredentials(): { email: string; password: string } {
return { email: `user-${crypto.randomUUID()}@test.local`, password: crypto.randomUUID() };
}
/** `setup` возвращает новый экземпляр сервиса без сессии. */
export function describeAuthServiceContract(setup: () => AuthService): void {
describe('контракт AuthService', () => {
it('до входа пользователя нет', () => {
const auth = setup();
expect(auth.user()).toBeNull();
expect(auth.isAuthenticated()).toBe(false);
});
it('register создаёт пользователя и сразу входит', async () => {
const auth = setup();
const credentials = newCredentials();
const user = await auth.register({ ...credentials, name: 'Иван' });
expect(user).toEqual({
id: expect.any(String),
email: credentials.email,
name: 'Иван',
});
expect(auth.user()).toEqual(user);
expect(auth.isAuthenticated()).toBe(true);
});
it('register без имени — пустое имя', async () => {
const auth = setup();
const user = await auth.register(newCredentials());
expect(user.name).toBe('');
});
it('register с занятым email → ValidationError с ошибкой в поле email', async () => {
const auth = setup();
const credentials = newCredentials();
await auth.register(credentials);
auth.logout();
const error = await auth
.register({ email: credentials.email, password: crypto.randomUUID() })
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(ValidationError);
expect((error as ValidationError).fieldErrors).toHaveProperty('email');
});
it('login с верным паролем входит и отдаёт id, email и имя', async () => {
const auth = setup();
const credentials = newCredentials();
const registered = await auth.register({ ...credentials, name: 'Иван' });
auth.logout();
const user = await auth.login(credentials.email, credentials.password);
expect(user).toEqual(registered);
expect(auth.user()).toEqual({
id: registered.id,
email: credentials.email,
name: 'Иван',
});
expect(auth.isAuthenticated()).toBe(true);
});
it('login с неверным паролем → InvalidCredentialsError, пользователя нет', async () => {
const auth = setup();
const credentials = newCredentials();
await auth.register(credentials);
auth.logout();
await expect(auth.login(credentials.email, 'wrong-password')).rejects.toBeInstanceOf(
InvalidCredentialsError,
);
expect(auth.user()).toBeNull();
expect(auth.isAuthenticated()).toBe(false);
});
it('login с несуществующим email → InvalidCredentialsError', async () => {
const auth = setup();
const { email, password } = newCredentials();
await expect(auth.login(email, password)).rejects.toBeInstanceOf(
InvalidCredentialsError,
);
});
it('logout завершает сессию', async () => {
const auth = setup();
await auth.register(newCredentials());
auth.logout();
expect(auth.user()).toBeNull();
expect(auth.isAuthenticated()).toBe(false);
});
});
}
@@ -0,0 +1,33 @@
import { NetworkError } from '../../shared/errors/domain-errors';
import { describeAuthServiceContract } from './auth-service.contract';
import { InMemoryAuthService } from './in-memory-auth-service';
describeAuthServiceContract(() => new InMemoryAuthService());
describe('InMemoryAuthService', () => {
it('signInAs делает пользователя вошедшим', () => {
const auth = new InMemoryAuthService();
const user = { id: 'u1', email: 'user@test.local', name: 'Иван' };
auth.signInAs(user);
expect(auth.user()).toEqual(user);
expect(auth.isAuthenticated()).toBe(true);
});
it('failNextWith отклоняет только следующий вызов', async () => {
const auth = new InMemoryAuthService();
const credentials = { email: 'user@test.local', password: 'password-1' };
await auth.register(credentials);
auth.logout();
const error = new NetworkError();
auth.failNextWith(error);
await expect(auth.login(credentials.email, credentials.password)).rejects.toBe(error);
expect(auth.user()).toBeNull();
await expect(auth.login(credentials.email, credentials.password)).resolves.toMatchObject({
email: credentials.email,
});
});
});
@@ -0,0 +1,42 @@
import { computed } from '@angular/core';
import type { User } from '../../users';
import { AuthService } from '../domain/auth-service';
// Заглушка контракта для тестов; реализация — задача 2.2.
/* eslint-disable @typescript-eslint/no-unused-vars -- параметры заглушки понадобятся реализации */
/** Фейк `AuthService` в памяти для тестов других слоёв и фич. */
export class InMemoryAuthService extends AuthService {
readonly user = computed<User | null>(() => {
throw new Error('Not implemented');
});
readonly isAuthenticated = computed<boolean>(() => {
throw new Error('Not implemented');
});
login(_email: string, _password: string): Promise<User> {
throw new Error('Not implemented');
}
register(_data: { email: string; password: string; name?: string }): Promise<User> {
throw new Error('Not implemented');
}
logout(): void {
throw new Error('Not implemented');
}
restore(): Promise<void> {
throw new Error('Not implemented');
}
/** Сразу делает пользователя вошедшим, без регистрации. */
signInAs(_user: User): void {
throw new Error('Not implemented');
}
/** Следующий вызов `login`, `register` или `restore` отклоняется этой ошибкой. */
failNextWith(_error: Error): void {
throw new Error('Not implemented');
}
}
+6
View File
@@ -0,0 +1,6 @@
/** Владелец аккаунта. */
export interface User {
id: string;
email: string;
name: string;
}
+1
View File
@@ -0,0 +1 @@
export type { User } from './domain/user';