From c1dce3cae79590f4acb6f2360a0bdd595c5427ff Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 19 Jul 2026 15:56:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(server):=20tests=20API,=20contrats=20OpenA?= =?UTF-8?q?PI=20et=20v=C3=A9rification=20d'int=C3=A9gration=20(ticket=20#5?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/README.md | 9 + server/docs/integration-checklist.md | 104 +++++ server/openapi.yaml | 591 +++++++++++++++++++++++++++ server/test/auth_api_test.dart | 289 +++++++++++++ 4 files changed, 993 insertions(+) create mode 100644 server/docs/integration-checklist.md create mode 100644 server/openapi.yaml create mode 100644 server/test/auth_api_test.dart diff --git a/server/README.md b/server/README.md index ef36ae9..af8253e 100644 --- a/server/README.md +++ b/server/README.md @@ -125,6 +125,15 @@ without failing the whole request. Accepting a share creates a new `409` for revoked or already answered shares, while missing shares or recipients return `404`. +## API Contract + +The current HTTP contract is documented in [`openapi.yaml`](openapi.yaml). It +covers health, authentication, sync and targeted sharing endpoints implemented +by this server package. + +To inspect it, paste the file into or open it with +a local OpenAPI-compatible viewer. + ## Docker Deployment Ticket #52 adds Docker packaging for a headless Linux deployment. TLS is not diff --git a/server/docs/integration-checklist.md b/server/docs/integration-checklist.md new file mode 100644 index 0000000..7c1b8fd --- /dev/null +++ b/server/docs/integration-checklist.md @@ -0,0 +1,104 @@ +# GameTime Server Integration Checklist + +This checklist covers the checks that require a real Docker daemon, PostgreSQL +instance, or Gitea registry. They are intentionally not part of the sandbox test +suite. + +## Docker Compose + +- Copy `server/.env.example` to `server/.env`. +- Replace `POSTGRES_PASSWORD` and `DATABASE_PASSWORD` with a real secret. +- Set `API_BIND_ADDRESS` to the Docker host interface reachable by the external + reverse proxy. +- Set `API_PORT` to the host port consumed by the reverse proxy. +- Run: + +```bash +cd server +docker compose config +docker compose up -d --build +docker compose ps +docker compose logs api +``` + +- Verify that the `api` service waits for the PostgreSQL healthcheck. +- Verify that migrations run automatically before the server starts. +- Verify that TLS is terminated by the external reverse proxy, not by the API + container. + +## PostgreSQL Migrations + +- Start a disposable PostgreSQL database or reuse the Compose database. +- Run the real migration integration test: + +```bash +cd server +TEST_DATABASE_URL=postgres://gametime:change-me@localhost:5432/gametime dart test +``` + +- Confirm the test sees these tables: + - `users` + - `auth_sessions` + - `synced_resources` + - `shares` + - `share_recipients` + +## Manual API Smoke Tests + +Run the following checks against the real server URL. Use HTTP directly only on +the private LAN path between the reverse proxy and Docker host; external access +should be HTTPS through the reverse proxy. + +```bash +curl http://localhost:8080/health +``` + +- Register a user with `POST /auth/register`. +- Login with `POST /auth/login` and store the returned bearer token. +- Call a protected endpoint without a token and confirm `401`. +- Logout with `POST /auth/logout` and confirm the token can no longer access + protected endpoints. +- Push a valid resource with `POST /sync/push`. +- Pull it with `GET /sync/pull`. +- Push an older version of the same resource and confirm `ignoredOlder`. +- Use `POST /sync/exchange` and confirm push results and pulled items are both + present. +- Create a second user, then create a share with `POST /shares`. +- Confirm unknown recipient emails are returned in `unresolvedEmails`. +- List shares with `GET /shares/inbox` as the recipient. +- Accept a share with `POST /shares/{id}/accept` and confirm a copied resource + appears in `GET /sync/pull` for the recipient. +- Decline a pending share with `POST /shares/{id}/decline`. +- Revoke a sent share with `POST /shares/{id}/revoke`. +- Confirm accepting a revoked or already answered share returns `409`. + +## LWW Concurrency + +- Run two concurrent `POST /sync/push` requests for the same + `(owner_user_id, resource_type, client_id)` with different `clientUpdatedAt` + values. +- Confirm the row with the strictly newer `clientUpdatedAt` wins. +- Confirm an equal timestamp is ignored by the later request. +- Inspect `server_updated_at` to confirm the update trigger advances it on + accepted updates. + +## Gitea Container Registry + +- Obtain the real Gitea registry host, owner/namespace, username and token. +- Run: + +```bash +cd server +GITEA_REGISTRY=gitea.example.com \ +GITEA_OWNER=my-org \ +GITEA_IMAGE_NAME=gametime-server \ +GITEA_IMAGE_TAG=latest \ +GITEA_USERNAME=my-user \ +GITEA_TOKEN='replace-with-token' \ +./scripts/push-gitea-image.sh +``` + +- Confirm the image exists in the Gitea Container Registry. +- Pull the pushed image on the deployment host. +- Deploy the pushed image with the same environment variables as the local + Compose build. diff --git a/server/openapi.yaml b/server/openapi.yaml new file mode 100644 index 0000000..5974096 --- /dev/null +++ b/server/openapi.yaml @@ -0,0 +1,591 @@ +openapi: 3.0.3 +info: + title: GameTime Server API + version: 0.1.0 + description: Headless API for GameTime authentication, incremental sync and targeted sharing. +servers: + - url: http://localhost:8080 + description: Local Docker or development server +tags: + - name: Health + - name: Auth + - name: Sync + - name: Shares +paths: + /health: + get: + tags: [Health] + summary: Healthcheck + responses: + '200': + description: Server is reachable. + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + + /auth/register: + post: + tags: [Auth] + summary: Register a user account + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterRequest' + responses: + '201': + description: User created. + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterResponse' + '400': + $ref: '#/components/responses/BadRequest' + '409': + description: Email already taken. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /auth/login: + post: + tags: [Auth] + summary: Login and create an API token session + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login accepted. The clear token is returned once. + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /auth/logout: + post: + tags: [Auth] + summary: Revoke the current API token session + security: + - bearerAuth: [] + responses: + '204': + description: Session revoked. + '401': + $ref: '#/components/responses/Unauthorized' + + /sync/push: + post: + tags: [Sync] + summary: Push client-side resource mutations + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SyncPushRequest' + responses: + '200': + description: Batch processed. Item-level validation errors are returned in results. + content: + application/json: + schema: + $ref: '#/components/schemas/SyncPushResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /sync/pull: + get: + tags: [Sync] + summary: Pull resources updated after a server cursor + security: + - bearerAuth: [] + parameters: + - name: since + in: query + required: false + schema: + type: string + format: date-time + description: ISO8601 UTC server cursor. If omitted, returns all resources. + responses: + '200': + description: Resources for the authenticated user. + content: + application/json: + schema: + $ref: '#/components/schemas/SyncPullResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /sync/exchange: + post: + tags: [Sync] + summary: Push then pull in one request + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SyncExchangeRequest' + responses: + '200': + description: Pull response after applying the push batch. + content: + application/json: + schema: + $ref: '#/components/schemas/SyncExchangeResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /shares: + post: + tags: [Shares] + summary: Create a targeted share + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateShareRequest' + responses: + '201': + description: Share created. Unknown recipient emails are reported but do not fail the request. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateShareResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + + /shares/inbox: + get: + tags: [Shares] + summary: List shares received by the authenticated user + security: + - bearerAuth: [] + responses: + '200': + description: Inbox sorted from newest to oldest. + content: + application/json: + schema: + $ref: '#/components/schemas/ShareInboxResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /shares/{id}/accept: + post: + tags: [Shares] + summary: Accept a received share + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ShareId' + responses: + '200': + description: Share accepted and copied into synced resources. + content: + application/json: + schema: + $ref: '#/components/schemas/AcceptShareResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + + /shares/{id}/decline: + post: + tags: [Shares] + summary: Decline a received share + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ShareId' + responses: + '204': + description: Share declined. + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + + /shares/{id}/revoke: + post: + tags: [Shares] + summary: Revoke a share sent by the authenticated user + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/ShareId' + responses: + '204': + description: Share revoked. + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + + parameters: + ShareId: + name: id + in: path + required: true + schema: + type: string + description: Share identifier. + + responses: + BadRequest: + description: Invalid JSON body, invalid parameter, or validation failure. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: Missing, malformed, expired, revoked or unknown bearer token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + NotFound: + description: Resource not found or not accessible by the authenticated user. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Conflict: + description: Current resource state rejects the requested transition. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + + HealthResponse: + type: object + required: [status] + properties: + status: + type: string + enum: [ok] + + RegisterRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + minLength: 8 + displayName: + type: string + nullable: true + + RegisterResponse: + type: object + required: [userId, email] + properties: + userId: + type: string + email: + type: string + format: email + + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + deviceLabel: + type: string + nullable: true + + LoginResponse: + type: object + required: [token, expiresAt] + properties: + token: + type: string + expiresAt: + type: string + format: date-time + + ResourceType: + type: string + enum: [exercise, program, workoutTemplate, workoutHistory, mediaAsset] + + ShareResourceType: + type: string + enum: [program, workoutTemplate] + + ShareRecipientStatus: + type: string + enum: [pending, accepted, declined, revoked] + + JsonObject: + type: object + additionalProperties: true + + SyncPushRequest: + type: object + required: [items] + properties: + deviceId: + type: string + nullable: true + description: Required by the application use case for each valid item. + items: + type: array + items: + $ref: '#/components/schemas/SyncPushItem' + + SyncPushItem: + type: object + required: [resourceType, clientId, schemaVersion, clientUpdatedAt, payload] + properties: + resourceType: + $ref: '#/components/schemas/ResourceType' + clientId: + type: string + schemaVersion: + type: integer + minimum: 1 + clientUpdatedAt: + type: string + format: date-time + deletedAt: + type: string + format: date-time + nullable: true + payload: + $ref: '#/components/schemas/JsonObject' + + SyncPushResponse: + type: object + required: [serverCursor, results] + properties: + serverCursor: + type: string + format: date-time + results: + type: array + items: + $ref: '#/components/schemas/SyncPushResultItem' + + SyncPushResultItem: + type: object + required: [status] + properties: + resourceType: + type: string + nullable: true + clientId: + type: string + nullable: true + serverId: + type: string + nullable: true + status: + type: string + enum: [accepted, ignoredOlder, error] + serverUpdatedAt: + type: string + format: date-time + nullable: true + message: + type: string + + SyncPullResponse: + type: object + required: [serverCursor, items] + properties: + serverCursor: + type: string + format: date-time + items: + type: array + items: + $ref: '#/components/schemas/SyncedResourceItem' + + SyncExchangeRequest: + allOf: + - $ref: '#/components/schemas/SyncPushRequest' + - type: object + properties: + since: + type: string + format: date-time + nullable: true + + SyncExchangeResponse: + type: object + required: [serverCursor, pushResults, items] + properties: + serverCursor: + type: string + format: date-time + pushResults: + type: array + items: + $ref: '#/components/schemas/SyncPushResultItem' + items: + type: array + items: + $ref: '#/components/schemas/SyncedResourceItem' + + SyncedResourceItem: + type: object + required: + - resourceType + - clientId + - serverId + - schemaVersion + - clientUpdatedAt + - serverUpdatedAt + - payload + properties: + resourceType: + $ref: '#/components/schemas/ResourceType' + clientId: + type: string + serverId: + type: string + schemaVersion: + type: integer + minimum: 1 + clientUpdatedAt: + type: string + format: date-time + serverUpdatedAt: + type: string + format: date-time + deletedAt: + type: string + format: date-time + nullable: true + payload: + $ref: '#/components/schemas/JsonObject' + + CreateShareRequest: + type: object + required: [resourceType, payload, recipientEmails] + properties: + resourceType: + $ref: '#/components/schemas/ShareResourceType' + payload: + $ref: '#/components/schemas/JsonObject' + recipientEmails: + type: array + minItems: 1 + items: + type: string + format: email + + CreateShareResponse: + type: object + required: [shareId, recipientUserIds, unresolvedEmails] + properties: + shareId: + type: string + recipientUserIds: + type: array + items: + type: string + unresolvedEmails: + type: array + items: + type: string + format: email + + ShareInboxResponse: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ShareInboxItem' + + ShareInboxItem: + type: object + required: + - shareId + - senderUserId + - resourceType + - payload + - status + - createdAt + properties: + shareId: + type: string + senderUserId: + type: string + resourceType: + $ref: '#/components/schemas/ShareResourceType' + payload: + $ref: '#/components/schemas/JsonObject' + status: + $ref: '#/components/schemas/ShareRecipientStatus' + createdAt: + type: string + format: date-time + respondedAt: + type: string + format: date-time + nullable: true + + AcceptShareResponse: + type: object + required: [createdResource] + properties: + createdResource: + $ref: '#/components/schemas/SyncedResourceItem' diff --git a/server/test/auth_api_test.dart b/server/test/auth_api_test.dart new file mode 100644 index 0000000..0ae0ede --- /dev/null +++ b/server/test/auth_api_test.dart @@ -0,0 +1,289 @@ +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 = {}; + final byEmail = {}; + + @override + Future findByEmail(String email) async { + return byEmail[email.trim().toLowerCase()]; + } + + @override + Future findById(String id) async => byId[id]; + + @override + Future insert(UserAccount user) async { + byId[user.id] = user; + byEmail[user.email] = user; + } + + @override + Future updatePasswordHash({ + required String userId, + required String passwordHash, + required DateTime updatedAt, + }) async {} +} + +final class _FakeAuthSessionRepository implements AuthSessionRepository { + final byTokenHash = {}; + final revokedSessionIds = []; + + @override + Future insert(AuthSession session) async { + byTokenHash[session.tokenHash] = session; + } + + @override + Future findByTokenHash(String tokenHash) async { + return byTokenHash[tokenHash]; + } + + @override + Future revoke({ + required String sessionId, + required DateTime revokedAt, + }) async { + revokedSessionIds.add(sessionId); + } +} + +final class _FakePasswordHasher implements PasswordHasher { + @override + Future hash(String password) async => 'hashed:$password'; + + @override + Future 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'; + } +}