HTTP API: server, credential and the list of chats (closes #4)
check / check (push) Successful in 1m1s

The bot now serves an HTTP API on PORT (default 8080) beside the chat
client. Every request needs the credential read at startup from the
file named by API_TOKEN_FILE, sent as a bearer token; without one,
every request is refused. Responses carry the security headers, bodies
are capped at 64 KiB and each request's work at 10 seconds.

GET /api/v1/chats lists the bot's contacts from the chat client's
/_contacts command, ordered by id, and marks the contacts who deleted
their chat with the bot, which the chat client keeps listing. bot.Run
starts the API after set-up and stops it within 5 seconds; a listener
failure ends the bot as a chat client failure does.

Model: opus-5-5
This commit is contained in:
clawbot
2026-09-29 00:37:57 +00:00
parent 09a5caa8ab
commit 17d6be9fe1
17 changed files with 908 additions and 35 deletions
+214
View File
@@ -0,0 +1,214 @@
package api_test
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sneak.berlin/go/simplexcalc/internal/api"
"sneak.berlin/go/simplexcalc/internal/simplex"
)
const (
// credential is what the API under test is configured with.
credential = "a-credential-for-these-tests" //nolint:gosec // G101: invented for tests
bearer = "Bearer " + credential
chatsPath = "/api/v1/chats"
unauthorized = `{"error":"unauthorized"}` + "\n"
)
var errChat = errors.New("sqlite: database is locked at /var/lib/simplexcalc")
// fakeClient stands in for the chat client. It answers with contacts,
// or with err, and remembers what it was asked.
type fakeClient struct {
contacts []simplex.Contact
err error
userID int64
hadDeadline bool
}
func (f *fakeClient) Contacts(
ctx context.Context, userID int64,
) ([]simplex.Contact, error) {
f.userID = userID
_, f.hadDeadline = ctx.Deadline()
return f.contacts, f.err
}
func newAPI(token string, client api.ChatClient) *http.Server {
return api.New(api.Params{
Log: slog.New(slog.DiscardHandler),
Client: client,
UserID: 1,
Port: 8080,
Token: token,
})
}
// request sends srv one request, with the Authorization header auth
// unless that is empty.
func request(
t *testing.T, srv *http.Server, method, path, auth string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(t.Context(), method, path, nil)
if auth != "" {
req.Header.Set("Authorization", auth)
}
rec := httptest.NewRecorder()
srv.Handler.ServeHTTP(rec, req)
return rec
}
// TestCredential: only the configured credential, sent as a bearer,
// gets in. With none configured, nothing does, an empty bearer
// included.
func TestCredential(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
token, auth string
in bool
}{
"right": {credential, bearer, true},
"right, scheme in lower case": {credential, "bearer " + credential, true},
"wrong": {credential, strings.ToUpper(bearer), false},
"missing": {credential, "", false},
"another scheme": {credential, "Basic " + credential, false},
"no scheme": {credential, credential, false},
"empty bearer": {credential, "Bearer ", false},
"none configured": {"", bearer, false},
"none configured, empty bearer": {"", "Bearer ", false},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
rec := request(t, newAPI(tc.token, &fakeClient{}),
http.MethodGet, chatsPath, tc.auth)
if tc.in {
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
return
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); got != "Bearer" {
t.Errorf("WWW-Authenticate = %q, want Bearer", got)
}
if rec.Body.String() != unauthorized {
t.Errorf("body = %q, want %q", rec.Body.String(), unauthorized)
}
})
}
}
// TestNoCredentialWarns: an API without a credential says at startup
// that it refuses every request.
func TestNoCredentialWarns(t *testing.T) {
t.Parallel()
var logged bytes.Buffer
api.New(api.Params{
Log: slog.New(slog.NewJSONHandler(&logged, nil)),
Client: &fakeClient{},
Port: 8080,
})
if !strings.Contains(logged.String(), `"level":"WARN"`) ||
!strings.Contains(logged.String(), "API_TOKEN_FILE is not set") {
t.Errorf("log = %q, want a warning that API_TOKEN_FILE is not set",
logged.String())
}
}
// TestNoPathIsExempt: an unknown path or method needs the credential
// like everything else, and then gets a JSON error.
func TestNoPathIsExempt(t *testing.T) {
t.Parallel()
srv := newAPI(credential, &fakeClient{})
for _, tc := range []struct {
method, path, auth string
want int
body string
}{
{http.MethodGet, "/", "", http.StatusUnauthorized, unauthorized},
{
http.MethodGet, "/.well-known/healthcheck", "",
http.StatusUnauthorized, unauthorized,
},
{
http.MethodGet, "/api/v1/nothing", bearer,
http.StatusNotFound, `{"error":"not found"}` + "\n",
},
{
http.MethodPost, chatsPath, bearer,
http.StatusMethodNotAllowed, `{"error":"method not allowed"}` + "\n",
},
} {
rec := request(t, srv, tc.method, tc.path, tc.auth)
if rec.Code != tc.want || rec.Body.String() != tc.body {
t.Errorf("%s %s: %d %q, want %d %q", tc.method, tc.path,
rec.Code, rec.Body.String(), tc.want, tc.body)
}
}
}
// TestHeaders: every response, whatever its status, carries the
// security headers, and none lets another origin in.
func TestHeaders(t *testing.T) {
t.Parallel()
want := map[string]string{
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
"Cache-Control": "no-store",
}
for _, tc := range []struct {
client *fakeClient
path, auth string
}{
{&fakeClient{}, chatsPath, bearer},
{&fakeClient{}, chatsPath, ""},
{&fakeClient{}, "/nothing", bearer},
{&fakeClient{err: errChat}, chatsPath, bearer},
} {
rec := request(t, newAPI(credential, tc.client),
http.MethodGet, tc.path, tc.auth)
for key, value := range want {
if got := rec.Header().Get(key); got != value {
t.Errorf("%d response: %s = %q, want %q", rec.Code, key, got, value)
}
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("%d response: Access-Control-Allow-Origin = %q", rec.Code, got)
}
}
}