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).
This commit is contained in:
parent
fc9d2298ac
commit
c6d2c48264
35 changed files with 3525 additions and 4 deletions
269
docs/api/README.md
Normal file
269
docs/api/README.md
Normal file
|
|
@ -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.
|
||||
699
docs/api/openapi.yaml
Normal file
699
docs/api/openapi.yaml
Normal file
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
88
lib/microwaveprop/accounts/user_api_token.ex
Normal file
88
lib/microwaveprop/accounts/user_api_token.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
95
lib/microwaveprop_web/api/auth.ex
Normal file
95
lib/microwaveprop_web/api/auth.ex
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
defmodule MicrowavepropWeb.Api.Auth do
|
||||
@moduledoc """
|
||||
Bearer-token authentication for `/api/v1`.
|
||||
|
||||
Looks up `Authorization: Bearer <token>` 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 <token>`."
|
||||
defp reason_message(:invalid_token), do: "Bearer token is invalid, expired, or revoked."
|
||||
end
|
||||
85
lib/microwaveprop_web/api/error_json.ex
Normal file
85
lib/microwaveprop_web/api/error_json.ex
Normal file
|
|
@ -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": "<short slug>",
|
||||
"status": <int>,
|
||||
"detail": "<human-readable explanation>",
|
||||
"errors": <optional changeset error map>
|
||||
}
|
||||
"""
|
||||
|
||||
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
|
||||
35
lib/microwaveprop_web/api/fallback_controller.ex
Normal file
35
lib/microwaveprop_web/api/fallback_controller.ex
Normal file
|
|
@ -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
|
||||
91
lib/microwaveprop_web/api/rate_limiter.ex
Normal file
91
lib/microwaveprop_web/api/rate_limiter.ex
Normal file
|
|
@ -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
|
||||
68
lib/microwaveprop_web/controllers/api/v1/auth_controller.ex
Normal file
68
lib/microwaveprop_web/controllers/api/v1/auth_controller.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
28
lib/microwaveprop_web/controllers/api/v1/beacon_json.ex
Normal file
28
lib/microwaveprop_web/controllers/api/v1/beacon_json.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
103
lib/microwaveprop_web/controllers/api/v1/contact_controller.ex
Normal file
103
lib/microwaveprop_web/controllers/api/v1/contact_controller.ex
Normal file
|
|
@ -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
|
||||
61
lib/microwaveprop_web/controllers/api/v1/contact_json.ex
Normal file
61
lib/microwaveprop_web/controllers/api/v1/contact_json.ex
Normal file
|
|
@ -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
|
||||
102
lib/microwaveprop_web/controllers/api/v1/me_controller.ex
Normal file
102
lib/microwaveprop_web/controllers/api/v1/me_controller.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
91
lib/microwaveprop_web/controllers/api/v1/score_controller.ex
Normal file
91
lib/microwaveprop_web/controllers/api/v1/score_controller.ex
Normal file
|
|
@ -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
|
||||
29
lib/microwaveprop_web/controllers/api/v1/token_json.ex
Normal file
29
lib/microwaveprop_web/controllers/api/v1/token_json.ex
Normal file
|
|
@ -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
|
||||
37
lib/microwaveprop_web/controllers/api/v1/user_json.ex
Normal file
37
lib/microwaveprop_web/controllers/api/v1/user_json.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
71
test/microwaveprop/accounts/user_api_token_test.exs
Normal file
71
test/microwaveprop/accounts/user_api_token_test.exs
Normal file
|
|
@ -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
|
||||
114
test/microwaveprop/accounts_api_token_test.exs
Normal file
114
test/microwaveprop/accounts_api_token_test.exs
Normal file
|
|
@ -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
|
||||
134
test/microwaveprop_web/api/auth_test.exs
Normal file
134
test/microwaveprop_web/api/auth_test.exs
Normal file
|
|
@ -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 <token>`."} = 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
|
||||
92
test/microwaveprop_web/api/coverage_extras_test.exs
Normal file
92
test/microwaveprop_web/api/coverage_extras_test.exs
Normal file
|
|
@ -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
|
||||
103
test/microwaveprop_web/api/error_json_test.exs
Normal file
103
test/microwaveprop_web/api/error_json_test.exs
Normal file
|
|
@ -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
|
||||
55
test/microwaveprop_web/api/fallback_controller_test.exs
Normal file
55
test/microwaveprop_web/api/fallback_controller_test.exs
Normal file
|
|
@ -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
|
||||
82
test/microwaveprop_web/api/rate_limiter_test.exs
Normal file
82
test/microwaveprop_web/api/rate_limiter_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
139
test/microwaveprop_web/controllers/api/v1/me_controller_test.exs
Normal file
139
test/microwaveprop_web/controllers/api/v1/me_controller_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue