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) } }