493 lines
14 KiB
Go
493 lines
14 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
|
|
var baseURL = func() string {
|
|
if v := strings.TrimRight(os.Getenv("API_BASE_URL"), "/"); v != "" {
|
|
return v
|
|
}
|
|
return "http://localhost:8080"
|
|
}()
|
|
|
|
var client = &http.Client{Timeout: 15 * time.Second}
|
|
|
|
type response struct {
|
|
status int
|
|
header http.Header
|
|
body []byte
|
|
}
|
|
|
|
func (r response) text() string { return string(r.body) }
|
|
|
|
// send mengirim request mentah. Kalau server tidak terjangkau, tes GAGAL (tidak skip).
|
|
func send(t *testing.T, method, path, rawBody string, headers map[string]string) response {
|
|
t.Helper()
|
|
|
|
var body io.Reader
|
|
if rawBody != "" {
|
|
body = strings.NewReader(rawBody)
|
|
}
|
|
req, err := http.NewRequest(method, baseURL+path, body)
|
|
if err != nil {
|
|
t.Fatalf("build request %s %s: %v", method, path, err)
|
|
}
|
|
if rawBody != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
res, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("%s %s failed (is the server running at %s?): %v", method, path, baseURL, err)
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
data, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read response body of %s %s: %v", method, path, err)
|
|
}
|
|
return response{status: res.StatusCode, header: res.Header, body: data}
|
|
}
|
|
|
|
func bearer(token string) map[string]string {
|
|
return map[string]string{"Authorization": "Bearer " + token}
|
|
}
|
|
|
|
func jsonString(t *testing.T, v any) string {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func randHex(t *testing.T, n int) string {
|
|
t.Helper()
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
t.Fatalf("random: %v", err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// uniqueName menghasilkan username unik per pemanggilan (3-32 karakter).
|
|
func uniqueName(t *testing.T) string { return "e2e_" + randHex(t, 8) }
|
|
|
|
func uniquePassword(t *testing.T) string { return "Pw!" + randHex(t, 8) }
|
|
|
|
func decodeObject(t *testing.T, r response) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(r.body, &m); err != nil {
|
|
t.Fatalf("body is not a JSON object: %v (status=%d body=%q)", err, r.status, r.text())
|
|
}
|
|
return m
|
|
}
|
|
|
|
func keysOf(m map[string]any) []string {
|
|
ks := make([]string, 0, len(m))
|
|
for k := range m {
|
|
ks = append(ks, k)
|
|
}
|
|
sort.Strings(ks)
|
|
return ks
|
|
}
|
|
|
|
func requireExactKeys(t *testing.T, m map[string]any, want ...string) {
|
|
t.Helper()
|
|
got := keysOf(m)
|
|
sort.Strings(want)
|
|
if strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("keys = %v, want exactly %v", got, want)
|
|
}
|
|
}
|
|
|
|
func requireStatus(t *testing.T, r response, want int) {
|
|
t.Helper()
|
|
if r.status != want {
|
|
t.Fatalf("status = %d, want %d (body=%q)", r.status, want, r.text())
|
|
}
|
|
}
|
|
|
|
func requireJSONContentType(t *testing.T, r response) {
|
|
t.Helper()
|
|
ct := strings.ToLower(r.header.Get("Content-Type"))
|
|
if !strings.HasPrefix(ct, "application/json") {
|
|
t.Fatalf("Content-Type = %q, want application/json (status=%d)", ct, r.status)
|
|
}
|
|
}
|
|
|
|
func register(t *testing.T, username, password string) response {
|
|
t.Helper()
|
|
return send(t, http.MethodPost, "/register", jsonString(t, map[string]string{
|
|
"username": username, "password": password,
|
|
}), nil)
|
|
}
|
|
|
|
func login(t *testing.T, username, password string) response {
|
|
t.Helper()
|
|
return send(t, http.MethodPost, "/login", jsonString(t, map[string]string{
|
|
"username": username, "password": password,
|
|
}), nil)
|
|
}
|
|
|
|
// newUser mendaftarkan user baru lalu login; mengembalikan username, password, token.
|
|
func newUser(t *testing.T) (username, password, token string) {
|
|
t.Helper()
|
|
username, password = uniqueName(t), uniquePassword(t)
|
|
requireStatus(t, register(t, username, password), http.StatusCreated)
|
|
|
|
res := login(t, username, password)
|
|
requireStatus(t, res, http.StatusOK)
|
|
token, _ = decodeObject(t, res)["session_token"].(string)
|
|
if token == "" {
|
|
t.Fatalf("login did not return a session_token: %q", res.text())
|
|
}
|
|
return username, password, token
|
|
}
|
|
|
|
func createSnippet(t *testing.T, token, title, content string) (id string, res response) {
|
|
t.Helper()
|
|
res = send(t, http.MethodPost, "/snippets", jsonString(t, map[string]string{
|
|
"title": title, "content": content,
|
|
}), bearer(token))
|
|
if res.status == http.StatusCreated {
|
|
id, _ = decodeObject(t, res)["id"].(string)
|
|
}
|
|
return id, res
|
|
}
|
|
|
|
func mustCreateSnippet(t *testing.T, token, title, content string) string {
|
|
t.Helper()
|
|
id, res := createSnippet(t, token, title, content)
|
|
requireStatus(t, res, http.StatusCreated)
|
|
if id == "" {
|
|
t.Fatalf("created snippet has no id: %q", res.text())
|
|
}
|
|
return id
|
|
}
|
|
|
|
func listSnippets(t *testing.T, token string) []map[string]any {
|
|
t.Helper()
|
|
res := send(t, http.MethodGet, "/snippets", "", bearer(token))
|
|
requireStatus(t, res, http.StatusOK)
|
|
var items []map[string]any
|
|
if err := json.Unmarshal(res.body, &items); err != nil {
|
|
t.Fatalf("GET /snippets body is not a JSON array: %v (body=%q)", err, res.text())
|
|
}
|
|
return items
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestE2E_RegisterDuplicate(t *testing.T) {
|
|
username, password := uniqueName(t), uniquePassword(t)
|
|
|
|
t.Run("first registration returns 201", func(t *testing.T) {
|
|
requireStatus(t, register(t, username, password), http.StatusCreated)
|
|
})
|
|
|
|
t.Run("same username again returns 409", func(t *testing.T) {
|
|
requireStatus(t, register(t, username, password), http.StatusConflict)
|
|
})
|
|
}
|
|
|
|
func TestE2E_PasswordNeverLeaked(t *testing.T) {
|
|
username, password := uniqueName(t), uniquePassword(t)
|
|
|
|
assertNoPassword := func(t *testing.T, r response) {
|
|
t.Helper()
|
|
if bytes.Contains(r.body, []byte(password)) {
|
|
t.Fatalf("response body contains the plaintext password: %q", r.text())
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(r.body, &m); err == nil {
|
|
for k := range m {
|
|
if strings.Contains(strings.ToLower(k), "password") {
|
|
t.Fatalf("response has a password-like key %q", k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("register response", func(t *testing.T) {
|
|
res := register(t, username, password)
|
|
requireStatus(t, res, http.StatusCreated)
|
|
assertNoPassword(t, res)
|
|
})
|
|
|
|
t.Run("login response", func(t *testing.T) {
|
|
res := login(t, username, password)
|
|
requireStatus(t, res, http.StatusOK)
|
|
assertNoPassword(t, res)
|
|
})
|
|
}
|
|
|
|
func TestE2E_LoginFailuresIndistinguishable(t *testing.T) {
|
|
username, password := uniqueName(t), uniquePassword(t)
|
|
requireStatus(t, register(t, username, password), http.StatusCreated)
|
|
|
|
var wrongPasswordBody []byte
|
|
|
|
t.Run("wrong password returns 401", func(t *testing.T) {
|
|
res := login(t, username, password+"-wrong")
|
|
requireStatus(t, res, http.StatusUnauthorized)
|
|
wrongPasswordBody = res.body
|
|
})
|
|
|
|
t.Run("unknown username returns 401 with an identical body", func(t *testing.T) {
|
|
res := login(t, uniqueName(t), password)
|
|
requireStatus(t, res, http.StatusUnauthorized)
|
|
if !bytes.Equal(res.body, wrongPasswordBody) {
|
|
t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", string(wrongPasswordBody), res.text())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_ProtectedEndpointsRequireToken(t *testing.T) {
|
|
validBody := jsonString(t, map[string]string{"title": "t", "content": "c"})
|
|
|
|
endpoints := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"POST /snippets", http.MethodPost, "/snippets", validBody},
|
|
{"GET /snippets", http.MethodGet, "/snippets", ""},
|
|
{"DELETE /snippets/{id}", http.MethodDelete, "/snippets/some-id", ""},
|
|
}
|
|
|
|
for _, ep := range endpoints {
|
|
t.Run(ep.name+" without Authorization header", func(t *testing.T) {
|
|
requireStatus(t, send(t, ep.method, ep.path, ep.body, nil), http.StatusUnauthorized)
|
|
})
|
|
t.Run(ep.name+" with a bogus bearer token", func(t *testing.T) {
|
|
requireStatus(t, send(t, ep.method, ep.path, ep.body, bearer("not-a-real-token")), http.StatusUnauthorized)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestE2E_SnippetLifecycle(t *testing.T) {
|
|
_, _, token := newUser(t)
|
|
title, content := "Lifecycle "+randHex(t, 4), "content-"+randHex(t, 4)
|
|
var id string
|
|
|
|
t.Run("create returns 201 with an id", func(t *testing.T) {
|
|
var res response
|
|
id, res = createSnippet(t, token, title, content)
|
|
requireStatus(t, res, http.StatusCreated)
|
|
if id == "" {
|
|
t.Fatalf("no id in response: %q", res.text())
|
|
}
|
|
})
|
|
|
|
t.Run("public GET returns the same snippet", func(t *testing.T) {
|
|
res := send(t, http.MethodGet, "/snippets/"+id, "", nil)
|
|
requireStatus(t, res, http.StatusOK)
|
|
m := decodeObject(t, res)
|
|
if m["id"] != id || m["title"] != title || m["content"] != content {
|
|
t.Fatalf("snippet mismatch: got %v", m)
|
|
}
|
|
})
|
|
|
|
t.Run("owner DELETE returns 204 with empty body", func(t *testing.T) {
|
|
res := send(t, http.MethodDelete, "/snippets/"+id, "", bearer(token))
|
|
requireStatus(t, res, http.StatusNoContent)
|
|
if len(res.body) != 0 {
|
|
t.Fatalf("204 body must be empty, got %q", res.text())
|
|
}
|
|
})
|
|
|
|
t.Run("GET after delete returns 404", func(t *testing.T) {
|
|
requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusNotFound)
|
|
})
|
|
}
|
|
|
|
func TestE2E_PublicRead(t *testing.T) {
|
|
_, _, tokenA := newUser(t)
|
|
title, content := "Public "+randHex(t, 4), "body-"+randHex(t, 4)
|
|
id := mustCreateSnippet(t, tokenA, title, content)
|
|
|
|
t.Run("readable with no Authorization header", func(t *testing.T) {
|
|
requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusOK)
|
|
})
|
|
|
|
t.Run("returned fields match what the author stored", func(t *testing.T) {
|
|
res := send(t, http.MethodGet, "/snippets/"+id, "", nil)
|
|
requireStatus(t, res, http.StatusOK)
|
|
m := decodeObject(t, res)
|
|
if m["title"] != title || m["content"] != content {
|
|
t.Fatalf("got %v, want title=%q content=%q", m, title, content)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_LogoutInvalidatesToken(t *testing.T) {
|
|
_, _, token := newUser(t)
|
|
|
|
t.Run("logout returns 204", func(t *testing.T) {
|
|
requireStatus(t, send(t, http.MethodPost, "/logout", "", bearer(token)), http.StatusNoContent)
|
|
})
|
|
|
|
t.Run("same token is rejected afterwards", func(t *testing.T) {
|
|
requireStatus(t, send(t, http.MethodGet, "/snippets", "", bearer(token)), http.StatusUnauthorized)
|
|
})
|
|
}
|
|
|
|
func TestE2E_DataIsolation(t *testing.T) {
|
|
_, _, tokenA := newUser(t)
|
|
_, _, tokenB := newUser(t)
|
|
idA := mustCreateSnippet(t, tokenA, "A "+randHex(t, 4), "content A")
|
|
idB := mustCreateSnippet(t, tokenB, "B "+randHex(t, 4), "content B")
|
|
|
|
check := func(t *testing.T, token, wantID, otherID string) {
|
|
t.Helper()
|
|
items := listSnippets(t, token)
|
|
if len(items) != 1 {
|
|
t.Fatalf("list has %d snippets, want exactly 1", len(items))
|
|
}
|
|
if items[0]["id"] != wantID {
|
|
t.Fatalf("list contains %v, want %s", items[0]["id"], wantID)
|
|
}
|
|
for _, it := range items {
|
|
if it["id"] == otherID {
|
|
t.Fatalf("list leaked another user's snippet %s", otherID)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("user A sees only A's snippet", func(t *testing.T) { check(t, tokenA, idA, idB) })
|
|
t.Run("user B sees only B's snippet", func(t *testing.T) { check(t, tokenB, idB, idA) })
|
|
}
|
|
|
|
func TestE2E_DeleteOwnership(t *testing.T) {
|
|
_, _, owner := newUser(t)
|
|
_, _, intruder := newUser(t)
|
|
id := mustCreateSnippet(t, owner, "Mine "+randHex(t, 4), "secret")
|
|
|
|
t.Run("non-owner delete returns 403", func(t *testing.T) {
|
|
requireStatus(t, send(t, http.MethodDelete, "/snippets/"+id, "", bearer(intruder)), http.StatusForbidden)
|
|
})
|
|
|
|
t.Run("non-owner delete of a nonexistent id returns 404", func(t *testing.T) {
|
|
missing := "missing-" + randHex(t, 8)
|
|
requireStatus(t, send(t, http.MethodDelete, "/snippets/"+missing, "", bearer(intruder)), http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("snippet is still readable after the failed deletes", func(t *testing.T) {
|
|
requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusOK)
|
|
})
|
|
}
|
|
|
|
func TestE2E_EmptyListIsArray(t *testing.T) {
|
|
_, _, token := newUser(t)
|
|
|
|
t.Run("fresh user's list is exactly []", func(t *testing.T) {
|
|
res := send(t, http.MethodGet, "/snippets", "", bearer(token))
|
|
requireStatus(t, res, http.StatusOK)
|
|
if got := strings.TrimSpace(res.text()); got != "[]" {
|
|
t.Fatalf("body = %q, want []", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestE2E_ContentTypeAndSchema(t *testing.T) {
|
|
_, _, owner := newUser(t)
|
|
_, _, other := newUser(t)
|
|
id := mustCreateSnippet(t, owner, "Schema "+randHex(t, 4), "content")
|
|
|
|
t.Run("200 is JSON and snippet has exactly id, title, content", func(t *testing.T) {
|
|
res := send(t, http.MethodGet, "/snippets/"+id, "", nil)
|
|
requireStatus(t, res, http.StatusOK)
|
|
requireJSONContentType(t, res)
|
|
requireExactKeys(t, decodeObject(t, res), "id", "title", "content")
|
|
})
|
|
|
|
t.Run("201 is JSON and snippet has exactly id, title, content", func(t *testing.T) {
|
|
_, res := createSnippet(t, owner, "Another "+randHex(t, 4), "content")
|
|
requireStatus(t, res, http.StatusCreated)
|
|
requireJSONContentType(t, res)
|
|
requireExactKeys(t, decodeObject(t, res), "id", "title", "content")
|
|
})
|
|
|
|
t.Run("list elements have exactly id, title, content", func(t *testing.T) {
|
|
for _, it := range listSnippets(t, owner) {
|
|
requireExactKeys(t, it, "id", "title", "content")
|
|
}
|
|
})
|
|
|
|
t.Run("400 is JSON", func(t *testing.T) {
|
|
res := send(t, http.MethodPost, "/snippets", `{"title": "", "content": ""}`, bearer(owner))
|
|
requireStatus(t, res, http.StatusBadRequest)
|
|
requireJSONContentType(t, res)
|
|
})
|
|
|
|
t.Run("401 is JSON", func(t *testing.T) {
|
|
res := send(t, http.MethodGet, "/snippets", "", nil)
|
|
requireStatus(t, res, http.StatusUnauthorized)
|
|
requireJSONContentType(t, res)
|
|
})
|
|
|
|
t.Run("403 is JSON", func(t *testing.T) {
|
|
res := send(t, http.MethodDelete, "/snippets/"+id, "", bearer(other))
|
|
requireStatus(t, res, http.StatusForbidden)
|
|
requireJSONContentType(t, res)
|
|
})
|
|
|
|
t.Run("404 is JSON", func(t *testing.T) {
|
|
res := send(t, http.MethodGet, "/snippets/missing-"+randHex(t, 8), "", nil)
|
|
requireStatus(t, res, http.StatusNotFound)
|
|
requireJSONContentType(t, res)
|
|
})
|
|
}
|
|
|
|
func TestE2E_BadInput(t *testing.T) {
|
|
_, _, token := newUser(t)
|
|
|
|
endpoints := []struct {
|
|
name string
|
|
path string
|
|
headers map[string]string
|
|
}{
|
|
{"/register", "/register", nil},
|
|
{"/login", "/login", nil},
|
|
{"POST /snippets", "/snippets", bearer(token)},
|
|
}
|
|
bodies := []struct {
|
|
name string
|
|
raw string
|
|
}{
|
|
{"malformed JSON", `{"username": "abc", "password":`},
|
|
{"empty body", ``},
|
|
{"JSON array body", `["a", "b"]`},
|
|
}
|
|
|
|
for _, ep := range endpoints {
|
|
for _, b := range bodies {
|
|
t.Run(ep.name+" with "+b.name, func(t *testing.T) {
|
|
res := send(t, http.MethodPost, ep.path, b.raw, ep.headers)
|
|
requireStatus(t, res, http.StatusBadRequest)
|
|
})
|
|
}
|
|
}
|
|
}
|