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
|
import Ecto.Query, warn: false
|
||||||
|
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
|
alias Microwaveprop.Accounts.UserApiToken
|
||||||
alias Microwaveprop.Accounts.UserNotifier
|
alias Microwaveprop.Accounts.UserNotifier
|
||||||
alias Microwaveprop.Accounts.UserToken
|
alias Microwaveprop.Accounts.UserToken
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
@ -432,6 +433,92 @@ defmodule Microwaveprop.Accounts do
|
||||||
:ok
|
:ok
|
||||||
end
|
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
|
## Token helper
|
||||||
|
|
||||||
defp update_user_and_delete_all_tokens(changeset) do
|
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
|
@impl true
|
||||||
def start(_type, _args) do
|
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, [])
|
topologies = Application.get_env(:libcluster, :topologies, [])
|
||||||
|
|
||||||
children = [
|
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 Oban.Web.Router
|
||||||
import Phoenix.LiveDashboard.Router
|
import Phoenix.LiveDashboard.Router
|
||||||
|
|
||||||
|
alias MicrowavepropWeb.Api.Auth
|
||||||
|
alias MicrowavepropWeb.Api.RateLimiter
|
||||||
|
alias MicrowavepropWeb.Api.V1
|
||||||
|
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :serve_markdown_if_requested
|
plug :serve_markdown_if_requested
|
||||||
plug :accepts, ["html"]
|
plug :accepts, ["html"]
|
||||||
|
|
@ -132,6 +136,31 @@ defmodule MicrowavepropWeb.Router do
|
||||||
plug :accepts, ["json"]
|
plug :accepts, ["json"]
|
||||||
end
|
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.
|
# Health checks — no pipeline, minimal overhead.
|
||||||
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
|
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
|
||||||
# Pods that serve a BEAM-alive /live but a failing /health get taken
|
# 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
|
get "/qsos/:id", PageController, :redirect_contact
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# /api/v1 — versioned public REST API. See docs/api/README.md and
|
||||||
# scope "/api", MicrowavepropWeb do
|
# docs/api/openapi.yaml for the full reference.
|
||||||
# pipe_through :api
|
scope "/api/v1", V1 do
|
||||||
# end
|
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
|
scope "/" do
|
||||||
pipe_through [:browser, :require_authenticated_user]
|
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