From c6d2c48264dd622e56c3c21f5af87ebe71a12389 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 9 May 2026 08:59:54 -0500 Subject: [PATCH] feat: secure /api/v1 REST API for regular-user actions Adds bearer-token authenticated REST API at /api/v1 covering every action a non-admin user can perform on the website: contact + beacon submission, beacon-monitor management, propagation queries, profile read/update, and self-service API token issuance/revocation. Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown once at creation), RFC 9457 problem+json error responses, RFC 9651 RateLimit-* headers backed by an ETS bucket (600/min per token, 60/min per anonymous IP, 30/min on /auth/tokens), private-contact filtering by viewer. Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml (OpenAPI 3.1 spec covering every endpoint, response, and schema). Tests: 124 new tests across schema, plug, error renderer, rate limiter, fallback, and every controller. 16/17 API modules at 100% line coverage; FallbackController at 87.5% (one defmodule line, an Erlang-cover artifact for action_fallback-only modules). --- docs/api/README.md | 269 +++++++ docs/api/openapi.yaml | 699 ++++++++++++++++++ lib/microwaveprop/accounts.ex | 87 +++ lib/microwaveprop/accounts/user_api_token.ex | 88 +++ lib/microwaveprop/application.ex | 4 + lib/microwaveprop_web/api/auth.ex | 95 +++ lib/microwaveprop_web/api/error_json.ex | 85 +++ .../api/fallback_controller.ex | 35 + lib/microwaveprop_web/api/rate_limiter.ex | 91 +++ .../controllers/api/v1/auth_controller.ex | 68 ++ .../controllers/api/v1/beacon_controller.ex | 52 ++ .../controllers/api/v1/beacon_json.ex | 28 + .../controllers/api/v1/beacon_monitor_json.ex | 18 + .../controllers/api/v1/contact_controller.ex | 103 +++ .../controllers/api/v1/contact_json.ex | 61 ++ .../controllers/api/v1/me_controller.ex | 102 +++ .../controllers/api/v1/profile_controller.ex | 32 + .../controllers/api/v1/score_controller.ex | 91 +++ .../controllers/api/v1/token_json.ex | 29 + .../controllers/api/v1/user_json.ex | 37 + lib/microwaveprop_web/router.ex | 72 +- ...20260509120000_create_users_api_tokens.exs | 20 + .../accounts/user_api_token_test.exs | 71 ++ .../microwaveprop/accounts_api_token_test.exs | 114 +++ test/microwaveprop_web/api/auth_test.exs | 134 ++++ .../api/coverage_extras_test.exs | 92 +++ .../microwaveprop_web/api/error_json_test.exs | 103 +++ .../api/fallback_controller_test.exs | 55 ++ .../api/rate_limiter_test.exs | 82 ++ .../api/v1/auth_controller_test.exs | 127 ++++ .../api/v1/beacon_controller_test.exs | 83 +++ .../api/v1/contact_controller_test.exs | 162 ++++ .../controllers/api/v1/me_controller_test.exs | 139 ++++ .../api/v1/profile_controller_test.exs | 65 ++ .../api/v1/score_controller_test.exs | 136 ++++ 35 files changed, 3525 insertions(+), 4 deletions(-) create mode 100644 docs/api/README.md create mode 100644 docs/api/openapi.yaml create mode 100644 lib/microwaveprop/accounts/user_api_token.ex create mode 100644 lib/microwaveprop_web/api/auth.ex create mode 100644 lib/microwaveprop_web/api/error_json.ex create mode 100644 lib/microwaveprop_web/api/fallback_controller.ex create mode 100644 lib/microwaveprop_web/api/rate_limiter.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/auth_controller.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/beacon_controller.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/beacon_json.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/beacon_monitor_json.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/contact_controller.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/contact_json.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/me_controller.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/profile_controller.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/score_controller.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/token_json.ex create mode 100644 lib/microwaveprop_web/controllers/api/v1/user_json.ex create mode 100644 priv/repo/migrations/20260509120000_create_users_api_tokens.exs create mode 100644 test/microwaveprop/accounts/user_api_token_test.exs create mode 100644 test/microwaveprop/accounts_api_token_test.exs create mode 100644 test/microwaveprop_web/api/auth_test.exs create mode 100644 test/microwaveprop_web/api/coverage_extras_test.exs create mode 100644 test/microwaveprop_web/api/error_json_test.exs create mode 100644 test/microwaveprop_web/api/fallback_controller_test.exs create mode 100644 test/microwaveprop_web/api/rate_limiter_test.exs create mode 100644 test/microwaveprop_web/controllers/api/v1/auth_controller_test.exs create mode 100644 test/microwaveprop_web/controllers/api/v1/beacon_controller_test.exs create mode 100644 test/microwaveprop_web/controllers/api/v1/contact_controller_test.exs create mode 100644 test/microwaveprop_web/controllers/api/v1/me_controller_test.exs create mode 100644 test/microwaveprop_web/controllers/api/v1/profile_controller_test.exs create mode 100644 test/microwaveprop_web/controllers/api/v1/score_controller_test.exs diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 00000000..2376fb0d --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,269 @@ +# Microwaveprop REST API + +The Microwaveprop public REST API exposes the read-and-write surface a +**regular user** of the website has access to: contact (QSO) submission, +beacon submission, beacon-monitor management, propagation queries, and +profile management. Admin-only operations (user management, beacon +approval, contact moderation) are deliberately excluded from this API. + +* **Base URL:** `https://prop.w5isp.com/api/v1` +* **Versioning:** path-based (`/api/v1`). Breaking changes will ship a + `/api/v2` rather than mutate `/api/v1`. +* **Auth:** opaque bearer tokens (`Authorization: Bearer mwp_...`). + Tokens are issued at `POST /api/v1/auth/tokens` and never leak the + user's password to API clients. +* **Format:** `application/json` for requests and successful responses; + errors use [RFC 9457 problem+json](https://www.rfc-editor.org/rfc/rfc9457). +* **OpenAPI 3.1 spec:** [`openapi.yaml`](./openapi.yaml). + +## Quickstart + +```bash +# 1. Mint a long-lived bearer token (one-time, requires your password). +curl -sS -X POST https://prop.w5isp.com/api/v1/auth/tokens \ + -H "Content-Type: application/json" \ + -d '{"email":"you@example.com","password":"...","name":"laptop"}' +# => {"data":{...},"token":"mwp_..."} <-- copy the token; it's shown once + +# 2. Use the token for everything else. +TOKEN=mwp_... + +curl -sS -H "Authorization: Bearer $TOKEN" https://prop.w5isp.com/api/v1/me +``` + +## Authentication + +| Endpoint | Auth | Notes | +|-------------------------|----------|---------------------------------------------| +| `POST /auth/tokens` | password | The only endpoint that accepts a password. | +| `GET /me`, `PATCH /me` | bearer | The user behind the bearer token. | +| `POST /contacts` | bearer | Regular user QSO submission. | +| `GET /contacts` | optional | Public; bearer reveals viewer-private rows. | +| `GET /beacons` | none | Approved beacons only. | +| `POST /beacons` | bearer | New beacons start unapproved. | +| `GET /scores` | none | Public read of propagation scores. | +| `GET /profiles/:call` | none | Public per-callsign profile. | + +### Token format + +Tokens are 32 random bytes URL-base64-encoded, prefixed with `mwp_`. The +prefix lets static-analysis tools (`gitleaks`, `truffleHog`, etc.) match +leaked tokens. Only the SHA-256 of the token is stored server-side. + +### Token lifecycle + +* Tokens may carry an optional `expires_at` (ISO 8601 UTC). Without one + they live until revoked. +* `GET /me/api-tokens` lists every non-revoked token belonging to the + authenticated user (without plaintext). +* `DELETE /me/api-tokens/:id` revokes a token (soft delete — the row + remains for audit). +* Revoking the token used by the current request immediately invalidates + every subsequent request from that token. + +## Errors + +All error responses follow [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457): + +```json +{ + "type": "about:blank", + "title": "validation_failed", + "status": 422, + "detail": "One or more fields are invalid.", + "errors": { + "callsign": ["must be 3-10 letters and digits"] + } +} +``` + +| Status | When | +|--------|-------------------------------------------------------------------| +| 400 | Missing/malformed query or body parameter. | +| 401 | No / invalid / revoked / expired bearer token. | +| 403 | Authenticated, but the action is forbidden for the caller. | +| 404 | Resource not found, or hidden by privacy (private contact, etc.). | +| 409 | Duplicate — an equivalent resource already exists. | +| 422 | Validation failed; `errors` carries per-field messages. | +| 429 | Rate limit exceeded; check `RateLimit-*` headers + `Retry-After`. | +| 5xx | Bug. Please open an issue. | + +## Rate limiting + +Each response carries the [RFC 9651](https://datatracker.ietf.org/doc/rfc9651/) +`RateLimit-*` headers: + +| Header | Meaning | +|-----------------------|----------------------------------------------------| +| `RateLimit-Limit` | Requests permitted in the current window. | +| `RateLimit-Remaining` | Requests remaining in the current window. | +| `RateLimit-Reset` | Seconds until the window resets. | +| `Retry-After` | Sent on 429s; retry no sooner than this many secs. | + +Defaults: + +| Caller | Limit | +|-----------------------------|----------------------------| +| Anonymous (per IP) | 60 req / minute | +| Authenticated (per token) | 600 req / minute | +| `POST /auth/tokens` (per IP)| 30 req / minute | + +## Endpoint reference + +### `POST /auth/tokens` — issue an API token + +Request: + +```json +{ + "email": "you@example.com", + "password": "your password", + "name": "iPad", + "expires_at": "2027-01-01T00:00:00Z" +} +``` + +`expires_at` is optional; omit it for a token that lives until revoked. + +Response (201): + +```json +{ + "data": { + "id": "01HX...", + "name": "iPad", + "inserted_at": "2026-05-09T12:34:00Z", + "last_used_at": null, + "expires_at": "2027-01-01T00:00:00Z", + "revoked_at": null + }, + "token": "mwp_AbCdEf..." +} +``` + +### `GET /me` — current user + +Returns the authenticated user's profile (callsign, name, email, home +QTH, is_admin flag). + +### `PATCH /me` — update home QTH + +Accepts any subset of `home_grid` (Maidenhead), `home_lat`, `home_lon`, +`home_elevation_m`. The grid is auto-derived from lat/lon and vice +versa. + +### `GET /me/contacts` + +Every QSO submitted under the authenticated user's account, newest first. + +### `GET /me/beacons` + +Every beacon (approved or pending) submitted by the user. + +### `GET /me/api-tokens` + +List the user's non-revoked API tokens. + +### `DELETE /me/api-tokens/:id` + +Revoke a token. Returns the updated record (with `revoked_at` set). + +### `GET /me/beacon-monitors`, `POST /me/beacon-monitors`, `DELETE /me/beacon-monitors/:id` + +CRUD for the user's distributed beacon monitor stations. Each monitor +has a `token` field — the credential the monitor program uses to +identify itself when reporting. + +### `GET /contacts` + +Paginated public list of QSOs. + +| Query | Default | Notes | +|-----------|---------|-----------------------------------------------------| +| `page` | `1` | 1-based. | +| `per_page`| `50` | Capped at 200. | +| `search` | — | One or two callsigns; matches station1 / station2. | + +When called with a bearer token, the user's own private contacts are +included in addition to the public set. + +### `GET /contacts/:id` + +A single QSO. Private QSOs return 404 to non-owners. + +### `POST /contacts` + +Submit a new QSO. Required fields: + +```json +{ + "station1": "W5XD", + "station2": "K5XD", + "qso_timestamp": "2026-05-08T12:34:00Z", + "band": "10000", + "grid1": "EM12", + "grid2": "EM13", + "mode": "CW" +} +``` + +Optional: `user_declared_prop_mode`, `height1_ft`, `height2_ft`, +`private`, `notes`. The QSO is automatically attributed to the +authenticated user; their email is recorded as `submitter_email`. + +A duplicate (same stations + same hour + same band) returns `409 Conflict` +with the existing record in the `existing` field. + +### `GET /beacons`, `GET /beacons/:id`, `POST /beacons` + +Approved beacons listing, single-beacon read, and unauthenticated submit +(the new beacon starts in `approved=false` state until an admin approves +it via the website). + +### `GET /scores/bands` + +Lists every band the propagation engine scores for, with their humidity- +effect classification. + +### `GET /scores` + +Returns the propagation score + factor breakdown at a grid point. + +| Query | Notes | +|--------------|----------------------------------------------------| +| `band` | MHz integer. Required. | +| `lat`, `lon` | Decimal degrees. Required. | +| `valid_time` | ISO 8601 UTC. Optional; defaults to latest hour. | + +### `GET /forecast` + +The 18-hour score timeline at a grid point. Same `band` + `lat` + `lon` +parameters; returns an array of `{valid_time, score}` tuples. + +### `GET /profiles/:callsign` + +Public per-user profile (callsign + name + home QTH) plus all public +contacts involving the callsign and all approved beacons submitted by +the user. Email is **not** exposed. + +## Conventions + +* All timestamps are ISO 8601 UTC (`...Z`). +* All identifiers are UUIDv7 (binary_id) strings. +* Bands are integer MHz strings (`"10000"`, `"24000"`). +* Latitude / longitude are decimal degrees. +* Maidenhead grid squares are 4- or 6-character (`EM12`, `EM12kx`). + +## Stability promise + +* Adding new fields to existing responses is non-breaking. +* Removing or renaming fields will only happen in a new `/api/vN`. +* Adding new endpoints to `/api/v1` is non-breaking. +* Tightening validation may produce new 422 responses; these are not + considered breaking either, but will be called out in the changelog. + +## See also + +* [`openapi.yaml`](./openapi.yaml) — the machine-readable spec. +* `/.well-known/api-catalog` — RFC 9727 service descriptor (public). +* `/algo` — the scoring algorithm in detail. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 00000000..511f7caf --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,699 @@ +openapi: 3.1.0 +info: + title: Microwaveprop Public API + version: "1.0.0" + summary: REST API for QSO submission, beacon management, and propagation queries. + description: | + Public REST API for the NTMS microwave propagation prediction service. + Implements every action a regular signed-in user can take on the + website (submit contacts and beacons, manage beacon monitors, + manage their own API tokens, query propagation scores), plus public + read-only endpoints for contacts, beacons, profiles, and scores. + + Admin-only operations (user management, beacon approval, contact + moderation) are intentionally **not** exposed here. + + See `docs/api/README.md` for prose, examples, and the stability + promise. + contact: + name: NTMS + url: https://w5hn.org/ + +servers: + - url: https://prop.w5isp.com/api/v1 + description: Production + - url: http://localhost:4000/api/v1 + description: Local development + +security: + - bearerAuth: [] + +tags: + - name: auth + - name: me + - name: contacts + - name: beacons + - name: monitors + - name: scores + - name: profiles + +paths: + /auth/tokens: + post: + tags: [auth] + summary: Issue a long-lived bearer token + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AuthRequest" } + responses: + "201": + description: Token created + content: + application/json: + schema: { $ref: "#/components/schemas/AuthResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/ValidationFailed" } + "429": { $ref: "#/components/responses/RateLimited" } + + /me: + get: + tags: [me] + summary: Current user profile + responses: + "200": + description: Profile + content: + application/json: + schema: { $ref: "#/components/schemas/MeResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + patch: + tags: [me] + summary: Update home QTH + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/HomeQthRequest" } + responses: + "200": + description: Updated profile + content: + application/json: + schema: { $ref: "#/components/schemas/MeResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/ValidationFailed" } + + /me/contacts: + get: + tags: [me] + summary: My QSOs + responses: + "200": + description: List + content: + application/json: + schema: { $ref: "#/components/schemas/ContactList" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /me/beacons: + get: + tags: [me] + summary: My beacons + responses: + "200": + description: List + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconList" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /me/api-tokens: + get: + tags: [me] + summary: List my API tokens + responses: + "200": + description: List + content: + application/json: + schema: { $ref: "#/components/schemas/TokenList" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /me/api-tokens/{id}: + delete: + tags: [me] + summary: Revoke an API token + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Revoked record + content: + application/json: + schema: { $ref: "#/components/schemas/TokenResponse" } + "404": { $ref: "#/components/responses/NotFound" } + + /me/beacon-monitors: + get: + tags: [monitors] + summary: List my beacon monitors + responses: + "200": + description: List + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconMonitorList" } + post: + tags: [monitors] + summary: Create a beacon monitor + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconMonitorCreate" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconMonitorResponse" } + "422": { $ref: "#/components/responses/ValidationFailed" } + + /me/beacon-monitors/{id}: + delete: + tags: [monitors] + summary: Delete a beacon monitor + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + "204": + description: No content + "404": { $ref: "#/components/responses/NotFound" } + + /contacts: + get: + tags: [contacts] + summary: List public QSOs + security: [] + parameters: + - in: query + name: page + schema: { type: integer, minimum: 1, default: 1 } + - in: query + name: per_page + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + - in: query + name: search + schema: { type: string } + responses: + "200": + description: Paginated list + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedContactList" } + post: + tags: [contacts] + summary: Submit a QSO + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ContactCreate" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/ContactResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/ValidationFailed" } + + /contacts/{id}: + get: + tags: [contacts] + summary: Show a single QSO + security: [] + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: QSO + content: + application/json: + schema: { $ref: "#/components/schemas/ContactResponse" } + "404": { $ref: "#/components/responses/NotFound" } + + /beacons: + get: + tags: [beacons] + summary: List approved beacons + security: [] + responses: + "200": + description: List + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconList" } + post: + tags: [beacons] + summary: Submit a beacon + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconCreate" } + responses: + "201": + description: Created (pending approval) + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/ValidationFailed" } + + /beacons/{id}: + get: + tags: [beacons] + summary: Show a single beacon + security: [] + parameters: + - in: path + name: id + required: true + schema: { type: string, format: uuid } + responses: + "200": + description: Beacon + content: + application/json: + schema: { $ref: "#/components/schemas/BeaconResponse" } + "404": { $ref: "#/components/responses/NotFound" } + + /profiles/{callsign}: + get: + tags: [profiles] + summary: Public per-callsign profile + security: [] + parameters: + - in: path + name: callsign + required: true + schema: { type: string } + responses: + "200": + description: Profile + contacts + approved beacons + content: + application/json: + schema: { $ref: "#/components/schemas/ProfileResponse" } + "404": { $ref: "#/components/responses/NotFound" } + + /scores/bands: + get: + tags: [scores] + summary: Bands with propagation scores + security: [] + responses: + "200": + description: List of bands + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + mhz: { type: integer } + label: { type: string } + humidity_effect: + type: string + enum: [beneficial, harmful, neutral] + + /scores: + get: + tags: [scores] + summary: Score + factor breakdown at a grid point + security: [] + parameters: + - in: query + name: band + required: true + schema: { type: integer } + - in: query + name: lat + required: true + schema: { type: number, format: double } + - in: query + name: lon + required: true + schema: { type: number, format: double } + - in: query + name: valid_time + schema: { type: string, format: date-time } + responses: + "200": + description: Score + content: + application/json: + schema: { $ref: "#/components/schemas/ScoreResponse" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + + /forecast: + get: + tags: [scores] + summary: 18-hour score timeline at a grid point + security: [] + parameters: + - in: query + name: band + required: true + schema: { type: integer } + - in: query + name: lat + required: true + schema: { type: number, format: double } + - in: query + name: lon + required: true + schema: { type: number, format: double } + responses: + "200": + description: Timeline + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + valid_time: { type: string, format: date-time } + score: { type: integer } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: opaque + + responses: + BadRequest: + description: Bad request + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + Unauthorized: + description: Authentication required + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + NotFound: + description: Resource not found + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + Conflict: + description: Duplicate + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + ValidationFailed: + description: Validation failed + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + RateLimited: + description: Rate limit exceeded + headers: + RateLimit-Limit: { schema: { type: integer } } + RateLimit-Remaining: { schema: { type: integer } } + RateLimit-Reset: { schema: { type: integer } } + Retry-After: { schema: { type: integer } } + content: + application/problem+json: + schema: { $ref: "#/components/schemas/Problem" } + + schemas: + Problem: + type: object + properties: + type: { type: string, default: "about:blank" } + title: { type: string } + status: { type: integer } + detail: { type: string } + errors: + type: object + additionalProperties: + type: array + items: { type: string } + + AuthRequest: + type: object + required: [email, password, name] + properties: + email: { type: string, format: email } + password: { type: string } + name: { type: string, maxLength: 100 } + expires_at: { type: string, format: date-time, nullable: true } + + AuthResponse: + type: object + properties: + token: { type: string } + data: { $ref: "#/components/schemas/Token" } + + Token: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + inserted_at: { type: string, format: date-time } + last_used_at: { type: string, format: date-time, nullable: true } + expires_at: { type: string, format: date-time, nullable: true } + revoked_at: { type: string, format: date-time, nullable: true } + + TokenList: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Token" } + + TokenResponse: + type: object + properties: + data: { $ref: "#/components/schemas/Token" } + + Me: + type: object + properties: + id: { type: string, format: uuid } + callsign: { type: string } + name: { type: string } + email: { type: string, format: email } + is_admin: { type: boolean } + confirmed_at: { type: string, format: date-time, nullable: true } + home_grid: { type: string, nullable: true } + home_lat: { type: number, format: double, nullable: true } + home_lon: { type: number, format: double, nullable: true } + home_elevation_m: { type: integer, nullable: true } + + MeResponse: + type: object + properties: + data: { $ref: "#/components/schemas/Me" } + + HomeQthRequest: + type: object + properties: + home_grid: { type: string } + home_lat: { type: number, format: double } + home_lon: { type: number, format: double } + home_elevation_m: { type: integer } + + Contact: + type: object + properties: + id: { type: string, format: uuid } + station1: { type: string } + station2: { type: string } + qso_timestamp: { type: string, format: date-time } + grid1: { type: string } + grid2: { type: string } + pos1: + type: object + properties: + lat: { type: number } + lon: { type: number } + pos2: + type: object + properties: + lat: { type: number } + lon: { type: number } + mode: { type: string } + band_mhz: { type: string } + distance_km: { type: string, nullable: true } + private: { type: boolean } + user_declared_prop_mode: { type: string, nullable: true } + propagation_mechanism: { type: string, nullable: true } + propagation_mechanism_confidence: + type: string + nullable: true + enum: [high, medium, low] + notes: { type: string, nullable: true } + "mine?": { type: boolean } + + ContactCreate: + type: object + required: [station1, station2, qso_timestamp, band, grid1, grid2] + properties: + station1: { type: string } + station2: { type: string } + qso_timestamp: { type: string, format: date-time } + band: { type: string } + grid1: { type: string } + grid2: { type: string } + mode: { type: string, enum: [CW, SSB, FM, FT8, FT4, Q65] } + user_declared_prop_mode: { type: string } + height1_ft: { type: integer } + height2_ft: { type: integer } + private: { type: boolean } + notes: { type: string, maxLength: 2000 } + + ContactResponse: + type: object + properties: + data: { $ref: "#/components/schemas/Contact" } + + ContactList: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Contact" } + + PaginatedContactList: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Contact" } + meta: + type: object + properties: + page: { type: integer } + per_page: { type: integer } + total_entries: { type: integer } + total_pages: { type: integer } + + Beacon: + type: object + properties: + id: { type: string, format: uuid } + callsign: { type: string } + frequency_mhz: { type: number } + lat: { type: number } + lon: { type: number } + grid: { type: string } + power_mw: { type: number } + height_ft: { type: integer } + on_the_air: { type: boolean } + approved: { type: boolean } + keying: { type: string } + bearing: { type: string } + beamwidth_deg: { type: number, nullable: true } + notes: { type: string, nullable: true } + inserted_at: { type: string, format: date-time } + + BeaconCreate: + type: object + required: [frequency_mhz, callsign, lat, lon, power_mw, height_ft, keying] + properties: + frequency_mhz: { type: number } + callsign: { type: string } + grid: { type: string } + lat: { type: number } + lon: { type: number } + power_mw: { type: number } + height_ft: { type: integer } + on_the_air: { type: boolean } + keying: { type: string } + bearing: { type: string } + beamwidth_deg: { type: number } + notes: { type: string } + + BeaconResponse: + type: object + properties: + data: { $ref: "#/components/schemas/Beacon" } + + BeaconList: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/Beacon" } + + BeaconMonitor: + type: object + properties: + id: { type: string, format: uuid } + name: { type: string } + token: { type: string, nullable: true } + last_seen_at: { type: string, format: date-time, nullable: true } + inserted_at: { type: string, format: date-time } + + BeaconMonitorCreate: + type: object + required: [name] + properties: + name: { type: string, maxLength: 100 } + + BeaconMonitorResponse: + type: object + properties: + data: { $ref: "#/components/schemas/BeaconMonitor" } + + BeaconMonitorList: + type: object + properties: + data: + type: array + items: { $ref: "#/components/schemas/BeaconMonitor" } + + ProfileResponse: + type: object + properties: + user: + type: object + properties: + id: { type: string, format: uuid } + callsign: { type: string } + name: { type: string } + home_grid: { type: string, nullable: true } + home_lat: { type: number, nullable: true } + home_lon: { type: number, nullable: true } + contacts: + type: array + items: { $ref: "#/components/schemas/Contact" } + beacons: + type: array + items: { $ref: "#/components/schemas/Beacon" } + + ScoreResponse: + type: object + properties: + data: + type: object + properties: + lat: { type: number } + lon: { type: number } + score: { type: integer } + factors: + type: object + additionalProperties: true + profile_source: + oneOf: + - { type: string, enum: [exact, unavailable] } + - { type: array } + valid_time: { type: string, format: date-time } diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index dd33c6f2..6a535288 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -6,6 +6,7 @@ defmodule Microwaveprop.Accounts do import Ecto.Query, warn: false alias Microwaveprop.Accounts.User + alias Microwaveprop.Accounts.UserApiToken alias Microwaveprop.Accounts.UserNotifier alias Microwaveprop.Accounts.UserToken alias Microwaveprop.Repo @@ -432,6 +433,92 @@ defmodule Microwaveprop.Accounts do :ok end + ## API tokens (long-lived bearer tokens for /api/v1) + + @doc """ + Creates a new long-lived API token for the user. Returns the + plaintext token (only shown once) alongside the persisted record. + """ + @spec create_api_token(User.t(), map()) :: + {:ok, {String.t(), UserApiToken.t()}} | {:error, Ecto.Changeset.t()} + def create_api_token(%User{} = user, attrs) do + case UserApiToken.build(user, attrs) do + {:ok, {plaintext, changeset}} -> + case Repo.insert(changeset) do + {:ok, record} -> {:ok, {plaintext, record}} + {:error, changeset} -> {:error, changeset} + end + + {:error, changeset} -> + {:error, changeset} + end + end + + @doc "Lists every non-revoked API token for the user, newest first." + @spec list_api_tokens(User.t()) :: [UserApiToken.t()] + def list_api_tokens(%User{id: user_id}) do + Repo.all( + from t in UserApiToken, + where: t.user_id == ^user_id and is_nil(t.revoked_at), + order_by: [desc: t.inserted_at] + ) + end + + @doc """ + Looks up the user owning the given plaintext bearer token. Returns + `{:ok, user, token}` for valid, unexpired, non-revoked tokens. The + token's `last_used_at` is updated as a side effect. + """ + @spec get_user_by_api_token(String.t()) :: + {:ok, User.t(), UserApiToken.t()} | {:error, :invalid_token} + def get_user_by_api_token(plaintext) when is_binary(plaintext) do + hash = UserApiToken.hash_token(plaintext) + now = DateTime.utc_now(:second) + + query = + from t in UserApiToken, + join: u in assoc(t, :user), + where: t.token_hash == ^hash, + where: is_nil(t.revoked_at), + where: is_nil(t.expires_at) or t.expires_at > ^now, + select: {u, t} + + case Repo.one(query) do + nil -> + {:error, :invalid_token} + + {user, token} -> + {1, _} = + Repo.update_all( + from(t in UserApiToken, where: t.id == ^token.id), + set: [last_used_at: now] + ) + + {:ok, user, %{token | last_used_at: now}} + end + end + + @doc """ + Revokes a user's API token by id. Returns `{:error, :not_found}` + when the id is not owned by the user. + """ + @spec revoke_api_token(User.t(), Ecto.UUID.t()) :: + {:ok, UserApiToken.t()} | {:error, :not_found} + def revoke_api_token(%User{id: user_id}, token_id) do + case Repo.get_by(UserApiToken, id: token_id, user_id: user_id) do + nil -> + {:error, :not_found} + + %UserApiToken{revoked_at: nil} = token -> + token + |> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second)) + |> Repo.update() + + %UserApiToken{} = token -> + {:ok, token} + end + end + ## Token helper defp update_user_and_delete_all_tokens(changeset) do diff --git a/lib/microwaveprop/accounts/user_api_token.ex b/lib/microwaveprop/accounts/user_api_token.ex new file mode 100644 index 00000000..615e171b --- /dev/null +++ b/lib/microwaveprop/accounts/user_api_token.ex @@ -0,0 +1,88 @@ +defmodule Microwaveprop.Accounts.UserApiToken do + @moduledoc """ + Long-lived bearer token for `/api/v1` access. Distinct from the + short-lived `users_tokens` rows used by the browser session and + email-confirmation flows so revoking an API token does not also + log the user out of the website. + + The plaintext token is shown to the user exactly once at creation. + Only the SHA-256 hash is persisted, mirroring the pattern used by + `Microwaveprop.Accounts.UserToken` for email-delivered tokens. + """ + + use Ecto.Schema + + import Ecto.Changeset + + alias Microwaveprop.Accounts.User + + @hash_algorithm :sha256 + @rand_size 32 + @prefix "mwp_" + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "users_api_tokens" do + field :name, :string + field :token_hash, :binary + field :last_used_at, :utc_datetime + field :expires_at, :utc_datetime + field :revoked_at, :utc_datetime + + belongs_to :user, User + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{} + + @doc "Returns the bearer-token prefix (e.g. `mwp_`)." + @spec token_prefix() :: String.t() + def token_prefix, do: @prefix + + @doc """ + Builds a `{plaintext_token, %UserApiToken{}}` tuple. The struct is + unsaved — callers persist it via `Repo.insert/1`. + """ + @spec build(User.t(), map()) :: {:ok, {String.t(), Ecto.Changeset.t()}} | {:error, Ecto.Changeset.t()} + def build(%User{id: user_id}, attrs) do + raw = :crypto.strong_rand_bytes(@rand_size) + plaintext = @prefix <> Base.url_encode64(raw, padding: false) + hash = hash_token(plaintext) + + changeset = + %__MODULE__{} + |> cast(attrs, [:name, :expires_at]) + |> validate_required([:name]) + |> validate_length(:name, min: 1, max: 100) + |> put_change(:token_hash, hash) + |> put_change(:user_id, user_id) + |> validate_future_expiry() + + if changeset.valid? do + {:ok, {plaintext, changeset}} + else + {:error, %{changeset | action: :insert}} + end + end + + @doc "SHA-256 hash of the plaintext bearer token." + @spec hash_token(String.t()) :: binary() + def hash_token(plaintext) when is_binary(plaintext) do + :crypto.hash(@hash_algorithm, plaintext) + end + + defp validate_future_expiry(changeset) do + case get_field(changeset, :expires_at) do + nil -> + changeset + + %DateTime{} = dt -> + if DateTime.after?(dt, DateTime.utc_now()) do + changeset + else + add_error(changeset, :expires_at, "must be in the future") + end + end + end +end diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index ed2b96a1..d38f84f1 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -11,6 +11,10 @@ defmodule Microwaveprop.Application do @impl true def start(_type, _args) do + # Eagerly create the API rate limiter's ETS table so the plug never + # races to create it on the request path. + :ok = MicrowavepropWeb.Api.RateLimiter.init_table() + topologies = Application.get_env(:libcluster, :topologies, []) children = [ diff --git a/lib/microwaveprop_web/api/auth.ex b/lib/microwaveprop_web/api/auth.ex new file mode 100644 index 00000000..f0988b44 --- /dev/null +++ b/lib/microwaveprop_web/api/auth.ex @@ -0,0 +1,95 @@ +defmodule MicrowavepropWeb.Api.Auth do + @moduledoc """ + Bearer-token authentication for `/api/v1`. + + Looks up `Authorization: Bearer ` headers, resolves them via + `Microwaveprop.Accounts.get_user_by_api_token/1`, and stashes the + user + token on `conn.assigns`. + + Two plugs: + + * `optional_auth/2` — never halts, sets `:current_api_user` when a + valid token is present and falls through unauthenticated otherwise. + Use for endpoints with a public read fallback. + * `require_auth/2` — halts with `401 Unauthorized` problem+json when + no valid token is present. Use for any endpoint that mutates state + or returns user-private data. + """ + + @behaviour Plug + + import Plug.Conn + + alias Microwaveprop.Accounts + alias MicrowavepropWeb.Api.ErrorJSON + + @impl true + def init(opts), do: Keyword.put_new(opts, :mode, :require) + + @impl true + def call(conn, opts) do + case Keyword.fetch!(opts, :mode) do + :require -> require_auth(conn, opts) + :optional -> optional_auth(conn, opts) + end + end + + @doc "Plug that halts with 401 when no valid bearer token is supplied." + @spec require_auth(Plug.Conn.t(), keyword()) :: Plug.Conn.t() + def require_auth(conn, _opts \\ []) do + case authenticate(conn) do + {:ok, conn} -> + conn + + {:error, reason} -> + conn + |> put_resp_header("www-authenticate", ~s(Bearer realm="api")) + |> ErrorJSON.send_problem(401, "unauthorized", reason_message(reason)) + end + end + + @doc "Plug that lets unauthenticated requests through but assigns the user when a token is valid." + @spec optional_auth(Plug.Conn.t(), keyword()) :: Plug.Conn.t() + def optional_auth(conn, _opts \\ []) do + case authenticate(conn) do + {:ok, conn} -> conn + {:error, :missing_token} -> assign_anonymous(conn) + {:error, reason} -> halt_with_invalid(conn, reason) + end + end + + defp authenticate(conn) do + with {:ok, plaintext} <- extract_bearer(conn), + {:ok, user, token} <- Accounts.get_user_by_api_token(plaintext) do + {:ok, + conn + |> assign(:current_api_user, user) + |> assign(:current_api_token, token)} + end + end + + defp extract_bearer(conn) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] when byte_size(token) > 0 -> {:ok, token} + ["bearer " <> token] when byte_size(token) > 0 -> {:ok, token} + [] -> {:error, :missing_token} + _ -> {:error, :invalid_authorization_header} + end + end + + defp assign_anonymous(conn) do + conn + |> assign(:current_api_user, nil) + |> assign(:current_api_token, nil) + end + + defp halt_with_invalid(conn, reason) do + conn + |> put_resp_header("www-authenticate", ~s(Bearer realm="api")) + |> ErrorJSON.send_problem(401, "unauthorized", reason_message(reason)) + end + + defp reason_message(:missing_token), do: "Missing bearer token in Authorization header." + defp reason_message(:invalid_authorization_header), do: "Authorization header must be `Bearer `." + defp reason_message(:invalid_token), do: "Bearer token is invalid, expired, or revoked." +end diff --git a/lib/microwaveprop_web/api/error_json.ex b/lib/microwaveprop_web/api/error_json.ex new file mode 100644 index 00000000..75ffb2f6 --- /dev/null +++ b/lib/microwaveprop_web/api/error_json.ex @@ -0,0 +1,85 @@ +defmodule MicrowavepropWeb.Api.ErrorJSON do + @moduledoc """ + RFC 9457 problem+json error responses for `/api/v1`. + + All error bodies share the shape: + + { + "type": "about:blank", + "title": "", + "status": , + "detail": "", + "errors": + } + """ + + import Plug.Conn + + alias Ecto.Changeset + alias Plug.Conn.Status + + @content_type "application/problem+json" + + @doc "Halts the connection with a problem+json body." + @spec send_problem(Plug.Conn.t(), pos_integer(), String.t(), String.t(), map()) :: Plug.Conn.t() + def send_problem(conn, status, title, detail, extra \\ %{}) do + body = + %{ + type: "about:blank", + title: title, + status: status, + detail: detail + } + |> Map.merge(extra) + |> Jason.encode!() + + conn + |> put_resp_content_type(@content_type) + |> send_resp(status, body) + |> halt() + end + + @doc "Halts with a 422 problem+json including a flattened changeset error map." + @spec send_changeset(Plug.Conn.t(), Changeset.t()) :: Plug.Conn.t() + def send_changeset(conn, %Changeset{} = changeset) do + send_problem( + conn, + 422, + "validation_failed", + "One or more fields are invalid.", + %{errors: translate_errors(changeset)} + ) + end + + @doc "Translates a changeset's errors into a flat map of field -> [messages]." + @spec translate_errors(Changeset.t()) :: map() + def translate_errors(%Changeset{} = changeset) do + Changeset.traverse_errors(changeset, fn {msg, opts} -> + Enum.reduce(opts, msg, fn {key, value}, acc -> + String.replace(acc, "%{#{key}}", to_string(value)) + end) + end) + end + + ## Default Phoenix error renderer hooks ---------------------------- + + @doc false + def render("400.json", _assigns), do: error_body(400, "bad_request", "Malformed request.") + def render("401.json", _assigns), do: error_body(401, "unauthorized", "Authentication is required.") + def render("403.json", _assigns), do: error_body(403, "forbidden", "You may not access this resource.") + def render("404.json", _assigns), do: error_body(404, "not_found", "Resource not found.") + def render("405.json", _assigns), do: error_body(405, "method_not_allowed", "HTTP method not allowed.") + def render("415.json", _assigns), do: error_body(415, "unsupported_media_type", "Unsupported media type.") + def render("429.json", _assigns), do: error_body(429, "too_many_requests", "Rate limit exceeded.") + def render("500.json", _assigns), do: error_body(500, "internal_server_error", "Something went wrong.") + + def render(template, _assigns) do + [code | _] = String.split(template, ".") + status = String.to_integer(code) + error_body(status, status |> Status.reason_atom() |> Atom.to_string(), "") + end + + defp error_body(status, title, detail) do + %{type: "about:blank", title: title, status: status, detail: detail} + end +end diff --git a/lib/microwaveprop_web/api/fallback_controller.ex b/lib/microwaveprop_web/api/fallback_controller.ex new file mode 100644 index 00000000..da575696 --- /dev/null +++ b/lib/microwaveprop_web/api/fallback_controller.ex @@ -0,0 +1,35 @@ +defmodule MicrowavepropWeb.Api.FallbackController do + @moduledoc """ + Translates `{:error, term}` tuples returned from `/api/v1` controller + actions into RFC 9457 problem+json responses. Wired via + `action_fallback/1` in `MicrowavepropWeb.Api.V1.BaseController`. + """ + + use Phoenix.Controller, formats: [:json] + + alias MicrowavepropWeb.Api.ErrorJSON + + def call(conn, {:error, %Ecto.Changeset{} = changeset}) do + ErrorJSON.send_changeset(conn, changeset) + end + + def call(conn, {:error, :not_found}) do + ErrorJSON.send_problem(conn, 404, "not_found", "Resource not found.") + end + + def call(conn, {:error, :forbidden}) do + ErrorJSON.send_problem(conn, 403, "forbidden", "You may not access this resource.") + end + + def call(conn, {:error, :unauthorized}) do + ErrorJSON.send_problem(conn, 401, "unauthorized", "Authentication is required.") + end + + def call(conn, {:error, :bad_request, detail}) when is_binary(detail) do + ErrorJSON.send_problem(conn, 400, "bad_request", detail) + end + + def call(conn, {:error, :duplicate, _existing}) do + ErrorJSON.send_problem(conn, 409, "conflict", "An equivalent resource already exists.") + end +end diff --git a/lib/microwaveprop_web/api/rate_limiter.ex b/lib/microwaveprop_web/api/rate_limiter.ex new file mode 100644 index 00000000..0d1e0ec8 --- /dev/null +++ b/lib/microwaveprop_web/api/rate_limiter.ex @@ -0,0 +1,91 @@ +defmodule MicrowavepropWeb.Api.RateLimiter do + @moduledoc """ + Tiny ETS-backed fixed-window rate limiter for `/api/v1`. + + No external dependency, no supervisor entry — the named ETS table + is created lazily on the first request and survives for the + lifetime of the BEAM. Each (bucket, window) pair holds a counter + incremented atomically via `:ets.update_counter/4`. + + Default limits (overridable per plug invocation): + * authenticated requests: 600 / minute, keyed by API token id + * anonymous requests: 60 / minute, keyed by client IP + + The plug emits the RFC 9651 `RateLimit` headers and a 429 + problem+json response when the bucket is exhausted. + """ + + @behaviour Plug + + import Plug.Conn + + alias MicrowavepropWeb.Api.ErrorJSON + + @table :microwaveprop_api_rate_limiter + @default_window_ms 60_000 + @default_anon_limit 60 + @default_auth_limit 600 + + @doc """ + Initializes the named ETS table. Idempotent — safe to call from + application boot and from tests. Returns `:ok` whether the table + existed already or was created by this call. + """ + @spec init_table() :: :ok + def init_table do + if :ets.whereis(@table) == :undefined do + :ets.new(@table, [:set, :public, :named_table, write_concurrency: true]) + end + + :ok + end + + @doc "Resets the ETS table. Test helper." + @spec reset() :: :ok + def reset do + init_table() + :ets.delete_all_objects(@table) + :ok + end + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, opts) do + init_table() + now = System.system_time(:millisecond) + window_ms = Keyword.get(opts, :window_ms, @default_window_ms) + {bucket, limit} = bucket_for(conn, opts) + window = div(now, window_ms) + key = {bucket, window} + count = :ets.update_counter(@table, key, {2, 1}, {key, 0}) + remaining = max(limit - count, 0) + reset_in = div((window + 1) * window_ms - now + 999, 1000) + + conn = + conn + |> put_resp_header("ratelimit-limit", Integer.to_string(limit)) + |> put_resp_header("ratelimit-remaining", Integer.to_string(remaining)) + |> put_resp_header("ratelimit-reset", Integer.to_string(reset_in)) + + if count > limit do + conn + |> put_resp_header("retry-after", Integer.to_string(reset_in)) + |> ErrorJSON.send_problem(429, "too_many_requests", "Rate limit exceeded; retry after #{reset_in}s.") + else + conn + end + end + + defp bucket_for(conn, opts) do + case conn.assigns[:current_api_token] do + %{id: id} -> + {{:token, id}, Keyword.get(opts, :auth_limit, @default_auth_limit)} + + _ -> + ip = conn.remote_ip |> :inet.ntoa() |> to_string() + {{:ip, ip}, Keyword.get(opts, :anon_limit, @default_anon_limit)} + end + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/auth_controller.ex b/lib/microwaveprop_web/controllers/api/v1/auth_controller.ex new file mode 100644 index 00000000..fbaacc95 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/auth_controller.ex @@ -0,0 +1,68 @@ +defmodule MicrowavepropWeb.Api.V1.AuthController do + @moduledoc """ + Email/password login that mints a long-lived `/api/v1` bearer token. + + This is the only `/api/v1` endpoint that accepts a password — every + other endpoint authenticates with the bearer token returned here. + """ + + use Phoenix.Controller, formats: [:json] + + alias Microwaveprop.Accounts + alias MicrowavepropWeb.Api.ErrorJSON + alias MicrowavepropWeb.Api.V1.TokenJSON + + plug :accepts, ["json"] + + @doc """ + POST /api/v1/auth/tokens + + Body: `{"email": "...", "password": "...", "name": "device label", + "expires_at": "ISO8601" (optional)}`. Returns the plaintext token + and the persisted record. + """ + def create(conn, params) do + with {:ok, email} <- fetch_string(params, "email"), + {:ok, password} <- fetch_string(params, "password"), + {:ok, name} <- fetch_string(params, "name"), + %Microwaveprop.Accounts.User{} = user <- + Accounts.get_user_by_email_and_password(email, password) do + token_attrs = %{name: name, expires_at: parse_expiry(params["expires_at"])} + + case Accounts.create_api_token(user, token_attrs) do + {:ok, {plaintext, record}} -> + conn + |> put_status(:created) + |> json(TokenJSON.show_with_plaintext(record, plaintext)) + + {:error, changeset} -> + ErrorJSON.send_changeset(conn, changeset) + end + else + nil -> + ErrorJSON.send_problem(conn, 401, "unauthorized", "Invalid email or password.") + + {:error, field} -> + ErrorJSON.send_problem(conn, 400, "bad_request", "Missing or invalid `#{field}`.") + end + end + + defp fetch_string(params, key) do + case Map.get(params, key) do + value when is_binary(value) and byte_size(value) > 0 -> {:ok, value} + _ -> {:error, key} + end + end + + defp parse_expiry(nil), do: nil + defp parse_expiry(""), do: nil + + defp parse_expiry(string) when is_binary(string) do + case DateTime.from_iso8601(string) do + {:ok, dt, _offset} -> dt + {:error, _} -> :invalid + end + end + + defp parse_expiry(_), do: :invalid +end diff --git a/lib/microwaveprop_web/controllers/api/v1/beacon_controller.ex b/lib/microwaveprop_web/controllers/api/v1/beacon_controller.ex new file mode 100644 index 00000000..035e3608 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/beacon_controller.ex @@ -0,0 +1,52 @@ +defmodule MicrowavepropWeb.Api.V1.BeaconController do + @moduledoc "Read approved beacons; submit new beacons (pending approval)." + + use Phoenix.Controller, formats: [:json] + + alias Microwaveprop.Beacons + alias MicrowavepropWeb.Api.ErrorJSON + alias MicrowavepropWeb.Api.V1.BeaconJSON + + plug :accepts, ["json"] + action_fallback MicrowavepropWeb.Api.FallbackController + + def index(conn, _params) do + beacons = Beacons.list_beacons() + json(conn, BeaconJSON.index(%{beacons: beacons})) + end + + def show(conn, %{"id" => id}) do + case fetch_beacon(id) do + nil -> ErrorJSON.send_problem(conn, 404, "not_found", "Beacon not found.") + beacon -> json(conn, BeaconJSON.show(%{beacon: beacon})) + end + end + + def create(conn, params) do + user = conn.assigns.current_api_user + + attrs = + Map.take( + params, + ~w(frequency_mhz callsign grid lat lon power_mw height_ft on_the_air keying bearing beamwidth_deg notes) + ) + + case Beacons.create_beacon(user, attrs) do + {:ok, beacon} -> + conn + |> put_status(:created) + |> json(BeaconJSON.show(%{beacon: beacon})) + + {:error, changeset} -> + ErrorJSON.send_changeset(conn, changeset) + end + end + + # `Beacons.get_beacon!/1` raises on bad ids; we want a clean 404. + defp fetch_beacon(id) do + Beacons.get_beacon!(id) + rescue + Ecto.NoResultsError -> nil + Ecto.Query.CastError -> nil + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/beacon_json.ex b/lib/microwaveprop_web/controllers/api/v1/beacon_json.ex new file mode 100644 index 00000000..6205d4a2 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/beacon_json.ex @@ -0,0 +1,28 @@ +defmodule MicrowavepropWeb.Api.V1.BeaconJSON do + @moduledoc "Renders beacon representations." + + alias Microwaveprop.Beacons.Beacon + + def index(%{beacons: beacons}), do: %{data: Enum.map(beacons, &data/1)} + def show(%{beacon: beacon}), do: %{data: data(beacon)} + + defp data(%Beacon{} = b) do + %{ + id: b.id, + callsign: b.callsign, + frequency_mhz: b.frequency_mhz, + lat: b.lat, + lon: b.lon, + grid: b.grid, + power_mw: b.power_mw, + height_ft: b.height_ft, + on_the_air: b.on_the_air, + approved: b.approved, + keying: b.keying, + bearing: b.bearing, + beamwidth_deg: b.beamwidth_deg, + notes: b.notes, + inserted_at: b.inserted_at + } + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/beacon_monitor_json.ex b/lib/microwaveprop_web/controllers/api/v1/beacon_monitor_json.ex new file mode 100644 index 00000000..ab49b53d --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/beacon_monitor_json.ex @@ -0,0 +1,18 @@ +defmodule MicrowavepropWeb.Api.V1.BeaconMonitorJSON do + @moduledoc "Renders beacon-monitor representations." + + alias Microwaveprop.BeaconMonitors.BeaconMonitor + + def index(%{monitors: monitors}), do: %{data: Enum.map(monitors, &data/1)} + def show(%{monitor: monitor}), do: %{data: data(monitor)} + + defp data(%BeaconMonitor{} = m) do + %{ + id: m.id, + name: m.name, + token: m.token, + last_seen_at: m.last_seen_at, + inserted_at: m.inserted_at + } + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/contact_controller.ex b/lib/microwaveprop_web/controllers/api/v1/contact_controller.ex new file mode 100644 index 00000000..cdcdc7db --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/contact_controller.ex @@ -0,0 +1,103 @@ +defmodule MicrowavepropWeb.Api.V1.ContactController do + @moduledoc "Read + create contacts (QSOs)." + + use Phoenix.Controller, formats: [:json] + + alias Microwaveprop.Accounts.Scope + alias Microwaveprop.Radio + alias Microwaveprop.Radio.Contact + alias Microwaveprop.Repo + alias MicrowavepropWeb.Api.ErrorJSON + alias MicrowavepropWeb.Api.V1.ContactJSON + + plug :accepts, ["json"] + action_fallback MicrowavepropWeb.Api.FallbackController + + @max_per_page 200 + + def index(conn, params) do + page = params |> Map.get("page", "1") |> parse_int(1) + per_page = params |> Map.get("per_page", "50") |> parse_int(50) |> min(@max_per_page) |> max(1) + search = params["search"] + viewer = conn.assigns[:current_api_user] + + %{entries: entries, total_entries: total, total_pages: total_pages} = + Radio.list_contacts( + page: page, + search: search, + scope: Scope.for_user(viewer) + ) + + json( + conn, + ContactJSON.index_paginated(%{ + contacts: entries, + viewer: viewer, + page: page, + per_page: per_page, + total_entries: total, + total_pages: total_pages + }) + ) + end + + def show(conn, %{"id" => id}) do + viewer = conn.assigns[:current_api_user] + + case Repo.get(Contact, id) do + nil -> + ErrorJSON.send_problem(conn, 404, "not_found", "Contact not found.") + + %Contact{private: true, user_id: owner_id} = contact -> + if owner_id && viewer && viewer.id == owner_id do + json(conn, ContactJSON.show(%{contact: contact, viewer: viewer})) + else + ErrorJSON.send_problem(conn, 404, "not_found", "Contact not found.") + end + + %Contact{} = contact -> + json(conn, ContactJSON.show(%{contact: contact, viewer: viewer})) + end + end + + def create(conn, params) do + user = conn.assigns.current_api_user + + attrs = + params + |> Map.take( + ~w(station1 station2 qso_timestamp mode band grid1 grid2 user_declared_prop_mode height1_ft height2_ft private notes) + ) + |> Map.put_new("submitter_email", user.email) + + case Radio.create_contact(attrs, user.id) do + {:ok, contact} -> + conn + |> put_status(:created) + |> json(ContactJSON.show(%{contact: contact, viewer: user})) + + {:error, %Ecto.Changeset{} = changeset} -> + ErrorJSON.send_changeset(conn, changeset) + + {:error, :duplicate, existing} -> + conn + |> put_status(:conflict) + |> json(%{ + type: "about:blank", + title: "duplicate_contact", + status: 409, + detail: "An equivalent contact already exists.", + existing: ContactJSON.show(%{contact: existing, viewer: user}).data + }) + end + end + + defp parse_int(value, fallback) when is_binary(value) do + case Integer.parse(value) do + {n, ""} when n >= 1 -> n + _ -> fallback + end + end + + defp parse_int(_, fallback), do: fallback +end diff --git a/lib/microwaveprop_web/controllers/api/v1/contact_json.ex b/lib/microwaveprop_web/controllers/api/v1/contact_json.ex new file mode 100644 index 00000000..ff9f80e1 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/contact_json.ex @@ -0,0 +1,61 @@ +defmodule MicrowavepropWeb.Api.V1.ContactJSON do + @moduledoc "Renders contact (QSO) representations." + + alias Microwaveprop.Accounts.User + alias Microwaveprop.Radio.Contact + + def index(%{contacts: contacts} = assigns) do + viewer = Map.get(assigns, :viewer) + %{data: Enum.map(contacts, &data(&1, viewer))} + end + + def index_paginated(%{contacts: contacts} = assigns) do + viewer = Map.get(assigns, :viewer) + + %{ + data: Enum.map(contacts, &data(&1, viewer)), + meta: %{ + page: assigns.page, + per_page: assigns.per_page, + total_entries: assigns.total_entries, + total_pages: assigns.total_pages + } + } + end + + def show(%{contact: contact} = assigns) do + viewer = Map.get(assigns, :viewer) + %{data: data(contact, viewer)} + end + + defp data(%Contact{} = c, viewer) do + %{ + id: c.id, + station1: c.station1, + station2: c.station2, + qso_timestamp: c.qso_timestamp, + grid1: c.grid1, + grid2: c.grid2, + pos1: c.pos1, + pos2: c.pos2, + mode: c.mode, + band_mhz: c.band && Decimal.to_string(c.band), + distance_km: c.distance_km && Decimal.to_string(c.distance_km), + private: c.private, + user_declared_prop_mode: c.user_declared_prop_mode, + propagation_mechanism: c.propagation_mechanism, + propagation_mechanism_confidence: c.propagation_mechanism_confidence, + notes: maybe_notes(c, viewer), + mine?: mine?(c, viewer) + } + end + + # Private notes are only visible to the submitter; everyone else sees nil. + defp maybe_notes(%Contact{user_id: nil}, _viewer), do: nil + defp maybe_notes(%Contact{notes: nil}, _viewer), do: nil + defp maybe_notes(%Contact{user_id: uid, notes: notes}, %User{id: uid}), do: notes + defp maybe_notes(_contact, _viewer), do: nil + + defp mine?(%Contact{user_id: uid}, %User{id: uid}) when not is_nil(uid), do: true + defp mine?(_contact, _viewer), do: false +end diff --git a/lib/microwaveprop_web/controllers/api/v1/me_controller.ex b/lib/microwaveprop_web/controllers/api/v1/me_controller.ex new file mode 100644 index 00000000..5c1b4f9c --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/me_controller.ex @@ -0,0 +1,102 @@ +defmodule MicrowavepropWeb.Api.V1.MeController do + @moduledoc """ + Endpoints scoped to the authenticated user's own profile, contacts, + beacons, beacon monitors, and API tokens. + """ + + use Phoenix.Controller, formats: [:json] + + alias Microwaveprop.Accounts + alias Microwaveprop.BeaconMonitors + alias Microwaveprop.Beacons + alias Microwaveprop.Radio + alias MicrowavepropWeb.Api.ErrorJSON + alias MicrowavepropWeb.Api.V1.BeaconJSON + alias MicrowavepropWeb.Api.V1.BeaconMonitorJSON + alias MicrowavepropWeb.Api.V1.ContactJSON + alias MicrowavepropWeb.Api.V1.TokenJSON + alias MicrowavepropWeb.Api.V1.UserJSON + + plug :accepts, ["json"] + action_fallback MicrowavepropWeb.Api.FallbackController + + ## Profile ----------------------------------------------------------- + + def show(conn, _params) do + user = conn.assigns.current_api_user + json(conn, UserJSON.me(user)) + end + + def update(conn, params) do + user = conn.assigns.current_api_user + attrs = Map.take(params, ["home_grid", "home_lat", "home_lon", "home_elevation_m"]) + + case Accounts.update_user_home_qth(user, attrs) do + {:ok, updated} -> json(conn, UserJSON.me(updated)) + {:error, changeset} -> ErrorJSON.send_changeset(conn, changeset) + end + end + + ## My contacts ------------------------------------------------------- + + def contacts(conn, _params) do + user = conn.assigns.current_api_user + contacts = Radio.list_contacts_for_user(user) + json(conn, ContactJSON.index(%{contacts: contacts, viewer: user})) + end + + ## My beacons -------------------------------------------------------- + + def beacons(conn, _params) do + user = conn.assigns.current_api_user + beacons = Beacons.list_beacons_for_user(user) + json(conn, BeaconJSON.index(%{beacons: beacons})) + end + + ## API tokens -------------------------------------------------------- + + def list_tokens(conn, _params) do + user = conn.assigns.current_api_user + tokens = Accounts.list_api_tokens(user) + json(conn, TokenJSON.index(%{tokens: tokens})) + end + + def revoke_token(conn, %{"id" => id}) do + user = conn.assigns.current_api_user + + with {:ok, token} <- Accounts.revoke_api_token(user, id) do + json(conn, TokenJSON.show(%{token: token})) + end + end + + ## Beacon monitors --------------------------------------------------- + + def list_monitors(conn, _params) do + user = conn.assigns.current_api_user + monitors = BeaconMonitors.list_monitors_for_user(user) + json(conn, BeaconMonitorJSON.index(%{monitors: monitors})) + end + + def create_monitor(conn, params) do + user = conn.assigns.current_api_user + attrs = Map.take(params, ["name"]) + + case BeaconMonitors.create_monitor(user, attrs) do + {:ok, monitor} -> + conn + |> put_status(:created) + |> json(BeaconMonitorJSON.show(%{monitor: monitor})) + + {:error, changeset} -> + ErrorJSON.send_changeset(conn, changeset) + end + end + + def delete_monitor(conn, %{"id" => id}) do + user = conn.assigns.current_api_user + + with {:ok, _monitor} <- BeaconMonitors.delete_monitor(user, id) do + send_resp(conn, 204, "") + end + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/profile_controller.ex b/lib/microwaveprop_web/controllers/api/v1/profile_controller.ex new file mode 100644 index 00000000..b263735b --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/profile_controller.ex @@ -0,0 +1,32 @@ +defmodule MicrowavepropWeb.Api.V1.ProfileController do + @moduledoc "Public per-callsign user profile (no email exposed)." + + use Phoenix.Controller, formats: [:json] + + alias Microwaveprop.Accounts + alias Microwaveprop.Beacons + alias Microwaveprop.Radio + alias MicrowavepropWeb.Api.ErrorJSON + alias MicrowavepropWeb.Api.V1.BeaconJSON + alias MicrowavepropWeb.Api.V1.ContactJSON + alias MicrowavepropWeb.Api.V1.UserJSON + + plug :accepts, ["json"] + + def show(conn, %{"callsign" => callsign}) do + case Accounts.get_user_by_callsign(callsign) do + nil -> + ErrorJSON.send_problem(conn, 404, "not_found", "Callsign not registered.") + + user -> + contacts = Radio.list_contacts_involving_callsign(user.callsign) + beacons = user |> Beacons.list_beacons_for_user() |> Enum.filter(& &1.approved) + + json(conn, %{ + user: UserJSON.public(user).data, + contacts: ContactJSON.index(%{contacts: contacts}).data, + beacons: BeaconJSON.index(%{beacons: beacons}).data + }) + end + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/score_controller.ex b/lib/microwaveprop_web/controllers/api/v1/score_controller.ex new file mode 100644 index 00000000..5f85bde2 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/score_controller.ex @@ -0,0 +1,91 @@ +defmodule MicrowavepropWeb.Api.V1.ScoreController do + @moduledoc """ + Read-only access to propagation scores. + + * `GET /api/v1/scores?band=10000&lat=32.9&lon=-97.0&valid_time=ISO` + returns the score + factor breakdown at a single grid point. + * `GET /api/v1/forecast?band=10000&lat=32.9&lon=-97.0` + returns the 18-hour score timeline at that point. + * `GET /api/v1/scores/bands` returns the bands the engine scores for. + """ + + use Phoenix.Controller, formats: [:json] + + alias Microwaveprop.Propagation + alias Microwaveprop.Propagation.BandConfig + alias MicrowavepropWeb.Api.ErrorJSON + + plug :accepts, ["json"] + + def bands(conn, _params) do + list = + Enum.map(BandConfig.all_freqs(), fn mhz -> + cfg = BandConfig.get(mhz) + %{mhz: mhz, label: Map.get(cfg, :label), humidity_effect: Map.get(cfg, :humidity_effect)} + end) + + json(conn, %{data: list}) + end + + def show(conn, params) do + with {:ok, band} <- parse_band(params["band"]), + {:ok, lat} <- parse_float(params["lat"], "lat"), + {:ok, lon} <- parse_float(params["lon"], "lon"), + {:ok, valid_time} <- parse_optional_time(params["valid_time"]) do + case Propagation.point_detail(band, lat, lon, valid_time) do + nil -> + ErrorJSON.send_problem(conn, 404, "not_found", "No score available for that point/time.") + + detail -> + json(conn, %{data: detail}) + end + else + {:error, message} -> ErrorJSON.send_problem(conn, 400, "bad_request", message) + end + end + + def forecast(conn, params) do + with {:ok, band} <- parse_band(params["band"]), + {:ok, lat} <- parse_float(params["lat"], "lat"), + {:ok, lon} <- parse_float(params["lon"], "lon") do + points = Propagation.point_forecast(band, lat, lon) + json(conn, %{data: points}) + else + {:error, message} -> ErrorJSON.send_problem(conn, 400, "bad_request", message) + end + end + + defp parse_band(nil), do: {:error, "Missing required `band` (MHz)."} + + defp parse_band(value) when is_binary(value) do + case Integer.parse(value) do + {n, ""} when n > 0 -> {:ok, n} + _ -> {:error, "`band` must be a positive integer (MHz)."} + end + end + + defp parse_band(_), do: {:error, "`band` must be a positive integer (MHz)."} + + defp parse_float(nil, key), do: {:error, "Missing required `#{key}`."} + + defp parse_float(value, key) when is_binary(value) do + case Float.parse(value) do + {f, ""} -> {:ok, f} + _ -> {:error, "`#{key}` must be a decimal number."} + end + end + + defp parse_float(_, key), do: {:error, "`#{key}` must be a decimal number."} + + defp parse_optional_time(nil), do: {:ok, nil} + defp parse_optional_time(""), do: {:ok, nil} + + defp parse_optional_time(string) when is_binary(string) do + case DateTime.from_iso8601(string) do + {:ok, dt, _offset} -> {:ok, dt} + {:error, _} -> {:error, "`valid_time` must be ISO 8601."} + end + end + + defp parse_optional_time(_), do: {:error, "`valid_time` must be ISO 8601."} +end diff --git a/lib/microwaveprop_web/controllers/api/v1/token_json.ex b/lib/microwaveprop_web/controllers/api/v1/token_json.ex new file mode 100644 index 00000000..47e7d1c2 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/token_json.ex @@ -0,0 +1,29 @@ +defmodule MicrowavepropWeb.Api.V1.TokenJSON do + @moduledoc "Renders user API token responses." + + alias Microwaveprop.Accounts.UserApiToken + + @doc "Token list view (no plaintext — never recoverable after creation)." + def index(%{tokens: tokens}) do + %{data: Enum.map(tokens, &data/1)} + end + + @doc "Single token without plaintext." + def show(%{token: token}), do: %{data: data(token)} + + @doc "Single token including the one-time plaintext value at the top level." + def show_with_plaintext(%UserApiToken{} = token, plaintext) do + %{data: data(token), token: plaintext} + end + + defp data(%UserApiToken{} = t) do + %{ + id: t.id, + name: t.name, + inserted_at: t.inserted_at, + last_used_at: t.last_used_at, + expires_at: t.expires_at, + revoked_at: t.revoked_at + } + end +end diff --git a/lib/microwaveprop_web/controllers/api/v1/user_json.ex b/lib/microwaveprop_web/controllers/api/v1/user_json.ex new file mode 100644 index 00000000..ac529073 --- /dev/null +++ b/lib/microwaveprop_web/controllers/api/v1/user_json.ex @@ -0,0 +1,37 @@ +defmodule MicrowavepropWeb.Api.V1.UserJSON do + @moduledoc "Renders user representations for /api/v1." + + alias Microwaveprop.Accounts.User + + @doc "Public profile (no email)." + def public(%User{} = user) do + %{ + data: %{ + id: user.id, + callsign: user.callsign, + name: user.name, + home_grid: user.home_grid, + home_lat: user.home_lat, + home_lon: user.home_lon + } + } + end + + @doc "Authenticated /me view (includes email + admin flag)." + def me(%User{} = user) do + %{ + data: %{ + id: user.id, + callsign: user.callsign, + name: user.name, + email: user.email, + is_admin: user.is_admin, + confirmed_at: user.confirmed_at, + home_grid: user.home_grid, + home_lat: user.home_lat, + home_lon: user.home_lon, + home_elevation_m: user.home_elevation_m + } + } + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index a63859f6..777f736a 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -5,6 +5,10 @@ defmodule MicrowavepropWeb.Router do import Oban.Web.Router import Phoenix.LiveDashboard.Router + alias MicrowavepropWeb.Api.Auth + alias MicrowavepropWeb.Api.RateLimiter + alias MicrowavepropWeb.Api.V1 + pipeline :browser do plug :serve_markdown_if_requested plug :accepts, ["html"] @@ -132,6 +136,31 @@ defmodule MicrowavepropWeb.Router do plug :accepts, ["json"] end + # Public read pipeline: optional bearer auth (so /me-aware queries can + # surface viewer-private data when a token is present), then rate + # limiting. No CSRF/session — pure JSON. + pipeline :api_v1_public do + plug :accepts, ["json"] + plug Auth, mode: :optional + plug RateLimiter + end + + # Authenticated pipeline: bearer auth required, then rate limiting + # against the token's bucket. + pipeline :api_v1_authed do + plug :accepts, ["json"] + plug Auth, mode: :require + plug RateLimiter + end + + # Login pipeline: same shape as :api but rate-limited per-IP because + # a missing token would otherwise burn through the anon bucket + # before authentication has a chance to bind a token. + pipeline :api_v1_login do + plug :accepts, ["json"] + plug RateLimiter, anon_limit: 30 + end + # Health checks — no pipeline, minimal overhead. # /live = liveness, BEAM only. /health = readiness, also pings Repo. # Pods that serve a BEAM-alive /live but a failing /health get taken @@ -222,10 +251,45 @@ defmodule MicrowavepropWeb.Router do get "/qsos/:id", PageController, :redirect_contact end - # Other scopes may use custom stacks. - # scope "/api", MicrowavepropWeb do - # pipe_through :api - # end + # /api/v1 — versioned public REST API. See docs/api/README.md and + # docs/api/openapi.yaml for the full reference. + scope "/api/v1", V1 do + pipe_through :api_v1_login + + post "/auth/tokens", AuthController, :create + end + + scope "/api/v1", V1 do + pipe_through :api_v1_public + + get "/contacts", ContactController, :index + get "/contacts/:id", ContactController, :show + get "/beacons", BeaconController, :index + get "/beacons/:id", BeaconController, :show + get "/profiles/:callsign", ProfileController, :show + get "/scores/bands", ScoreController, :bands + get "/scores", ScoreController, :show + get "/forecast", ScoreController, :forecast + end + + scope "/api/v1", V1 do + pipe_through :api_v1_authed + + get "/me", MeController, :show + patch "/me", MeController, :update + get "/me/contacts", MeController, :contacts + get "/me/beacons", MeController, :beacons + + get "/me/api-tokens", MeController, :list_tokens + delete "/me/api-tokens/:id", MeController, :revoke_token + + get "/me/beacon-monitors", MeController, :list_monitors + post "/me/beacon-monitors", MeController, :create_monitor + delete "/me/beacon-monitors/:id", MeController, :delete_monitor + + post "/contacts", ContactController, :create + post "/beacons", BeaconController, :create + end scope "/" do pipe_through [:browser, :require_authenticated_user] diff --git a/priv/repo/migrations/20260509120000_create_users_api_tokens.exs b/priv/repo/migrations/20260509120000_create_users_api_tokens.exs new file mode 100644 index 00000000..7a0dd9d7 --- /dev/null +++ b/priv/repo/migrations/20260509120000_create_users_api_tokens.exs @@ -0,0 +1,20 @@ +defmodule Microwaveprop.Repo.Migrations.CreateUsersApiTokens do + use Ecto.Migration + + def change do + create table(:users_api_tokens, primary_key: false) do + add :id, :binary_id, primary_key: true + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :name, :string, null: false + add :token_hash, :binary, null: false + add :last_used_at, :utc_datetime + add :expires_at, :utc_datetime + add :revoked_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:users_api_tokens, [:user_id]) + create unique_index(:users_api_tokens, [:token_hash]) + end +end diff --git a/test/microwaveprop/accounts/user_api_token_test.exs b/test/microwaveprop/accounts/user_api_token_test.exs new file mode 100644 index 00000000..39524847 --- /dev/null +++ b/test/microwaveprop/accounts/user_api_token_test.exs @@ -0,0 +1,71 @@ +defmodule Microwaveprop.Accounts.UserApiTokenTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Accounts.UserApiToken + alias Microwaveprop.AccountsFixtures + + describe "token_prefix/0" do + test "is a stable namespaced prefix" do + assert UserApiToken.token_prefix() == "mwp_" + end + end + + describe "build/2" do + setup do + %{user: AccountsFixtures.user_fixture()} + end + + test "returns plaintext + valid changeset on good attrs", %{user: user} do + assert {:ok, {plaintext, changeset}} = + UserApiToken.build(user, %{name: "MyLaptop"}) + + assert String.starts_with?(plaintext, "mwp_") + assert changeset.valid? + assert Ecto.Changeset.get_change(changeset, :user_id) == user.id + assert Ecto.Changeset.get_change(changeset, :token_hash) == UserApiToken.hash_token(plaintext) + end + + test "name is required", %{user: user} do + assert {:error, changeset} = UserApiToken.build(user, %{}) + assert %{name: ["can't be blank"]} = errors_on(changeset) + refute changeset.valid? + assert changeset.action == :insert + end + + test "name length is bounded", %{user: user} do + assert {:error, changeset} = + UserApiToken.build(user, %{name: String.duplicate("x", 200)}) + + assert %{name: ["should be at most 100 character(s)"]} = errors_on(changeset) + end + + test "expires_at must be in the future", %{user: user} do + past = DateTime.add(DateTime.utc_now(), -60, :second) + + assert {:error, changeset} = + UserApiToken.build(user, %{name: "n", expires_at: past}) + + assert %{expires_at: ["must be in the future"]} = errors_on(changeset) + end + + test "expires_at in the future is accepted", %{user: user} do + future = DateTime.add(DateTime.utc_now(), 3600, :second) + + assert {:ok, {_plaintext, changeset}} = + UserApiToken.build(user, %{name: "n", expires_at: future}) + + assert changeset.valid? + end + end + + describe "hash_token/1" do + test "produces a 32-byte sha256 hash" do + assert byte_size(UserApiToken.hash_token("abc")) == 32 + end + + test "is deterministic" do + assert UserApiToken.hash_token("x") == UserApiToken.hash_token("x") + assert UserApiToken.hash_token("x") != UserApiToken.hash_token("y") + end + end +end diff --git a/test/microwaveprop/accounts_api_token_test.exs b/test/microwaveprop/accounts_api_token_test.exs new file mode 100644 index 00000000..f92b35b6 --- /dev/null +++ b/test/microwaveprop/accounts_api_token_test.exs @@ -0,0 +1,114 @@ +defmodule Microwaveprop.AccountsApiTokenTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Accounts + alias Microwaveprop.Accounts.UserApiToken + alias Microwaveprop.AccountsFixtures + + setup do + %{user: AccountsFixtures.user_fixture()} + end + + describe "create_api_token/2" do + test "returns plaintext + persisted record", %{user: user} do + assert {:ok, {plaintext, %UserApiToken{} = record}} = + Accounts.create_api_token(user, %{name: "Laptop"}) + + assert String.starts_with?(plaintext, "mwp_") + assert record.user_id == user.id + assert record.token_hash == UserApiToken.hash_token(plaintext) + assert record.id + end + + test "surfaces validation errors", %{user: user} do + assert {:error, %Ecto.Changeset{}} = + Accounts.create_api_token(user, %{name: ""}) + end + + test "rejects expires_at in the past", %{user: user} do + past = DateTime.add(DateTime.utc_now(), -1, :second) + + assert {:error, changeset} = + Accounts.create_api_token(user, %{name: "x", expires_at: past}) + + assert %{expires_at: ["must be in the future"]} = errors_on(changeset) + end + end + + describe "list_api_tokens/1" do + test "returns only non-revoked tokens for the user, newest first", %{user: user} do + other = AccountsFixtures.user_fixture() + + {:ok, {_, t1}} = Accounts.create_api_token(user, %{name: "A"}) + {:ok, {_, t2}} = Accounts.create_api_token(user, %{name: "B"}) + {:ok, {_, _t3}} = Accounts.create_api_token(other, %{name: "Other"}) + + {:ok, _} = Accounts.revoke_api_token(user, t1.id) + + assert [token] = Accounts.list_api_tokens(user) + assert token.id == t2.id + end + end + + describe "get_user_by_api_token/1" do + test "returns user + token + updates last_used_at on valid", %{user: user} do + {:ok, {plaintext, original}} = Accounts.create_api_token(user, %{name: "A"}) + assert is_nil(original.last_used_at) + + assert {:ok, returned_user, returned_token} = + Accounts.get_user_by_api_token(plaintext) + + assert returned_user.id == user.id + assert returned_token.id == original.id + assert %DateTime{} = returned_token.last_used_at + end + + test "rejects unknown plaintext" do + assert {:error, :invalid_token} = Accounts.get_user_by_api_token("nope") + end + + test "rejects revoked tokens", %{user: user} do + {:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A"}) + {:ok, _} = Accounts.revoke_api_token(user, record.id) + + assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext) + end + + test "rejects expired tokens", %{user: user} do + future = DateTime.add(DateTime.utc_now(), 60, :second) + {:ok, {plaintext, record}} = Accounts.create_api_token(user, %{name: "A", expires_at: future}) + + # Force the row past its expiry. + Repo.update_all( + from(t in UserApiToken, where: t.id == ^record.id), + set: [expires_at: DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.truncate(:second)] + ) + + assert {:error, :invalid_token} = Accounts.get_user_by_api_token(plaintext) + end + end + + describe "revoke_api_token/2" do + test "marks the token revoked", %{user: user} do + {:ok, {_pt, record}} = Accounts.create_api_token(user, %{name: "x"}) + + assert {:ok, revoked} = Accounts.revoke_api_token(user, record.id) + assert %DateTime{} = revoked.revoked_at + end + + test "is idempotent for already-revoked tokens", %{user: user} do + {:ok, {_pt, record}} = Accounts.create_api_token(user, %{name: "x"}) + {:ok, first} = Accounts.revoke_api_token(user, record.id) + {:ok, second} = Accounts.revoke_api_token(user, record.id) + + assert DateTime.compare(first.revoked_at, second.revoked_at) == :eq + end + + test "returns :not_found for tokens owned by other users", %{user: user} do + other = AccountsFixtures.user_fixture() + {:ok, {_pt, record}} = Accounts.create_api_token(user, %{name: "x"}) + + assert {:error, :not_found} = Accounts.revoke_api_token(other, record.id) + end + end +end diff --git a/test/microwaveprop_web/api/auth_test.exs b/test/microwaveprop_web/api/auth_test.exs new file mode 100644 index 00000000..68aa2e28 --- /dev/null +++ b/test/microwaveprop_web/api/auth_test.exs @@ -0,0 +1,134 @@ +defmodule MicrowavepropWeb.Api.AuthTest do + use Microwaveprop.DataCase, async: true + + import Plug.Conn + import Plug.Test + + alias Microwaveprop.Accounts + alias Microwaveprop.AccountsFixtures + alias MicrowavepropWeb.Api.Auth + + setup do + user = AccountsFixtures.user_fixture() + {:ok, {plaintext, token}} = Accounts.create_api_token(user, %{name: "T"}) + %{user: user, plaintext: plaintext, token: token} + end + + describe "init/1" do + test "defaults to require mode" do + assert Auth.init([]) == [mode: :require] + end + + test "respects an explicit mode" do + assert Auth.init(mode: :optional) == [mode: :optional] + end + end + + describe "call/2 in :require mode" do + test "halts with 401 problem+json when no header is present" do + conn = Auth.call(conn(:get, "/"), Auth.init([])) + + assert conn.halted + assert conn.status == 401 + assert get_resp_header(conn, "www-authenticate") == [~s(Bearer realm="api")] + assert %{"detail" => "Missing bearer token in Authorization header."} = Jason.decode!(conn.resp_body) + end + + test "halts when the header is malformed" do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "Token abc") + |> Auth.call(Auth.init([])) + + assert conn.status == 401 + assert %{"detail" => "Authorization header must be `Bearer `."} = Jason.decode!(conn.resp_body) + end + + test "halts when the bearer scheme is present but token is empty" do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "Bearer ") + |> Auth.call(Auth.init([])) + + assert conn.status == 401 + end + + test "halts when the token is unknown / revoked" do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "Bearer mwp_does-not-exist") + |> Auth.call(Auth.init([])) + + assert conn.status == 401 + assert %{"detail" => "Bearer token is invalid, expired, or revoked."} = Jason.decode!(conn.resp_body) + end + + test "lets a valid token through and assigns user + token", %{user: user, plaintext: pt} do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "Bearer #{pt}") + |> Auth.call(Auth.init([])) + + refute conn.halted + assert conn.assigns.current_api_user.id == user.id + assert conn.assigns.current_api_token + end + + test "accepts the lowercase `bearer` scheme too", %{plaintext: pt} do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "bearer #{pt}") + |> Auth.call(Auth.init([])) + + refute conn.halted + end + end + + describe "call/2 in :optional mode" do + test "passes through when no token is present and assigns nil" do + conn = Auth.call(conn(:get, "/"), Auth.init(mode: :optional)) + + refute conn.halted + assert conn.assigns.current_api_user == nil + assert conn.assigns.current_api_token == nil + end + + test "halts when an invalid token is provided" do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "Bearer mwp_bad") + |> Auth.call(Auth.init(mode: :optional)) + + assert conn.halted + assert conn.status == 401 + end + + test "halts when the header is malformed in :optional mode" do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "garbage") + |> Auth.call(Auth.init(mode: :optional)) + + assert conn.halted + assert conn.status == 401 + end + + test "lets a valid token bind user + token", %{user: user, plaintext: pt} do + conn = + :get + |> conn("/") + |> put_req_header("authorization", "Bearer #{pt}") + |> Auth.call(Auth.init(mode: :optional)) + + refute conn.halted + assert conn.assigns.current_api_user.id == user.id + end + end +end diff --git a/test/microwaveprop_web/api/coverage_extras_test.exs b/test/microwaveprop_web/api/coverage_extras_test.exs new file mode 100644 index 00000000..754bbe37 --- /dev/null +++ b/test/microwaveprop_web/api/coverage_extras_test.exs @@ -0,0 +1,92 @@ +defmodule MicrowavepropWeb.Api.CoverageExtrasTest do + @moduledoc """ + Extra unit tests targeting defensive fall-through clauses that the + routed integration tests don't exercise (Phoenix's params decoder + always hands us strings, but the API controllers still ship `_, _` + catch-alls so library-style direct invocation behaves predictably). + """ + + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.AccountsFixtures + alias Microwaveprop.Radio.Contact + alias MicrowavepropWeb.Api.Auth + alias MicrowavepropWeb.Api.RateLimiter + alias MicrowavepropWeb.Api.V1.ContactJSON + + describe "Auth.require_auth/1 + .optional_auth/1 default args" do + test "require_auth/1 with no opts halts unauthenticated requests" do + conn = Auth.require_auth(Plug.Test.conn(:get, "/")) + assert conn.status == 401 + end + + test "optional_auth/1 with no opts passes anonymous requests through" do + conn = Auth.optional_auth(Plug.Test.conn(:get, "/")) + refute conn.halted + end + end + + describe "ContactJSON.maybe_notes branches" do + test "anonymous-submitted contacts (user_id nil) hide notes from everyone" do + contact = %Contact{user_id: nil, notes: "secret", band: nil} + data = ContactJSON.show(%{contact: contact, viewer: AccountsFixtures.user_fixture()}).data + assert data.notes == nil + end + + test "non-owner viewers cannot read notes" do + owner = AccountsFixtures.user_fixture() + other = AccountsFixtures.user_fixture() + contact = %Contact{user_id: owner.id, notes: "secret", band: nil} + data = ContactJSON.show(%{contact: contact, viewer: other}).data + assert data.notes == nil + end + + test "owner sees the notes they wrote" do + owner = AccountsFixtures.user_fixture() + contact = %Contact{user_id: owner.id, notes: "private!", band: nil} + data = ContactJSON.show(%{contact: contact, viewer: owner}).data + assert data.notes == "private!" + assert data.mine? == true + end + + test "anonymous viewer with anonymous-submitted contact returns nil notes" do + contact = %Contact{user_id: nil, notes: nil, band: nil} + data = ContactJSON.show(%{contact: contact, viewer: nil}).data + assert data.notes == nil + assert data.mine? == false + end + end + + describe "RateLimiter ETS race recovery" do + test "reset/0 + concurrent ensure_table do not crash" do + RateLimiter.reset() + + # Force the ets table to exist and then immediately call call/2 with + # a fresh conn — exercises the rescue clause on subsequent racing + # creations under write_concurrency. + tasks = + for _ <- 1..8 do + Task.async(fn -> RateLimiter.call(Plug.Test.conn(:get, "/"), anon_limit: 100) end) + end + + results = Enum.map(tasks, &Task.await/1) + assert Enum.all?(results, &(&1.status in [200, 429] or &1.status == nil)) + end + end + + describe "ContactController.parse_int catch-all" do + test "parse_int falls back to the default when given an atom value" do + # The route always encodes ints as binaries, so this clause is + # invoked only for non-routed callers. Drive the action directly. + conn = Phoenix.ConnTest.build_conn(:get, "/api/v1/contacts") + + conn = + MicrowavepropWeb.Api.V1.ContactController.index(conn, %{ + "page" => :weird, + "per_page" => :also_weird + }) + + assert conn.status == 200 + end + end +end diff --git a/test/microwaveprop_web/api/error_json_test.exs b/test/microwaveprop_web/api/error_json_test.exs new file mode 100644 index 00000000..b1f41edd --- /dev/null +++ b/test/microwaveprop_web/api/error_json_test.exs @@ -0,0 +1,103 @@ +defmodule MicrowavepropWeb.Api.ErrorJSONTest do + use ExUnit.Case, async: true + + import Plug.Conn + import Plug.Test + + alias Ecto.Changeset + alias MicrowavepropWeb.Api.ErrorJSON + + defmodule Dummy do + @moduledoc false + use Ecto.Schema + + import Changeset + + embedded_schema do + field :name, :string + field :age, :integer + end + + def cs(attrs) do + %__MODULE__{} + |> cast(attrs, [:name, :age]) + |> validate_required([:name]) + |> validate_number(:age, greater_than: 0) + end + end + + describe "send_problem/4" do + test "sets content-type, status, halts, and emits a problem+json body" do + conn = conn(:get, "/") + + conn = ErrorJSON.send_problem(conn, 401, "unauthorized", "Token missing.") + + assert conn.halted + assert conn.status == 401 + assert get_resp_header(conn, "content-type") == ["application/problem+json; charset=utf-8"] + + assert %{ + "type" => "about:blank", + "title" => "unauthorized", + "status" => 401, + "detail" => "Token missing." + } = Jason.decode!(conn.resp_body) + end + + test "merges extra fields into the body" do + conn = ErrorJSON.send_problem(conn(:get, "/"), 422, "x", "y", %{errors: %{a: ["b"]}}) + + assert %{"errors" => %{"a" => ["b"]}} = Jason.decode!(conn.resp_body) + end + end + + describe "send_changeset/2" do + test "renders flat field error map at status 422" do + cs = Dummy.cs(%{age: -1}) + + conn = ErrorJSON.send_changeset(conn(:get, "/"), cs) + + assert conn.status == 422 + body = Jason.decode!(conn.resp_body) + assert body["title"] == "validation_failed" + assert %{"name" => ["can't be blank"], "age" => ["must be greater than 0"]} = body["errors"] + end + end + + describe "translate_errors/1" do + test "interpolates option placeholders into messages" do + cs = + %Changeset{} + |> Map.put(:errors, name: {"too short %{count}", [count: 3]}) + |> Map.put(:types, %{name: :string}) + |> Map.put(:data, %{}) + + assert %{name: ["too short 3"]} = ErrorJSON.translate_errors(cs) + end + end + + describe "render/2" do + for {tpl, expected_status, expected_title} <- [ + {"400.json", 400, "bad_request"}, + {"401.json", 401, "unauthorized"}, + {"403.json", 403, "forbidden"}, + {"404.json", 404, "not_found"}, + {"405.json", 405, "method_not_allowed"}, + {"415.json", 415, "unsupported_media_type"}, + {"429.json", 429, "too_many_requests"}, + {"500.json", 500, "internal_server_error"} + ] do + test "produces a problem map for #{tpl}" do + body = ErrorJSON.render(unquote(tpl), %{}) + assert body.status == unquote(expected_status) + assert body.title == unquote(expected_title) + end + end + + test "falls back generically for unknown templates" do + body = ErrorJSON.render("418.json", %{}) + assert body.status == 418 + assert body.title == "im_a_teapot" + end + end +end diff --git a/test/microwaveprop_web/api/fallback_controller_test.exs b/test/microwaveprop_web/api/fallback_controller_test.exs new file mode 100644 index 00000000..ead7641a --- /dev/null +++ b/test/microwaveprop_web/api/fallback_controller_test.exs @@ -0,0 +1,55 @@ +defmodule MicrowavepropWeb.Api.FallbackControllerTest do + use ExUnit.Case, async: true + + import Plug.Test + + alias Microwaveprop.Accounts.User + alias MicrowavepropWeb.Api.FallbackController + + # Force the module to be (re-)loaded so cover-tracked module-load lines + # are counted even when only routed tests would otherwise reach this code. + setup_all do + Code.ensure_loaded(FallbackController) + :ok + end + + defp build_conn do + :get + |> conn("/") + |> Plug.Conn.put_private(:phoenix_endpoint, MicrowavepropWeb.Endpoint) + end + + test "renders changeset errors as 422" do + cs = User.registration_changeset(%User{}, %{}, validate_unique: false) + + conn = FallbackController.call(build_conn(), {:error, cs}) + + assert conn.status == 422 + end + + test "renders :not_found as 404" do + conn = FallbackController.call(build_conn(), {:error, :not_found}) + assert conn.status == 404 + end + + test "renders :forbidden as 403" do + conn = FallbackController.call(build_conn(), {:error, :forbidden}) + assert conn.status == 403 + end + + test "renders :unauthorized as 401" do + conn = FallbackController.call(build_conn(), {:error, :unauthorized}) + assert conn.status == 401 + end + + test "renders :bad_request with detail as 400" do + conn = FallbackController.call(build_conn(), {:error, :bad_request, "missing thing"}) + assert conn.status == 400 + assert %{"detail" => "missing thing"} = Jason.decode!(conn.resp_body) + end + + test "renders :duplicate as 409" do + conn = FallbackController.call(build_conn(), {:error, :duplicate, %{}}) + assert conn.status == 409 + end +end diff --git a/test/microwaveprop_web/api/rate_limiter_test.exs b/test/microwaveprop_web/api/rate_limiter_test.exs new file mode 100644 index 00000000..946ceb14 --- /dev/null +++ b/test/microwaveprop_web/api/rate_limiter_test.exs @@ -0,0 +1,82 @@ +defmodule MicrowavepropWeb.Api.RateLimiterTest do + use ExUnit.Case, async: false + + import Plug.Conn + import Plug.Test + + alias MicrowavepropWeb.Api.RateLimiter + + setup do + RateLimiter.reset() + :ok + end + + describe "init/1" do + test "passes options through unchanged" do + assert RateLimiter.init(anon_limit: 5) == [anon_limit: 5] + end + end + + describe "call/2 anonymous" do + test "annotates RateLimit headers and lets requests through under the limit" do + conn = RateLimiter.call(conn(:get, "/"), anon_limit: 3) + + refute conn.halted + assert ["3"] = get_resp_header(conn, "ratelimit-limit") + assert ["2"] = get_resp_header(conn, "ratelimit-remaining") + assert [reset] = get_resp_header(conn, "ratelimit-reset") + assert String.to_integer(reset) >= 1 + end + + test "halts with 429 when the bucket is exhausted" do + opts = [anon_limit: 2, window_ms: 60_000] + + _ = RateLimiter.call(conn(:get, "/"), opts) + _ = RateLimiter.call(conn(:get, "/"), opts) + conn = RateLimiter.call(conn(:get, "/"), opts) + + assert conn.halted + assert conn.status == 429 + assert get_resp_header(conn, "retry-after") != [] + assert ["0"] = get_resp_header(conn, "ratelimit-remaining") + end + end + + describe "call/2 authenticated" do + test "uses the auth_limit when current_api_token is assigned" do + conn = + :get + |> conn("/") + |> assign(:current_api_token, %{id: "tok-1"}) + |> RateLimiter.call(auth_limit: 5, anon_limit: 1) + + refute conn.halted + assert ["5"] = get_resp_header(conn, "ratelimit-limit") + end + + test "different tokens occupy different buckets" do + opts = [auth_limit: 1] + + conn_a = + :get + |> conn("/") + |> assign(:current_api_token, %{id: "A"}) + |> RateLimiter.call(opts) + + conn_b = + :get + |> conn("/") + |> assign(:current_api_token, %{id: "B"}) + |> RateLimiter.call(opts) + + refute conn_a.halted + refute conn_b.halted + end + end + + describe "reset/0" do + test "clears the table even before any call" do + assert RateLimiter.reset() == :ok + end + end +end diff --git a/test/microwaveprop_web/controllers/api/v1/auth_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/auth_controller_test.exs new file mode 100644 index 00000000..c400b6f8 --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/auth_controller_test.exs @@ -0,0 +1,127 @@ +defmodule MicrowavepropWeb.Api.V1.AuthControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + alias Microwaveprop.AccountsFixtures + alias MicrowavepropWeb.Api.RateLimiter + + @password AccountsFixtures.valid_user_password() + + setup do + RateLimiter.reset() + user = AccountsFixtures.user_fixture() + %{user: user} + end + + describe "POST /api/v1/auth/tokens" do + test "issues a bearer token for valid credentials", %{conn: conn, user: user} do + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => @password, + "name" => "Test laptop" + }) + + assert %{"token" => token, "data" => data} = json_response(conn, 201) + assert String.starts_with?(token, "mwp_") + assert data["name"] == "Test laptop" + assert is_nil(data["last_used_at"]) + assert is_nil(data["revoked_at"]) + end + + test "rejects bad credentials with 401", %{conn: conn, user: user} do + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => "wrong", + "name" => "x" + }) + + assert %{"detail" => "Invalid email or password.", "status" => 401} = json_response(conn, 401) + end + + test "rejects bad credentials when user does not exist", %{conn: conn} do + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => "ghost@nope.example", + "password" => "anything", + "name" => "x" + }) + + assert json_response(conn, 401) + end + + for missing <- ["email", "password", "name"] do + test "rejects missing #{missing} with 400", %{conn: conn, user: user} do + body = Map.delete(%{"email" => user.email, "password" => @password, "name" => "x"}, unquote(missing)) + + conn = post(conn, ~p"/api/v1/auth/tokens", body) + assert %{"status" => 400} = json_response(conn, 400) + end + end + + test "accepts an ISO 8601 expires_at", %{conn: conn, user: user} do + future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601() + + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => @password, + "name" => "x", + "expires_at" => future + }) + + assert %{"data" => %{"expires_at" => exp}} = json_response(conn, 201) + assert is_binary(exp) + end + + test "rejects malformed expires_at via changeset 422", %{conn: conn, user: user} do + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => @password, + "name" => "x", + "expires_at" => "garbage" + }) + + assert %{"status" => 422} = json_response(conn, 422) + end + + test "treats blank expires_at as no expiry", %{conn: conn, user: user} do + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => @password, + "name" => "x", + "expires_at" => "" + }) + + assert %{"data" => %{"expires_at" => nil}} = json_response(conn, 201) + end + + test "rejects non-string expires_at via changeset 422", %{conn: conn, user: user} do + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => @password, + "name" => "x", + "expires_at" => 123 + }) + + assert %{"status" => 422} = json_response(conn, 422) + end + + test "rejects expires_at in the past via changeset 422", %{conn: conn, user: user} do + past = DateTime.utc_now() |> DateTime.add(-1, :second) |> DateTime.to_iso8601() + + conn = + post(conn, ~p"/api/v1/auth/tokens", %{ + "email" => user.email, + "password" => @password, + "name" => "x", + "expires_at" => past + }) + + assert %{"errors" => %{"expires_at" => _}} = json_response(conn, 422) + end + end +end diff --git a/test/microwaveprop_web/controllers/api/v1/beacon_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/beacon_controller_test.exs new file mode 100644 index 00000000..2447dec5 --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/beacon_controller_test.exs @@ -0,0 +1,83 @@ +defmodule MicrowavepropWeb.Api.V1.BeaconControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + alias Microwaveprop.Accounts + alias Microwaveprop.AccountsFixtures + alias Microwaveprop.Beacons + alias MicrowavepropWeb.Api.RateLimiter + + @valid %{ + "frequency_mhz" => 10_368.1, + "callsign" => "W5HN", + "lat" => 32.897, + "lon" => -97.038, + "power_mw" => 1000.0, + "height_ft" => 100, + "keying" => "on_off" + } + + setup %{conn: conn} do + RateLimiter.reset() + user = AccountsFixtures.user_fixture() + {:ok, {plaintext, _}} = Accounts.create_api_token(user, %{name: "t"}) + %{conn: conn, user: user, plaintext: plaintext} + end + + describe "GET /api/v1/beacons" do + test "returns approved beacons only", %{conn: conn, user: user} do + {:ok, approved} = Beacons.create_beacon(user, @valid) + {:ok, _} = Beacons.approve_beacon(approved) + {:ok, _pending} = Beacons.create_beacon(user, Map.put(@valid, "callsign", "K5XX")) + + body = conn |> get(~p"/api/v1/beacons") |> json_response(200) + + assert Enum.any?(body["data"], &(&1["callsign"] == "W5HN")) + refute Enum.any?(body["data"], &(&1["callsign"] == "K5XX")) + end + end + + describe "GET /api/v1/beacons/:id" do + test "returns the beacon when found", %{conn: conn, user: user} do + {:ok, b} = Beacons.create_beacon(user, @valid) + body = conn |> get(~p"/api/v1/beacons/#{b.id}") |> json_response(200) + assert body["data"]["id"] == b.id + end + + test "404 on unknown id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/beacons/00000000-0000-0000-0000-000000000000") + assert json_response(conn, 404) + end + + test "404 on malformed id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/beacons/not-a-uuid") + assert json_response(conn, 404) + end + end + + describe "POST /api/v1/beacons" do + test "requires auth", %{conn: conn} do + conn = post(conn, ~p"/api/v1/beacons", @valid) + assert json_response(conn, 401) + end + + test "creates a beacon under the authenticated user", %{conn: conn, plaintext: pt} do + conn = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> post(~p"/api/v1/beacons", @valid) + + body = json_response(conn, 201) + assert body["data"]["callsign"] == "W5HN" + assert body["data"]["approved"] == false + end + + test "422 on invalid payload", %{conn: conn, plaintext: pt} do + conn = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> post(~p"/api/v1/beacons", %{"callsign" => "x"}) + + assert json_response(conn, 422) + end + end +end diff --git a/test/microwaveprop_web/controllers/api/v1/contact_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/contact_controller_test.exs new file mode 100644 index 00000000..e8c59bb0 --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/contact_controller_test.exs @@ -0,0 +1,162 @@ +defmodule MicrowavepropWeb.Api.V1.ContactControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + alias Microwaveprop.Accounts + alias Microwaveprop.AccountsFixtures + alias Microwaveprop.Radio + alias MicrowavepropWeb.Api.RateLimiter + + @valid_attrs %{ + "station1" => "W5XD", + "station2" => "K5XD", + "qso_timestamp" => "2026-01-01T00:00:00Z", + "band" => "10000", + "grid1" => "EM12", + "grid2" => "EM13", + "mode" => "CW", + "submitter_email" => "fixture@example.com" + } + + setup %{conn: conn} do + RateLimiter.reset() + user = AccountsFixtures.user_fixture() + {:ok, {plaintext, _r}} = Accounts.create_api_token(user, %{name: "t"}) + %{conn: conn, user: user, plaintext: plaintext} + end + + describe "GET /api/v1/contacts" do + test "returns paginated public contacts", %{conn: conn, user: user} do + {:ok, _} = Radio.create_contact(@valid_attrs, user.id) + + body = conn |> get(~p"/api/v1/contacts") |> json_response(200) + + assert is_list(body["data"]) + assert body["meta"]["page"] == 1 + assert body["meta"]["per_page"] == 50 + assert body["meta"]["total_entries"] >= 1 + end + + test "honors page and per_page", %{conn: conn} do + conn = get(conn, ~p"/api/v1/contacts?page=2&per_page=10") + body = json_response(conn, 200) + assert body["meta"]["page"] == 2 + assert body["meta"]["per_page"] == 10 + end + + test "clamps insane per_page values", %{conn: conn} do + conn = get(conn, ~p"/api/v1/contacts?per_page=99999") + body = json_response(conn, 200) + assert body["meta"]["per_page"] == 200 + end + + test "rejects invalid pagination values silently with defaults", %{conn: conn} do + conn = get(conn, ~p"/api/v1/contacts?page=abc&per_page=zero") + body = json_response(conn, 200) + assert body["meta"]["page"] == 1 + assert body["meta"]["per_page"] == 50 + end + + test "supports search", %{conn: conn, user: user} do + {:ok, _} = Radio.create_contact(@valid_attrs, user.id) + body = conn |> get(~p"/api/v1/contacts?search=W5XD") |> json_response(200) + assert body["meta"]["total_entries"] >= 1 + end + end + + describe "GET /api/v1/contacts/:id" do + test "returns the contact for a known id", %{conn: conn, user: user} do + {:ok, contact} = Radio.create_contact(@valid_attrs, user.id) + + body = conn |> get(~p"/api/v1/contacts/#{contact.id}") |> json_response(200) + assert body["data"]["id"] == contact.id + end + + test "404 for unknown id", %{conn: conn} do + conn = get(conn, ~p"/api/v1/contacts/00000000-0000-0000-0000-000000000000") + assert json_response(conn, 404) + end + + test "404 hides private contacts from anonymous viewers", %{conn: conn, user: user} do + {:ok, contact} = + @valid_attrs + |> Map.put("private", true) + |> Radio.create_contact(user.id) + + conn = get(conn, ~p"/api/v1/contacts/#{contact.id}") + assert json_response(conn, 404) + end + + test "owner can fetch their own private contact", %{conn: conn, user: user, plaintext: pt} do + {:ok, contact} = + @valid_attrs + |> Map.put("private", true) + |> Radio.create_contact(user.id) + + body = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> get(~p"/api/v1/contacts/#{contact.id}") + |> json_response(200) + + assert body["data"]["id"] == contact.id + assert body["data"]["private"] == true + end + + test "non-owner gets 404 on someone else's private contact", %{conn: conn, user: owner} do + other = AccountsFixtures.user_fixture() + {:ok, {pt, _}} = Accounts.create_api_token(other, %{name: "x"}) + + {:ok, contact} = + @valid_attrs + |> Map.put("private", true) + |> Radio.create_contact(owner.id) + + conn = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> get(~p"/api/v1/contacts/#{contact.id}") + + assert json_response(conn, 404) + end + end + + describe "POST /api/v1/contacts" do + test "requires auth", %{conn: conn} do + conn = post(conn, ~p"/api/v1/contacts", @valid_attrs) + assert json_response(conn, 401) + end + + test "creates a contact for the authenticated user", %{conn: conn, plaintext: pt} do + conn = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> post(~p"/api/v1/contacts", @valid_attrs) + + body = json_response(conn, 201) + assert body["data"]["station1"] == "W5XD" + assert body["data"]["mine?"] == true + end + + test "returns 422 on validation errors", %{conn: conn, plaintext: pt} do + conn = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> post(~p"/api/v1/contacts", %{"station1" => "X"}) + + assert json_response(conn, 422) + end + + test "returns 409 on duplicate submission", %{conn: conn, plaintext: pt, user: user} do + {:ok, _} = Radio.create_contact(@valid_attrs, user.id) + + conn = + conn + |> put_req_header("authorization", "Bearer " <> pt) + |> post(~p"/api/v1/contacts", @valid_attrs) + + body = json_response(conn, 409) + assert body["title"] == "duplicate_contact" + assert body["existing"] + end + end +end diff --git a/test/microwaveprop_web/controllers/api/v1/me_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/me_controller_test.exs new file mode 100644 index 00000000..23c79e74 --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/me_controller_test.exs @@ -0,0 +1,139 @@ +defmodule MicrowavepropWeb.Api.V1.MeControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + alias Microwaveprop.Accounts + alias Microwaveprop.AccountsFixtures + alias MicrowavepropWeb.Api.RateLimiter + + setup %{conn: conn} do + RateLimiter.reset() + user = AccountsFixtures.user_fixture() + {:ok, {plaintext, _record}} = Accounts.create_api_token(user, %{name: "test"}) + + authed = put_req_header(conn, "authorization", "Bearer " <> plaintext) + %{user: user, plaintext: plaintext, authed: authed} + end + + describe "GET /api/v1/me" do + test "requires auth", %{conn: conn} do + conn = get(conn, ~p"/api/v1/me") + assert json_response(conn, 401) + end + + test "returns the authenticated user's profile", %{authed: conn, user: user} do + conn = get(conn, ~p"/api/v1/me") + body = json_response(conn, 200) + assert body["data"]["email"] == user.email + assert body["data"]["callsign"] == user.callsign + assert is_boolean(body["data"]["is_admin"]) + end + end + + describe "PATCH /api/v1/me" do + test "updates the home QTH from a Maidenhead grid", %{authed: conn} do + conn = patch(conn, ~p"/api/v1/me", %{"home_grid" => "EM12kx"}) + body = json_response(conn, 200) + assert body["data"]["home_grid"] == "EM12kx" + assert is_float(body["data"]["home_lat"]) + end + + test "returns 422 on bad QTH", %{authed: conn} do + conn = patch(conn, ~p"/api/v1/me", %{"home_grid" => "ZZ99zz"}) + assert json_response(conn, 422) + end + end + + describe "GET /api/v1/me/contacts" do + test "returns contacts owned by the user", %{authed: conn, user: user} do + {:ok, contact} = + Microwaveprop.Radio.create_contact( + %{ + "station1" => "W5XD", + "station2" => "K5XD", + "qso_timestamp" => "2026-01-01T00:00:00Z", + "band" => "10000", + "grid1" => "EM12", + "grid2" => "EM13", + "mode" => "CW", + "submitter_email" => "fixture@example.com" + }, + user.id + ) + + conn = get(conn, ~p"/api/v1/me/contacts") + body = json_response(conn, 200) + assert [%{"id" => id, "mine?" => true}] = body["data"] + assert id == contact.id + end + end + + describe "GET /api/v1/me/beacons" do + test "returns beacons submitted by the user", %{authed: conn, user: user} do + {:ok, _b} = + Microwaveprop.Beacons.create_beacon(user, %{ + frequency_mhz: 10_368.1, + callsign: "W5HN", + lat: 32.897, + lon: -97.038, + power_mw: 1000.0, + height_ft: 100, + keying: "on_off" + }) + + conn = get(conn, ~p"/api/v1/me/beacons") + assert %{"data" => [_one]} = json_response(conn, 200) + end + end + + describe "API token CRUD" do + test "lists, then revokes, an API token", %{authed: conn} do + list = conn |> get(~p"/api/v1/me/api-tokens") |> json_response(200) + assert [%{"id" => id} | _] = list["data"] + + revoke = conn |> delete(~p"/api/v1/me/api-tokens/#{id}") |> json_response(200) + assert revoke["data"]["revoked_at"] + end + + test "404 when revoking unknown id", %{authed: conn} do + conn = + delete( + conn, + ~p"/api/v1/me/api-tokens/00000000-0000-0000-0000-000000000000" + ) + + assert json_response(conn, 404) + end + end + + describe "beacon monitor CRUD" do + test "creates, lists, and deletes a beacon monitor", %{authed: conn} do + created = + conn + |> post(~p"/api/v1/me/beacon-monitors", %{"name" => "Tower"}) + |> json_response(201) + + id = created["data"]["id"] + + list = conn |> get(~p"/api/v1/me/beacon-monitors") |> json_response(200) + assert Enum.any?(list["data"], &(&1["id"] == id)) + + conn = delete(conn, ~p"/api/v1/me/beacon-monitors/#{id}") + assert response(conn, 204) + end + + test "422 when creating with empty name", %{authed: conn} do + conn = post(conn, ~p"/api/v1/me/beacon-monitors", %{"name" => ""}) + assert json_response(conn, 422) + end + + test "404 when deleting unknown monitor", %{authed: conn} do + conn = + delete( + conn, + ~p"/api/v1/me/beacon-monitors/00000000-0000-0000-0000-000000000000" + ) + + assert json_response(conn, 404) + end + end +end diff --git a/test/microwaveprop_web/controllers/api/v1/profile_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/profile_controller_test.exs new file mode 100644 index 00000000..0949b221 --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/profile_controller_test.exs @@ -0,0 +1,65 @@ +defmodule MicrowavepropWeb.Api.V1.ProfileControllerTest do + use MicrowavepropWeb.ConnCase, async: true + + alias Microwaveprop.AccountsFixtures + alias Microwaveprop.Beacons + alias Microwaveprop.Radio + alias MicrowavepropWeb.Api.RateLimiter + + setup do + RateLimiter.reset() + :ok + end + + describe "GET /api/v1/profiles/:callsign" do + test "returns the profile + contacts + approved beacons", %{conn: conn} do + user = AccountsFixtures.user_fixture(%{callsign: "W5TEST"}) + + {:ok, _contact} = + Radio.create_contact( + %{ + "station1" => "W5TEST", + "station2" => "K5OK", + "qso_timestamp" => "2026-01-02T00:00:00Z", + "band" => "10000", + "grid1" => "EM12", + "grid2" => "EM13", + "mode" => "CW", + "submitter_email" => "fixture@example.com" + }, + user.id + ) + + {:ok, beacon} = + Beacons.create_beacon(user, %{ + frequency_mhz: 10_368.1, + callsign: "W5HN", + lat: 32.897, + lon: -97.038, + power_mw: 1000.0, + height_ft: 100, + keying: "on_off" + }) + + {:ok, _approved} = Beacons.approve_beacon(beacon) + + body = conn |> get(~p"/api/v1/profiles/W5TEST") |> json_response(200) + assert body["user"]["callsign"] == "W5TEST" + refute Map.has_key?(body["user"], "email") + assert length(body["contacts"]) >= 1 + assert length(body["beacons"]) >= 1 + end + + test "case-insensitive lookup", %{conn: conn} do + _user = AccountsFixtures.user_fixture(%{callsign: "W5LOWER"}) + + body = conn |> get(~p"/api/v1/profiles/w5lower") |> json_response(200) + assert body["user"]["callsign"] == "W5LOWER" + end + + test "404 when callsign is not registered", %{conn: conn} do + conn = get(conn, ~p"/api/v1/profiles/Z9NOPE") + assert json_response(conn, 404) + end + end +end diff --git a/test/microwaveprop_web/controllers/api/v1/score_controller_test.exs b/test/microwaveprop_web/controllers/api/v1/score_controller_test.exs new file mode 100644 index 00000000..917046b6 --- /dev/null +++ b/test/microwaveprop_web/controllers/api/v1/score_controller_test.exs @@ -0,0 +1,136 @@ +defmodule MicrowavepropWeb.Api.V1.ScoreControllerTest do + use MicrowavepropWeb.ConnCase, async: false + + alias Microwaveprop.Propagation + alias MicrowavepropWeb.Api.RateLimiter + alias MicrowavepropWeb.Api.V1.ScoreController + + setup do + RateLimiter.reset() + :ok + end + + describe "GET /api/v1/scores/bands" do + test "lists bands with mhz + label", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores/bands") + body = json_response(conn, 200) + assert is_list(body["data"]) + assert Enum.all?(body["data"], &is_integer(&1["mhz"])) + end + end + + describe "GET /api/v1/scores" do + test "returns 404 when no score data is on disk", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?band=10000&lat=32.9&lon=-97.0") + assert json_response(conn, 404) + end + + test "rejects missing band with 400", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?lat=32.9&lon=-97.0") + assert json_response(conn, 400) + end + + test "rejects non-integer band", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?band=foo&lat=32.9&lon=-97.0") + assert json_response(conn, 400) + end + + test "rejects missing lat/lon", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?band=10000") + assert json_response(conn, 400) + end + + test "rejects non-decimal lat", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?band=10000&lat=foo&lon=-97.0") + assert json_response(conn, 400) + end + + test "rejects malformed valid_time", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?band=10000&lat=32&lon=-97&valid_time=garbage") + assert json_response(conn, 400) + end + + test "treats blank valid_time as nil and reaches the lookup path", %{conn: conn} do + conn = get(conn, ~p"/api/v1/scores?band=10000&lat=32&lon=-97&valid_time=") + assert json_response(conn, 404) + end + + test "returns 200 + data when a score exists at the snapped grid point", %{conn: conn} do + band = 10_000 + valid_time = ~U[2026-05-01 12:00:00Z] + + # Lat/lon land exactly on the 1/8° grid so snap_to_grid is a no-op. + score = %{band_mhz: band, lat: 32.875, lon: -97.125, score: 75} + {:ok, 1} = Propagation.replace_scores([score], valid_time) + + conn = + get( + conn, + ~p"/api/v1/scores?band=10000&lat=32.875&lon=-97.125&valid_time=2026-05-01T12:00:00Z" + ) + + body = json_response(conn, 200) + assert body["data"]["score"] == 75 + assert body["data"]["valid_time"] + end + end + + describe "GET /api/v1/forecast" do + test "returns an empty list when no data is on disk", %{conn: conn} do + conn = get(conn, ~p"/api/v1/forecast?band=10000&lat=32.9&lon=-97.0") + body = json_response(conn, 200) + assert is_list(body["data"]) + end + + test "returns 400 on missing band", %{conn: conn} do + conn = get(conn, ~p"/api/v1/forecast?lat=32&lon=-97") + assert json_response(conn, 400) + end + end + + # Defensive parse-helper clauses fire only when the controller's `params` + # map carries non-string values (e.g. nested JSON objects) — Plug's normal + # query/JSON decoding never produces that shape, so we exercise them by + # invoking the controller action directly. + describe "parse helpers (direct invocation)" do + test "parse_band rejects non-binary, non-nil values" do + conn = Map.put(build_conn(), :params, %{}) + + conn = + ScoreController.show(conn, %{ + "band" => 10_000, + "lat" => "32", + "lon" => "-97" + }) + + assert conn.status == 400 + end + + test "parse_float rejects non-binary lon" do + conn = Map.put(build_conn(), :params, %{}) + + conn = + ScoreController.show(conn, %{ + "band" => "10000", + "lat" => "32", + "lon" => 0 + }) + + assert conn.status == 400 + end + + test "parse_optional_time rejects non-binary, non-nil" do + conn = Map.put(build_conn(), :params, %{}) + + conn = + ScoreController.show(conn, %{ + "band" => "10000", + "lat" => "32", + "lon" => "-97", + "valid_time" => 42 + }) + + assert conn.status == 400 + end + end +end