feat(server): tests API, contrats OpenAPI et vérification d'intégration (ticket #53)
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>
This commit is contained in:
@ -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
|
`409` for revoked or already answered shares, while missing shares or recipients
|
||||||
return `404`.
|
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 <https://editor.swagger.io/> or open it with
|
||||||
|
a local OpenAPI-compatible viewer.
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
||||||
Ticket #52 adds Docker packaging for a headless Linux deployment. TLS is not
|
Ticket #52 adds Docker packaging for a headless Linux deployment. TLS is not
|
||||||
|
|||||||
104
server/docs/integration-checklist.md
Normal file
104
server/docs/integration-checklist.md
Normal file
@ -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.
|
||||||
591
server/openapi.yaml
Normal file
591
server/openapi.yaml
Normal file
@ -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'
|
||||||
289
server/test/auth_api_test.dart
Normal file
289
server/test/auth_api_test.dart
Normal file
@ -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 = <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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user