mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-22 13:02:26 +00:00
165 lines
5.9 KiB
Go
165 lines
5.9 KiB
Go
// Copyright 2026 Beacon Contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"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")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAccountErrorLogsCause(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
rec := httptest.NewRecorder()
|
|
accountError(rec, errors.New("private-error"))
|
|
if rec.Code != 500 || strings.Contains(rec.Body.String(), "private-error") {
|
|
t.Fatalf("response leaked or wrong status: %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
if !strings.Contains(buf.String(), "private-error") {
|
|
t.Fatalf("cause not logged: %s", buf.String())
|
|
}
|
|
}
|