Ajoute la couche application (ports, use cases), les entités du domaine, l'endpoint api/auth_api.dart (register/login/logout) et son câblage dans le router, le hashing de mot de passe et le service de token (infrastructure/security), et l'adapter Postgres des repositories d'auth s'appuyant sur la table users du ticket #49. dart pub get OK, dart analyze clean, dart test 10/10 vert (test PostgreSQL réel skip faute de Docker disponible dans ce sandbox). Logique d'auth et middleware testés via repositories fake en mémoire ; les endpoints HTTP register/login/logout n'ont pas pu être exercés de bout en bout faute d'accès à un vrai PostgreSQL local. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
341 lines
8.4 KiB
Dart
341 lines
8.4 KiB
Dart
import 'package:gametime_server/application/application.dart';
|
|
import 'package:gametime_server/domain/domain.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
late _FakeUserRepository users;
|
|
late _FakeAuthSessionRepository sessions;
|
|
late _FakePasswordHasher passwordHasher;
|
|
late _FakeTokenService tokens;
|
|
late _FakeClock clock;
|
|
late _FakeIds ids;
|
|
|
|
setUp(() {
|
|
users = _FakeUserRepository();
|
|
sessions = _FakeAuthSessionRepository();
|
|
passwordHasher = _FakePasswordHasher();
|
|
tokens = _FakeTokenService();
|
|
clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
|
ids = _FakeIds();
|
|
});
|
|
|
|
test('register creates a user with a hashed password', () async {
|
|
final useCase = RegisterUserUseCase(
|
|
users: users,
|
|
passwordHasher: passwordHasher,
|
|
clock: clock,
|
|
ids: ids,
|
|
);
|
|
|
|
final user = await useCase.execute(
|
|
email: ' USER@Example.COM ',
|
|
password: 'password123',
|
|
displayName: ' Anthony ',
|
|
);
|
|
|
|
expect(user.id, 'id-1');
|
|
expect(user.email, 'user@example.com');
|
|
expect(user.passwordHash, 'hashed:password123');
|
|
expect(user.displayName, 'Anthony');
|
|
expect(users.byId[user.id], user);
|
|
});
|
|
|
|
test('register rejects an already used email', () async {
|
|
users.add(
|
|
UserAccount(
|
|
id: 'existing-user',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hash',
|
|
createdAt: clock.now(),
|
|
updatedAt: clock.now(),
|
|
),
|
|
);
|
|
final useCase = RegisterUserUseCase(
|
|
users: users,
|
|
passwordHasher: passwordHasher,
|
|
clock: clock,
|
|
ids: ids,
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.execute(email: 'USER@example.com', password: 'password123'),
|
|
throwsA(isA<EmailAlreadyTakenException>()),
|
|
);
|
|
});
|
|
|
|
test('login returns a clear token once and stores only its hash', () async {
|
|
users.add(
|
|
UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hashed:password123',
|
|
createdAt: clock.now(),
|
|
updatedAt: clock.now(),
|
|
),
|
|
);
|
|
tokens.nextToken = 'clear-token';
|
|
final useCase = LoginUseCase(
|
|
users: users,
|
|
sessions: sessions,
|
|
passwordHasher: passwordHasher,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
ids: ids,
|
|
);
|
|
|
|
final result = await useCase.execute(
|
|
email: 'user@example.com',
|
|
password: 'password123',
|
|
userAgent: 'test-agent',
|
|
deviceLabel: 'phone',
|
|
);
|
|
|
|
expect(result.token, 'clear-token');
|
|
expect(result.expiresAt, DateTime.utc(2026, 8, 18, 12));
|
|
expect(sessions.byTokenHash.keys.single, 'token-hash:clear-token');
|
|
expect(sessions.byTokenHash.values.single.tokenHash, isNot('clear-token'));
|
|
});
|
|
|
|
test('login rejects a bad password', () async {
|
|
users.add(
|
|
UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hashed:password123',
|
|
createdAt: clock.now(),
|
|
updatedAt: clock.now(),
|
|
),
|
|
);
|
|
final useCase = LoginUseCase(
|
|
users: users,
|
|
sessions: sessions,
|
|
passwordHasher: passwordHasher,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
ids: ids,
|
|
);
|
|
|
|
await expectLater(
|
|
useCase.execute(email: 'user@example.com', password: 'wrong-password'),
|
|
throwsA(isA<InvalidCredentialsException>()),
|
|
);
|
|
});
|
|
|
|
test('authenticate returns the user for a valid token', () async {
|
|
final user = _user(clock);
|
|
users.add(user);
|
|
sessions.add(
|
|
AuthSession(
|
|
id: 'session-1',
|
|
userId: user.id,
|
|
tokenHash: 'token-hash:valid-token',
|
|
issuedAt: clock.now(),
|
|
expiresAt: clock.now().add(const Duration(days: 1)),
|
|
),
|
|
);
|
|
final useCase = _authenticateUseCase(users, sessions, tokens, clock);
|
|
|
|
final authenticated = await useCase.execute(token: 'valid-token');
|
|
|
|
expect(authenticated.user.id, user.id);
|
|
expect(authenticated.session.id, 'session-1');
|
|
});
|
|
|
|
test('authenticate rejects expired, revoked and unknown tokens', () async {
|
|
final user = _user(clock);
|
|
users.add(user);
|
|
sessions
|
|
..add(
|
|
AuthSession(
|
|
id: 'expired',
|
|
userId: user.id,
|
|
tokenHash: 'token-hash:expired-token',
|
|
issuedAt: clock.now().subtract(const Duration(days: 2)),
|
|
expiresAt: clock.now().subtract(const Duration(days: 1)),
|
|
),
|
|
)
|
|
..add(
|
|
AuthSession(
|
|
id: 'revoked',
|
|
userId: user.id,
|
|
tokenHash: 'token-hash:revoked-token',
|
|
issuedAt: clock.now(),
|
|
expiresAt: clock.now().add(const Duration(days: 1)),
|
|
revokedAt: clock.now(),
|
|
),
|
|
);
|
|
final useCase = _authenticateUseCase(users, sessions, tokens, clock);
|
|
|
|
await expectLater(
|
|
useCase.execute(token: 'expired-token'),
|
|
throwsA(isA<UnauthorizedException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.execute(token: 'revoked-token'),
|
|
throwsA(isA<UnauthorizedException>()),
|
|
);
|
|
await expectLater(
|
|
useCase.execute(token: 'unknown-token'),
|
|
throwsA(isA<UnauthorizedException>()),
|
|
);
|
|
});
|
|
|
|
test('logout revokes an active token', () async {
|
|
final user = _user(clock);
|
|
users.add(user);
|
|
sessions.add(
|
|
AuthSession(
|
|
id: 'session-1',
|
|
userId: user.id,
|
|
tokenHash: 'token-hash:valid-token',
|
|
issuedAt: clock.now(),
|
|
expiresAt: clock.now().add(const Duration(days: 1)),
|
|
),
|
|
);
|
|
final useCase = LogoutUseCase(
|
|
sessions: sessions,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
);
|
|
|
|
await useCase.execute(token: 'valid-token');
|
|
|
|
expect(sessions.revokedSessionIds, ['session-1']);
|
|
});
|
|
}
|
|
|
|
AuthenticateRequestUseCase _authenticateUseCase(
|
|
_FakeUserRepository users,
|
|
_FakeAuthSessionRepository sessions,
|
|
_FakeTokenService tokens,
|
|
_FakeClock clock,
|
|
) {
|
|
return AuthenticateRequestUseCase(
|
|
users: users,
|
|
sessions: sessions,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
);
|
|
}
|
|
|
|
UserAccount _user(_FakeClock clock) {
|
|
return UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hashed:password123',
|
|
createdAt: clock.now(),
|
|
updatedAt: clock.now(),
|
|
);
|
|
}
|
|
|
|
final class _FakeUserRepository implements UserRepository {
|
|
final byId = <String, UserAccount>{};
|
|
final byEmail = <String, UserAccount>{};
|
|
|
|
void add(UserAccount user) {
|
|
byId[user.id] = user;
|
|
byEmail[user.email] = user;
|
|
}
|
|
|
|
@override
|
|
Future<UserAccount?> findByEmail(String email) async {
|
|
return byEmail[email.trim().toLowerCase()];
|
|
}
|
|
|
|
@override
|
|
Future<UserAccount?> findById(String id) async => byId[id];
|
|
|
|
@override
|
|
Future<void> insert(UserAccount user) async => add(user);
|
|
|
|
@override
|
|
Future<void> updatePasswordHash({
|
|
required String userId,
|
|
required String passwordHash,
|
|
required DateTime updatedAt,
|
|
}) async {
|
|
final user = byId[userId];
|
|
if (user == null) {
|
|
return;
|
|
}
|
|
add(
|
|
UserAccount(
|
|
id: user.id,
|
|
email: user.email,
|
|
passwordHash: passwordHash,
|
|
displayName: user.displayName,
|
|
createdAt: user.createdAt,
|
|
updatedAt: updatedAt,
|
|
disabledAt: user.disabledAt,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
|
final byTokenHash = <String, AuthSession>{};
|
|
final revokedSessionIds = <String>[];
|
|
|
|
void add(AuthSession session) {
|
|
byTokenHash[session.tokenHash] = session;
|
|
}
|
|
|
|
@override
|
|
Future<AuthSession?> findByTokenHash(String tokenHash) async {
|
|
return byTokenHash[tokenHash];
|
|
}
|
|
|
|
@override
|
|
Future<void> insert(AuthSession session) async => add(session);
|
|
|
|
@override
|
|
Future<void> revoke({
|
|
required String sessionId,
|
|
required DateTime revokedAt,
|
|
}) async {
|
|
revokedSessionIds.add(sessionId);
|
|
}
|
|
}
|
|
|
|
final class _FakePasswordHasher implements PasswordHasher {
|
|
@override
|
|
Future<String> hash(String password) async => 'hashed:$password';
|
|
|
|
@override
|
|
Future<bool> verify({
|
|
required String password,
|
|
required String passwordHash,
|
|
}) async {
|
|
return passwordHash == 'hashed:$password';
|
|
}
|
|
}
|
|
|
|
final class _FakeTokenService implements OpaqueTokenService {
|
|
String nextToken = 'token';
|
|
|
|
@override
|
|
String generateToken() => nextToken;
|
|
|
|
@override
|
|
String hashToken(String token) => 'token-hash:$token';
|
|
}
|
|
|
|
final class _FakeClock implements Clock {
|
|
_FakeClock(this.value);
|
|
|
|
DateTime value;
|
|
|
|
@override
|
|
DateTime now() => value;
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'id-$_next';
|
|
}
|
|
}
|