Files
W04_048_Zagar_Database/e2e/api_test.go
T

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