package e2e import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strings" "testing" "time" ) var httpClient = &http.Client{Timeout: 10 * time.Second} func baseURL() string { if v := os.Getenv("API_BASE_URL"); v != "" { return strings.TrimRight(v, "/") } return "http://localhost:8080" } func randSuffix() string { b := make([]byte, 8) if _, err := rand.Read(b); err != nil { panic(err) } return hex.EncodeToString(b) } func uniqueUsername(prefix string) string { return fmt.Sprintf("%s_%s", prefix, randSuffix()) } func doRequest(t *testing.T, method, path string, body []byte, headers map[string]string) (*http.Response, []byte) { t.Helper() var reader io.Reader if body != nil { reader = bytes.NewReader(body) } req, err := http.NewRequest(method, baseURL()+path, reader) if err != nil { t.Fatalf("NewRequest: %v", err) } if body != nil { req.Header.Set("Content-Type", "application/json") } for k, v := range headers { req.Header.Set(k, v) } resp, err := httpClient.Do(req) if err != nil { t.Fatalf("HTTP %s %s failed (server running at %s?): %v", method, path, baseURL(), err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return resp, respBody } func assertStatus(t *testing.T, got, want int, body []byte) { t.Helper() if got != want { t.Fatalf("status: got %d, want %d (body=%s)", got, want, string(body)) } } func assertJSONContentType(t *testing.T, h http.Header) { t.Helper() ct := h.Get("Content-Type") if !strings.HasPrefix(ct, "application/json") { t.Fatalf("Content-Type: got %q, want prefix application/json", ct) } } func decodeObject(t *testing.T, body []byte) map[string]any { t.Helper() var m map[string]any if err := json.Unmarshal(body, &m); err != nil { t.Fatalf("body is not a JSON object: %v (body=%s)", err, string(body)) } return m } func registerUser(t *testing.T, username, password string) { t.Helper() body, _ := json.Marshal(map[string]string{"username": username, "password": password}) resp, respBody := doRequest(t, http.MethodPost, "/register", body, nil) assertStatus(t, resp.StatusCode, http.StatusCreated, respBody) } func loginUser(t *testing.T, username, password string) string { t.Helper() body, _ := json.Marshal(map[string]string{"username": username, "password": password}) resp, respBody := doRequest(t, http.MethodPost, "/login", body, nil) assertStatus(t, resp.StatusCode, http.StatusOK, respBody) m := decodeObject(t, respBody) tok, _ := m["session_token"].(string) if tok == "" { t.Fatalf("login: missing session_token; body=%s", string(respBody)) } return tok } func registerAndLogin(t *testing.T, prefix string) (string, string) { t.Helper() username := uniqueUsername(prefix) registerUser(t, username, "pass1234") token := loginUser(t, username, "pass1234") return username, token } func createSnippet(t *testing.T, token, title, content string) string { t.Helper() body, _ := json.Marshal(map[string]string{"title": title, "content": content}) headers := map[string]string{"Authorization": "Bearer " + token} resp, respBody := doRequest(t, http.MethodPost, "/snippets", body, headers) assertStatus(t, resp.StatusCode, http.StatusCreated, respBody) m := decodeObject(t, respBody) id, _ := m["id"].(string) if id == "" { t.Fatalf("createSnippet: missing id; body=%s", string(respBody)) } return id } func TestE2E_RegisterDuplicate(t *testing.T) { t.Run("first register returns 201", func(t *testing.T) { username := uniqueUsername("dup1") body, _ := json.Marshal(map[string]string{"username": username, "password": "pass1234"}) resp, respBody := doRequest(t, http.MethodPost, "/register", body, nil) assertStatus(t, resp.StatusCode, http.StatusCreated, respBody) }) t.Run("second register with same username returns 409", func(t *testing.T) { username := uniqueUsername("dup2") body, _ := json.Marshal(map[string]string{"username": username, "password": "pass1234"}) resp, _ := doRequest(t, http.MethodPost, "/register", body, nil) assertStatus(t, resp.StatusCode, http.StatusCreated, nil) resp2, respBody2 := doRequest(t, http.MethodPost, "/register", body, nil) assertStatus(t, resp2.StatusCode, http.StatusConflict, respBody2) }) } func TestE2E_PasswordNeverLeaked(t *testing.T) { t.Run("register body has no password keys or plaintext", func(t *testing.T) { username := uniqueUsername("leak1") plain := "secret_pw_leak_1" body, _ := json.Marshal(map[string]string{"username": username, "password": plain}) resp, respBody := doRequest(t, http.MethodPost, "/register", body, nil) assertStatus(t, resp.StatusCode, http.StatusCreated, respBody) raw := string(respBody) if strings.Contains(raw, "password") { t.Fatalf("register body contains 'password': %s", raw) } if strings.Contains(raw, plain) { t.Fatalf("register body leaks plaintext password: %s", raw) } }) t.Run("login body has no password keys or plaintext", func(t *testing.T) { username := uniqueUsername("leak2") plain := "secret_pw_leak_2" body, _ := json.Marshal(map[string]string{"username": username, "password": plain}) resp, _ := doRequest(t, http.MethodPost, "/register", body, nil) assertStatus(t, resp.StatusCode, http.StatusCreated, nil) resp2, respBody2 := doRequest(t, http.MethodPost, "/login", body, nil) assertStatus(t, resp2.StatusCode, http.StatusOK, respBody2) raw := string(respBody2) if strings.Contains(raw, "password") { t.Fatalf("login body contains 'password': %s", raw) } if strings.Contains(raw, plain) { t.Fatalf("login body leaks plaintext password: %s", raw) } }) } func TestE2E_LoginFailuresIndistinguishable(t *testing.T) { username := uniqueUsername("loginfail") registerUser(t, username, "correct_pw") t.Run("wrong password and unknown user both return 401", func(t *testing.T) { wrongBody, _ := json.Marshal(map[string]string{"username": username, "password": "wrong_pw"}) respWrong, _ := doRequest(t, http.MethodPost, "/login", wrongBody, nil) ghostBody, _ := json.Marshal(map[string]string{"username": uniqueUsername("ghost"), "password": "any"}) respGhost, _ := doRequest(t, http.MethodPost, "/login", ghostBody, nil) if respWrong.StatusCode != http.StatusUnauthorized { t.Fatalf("wrong password: want 401, got %d", respWrong.StatusCode) } if respGhost.StatusCode != http.StatusUnauthorized { t.Fatalf("unknown user: want 401, got %d", respGhost.StatusCode) } }) t.Run("the two response bodies are byte-for-byte identical", func(t *testing.T) { wrongBody, _ := json.Marshal(map[string]string{"username": username, "password": "wrong_pw"}) _, b1 := doRequest(t, http.MethodPost, "/login", wrongBody, nil) ghostBody, _ := json.Marshal(map[string]string{"username": uniqueUsername("ghost"), "password": "any"}) _, b2 := doRequest(t, http.MethodPost, "/login", ghostBody, nil) if !bytes.Equal(b1, b2) { t.Fatalf("bodies differ:\nwrong=%q\nghost=%q", b1, b2) } }) } func TestE2E_ProtectedEndpointsRequireToken(t *testing.T) { _, token := registerAndLogin(t, "prot") id := createSnippet(t, token, "t", "c") cases := []struct { name string method string path string headers map[string]string }{ {"POST /snippets without Authorization", http.MethodPost, "/snippets", nil}, {"POST /snippets with bad token", http.MethodPost, "/snippets", map[string]string{"Authorization": "Bearer not-a-real-token"}}, {"GET /snippets without Authorization", http.MethodGet, "/snippets", nil}, {"GET /snippets with bad token", http.MethodGet, "/snippets", map[string]string{"Authorization": "Bearer not-a-real-token"}}, {"DELETE /snippets/{id} without Authorization", http.MethodDelete, "/snippets/" + id, nil}, {"DELETE /snippets/{id} with bad token", http.MethodDelete, "/snippets/" + id, map[string]string{"Authorization": "Bearer not-a-real-token"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { var reqBody []byte if tc.method == http.MethodPost { reqBody = []byte(`{"title":"x","content":"y"}`) } resp, respBody := doRequest(t, tc.method, tc.path, reqBody, tc.headers) assertStatus(t, resp.StatusCode, http.StatusUnauthorized, respBody) }) } } func TestE2E_SnippetLifecycle(t *testing.T) { _, token := registerAndLogin(t, "life") auth := map[string]string{"Authorization": "Bearer " + token} t.Run("create snippet returns 201", func(t *testing.T) { body, _ := json.Marshal(map[string]string{"title": "hello", "content": "world"}) resp, respBody := doRequest(t, http.MethodPost, "/snippets", body, auth) assertStatus(t, resp.StatusCode, http.StatusCreated, respBody) }) snippetID := createSnippet(t, token, "hello", "world") t.Run("public GET matches what was created", func(t *testing.T) { resp, respBody := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) assertStatus(t, resp.StatusCode, http.StatusOK, respBody) m := decodeObject(t, respBody) if m["title"] != "hello" || m["content"] != "world" { t.Fatalf("snippet mismatch: %v", m) } }) t.Run("owner DELETE returns 204 empty body", func(t *testing.T) { resp, respBody := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, auth) assertStatus(t, resp.StatusCode, http.StatusNoContent, respBody) if len(respBody) != 0 { t.Fatalf("expected empty body, got %q", string(respBody)) } }) t.Run("GET after DELETE returns 404", func(t *testing.T) { resp, respBody := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) assertStatus(t, resp.StatusCode, http.StatusNotFound, respBody) }) } func TestE2E_PublicRead(t *testing.T) { _, tokenA := registerAndLogin(t, "puba") snippetID := createSnippet(t, tokenA, "public-title", "public-content") t.Run("public GET of user A's snippet succeeds without Authorization", func(t *testing.T) { resp, respBody := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) assertStatus(t, resp.StatusCode, http.StatusOK, respBody) m := decodeObject(t, respBody) if m["title"] != "public-title" || m["content"] != "public-content" { t.Fatalf("content mismatch: %v", m) } }) t.Run("response Content-Type is application/json", func(t *testing.T) { resp, _ := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) assertJSONContentType(t, resp.Header) }) }