mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 05:49:38 +00:00
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:
co-authored by
Claude Opus 5.5
parent
115b5fb036
commit
74d3c660f9
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user