From 74d3c660f9f1650224f3ac2913670612806e3a5c Mon Sep 17 00:00:00 2001 From: kamask Date: Sun, 8 Nov 2026 22:40:04 +0300 Subject: [PATCH] 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 --- docs/testing.md | 2 + src/app/auth/data/auth-cookie.spec.ts | 132 +++++++++++++++ ...ocketbase-auth-service.integration.spec.ts | 153 ++++++++++++++++++ src/app/auth/data/pocketbase-auth-service.ts | 33 ++++ src/app/auth/domain/auth-service.ts | 12 ++ src/app/auth/provide-auth.ts | 6 + src/app/auth/testing/auth-service.contract.ts | 107 ++++++++++++ .../testing/in-memory-auth-service.spec.ts | 33 ++++ .../auth/testing/in-memory-auth-service.ts | 42 +++++ src/app/users/domain/user.ts | 6 + src/app/users/index.ts | 1 + 11 files changed, 527 insertions(+) create mode 100644 src/app/auth/data/auth-cookie.spec.ts create mode 100644 src/app/auth/data/pocketbase-auth-service.integration.spec.ts create mode 100644 src/app/auth/data/pocketbase-auth-service.ts create mode 100644 src/app/auth/domain/auth-service.ts create mode 100644 src/app/auth/provide-auth.ts create mode 100644 src/app/auth/testing/auth-service.contract.ts create mode 100644 src/app/auth/testing/in-memory-auth-service.spec.ts create mode 100644 src/app/auth/testing/in-memory-auth-service.ts create mode 100644 src/app/users/domain/user.ts create mode 100644 src/app/users/index.ts diff --git a/docs/testing.md b/docs/testing.md index ef308dc..e3d8908 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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`: diff --git a/src/app/auth/data/auth-cookie.spec.ts b/src/app/auth/data/auth-cookie.spec.ts new file mode 100644 index 0000000..191e146 --- /dev/null +++ b/src/app/auth/data/auth-cookie.spec.ts @@ -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); + }); +}); diff --git a/src/app/auth/data/pocketbase-auth-service.integration.spec.ts b/src/app/auth/data/pocketbase-auth-service.integration.spec.ts new file mode 100644 index 0000000..1fb87b0 --- /dev/null +++ b/src/app/auth/data/pocketbase-auth-service.integration.spec.ts @@ -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>) { + 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); + }); + }); +}); diff --git a/src/app/auth/data/pocketbase-auth-service.ts b/src/app/auth/data/pocketbase-auth-service.ts new file mode 100644 index 0000000..8b38f76 --- /dev/null +++ b/src/app/auth/data/pocketbase-auth-service.ts @@ -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(() => { + throw new Error('Not implemented'); + }); + + readonly isAuthenticated = computed(() => { + throw new Error('Not implemented'); + }); + + login(_email: string, _password: string): Promise { + throw new Error('Not implemented'); + } + + register(_data: { email: string; password: string; name?: string }): Promise { + throw new Error('Not implemented'); + } + + logout(): void { + throw new Error('Not implemented'); + } + + restore(): Promise { + throw new Error('Not implemented'); + } +} diff --git a/src/app/auth/domain/auth-service.ts b/src/app/auth/domain/auth-service.ts new file mode 100644 index 0000000..55228e3 --- /dev/null +++ b/src/app/auth/domain/auth-service.ts @@ -0,0 +1,12 @@ +import type { Signal } from '@angular/core'; +import type { User } from '../../users'; + +/** Сессия пользователя: вход, регистрация, выход, текущий пользователь. */ +export abstract class AuthService { + abstract readonly user: Signal; + abstract readonly isAuthenticated: Signal; + abstract login(email: string, password: string): Promise; + abstract register(data: { email: string; password: string; name?: string }): Promise; + abstract logout(): void; + abstract restore(): Promise; +} diff --git a/src/app/auth/provide-auth.ts b/src/app/auth/provide-auth.ts new file mode 100644 index 0000000..7c5ce89 --- /dev/null +++ b/src/app/auth/provide-auth.ts @@ -0,0 +1,6 @@ +import type { EnvironmentProviders } from '@angular/core'; + +// Заглушка контракта для тестов; реализация — задача 2.2. +export function provideAuth(): EnvironmentProviders { + throw new Error('Not implemented'); +} diff --git a/src/app/auth/testing/auth-service.contract.ts b/src/app/auth/testing/auth-service.contract.ts new file mode 100644 index 0000000..97fe977 --- /dev/null +++ b/src/app/auth/testing/auth-service.contract.ts @@ -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); + }); + }); +} diff --git a/src/app/auth/testing/in-memory-auth-service.spec.ts b/src/app/auth/testing/in-memory-auth-service.spec.ts new file mode 100644 index 0000000..02c2fa3 --- /dev/null +++ b/src/app/auth/testing/in-memory-auth-service.spec.ts @@ -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, + }); + }); +}); diff --git a/src/app/auth/testing/in-memory-auth-service.ts b/src/app/auth/testing/in-memory-auth-service.ts new file mode 100644 index 0000000..b03622e --- /dev/null +++ b/src/app/auth/testing/in-memory-auth-service.ts @@ -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(() => { + throw new Error('Not implemented'); + }); + + readonly isAuthenticated = computed(() => { + throw new Error('Not implemented'); + }); + + login(_email: string, _password: string): Promise { + throw new Error('Not implemented'); + } + + register(_data: { email: string; password: string; name?: string }): Promise { + throw new Error('Not implemented'); + } + + logout(): void { + throw new Error('Not implemented'); + } + + restore(): Promise { + 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'); + } +} diff --git a/src/app/users/domain/user.ts b/src/app/users/domain/user.ts new file mode 100644 index 0000000..dcd3286 --- /dev/null +++ b/src/app/users/domain/user.ts @@ -0,0 +1,6 @@ +/** Владелец аккаунта. */ +export interface User { + id: string; + email: string; + name: string; +} diff --git a/src/app/users/index.ts b/src/app/users/index.ts new file mode 100644 index 0000000..8639f59 --- /dev/null +++ b/src/app/users/index.ts @@ -0,0 +1 @@ +export type { User } from './domain/user';