mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-22 15:23:30 +00:00
feat(admin): add protected account lifecycle endpoints (#149)
This commit is contained in:
1 parent
951b79bf6e
commit
4d2642abec
10 files changed
+1352
-3
No files matched your search
@@ -133,8 +133,7 @@ broker workers, not connection status or a tunable processing-worker pool).
|
||||
The CORS lists are the options supplied to the middleware; its normal matching
|
||||
normalization still applies. The response excludes
|
||||
credential fields, broker addresses, channel material, database settings and
|
||||
other configuration. Account operations are not implemented; unknown admin paths
|
||||
return 404 and unsupported
|
||||
other configuration. Unknown admin paths return 404 and unsupported
|
||||
methods on the config endpoint return 405 after authentication.
|
||||
Global CORS preflights remain public. Use a long, randomly generated key, keep
|
||||
it out of source control and logs, and send it only in the Authorization header,
|
||||
@@ -158,6 +157,21 @@ auth/credential fields and broker count cannot be changed here; there is no
|
||||
configurable `ingest.worker_count`. Cross-origin admin clients need PUT allowed
|
||||
in the saved CORS methods. CORS controls browser access, not authentication.
|
||||
|
||||
Operator accounts are available at `GET/POST /api/v1/admin/accounts` and
|
||||
`GET/DELETE /api/v1/admin/accounts/{id}`. POST accepts a JSON `name` field in a
|
||||
body up to 4 KiB; names are trimmed, case-sensitive and limited to 128 Unicode
|
||||
characters without control characters. Active names are unique. DELETE soft
|
||||
deactivates the record (204); missing IDs return 404 and an already inactive
|
||||
record returns 409. A deactivated name may be reused by a new account.
|
||||
Lists include active and inactive records, newest first, without pagination.
|
||||
These are operator-defined records; no login, session or API token is created.
|
||||
Cross-origin account clients need both `POST` and `DELETE` in the saved
|
||||
`cors.allowed_methods`; the default `GET, HEAD, OPTIONS` is read-only. For an
|
||||
admin UI that also updates configuration, use `[GET, HEAD, OPTIONS, POST, PUT,
|
||||
DELETE]`, restrict `cors.allowed_origins` to that UI, and allow `Authorization`
|
||||
and `Content-Type` headers. Otherwise browser preflight blocks these requests
|
||||
even when the same bearer-authenticated request works with curl.
|
||||
|
||||
### Environment variables (`.env`)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/MeshCore-Beacon/beacon-server/db"
|
||||
_ "github.com/MeshCore-Beacon/beacon-server/docs"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api/router"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/background"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/cache"
|
||||
@@ -307,6 +308,7 @@ func main() {
|
||||
MaxConnsPerIP: resolved.MaxConnsPerIP,
|
||||
MaxConnectsPerMinute: resolved.MaxConnectsPerMinute,
|
||||
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
|
||||
AdminRoutes: map[string]http.Handler{"/accounts": handlers.AccountsRouter(store)},
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
|
||||
+1
-1
@@ -166,7 +166,7 @@ websocket:
|
||||
#cors:
|
||||
# allowed_origins:
|
||||
# - "https://app.example.com"
|
||||
# allowed_methods: [GET, HEAD, OPTIONS, POST]
|
||||
# allowed_methods: [GET, HEAD, OPTIONS, POST, PUT, DELETE] # admin UI writes
|
||||
# allowed_headers: [Accept, Authorization, Content-Type]
|
||||
# allow_credentials: false
|
||||
# max_age: 300
|
||||
|
||||
+358
@@ -22,6 +22,322 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/admin/accounts": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Includes active and inactive accounts, newest first. These records are not login users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "List operator accounts",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Create an operator account",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB",
|
||||
"name": "account",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"415": {
|
||||
"description": "Unsupported Media Type",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/accounts/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Returns active or inactive account records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Get an operator account",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Account UUID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Soft deactivation preserves the record. Its name may be reused by a new account.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Deactivate an operator account",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Account UUID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Deactivated"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/config": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -2640,6 +2956,37 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.Account": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"deactivated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2893,6 +3240,17 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -20,6 +20,322 @@
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/admin/accounts": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Includes active and inactive accounts, newest first. These records are not login users.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "List operator accounts",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Create an operator account",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB",
|
||||
"name": "account",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"413": {
|
||||
"description": "Request Entity Too Large",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"415": {
|
||||
"description": "Unsupported Media Type",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/accounts/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Returns active or inactive account records.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Get an operator account",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Account UUID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"AdminKey": []
|
||||
}
|
||||
],
|
||||
"description": "Soft deactivation preserves the record. Its name may be reused by a new account.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Deactivate an operator account",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Account UUID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Deactivated"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Conflict",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "Service Unavailable",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/internal_api_handlers.APIError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/config": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -2638,6 +2954,37 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.Account": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"deactivated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -2891,6 +3238,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.Account:
|
||||
properties:
|
||||
active:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
deactivated_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account'
|
||||
type: array
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig:
|
||||
properties:
|
||||
configured:
|
||||
@@ -177,6 +197,13 @@ definitions:
|
||||
nodeTypeName:
|
||||
type: string
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop:
|
||||
properties:
|
||||
fromIata:
|
||||
@@ -1289,6 +1316,215 @@ info:
|
||||
title: MeshCore Beacon API
|
||||
version: 1.6.0
|
||||
paths:
|
||||
/admin/accounts:
|
||||
get:
|
||||
description: Includes active and inactive accounts, newest first. These records
|
||||
are not login users.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
security:
|
||||
- AdminKey: []
|
||||
summary: List operator accounts
|
||||
tags:
|
||||
- Admin
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Trims the name; names are case-sensitive and unique among active
|
||||
accounts. No credential or login is created.
|
||||
parameters:
|
||||
- description: Account name (1-128 Unicode characters, no control characters);
|
||||
body at most 4 KiB
|
||||
in: body
|
||||
name: account
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"413":
|
||||
description: Request Entity Too Large
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"415":
|
||||
description: Unsupported Media Type
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
security:
|
||||
- AdminKey: []
|
||||
summary: Create an operator account
|
||||
tags:
|
||||
- Admin
|
||||
/admin/accounts/{id}:
|
||||
delete:
|
||||
description: Soft deactivation preserves the record. Its name may be reused
|
||||
by a new account.
|
||||
parameters:
|
||||
- description: Account UUID
|
||||
format: uuid
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: Deactivated
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"409":
|
||||
description: Conflict
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
security:
|
||||
- AdminKey: []
|
||||
summary: Deactivate an operator account
|
||||
tags:
|
||||
- Admin
|
||||
get:
|
||||
description: Returns active or inactive account records.
|
||||
parameters:
|
||||
- description: Account UUID
|
||||
format: uuid
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
"503":
|
||||
description: Service Unavailable
|
||||
schema:
|
||||
additionalProperties:
|
||||
$ref: '#/definitions/internal_api_handlers.APIError'
|
||||
type: object
|
||||
security:
|
||||
- AdminKey: []
|
||||
summary: Get an operator account
|
||||
tags:
|
||||
- Admin
|
||||
/admin/config:
|
||||
get:
|
||||
description: Returns current CORS options, auth configuration status and configured
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package api
|
||||
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AccountsRouter must be mounted inside the authenticated admin subtree.
|
||||
func AccountsRouter(store api.AccountStore) http.Handler {
|
||||
if store == nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respondError(w, 503, "account storage is unavailable") })
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
r.Get("/", listAccounts(store))
|
||||
r.Post("/", createAccount(store))
|
||||
r.Get("/{id}", getAccount(store))
|
||||
r.Delete("/{id}", deactivateAccount(store))
|
||||
return r
|
||||
}
|
||||
|
||||
// listAccounts godoc
|
||||
// @Summary List operator accounts
|
||||
// @Description Includes active and inactive accounts, newest first. These records are not login users.
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Security AdminKey
|
||||
// @Success 200 {object} api.AccountList
|
||||
// @Failure 401,503,500 {object} map[string]APIError
|
||||
// @Router /admin/accounts [get]
|
||||
func listAccounts(store api.AccountStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := store.ListAccounts(r.Context())
|
||||
if err != nil {
|
||||
accountError(w, err)
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []api.Account{}
|
||||
}
|
||||
respond(w, 200, api.AccountList{Items: items})
|
||||
}
|
||||
}
|
||||
|
||||
// createAccount godoc
|
||||
// @Summary Create an operator account
|
||||
// @Description Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.
|
||||
// @Tags Admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security AdminKey
|
||||
// @Param account body api.CreateAccountRequest true "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB"
|
||||
// @Success 201 {object} api.Account
|
||||
// @Failure 400,401,409,413,415,503,500 {object} map[string]APIError
|
||||
// @Router /admin/accounts [post]
|
||||
func createAccount(store api.AccountStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || mediaType != "application/json" {
|
||||
respondError(w, 415, "Content-Type must be application/json")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
var input api.CreateAccountRequest
|
||||
err = decoder.Decode(&input)
|
||||
if err == nil {
|
||||
var extra any
|
||||
err = decoder.Decode(&extra)
|
||||
if errors.Is(err, io.EOF) {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
err = errors.New("multiple JSON values")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
var sizeError *http.MaxBytesError
|
||||
if errors.As(err, &sizeError) {
|
||||
respondError(w, 413, "request body exceeds 4 KiB")
|
||||
} else {
|
||||
respondError(w, 400, "invalid account JSON")
|
||||
}
|
||||
return
|
||||
}
|
||||
name, err := api.NormalizeAccountName(input.Name)
|
||||
if err != nil {
|
||||
accountError(w, err)
|
||||
return
|
||||
}
|
||||
account, err := store.CreateAccount(r.Context(), name)
|
||||
if err != nil {
|
||||
accountError(w, err)
|
||||
return
|
||||
}
|
||||
respond(w, 201, account)
|
||||
}
|
||||
}
|
||||
|
||||
// getAccount godoc
|
||||
// @Summary Get an operator account
|
||||
// @Description Returns active or inactive account records.
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Security AdminKey
|
||||
// @Param id path string true "Account UUID" format(uuid)
|
||||
// @Success 200 {object} api.Account
|
||||
// @Failure 400,401,404,503,500 {object} map[string]APIError
|
||||
// @Router /admin/accounts/{id} [get]
|
||||
func getAccount(store api.AccountStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, 400, "invalid account ID")
|
||||
return
|
||||
}
|
||||
account, err := store.GetAccount(r.Context(), id)
|
||||
if err != nil {
|
||||
accountError(w, err)
|
||||
return
|
||||
}
|
||||
respond(w, 200, account)
|
||||
}
|
||||
}
|
||||
|
||||
// deactivateAccount godoc
|
||||
// @Summary Deactivate an operator account
|
||||
// @Description Soft deactivation preserves the record. Its name may be reused by a new account.
|
||||
// @Tags Admin
|
||||
// @Produce json
|
||||
// @Security AdminKey
|
||||
// @Param id path string true "Account UUID" format(uuid)
|
||||
// @Success 204 "Deactivated"
|
||||
// @Failure 400,401,404,409,503,500 {object} map[string]APIError
|
||||
// @Router /admin/accounts/{id} [delete]
|
||||
func deactivateAccount(store api.AccountStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := uuid.Parse(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, 400, "invalid account ID")
|
||||
return
|
||||
}
|
||||
if err := store.DeactivateAccount(r.Context(), id); err != nil {
|
||||
accountError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
}
|
||||
|
||||
func accountError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, api.ErrAccountNameInvalid):
|
||||
respondError(w, 400, api.ErrAccountNameInvalid.Error())
|
||||
case errors.Is(err, api.ErrAccountNameConflict):
|
||||
respondError(w, 409, api.ErrAccountNameConflict.Error())
|
||||
case errors.Is(err, api.ErrAccountNotFound):
|
||||
respondError(w, 404, api.ErrAccountNotFound.Error())
|
||||
case errors.Is(err, api.ErrAccountInactive):
|
||||
respondError(w, 409, api.ErrAccountInactive.Error())
|
||||
default:
|
||||
respondError(w, 500, "internal server error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
mw "github.com/MeshCore-Beacon/beacon-server/internal/api/middleware"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type accountStub struct {
|
||||
account api.Account
|
||||
items []api.Account
|
||||
err error
|
||||
calls int
|
||||
name string
|
||||
id uuid.UUID
|
||||
}
|
||||
|
||||
func (s *accountStub) CreateAccount(_ context.Context, name string) (api.Account, error) {
|
||||
s.calls++
|
||||
s.name = name
|
||||
return s.account, s.err
|
||||
}
|
||||
func (s *accountStub) ListAccounts(context.Context) ([]api.Account, error) {
|
||||
s.calls++
|
||||
return s.items, s.err
|
||||
}
|
||||
func (s *accountStub) GetAccount(_ context.Context, id uuid.UUID) (api.Account, error) {
|
||||
s.calls++
|
||||
s.id = id
|
||||
return s.account, s.err
|
||||
}
|
||||
func (s *accountStub) DeactivateAccount(_ context.Context, id uuid.UUID) error {
|
||||
s.calls++
|
||||
s.id = id
|
||||
return s.err
|
||||
}
|
||||
|
||||
func accountRequest(handler http.Handler, method, path, body, contentType, token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestAccountInputAndErrors(t *testing.T) {
|
||||
id := uuid.New()
|
||||
for _, tc := range []struct {
|
||||
name, method, path, body, media string
|
||||
err error
|
||||
status, calls int
|
||||
}{
|
||||
{"create", "POST", "/", `{"name":" Alice "}`, "application/json", nil, 201, 1},
|
||||
{"media parameters", "POST", "/", `{"name":"Alice"}`, "application/json; charset=utf-8", nil, 201, 1},
|
||||
{"empty", "POST", "/", `{}`, "application/json", nil, 400, 0},
|
||||
{"null", "POST", "/", `null`, "application/json", nil, 400, 0},
|
||||
{"wrong field", "POST", "/", `{"name":"Alice","admin":true}`, "application/json", nil, 400, 0},
|
||||
{"trailing JSON", "POST", "/", `{"name":"Alice"} {}`, "application/json", nil, 400, 0},
|
||||
{"malformed", "POST", "/", `{"name":`, "application/json", nil, 400, 0},
|
||||
{"wrong name type", "POST", "/", `{"name":3}`, "application/json", nil, 400, 0},
|
||||
{"control", "POST", "/", `{"name":"x\u0000y"}`, "application/json", nil, 400, 0},
|
||||
{"oversized", "POST", "/", `{"name":"` + strings.Repeat("a", 5000) + `"}`, "application/json", nil, 413, 0},
|
||||
{"oversized trailing space", "POST", "/", `{"name":"Alice"}` + strings.Repeat(" ", 5000), "application/json", nil, 413, 0},
|
||||
{"missing media", "POST", "/", `{"name":"Alice"}`, "", nil, 415, 0},
|
||||
{"wrong media", "POST", "/", `{"name":"Alice"}`, "text/plain", nil, 415, 0},
|
||||
{"conflict", "POST", "/", `{"name":"Alice"}`, "application/json", fmt.Errorf("private-error: %w", api.ErrAccountNameConflict), 409, 1},
|
||||
{"get", "GET", "/" + id.String(), "", "", nil, 200, 1},
|
||||
{"bad ID", "GET", "/invalid", "", "", nil, 400, 0},
|
||||
{"bad delete ID", "DELETE", "/invalid", "", "", nil, 400, 0},
|
||||
{"not found", "GET", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1},
|
||||
{"deactivate", "DELETE", "/" + id.String(), "", "", nil, 204, 1},
|
||||
{"inactive", "DELETE", "/" + id.String(), "", "", api.ErrAccountInactive, 409, 1},
|
||||
{"delete absent", "DELETE", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1},
|
||||
{"list database failure", "GET", "/", "", "", errors.New("private-error"), 500, 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := &accountStub{account: api.Account{ID: id, Name: "Alice", CreatedAt: time.Unix(1, 0).UTC(), Active: true}, err: tc.err}
|
||||
handler := mw.BearerAuth("test-key", AccountsRouter(store))
|
||||
w := accountRequest(handler, tc.method, tc.path, tc.body, tc.media, "test-key")
|
||||
if w.Code != tc.status || store.calls != tc.calls {
|
||||
t.Fatalf("status=%d calls=%d", w.Code, store.calls)
|
||||
}
|
||||
if w.Header().Get("Cache-Control") != "no-store" || strings.Contains(w.Body.String(), "private-error") {
|
||||
t.Fatal("cache policy or error disclosure")
|
||||
}
|
||||
if tc.status == 204 {
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatal("204 body")
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Header().Get("Content-Type") != "application/json" || !json.Valid(w.Body.Bytes()) {
|
||||
t.Fatal("invalid JSON response")
|
||||
}
|
||||
if tc.status == 201 && store.name != "Alice" {
|
||||
t.Fatal("untrimmed name reached storage")
|
||||
}
|
||||
if (tc.method == "DELETE" || tc.method == "GET") && tc.calls > 0 && tc.path != "/" && store.id != id {
|
||||
t.Fatal("wrong account ID")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountListAndAuthorization(t *testing.T) {
|
||||
for _, items := range [][]api.Account{nil, {{ID: uuid.New(), Name: "inactive", Active: false}}} {
|
||||
store := &accountStub{items: items}
|
||||
handler := mw.BearerAuth("test-key", AccountsRouter(store))
|
||||
w := accountRequest(handler, "GET", "/", "", "", "test-key")
|
||||
var body api.AccountList
|
||||
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Items == nil || len(body.Items) != len(items) {
|
||||
t.Fatal("account list shape")
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"", "test-key"} {
|
||||
store := &accountStub{}
|
||||
handler := mw.BearerAuth(key, AccountsRouter(store))
|
||||
for _, method := range []string{"GET", "POST", "DELETE"} {
|
||||
w := accountRequest(handler, method, "/", "{}", "application/json", "")
|
||||
want := 401
|
||||
if key == "" {
|
||||
want = 503
|
||||
}
|
||||
if w.Code != want || store.calls != 0 {
|
||||
t.Fatal("unauthorized store access")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Copyright 2026 Beacon Contributors
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
|
||||
"github.com/MeshCore-Beacon/beacon-server/internal/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type routedAccountStore struct {
|
||||
api.AccountStore
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *routedAccountStore) CreateAccount(_ context.Context, name string) (api.Account, error) {
|
||||
s.calls++
|
||||
return api.Account{ID: uuid.New(), Name: name, Active: true}, nil
|
||||
}
|
||||
func TestAccountRouterUsesProtectedStore(t *testing.T) {
|
||||
store := &routedAccountStore{}
|
||||
r := New(nil, nil, nil, Options{Auth: config.AuthConfig{APIKey: "test-key"}, AdminRoutes: map[string]http.Handler{"/accounts": handlers.AccountsRouter(store)}})
|
||||
for _, token := range []string{"", "wrong", "test-key"} {
|
||||
req := httptest.NewRequest("POST", "/api/v1/admin/accounts", strings.NewReader(`{"name":"test"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
want := 401
|
||||
if token == "test-key" {
|
||||
want = 201
|
||||
}
|
||||
if w.Code != want {
|
||||
t.Fatalf("route status=%d want=%d", w.Code, want)
|
||||
}
|
||||
}
|
||||
if store.calls != 1 {
|
||||
t.Fatal("auth boundary was bypassed")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest("POST", "/api/v1/accounts", nil))
|
||||
if w.Code != 404 || store.calls != 1 {
|
||||
t.Fatal("accounts mounted publicly")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user