Ajoute la spécification openapi.yaml documentant les 12 endpoints réels du serveur (health, auth, sync, shares), les tests API auth (test/auth_api_test.dart couvrant register/login/logout) et la checklist de vérification d'intégration (docs/integration-checklist.md). dart analyze clean, dart test 29/29 vert. Dernier ticket du chantier serveur (#47 à #53), tous terminés. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
290 lines
7.5 KiB
Dart
290 lines
7.5 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:gametime_server/api/auth_api.dart';
|
|
import 'package:gametime_server/api/router.dart';
|
|
import 'package:gametime_server/application/application.dart';
|
|
import 'package:gametime_server/domain/domain.dart';
|
|
import 'package:shelf/shelf.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
test(
|
|
'register endpoint returns the created user without password data',
|
|
() async {
|
|
final users = _FakeUserRepository();
|
|
final handler = buildApiHandler(authApi: _authApi(users: users));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/auth/register'),
|
|
body: jsonEncode({
|
|
'email': ' USER@example.com ',
|
|
'password': 'password123',
|
|
'displayName': ' User ',
|
|
}),
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
|
|
expect(response.statusCode, 201);
|
|
expect(body, {'userId': 'id-1', 'email': 'user@example.com'});
|
|
expect(body.containsKey('password'), isFalse);
|
|
expect(body.containsKey('passwordHash'), isFalse);
|
|
},
|
|
);
|
|
|
|
test('register endpoint returns 409 when email is already used', () async {
|
|
final users = _FakeUserRepository();
|
|
await users.insert(
|
|
UserAccount(
|
|
id: 'existing-user',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hash',
|
|
createdAt: DateTime.utc(2026, 7, 19),
|
|
updatedAt: DateTime.utc(2026, 7, 19),
|
|
),
|
|
);
|
|
final handler = buildApiHandler(authApi: _authApi(users: users));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/auth/register'),
|
|
body: jsonEncode({
|
|
'email': 'user@example.com',
|
|
'password': 'password123',
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(response.statusCode, 409);
|
|
});
|
|
|
|
test('login endpoint returns a token and expiration', () async {
|
|
final users = _FakeUserRepository();
|
|
await users.insert(
|
|
UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hashed:password123',
|
|
createdAt: DateTime.utc(2026, 7, 19),
|
|
updatedAt: DateTime.utc(2026, 7, 19),
|
|
),
|
|
);
|
|
final handler = buildApiHandler(authApi: _authApi(users: users));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/auth/login'),
|
|
body: jsonEncode({
|
|
'email': 'user@example.com',
|
|
'password': 'password123',
|
|
'deviceLabel': 'phone',
|
|
}),
|
|
),
|
|
);
|
|
|
|
final body = jsonDecode(await response.readAsString()) as Map;
|
|
|
|
expect(response.statusCode, 200);
|
|
expect(body['token'], 'clear-token');
|
|
expect(body['expiresAt'], '2026-08-18T12:00:00.000Z');
|
|
});
|
|
|
|
test('login endpoint returns 401 for invalid credentials', () async {
|
|
final users = _FakeUserRepository();
|
|
await users.insert(
|
|
UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hashed:password123',
|
|
createdAt: DateTime.utc(2026, 7, 19),
|
|
updatedAt: DateTime.utc(2026, 7, 19),
|
|
),
|
|
);
|
|
final handler = buildApiHandler(authApi: _authApi(users: users));
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/auth/login'),
|
|
body: jsonEncode({
|
|
'email': 'user@example.com',
|
|
'password': 'wrong-password',
|
|
}),
|
|
),
|
|
);
|
|
|
|
expect(response.statusCode, 401);
|
|
});
|
|
|
|
test('logout endpoint revokes the bearer session', () async {
|
|
final users = _FakeUserRepository();
|
|
await users.insert(
|
|
UserAccount(
|
|
id: 'user-1',
|
|
email: 'user@example.com',
|
|
passwordHash: 'hashed:password123',
|
|
createdAt: DateTime.utc(2026, 7, 19),
|
|
updatedAt: DateTime.utc(2026, 7, 19),
|
|
),
|
|
);
|
|
final sessions = _FakeAuthSessionRepository();
|
|
await sessions.insert(
|
|
AuthSession(
|
|
id: 'session-1',
|
|
userId: 'user-1',
|
|
tokenHash: 'token-hash:clear-token',
|
|
issuedAt: DateTime.utc(2026, 7, 19, 12),
|
|
expiresAt: DateTime.utc(2026, 7, 20, 12),
|
|
),
|
|
);
|
|
final handler = buildApiHandler(
|
|
authApi: _authApi(users: users, sessions: sessions),
|
|
);
|
|
|
|
final response = await handler(
|
|
Request(
|
|
'POST',
|
|
Uri.parse('http://localhost/auth/logout'),
|
|
headers: {'authorization': 'Bearer clear-token'},
|
|
),
|
|
);
|
|
|
|
expect(response.statusCode, 204);
|
|
expect(sessions.revokedSessionIds, ['session-1']);
|
|
});
|
|
}
|
|
|
|
AuthApi _authApi({
|
|
_FakeUserRepository? users,
|
|
_FakeAuthSessionRepository? sessions,
|
|
}) {
|
|
final userRepository = users ?? _FakeUserRepository();
|
|
final sessionRepository = sessions ?? _FakeAuthSessionRepository();
|
|
final passwordHasher = _FakePasswordHasher();
|
|
final tokens = _FakeTokenService();
|
|
final clock = _FakeClock(DateTime.utc(2026, 7, 19, 12));
|
|
final ids = _FakeIds();
|
|
final authenticateRequest = AuthenticateRequestUseCase(
|
|
users: userRepository,
|
|
sessions: sessionRepository,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
);
|
|
return AuthApi(
|
|
registerUser: RegisterUserUseCase(
|
|
users: userRepository,
|
|
passwordHasher: passwordHasher,
|
|
clock: clock,
|
|
ids: ids,
|
|
),
|
|
login: LoginUseCase(
|
|
users: userRepository,
|
|
sessions: sessionRepository,
|
|
passwordHasher: passwordHasher,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
ids: ids,
|
|
),
|
|
logout: LogoutUseCase(
|
|
sessions: sessionRepository,
|
|
tokens: tokens,
|
|
clock: clock,
|
|
),
|
|
authenticateRequest: authenticateRequest,
|
|
);
|
|
}
|
|
|
|
final class _FakeUserRepository implements UserRepository {
|
|
final byId = <String, UserAccount>{};
|
|
final byEmail = <String, UserAccount>{};
|
|
|
|
@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 {
|
|
byId[user.id] = user;
|
|
byEmail[user.email] = user;
|
|
}
|
|
|
|
@override
|
|
Future<void> updatePasswordHash({
|
|
required String userId,
|
|
required String passwordHash,
|
|
required DateTime updatedAt,
|
|
}) async {}
|
|
}
|
|
|
|
final class _FakeAuthSessionRepository implements AuthSessionRepository {
|
|
final byTokenHash = <String, AuthSession>{};
|
|
final revokedSessionIds = <String>[];
|
|
|
|
@override
|
|
Future<void> insert(AuthSession session) async {
|
|
byTokenHash[session.tokenHash] = session;
|
|
}
|
|
|
|
@override
|
|
Future<AuthSession?> findByTokenHash(String tokenHash) async {
|
|
return byTokenHash[tokenHash];
|
|
}
|
|
|
|
@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 {
|
|
@override
|
|
String generateToken() => 'clear-token';
|
|
|
|
@override
|
|
String hashToken(String token) => 'token-hash:$token';
|
|
}
|
|
|
|
final class _FakeClock implements Clock {
|
|
const _FakeClock(this.value);
|
|
|
|
final DateTime value;
|
|
|
|
@override
|
|
DateTime now() => value;
|
|
}
|
|
|
|
final class _FakeIds implements IdGenerator {
|
|
var _next = 0;
|
|
|
|
@override
|
|
String newId() {
|
|
_next += 1;
|
|
return 'id-$_next';
|
|
}
|
|
}
|