HTTP API: register, list and remove webhooks, kept across restarts (closes #6)
check / check (push) Successful in 1m2s
check / check (push) Successful in 1m2s
POST, GET and DELETE under /api/v1/chats/{id}/webhooks, for the chats
that GET /api/v1/chats lists. The webhooks are kept in
$DATA_DIR/webhooks.json, mode 0600, which each change replaces whole
through a temporary file in the same directory and a rename. bot.Run
reads the file before it starts the chat client: absent means none, and
a file that cannot be read aborts startup. Reading a JSON request body
moved into decodeBody, which the messages endpoint now shares. Nothing
is posted to a webhook yet.
Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/simplexcalc/internal/api"
|
||||
"sneak.berlin/go/simplexcalc/internal/simplex"
|
||||
)
|
||||
|
||||
const (
|
||||
hookURL = "https://example.com/hook"
|
||||
|
||||
// unknownID is shaped like a webhook's id, and no webhook has it.
|
||||
unknownID = "00112233445566778899aabbccddeeff"
|
||||
)
|
||||
|
||||
// hook is a webhook as the API answers with it.
|
||||
type hook struct {
|
||||
ID string `json:"id"`
|
||||
ChatID int64 `json:"chat_id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// String returns h as the API writes it.
|
||||
func (h hook) String() string {
|
||||
return `{"id":"` + h.ID + `","chat_id":` + strconv.FormatInt(h.ChatID, 10) +
|
||||
`,"url":"` + h.URL + `"}`
|
||||
}
|
||||
|
||||
// twoChats returns the bot's contacts in these tests: chat 3, which
|
||||
// webhooksPath names, and chat 4, whose contact has deleted it.
|
||||
func twoChats() []simplex.Contact {
|
||||
return append(oneChat(), simplex.Contact{ContactID: 4, Status: "deleted"})
|
||||
}
|
||||
|
||||
// readWebhooks returns the webhooks kept in dir.
|
||||
func readWebhooks(t *testing.T, dir string) *api.Webhooks {
|
||||
t.Helper()
|
||||
|
||||
webhooks, err := api.ReadWebhooks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return webhooks
|
||||
}
|
||||
|
||||
// webhookAPI returns an API with webhooks, whose chats are those of
|
||||
// twoChats.
|
||||
func webhookAPI(webhooks *api.Webhooks) *http.Server {
|
||||
return api.New(api.Params{
|
||||
Log: slog.New(slog.DiscardHandler),
|
||||
Client: &fakeClient{contacts: twoChats()},
|
||||
UserID: 1,
|
||||
Port: 8080,
|
||||
Token: credential,
|
||||
Webhooks: webhooks,
|
||||
})
|
||||
}
|
||||
|
||||
// register registers u on the chat whose webhooks are at path, and
|
||||
// returns the webhook answered with. The answer must have status.
|
||||
func register(t *testing.T, srv *http.Server, path, u string, status int) hook {
|
||||
t.Helper()
|
||||
|
||||
rec := post(t, srv, path, `{"url":"`+u+`"}`)
|
||||
|
||||
var h hook
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &h)
|
||||
if err != nil || rec.Code != status || rec.Body.String() != h.String()+"\n" {
|
||||
t.Fatalf("registering %s: %d %q, want %d and a webhook",
|
||||
u, rec.Code, rec.Body.String(), status)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// listed fails the test unless the chat whose webhooks are at path lists
|
||||
// want, in that order.
|
||||
func listed(t *testing.T, srv *http.Server, path string, want ...hook) {
|
||||
t.Helper()
|
||||
|
||||
hooks := make([]string, 0, len(want))
|
||||
for _, h := range want {
|
||||
hooks = append(hooks, h.String())
|
||||
}
|
||||
|
||||
body := `{"webhooks":[` + strings.Join(hooks, ",") + "]}\n"
|
||||
|
||||
rec := request(t, srv, http.MethodGet, path, bearer)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != body {
|
||||
t.Errorf("GET %s: %d %q, want 200 %q", path, rec.Code, rec.Body.String(), body)
|
||||
}
|
||||
}
|
||||
|
||||
// eachEndpoint lists the webhooks of the chat whose webhooks are at path,
|
||||
// registers one and removes one, and returns the three answers.
|
||||
func eachEndpoint(
|
||||
t *testing.T, srv *http.Server, path string,
|
||||
) []*httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
return []*httptest.ResponseRecorder{
|
||||
request(t, srv, http.MethodGet, path, bearer),
|
||||
post(t, srv, path, `{"url":"`+hookURL+`"}`),
|
||||
request(t, srv, http.MethodDelete, path+"/"+unknownID, bearer),
|
||||
}
|
||||
}
|
||||
|
||||
// onlyFile fails the test unless dir holds the webhooks file and nothing
|
||||
// else: no temporary file is left.
|
||||
func onlyFile(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil || len(entries) != 1 || entries[0].Name() != "webhooks.json" {
|
||||
t.Errorf("the directory holds %v (%v), want webhooks.json alone", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// noFile fails the test if dir has a webhooks file.
|
||||
func noFile(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := os.Stat(filepath.Join(dir, "webhooks.json"))
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Errorf("webhooks.json: %v, want it not written", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhooks: a URL registered on a chat gets 201 and a new id of 32
|
||||
// hexadecimal digits; the same URL again gets 200 and the same webhook.
|
||||
// Each chat lists its own, in the order registered, the chat of a contact
|
||||
// who deleted it included.
|
||||
func TestWebhooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := webhookAPI(readWebhooks(t, t.TempDir()))
|
||||
|
||||
listed(t, srv, webhooksPath)
|
||||
|
||||
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||
again := register(t, srv, webhooksPath, hookURL, http.StatusOK)
|
||||
second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated)
|
||||
other := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated)
|
||||
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(first.ID) ||
|
||||
first.ChatID != 3 || first.URL != hookURL {
|
||||
t.Errorf("registered %v, want a new id, chat 3 and %s", first, hookURL)
|
||||
}
|
||||
|
||||
if again != first {
|
||||
t.Errorf("registered again: %v, want %v", again, first)
|
||||
}
|
||||
|
||||
if second.ID == first.ID || other.ID == first.ID || other.ID == second.ID {
|
||||
t.Errorf("ids repeat: %v, %v, %v", first, second, other)
|
||||
}
|
||||
|
||||
listed(t, srv, webhooksPath, first, second)
|
||||
listed(t, srv, "/api/v1/chats/4/webhooks", other)
|
||||
}
|
||||
|
||||
// TestRemoveWebhook: a removed webhook gets 204 with no body and is no
|
||||
// longer listed. Removing it again, removing a webhook through a chat it
|
||||
// is not on, or removing an id no webhook has, gets 404.
|
||||
func TestRemoveWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := webhookAPI(readWebhooks(t, t.TempDir()))
|
||||
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||
second := register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated)
|
||||
|
||||
rec := request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer)
|
||||
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Errorf("removing: %d %q, want 204 and no body", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
noSuchWebhook := `{"error":"no such webhook"}` + "\n"
|
||||
|
||||
for _, path := range []string{
|
||||
webhooksPath + "/" + first.ID,
|
||||
"/api/v1/chats/4/webhooks/" + second.ID,
|
||||
webhooksPath + "/" + unknownID,
|
||||
} {
|
||||
rec := request(t, srv, http.MethodDelete, path, bearer)
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchWebhook {
|
||||
t.Errorf("DELETE %s: %d %q, want 404 %q",
|
||||
path, rec.Code, rec.Body.String(), noSuchWebhook)
|
||||
}
|
||||
}
|
||||
|
||||
listed(t, srv, webhooksPath, second)
|
||||
}
|
||||
|
||||
// TestRegisterRefused: a body that is not JSON with a URL a webhook can
|
||||
// have, or that is over 64 KiB, registers nothing. A URL of 2048 bytes
|
||||
// is registered.
|
||||
func TestRegisterRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
notJSON := `{"error":"the body must be JSON such as ` +
|
||||
`{\"url\":\"https://example.com/hook\"}"}` + "\n"
|
||||
badURL := `{"error":"url must be an http or https URL with a host, ` +
|
||||
`at most 2048 bytes long"}` + "\n"
|
||||
longest := hookURL + "/" + strings.Repeat("a", 2048-len(hookURL)-1)
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
body string
|
||||
status int
|
||||
answer string
|
||||
}{
|
||||
"no body": {"", http.StatusBadRequest, notJSON},
|
||||
"a bare URL": {hookURL, http.StatusBadRequest, notJSON},
|
||||
"url not a string": {`{"url":5}`, http.StatusBadRequest, notJSON},
|
||||
"no url": {`{}`, http.StatusBadRequest, badURL},
|
||||
"relative": {`{"url":"/hook"}`, http.StatusBadRequest, badURL},
|
||||
"no scheme": {`{"url":"example.com/hook"}`, http.StatusBadRequest, badURL},
|
||||
"another scheme": {`{"url":"ftp://example.com/"}`, http.StatusBadRequest, badURL},
|
||||
"no host": {`{"url":"https:///hook"}`, http.StatusBadRequest, badURL},
|
||||
"a port, no host": {`{"url":"http://:80/hook"}`, http.StatusBadRequest, badURL},
|
||||
"not a URL": {`{"url":"https://a b/"}`, http.StatusBadRequest, badURL},
|
||||
"over 2048 bytes": {`{"url":"` + longest + `a"}`, http.StatusBadRequest, badURL},
|
||||
"over 64 KiB": {
|
||||
`{"url":"` + hookURL + "/" + strings.Repeat("a", 64<<10) + `"}`,
|
||||
http.StatusRequestEntityTooLarge, `{"error":"the body is too large"}` + "\n",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
rec := post(t, webhookAPI(readWebhooks(t, dir)), webhooksPath, tc.body)
|
||||
|
||||
if rec.Code != tc.status || rec.Body.String() != tc.answer {
|
||||
t.Errorf("response = %d %q, want %d %q",
|
||||
rec.Code, rec.Body.String(), tc.status, tc.answer)
|
||||
}
|
||||
|
||||
noFile(t, dir)
|
||||
})
|
||||
}
|
||||
|
||||
register(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath, longest,
|
||||
http.StatusCreated)
|
||||
}
|
||||
|
||||
// TestWebhooksNoSuchChat: an id that GET /api/v1/chats does not list gets
|
||||
// 404 from each webhook endpoint, 1 and 2 included, and nothing is
|
||||
// written. When the chats cannot be read to look the id up, the answer
|
||||
// is 500.
|
||||
func TestWebhooksNoSuchChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, id := range []string{
|
||||
"1", "2", "5", "tester", "0", "-3", "3.5", "99999999999999999999",
|
||||
} {
|
||||
dir := t.TempDir()
|
||||
|
||||
for _, rec := range eachEndpoint(t, webhookAPI(readWebhooks(t, dir)),
|
||||
"/api/v1/chats/"+id+"/webhooks") {
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != noSuchChat {
|
||||
t.Errorf("chat %q: %d %q, want 404 %q",
|
||||
id, rec.Code, rec.Body.String(), noSuchChat)
|
||||
}
|
||||
}
|
||||
|
||||
noFile(t, dir)
|
||||
}
|
||||
|
||||
srv := api.New(api.Params{
|
||||
Log: slog.New(slog.DiscardHandler),
|
||||
Client: &fakeClient{contactsErr: errChat},
|
||||
Token: credential,
|
||||
Webhooks: readWebhooks(t, t.TempDir()),
|
||||
})
|
||||
want := `{"error":"the chats could not be read"}` + "\n"
|
||||
|
||||
for _, rec := range eachEndpoint(t, srv, webhooksPath) {
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
|
||||
t.Errorf("chats unreadable: %d %q, want 500 %q",
|
||||
rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhooksKept: a new read of the directory finds the webhooks
|
||||
// registered, and none once they are removed.
|
||||
func TestWebhooksKept(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
srv := webhookAPI(readWebhooks(t, dir))
|
||||
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||
second := register(t, srv, "/api/v1/chats/4/webhooks", hookURL, http.StatusCreated)
|
||||
|
||||
srv = webhookAPI(readWebhooks(t, dir))
|
||||
listed(t, srv, webhooksPath, first)
|
||||
listed(t, srv, "/api/v1/chats/4/webhooks", second)
|
||||
|
||||
for _, path := range []string{
|
||||
webhooksPath + "/" + first.ID, "/api/v1/chats/4/webhooks/" + second.ID,
|
||||
} {
|
||||
rec := request(t, srv, http.MethodDelete, path, bearer)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE %s: %d %q, want 204", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
srv = webhookAPI(readWebhooks(t, dir))
|
||||
listed(t, srv, webhooksPath)
|
||||
listed(t, srv, "/api/v1/chats/4/webhooks")
|
||||
}
|
||||
|
||||
// TestWebhooksFile: the file has mode 0600, and a change replaces it with
|
||||
// a new file rather than writing into it: through a second name, the old
|
||||
// file still holds what it held. No temporary file is left.
|
||||
func TestWebhooksFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "webhooks.json")
|
||||
srv := webhookAPI(readWebhooks(t, dir))
|
||||
register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||
|
||||
info, err := os.Stat(file)
|
||||
if err != nil || info.Mode() != 0o600 {
|
||||
t.Errorf("webhooks.json: %v (%v), want mode 0600", info, err)
|
||||
}
|
||||
|
||||
before, err := os.ReadFile(file) //nolint:gosec // G304: the test's own file.
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
old := filepath.Join(t.TempDir(), "old")
|
||||
|
||||
err = os.Link(file, old)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
register(t, srv, webhooksPath, hookURL+"/2", http.StatusCreated)
|
||||
|
||||
after, err := os.ReadFile(old) //nolint:gosec // G304: the test's own file.
|
||||
if err != nil || !bytes.Equal(after, before) {
|
||||
t.Errorf("the old file holds %q (%v), want %q as before", after, err, before)
|
||||
}
|
||||
|
||||
onlyFile(t, dir)
|
||||
}
|
||||
|
||||
// TestWebhooksWriteFailure: a change whose file cannot be replaced gets
|
||||
// 500 and is not made, and its temporary file is removed. A directory
|
||||
// stands where the file goes, which a rename cannot replace; a directory
|
||||
// without write permission would not do, as tests may run as root.
|
||||
func TestWebhooksWriteFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "webhooks.json")
|
||||
srv := webhookAPI(readWebhooks(t, dir))
|
||||
first := register(t, srv, webhooksPath, hookURL, http.StatusCreated)
|
||||
|
||||
err := os.Remove(file)
|
||||
if err == nil {
|
||||
err = os.Mkdir(file, 0o700)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := `{"error":"the webhooks could not be saved"}` + "\n"
|
||||
|
||||
for _, rec := range []*httptest.ResponseRecorder{
|
||||
post(t, srv, webhooksPath, `{"url":"`+hookURL+`/2"}`),
|
||||
request(t, srv, http.MethodDelete, webhooksPath+"/"+first.ID, bearer),
|
||||
} {
|
||||
if rec.Code != http.StatusInternalServerError || rec.Body.String() != want {
|
||||
t.Errorf("response = %d %q, want 500 %q", rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
listed(t, srv, webhooksPath, first)
|
||||
onlyFile(t, dir)
|
||||
}
|
||||
|
||||
// TestReadWebhooksRefuses: a webhooks file that holds anything but
|
||||
// webhooks as the bot writes them cannot be read, and the error names
|
||||
// it. An absent file is no webhooks.
|
||||
func TestReadWebhooksRefuses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
record := func(id, chatID, url string) string {
|
||||
return `{"webhooks":[{"id":"` + id + `","chat_id":` + chatID +
|
||||
`,"url":"` + url + `"}]}`
|
||||
}
|
||||
|
||||
for name, contents := range map[string]string{
|
||||
"empty file": "",
|
||||
"a word": "webhooks",
|
||||
"cut short": `{"webhooks":[`,
|
||||
"more after it": `{"webhooks":[]} {}`,
|
||||
"null": "null",
|
||||
"no list": "{}",
|
||||
"null list": `{"webhooks":null}`,
|
||||
"list alone": "[]",
|
||||
"empty webhook": `{"webhooks":[{}]}`,
|
||||
"id not hex": record("not-an-id", "3", hookURL),
|
||||
"id too short": record("0011", "3", hookURL),
|
||||
"chat 0": record(unknownID, "0", hookURL),
|
||||
"url of another kind": record(unknownID, "3", "ftp://example.com/"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
err := os.WriteFile(filepath.Join(dir, "webhooks.json"), []byte(contents), 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = api.ReadWebhooks(dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "webhooks.json") {
|
||||
t.Errorf("ReadWebhooks = %v, want an error naming webhooks.json", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
listed(t, webhookAPI(readWebhooks(t, t.TempDir())), webhooksPath)
|
||||
}
|
||||
|
||||
// TestWebhooksConcurrent: registrations at the same time, among listings
|
||||
// and removals, each get their own webhook, all kept; the same URL
|
||||
// registered at the same time is registered once.
|
||||
func TestWebhooksConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const n = 20
|
||||
|
||||
dir := t.TempDir()
|
||||
webhooks := readWebhooks(t, dir)
|
||||
answers := make([]*httptest.ResponseRecorder, 2*n)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range n {
|
||||
wg.Go(func() {
|
||||
answers[i] = post(t, webhookAPI(webhooks), webhooksPath,
|
||||
`{"url":"`+hookURL+"/"+strconv.Itoa(i)+`"}`)
|
||||
})
|
||||
wg.Go(func() {
|
||||
answers[n+i] = post(t, webhookAPI(webhooks), webhooksPath,
|
||||
`{"url":"`+hookURL+`"}`)
|
||||
})
|
||||
wg.Go(func() {
|
||||
srv := webhookAPI(webhooks)
|
||||
request(t, srv, http.MethodGet, webhooksPath, bearer)
|
||||
request(t, srv, http.MethodDelete, webhooksPath+"/"+unknownID, bearer)
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
created := 0
|
||||
ids := map[string]bool{}
|
||||
|
||||
for _, rec := range answers {
|
||||
var h hook
|
||||
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &h)
|
||||
ids[h.ID] = true
|
||||
|
||||
if rec.Code == http.StatusCreated {
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
if created != n+1 || len(ids) != n+1 {
|
||||
t.Errorf("%d registered, %d ids; want %d of each", created, len(ids), n+1)
|
||||
}
|
||||
|
||||
rec := request(t, webhookAPI(readWebhooks(t, dir)),
|
||||
http.MethodGet, webhooksPath, bearer)
|
||||
|
||||
var got struct {
|
||||
Webhooks []hook `json:"webhooks"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if err != nil || len(got.Webhooks) != n+1 {
|
||||
t.Errorf("read again: %d webhooks (%v), want %d", len(got.Webhooks), err, n+1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user