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