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:
Graham McIntire 2026-05-09 08:59:54 -05:00
parent fc9d2298ac
commit c6d2c48264
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
35 changed files with 3525 additions and 4 deletions

269
docs/api/README.md Normal file
View 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
View 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 }

View file

@ -6,6 +6,7 @@ defmodule Microwaveprop.Accounts do
import Ecto.Query, warn: false
alias Microwaveprop.Accounts.User
alias Microwaveprop.Accounts.UserApiToken
alias Microwaveprop.Accounts.UserNotifier
alias Microwaveprop.Accounts.UserToken
alias Microwaveprop.Repo
@ -432,6 +433,92 @@ defmodule Microwaveprop.Accounts do
:ok
end
## API tokens (long-lived bearer tokens for /api/v1)
@doc """
Creates a new long-lived API token for the user. Returns the
plaintext token (only shown once) alongside the persisted record.
"""
@spec create_api_token(User.t(), map()) ::
{:ok, {String.t(), UserApiToken.t()}} | {:error, Ecto.Changeset.t()}
def create_api_token(%User{} = user, attrs) do
case UserApiToken.build(user, attrs) do
{:ok, {plaintext, changeset}} ->
case Repo.insert(changeset) do
{:ok, record} -> {:ok, {plaintext, record}}
{:error, changeset} -> {:error, changeset}
end
{:error, changeset} ->
{:error, changeset}
end
end
@doc "Lists every non-revoked API token for the user, newest first."
@spec list_api_tokens(User.t()) :: [UserApiToken.t()]
def list_api_tokens(%User{id: user_id}) do
Repo.all(
from t in UserApiToken,
where: t.user_id == ^user_id and is_nil(t.revoked_at),
order_by: [desc: t.inserted_at]
)
end
@doc """
Looks up the user owning the given plaintext bearer token. Returns
`{:ok, user, token}` for valid, unexpired, non-revoked tokens. The
token's `last_used_at` is updated as a side effect.
"""
@spec get_user_by_api_token(String.t()) ::
{:ok, User.t(), UserApiToken.t()} | {:error, :invalid_token}
def get_user_by_api_token(plaintext) when is_binary(plaintext) do
hash = UserApiToken.hash_token(plaintext)
now = DateTime.utc_now(:second)
query =
from t in UserApiToken,
join: u in assoc(t, :user),
where: t.token_hash == ^hash,
where: is_nil(t.revoked_at),
where: is_nil(t.expires_at) or t.expires_at > ^now,
select: {u, t}
case Repo.one(query) do
nil ->
{:error, :invalid_token}
{user, token} ->
{1, _} =
Repo.update_all(
from(t in UserApiToken, where: t.id == ^token.id),
set: [last_used_at: now]
)
{:ok, user, %{token | last_used_at: now}}
end
end
@doc """
Revokes a user's API token by id. Returns `{:error, :not_found}`
when the id is not owned by the user.
"""
@spec revoke_api_token(User.t(), Ecto.UUID.t()) ::
{:ok, UserApiToken.t()} | {:error, :not_found}
def revoke_api_token(%User{id: user_id}, token_id) do
case Repo.get_by(UserApiToken, id: token_id, user_id: user_id) do
nil ->
{:error, :not_found}
%UserApiToken{revoked_at: nil} = token ->
token
|> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second))
|> Repo.update()
%UserApiToken{} = token ->
{:ok, token}
end
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do

View 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

View file

@ -11,6 +11,10 @@ defmodule Microwaveprop.Application do
@impl true
def start(_type, _args) do
# Eagerly create the API rate limiter's ETS table so the plug never
# races to create it on the request path.
:ok = MicrowavepropWeb.Api.RateLimiter.init_table()
topologies = Application.get_env(:libcluster, :topologies, [])
children = [

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View 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

View file

@ -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

View 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

View 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

View 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

View file

@ -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

View 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

View 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

View 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

View file

@ -5,6 +5,10 @@ defmodule MicrowavepropWeb.Router do
import Oban.Web.Router
import Phoenix.LiveDashboard.Router
alias MicrowavepropWeb.Api.Auth
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.Api.V1
pipeline :browser do
plug :serve_markdown_if_requested
plug :accepts, ["html"]
@ -132,6 +136,31 @@ defmodule MicrowavepropWeb.Router do
plug :accepts, ["json"]
end
# Public read pipeline: optional bearer auth (so /me-aware queries can
# surface viewer-private data when a token is present), then rate
# limiting. No CSRF/session — pure JSON.
pipeline :api_v1_public do
plug :accepts, ["json"]
plug Auth, mode: :optional
plug RateLimiter
end
# Authenticated pipeline: bearer auth required, then rate limiting
# against the token's bucket.
pipeline :api_v1_authed do
plug :accepts, ["json"]
plug Auth, mode: :require
plug RateLimiter
end
# Login pipeline: same shape as :api but rate-limited per-IP because
# a missing token would otherwise burn through the anon bucket
# before authentication has a chance to bind a token.
pipeline :api_v1_login do
plug :accepts, ["json"]
plug RateLimiter, anon_limit: 30
end
# Health checks — no pipeline, minimal overhead.
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
# Pods that serve a BEAM-alive /live but a failing /health get taken
@ -222,10 +251,45 @@ defmodule MicrowavepropWeb.Router do
get "/qsos/:id", PageController, :redirect_contact
end
# Other scopes may use custom stacks.
# scope "/api", MicrowavepropWeb do
# pipe_through :api
# end
# /api/v1 — versioned public REST API. See docs/api/README.md and
# docs/api/openapi.yaml for the full reference.
scope "/api/v1", V1 do
pipe_through :api_v1_login
post "/auth/tokens", AuthController, :create
end
scope "/api/v1", V1 do
pipe_through :api_v1_public
get "/contacts", ContactController, :index
get "/contacts/:id", ContactController, :show
get "/beacons", BeaconController, :index
get "/beacons/:id", BeaconController, :show
get "/profiles/:callsign", ProfileController, :show
get "/scores/bands", ScoreController, :bands
get "/scores", ScoreController, :show
get "/forecast", ScoreController, :forecast
end
scope "/api/v1", V1 do
pipe_through :api_v1_authed
get "/me", MeController, :show
patch "/me", MeController, :update
get "/me/contacts", MeController, :contacts
get "/me/beacons", MeController, :beacons
get "/me/api-tokens", MeController, :list_tokens
delete "/me/api-tokens/:id", MeController, :revoke_token
get "/me/beacon-monitors", MeController, :list_monitors
post "/me/beacon-monitors", MeController, :create_monitor
delete "/me/beacon-monitors/:id", MeController, :delete_monitor
post "/contacts", ContactController, :create
post "/beacons", BeaconController, :create
end
scope "/" do
pipe_through [:browser, :require_authenticated_user]

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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