This commit is contained in:
Yovan
2026-10-03 20:28:25 +07:00
parent 14283c2b61
commit 650b24619f
23 changed files with 2373 additions and 30 deletions
+614
View File
@@ -0,0 +1,614 @@
package e2e
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"time"
)
var baseURL = func() string {
if v := os.Getenv("API_BASE_URL"); v != "" {
return strings.TrimRight(v, "/")
}
return "http://localhost:8080"
}()
var httpClient = &http.Client{Timeout: 10 * time.Second}
// randomSuffix returns a short random hex string so every test run uses
// unique usernames — the grader truncates tables between runs, not between
// tests, and may run this suite against several servers back to back.
func randomSuffix() string {
b := make([]byte, 6)
if _, err := rand.Read(b); err != nil {
// crypto/rand failing is exceptional enough to just panic; every
// caller is inside a test anyway.
panic(err)
}
return hex.EncodeToString(b)
}
func uniqueUsername() string {
return "e2e_" + randomSuffix()
}
type apiResponse struct {
status int
header http.Header
body []byte
}
// doRequest fails the test loudly (never skips) if the server cannot be
// reached at all, per the suite's "fail loudly if unreachable" requirement.
func doRequest(t *testing.T, method, path string, body any, headers map[string]string) apiResponse {
t.Helper()
var reader io.Reader
switch v := body.(type) {
case nil:
reader = nil
case []byte:
reader = bytes.NewReader(v)
default:
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("failed to marshal request body: %v", err)
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, baseURL+path, reader)
if err != nil {
t.Fatalf("failed to build request: %v", err)
}
if reader != 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("request %s %s failed (is the server running at %s?): %v", method, path, baseURL, err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("failed to read response body: %v", err)
}
return apiResponse{status: resp.StatusCode, header: resp.Header, body: b}
}
func register(t *testing.T, username, password string) apiResponse {
t.Helper()
return doRequest(t, http.MethodPost, "/register", map[string]string{"username": username, "password": password}, nil)
}
func login(t *testing.T, username, password string) apiResponse {
t.Helper()
return doRequest(t, http.MethodPost, "/login", map[string]string{"username": username, "password": password}, nil)
}
func sessionTokenFrom(t *testing.T, resp apiResponse) string {
t.Helper()
var body map[string]any
if err := json.Unmarshal(resp.body, &body); err != nil {
t.Fatalf("expected JSON login response, got %q: %v", resp.body, err)
}
tok, _ := body["session_token"].(string)
if tok == "" {
t.Fatalf("expected non-empty session_token in %q", resp.body)
}
return tok
}
func TestE2E_RegisterDuplicate(t *testing.T) {
username := uniqueUsername()
password := "password123"
t.Run("first registration succeeds with 201", func(t *testing.T) {
resp := register(t, username, password)
if resp.status != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.status, resp.body)
}
})
t.Run("second registration with the same username returns 409", func(t *testing.T) {
resp := register(t, username, password)
if resp.status != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", resp.status, resp.body)
}
})
}
func TestE2E_PasswordNeverLeaked(t *testing.T) {
username := uniqueUsername()
password := "s3cr3t-" + randomSuffix()
t.Run("register response has no password key and no plaintext password", func(t *testing.T) {
resp := register(t, username, password)
if resp.status != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.status, resp.body)
}
lower := strings.ToLower(string(resp.body))
if strings.Contains(lower, "password") {
t.Fatalf("register response must not contain any password-related key: %s", resp.body)
}
if strings.Contains(string(resp.body), password) {
t.Fatalf("register response must not leak the plaintext password: %s", resp.body)
}
})
t.Run("login response has no password key and no plaintext password", func(t *testing.T) {
resp := login(t, username, password)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
lower := strings.ToLower(string(resp.body))
if strings.Contains(lower, "password") {
t.Fatalf("login response must not contain any password-related key: %s", resp.body)
}
if strings.Contains(string(resp.body), password) {
t.Fatalf("login response must not leak the plaintext password: %s", resp.body)
}
})
}
func TestE2E_LoginFailuresIndistinguishable(t *testing.T) {
username := uniqueUsername()
password := "password123"
if reg := register(t, username, password); reg.status != http.StatusCreated {
t.Fatalf("setup: expected 201 registering, got %d: %s", reg.status, reg.body)
}
var wrongPassBody, unknownUserBody []byte
t.Run("wrong password returns 401", func(t *testing.T) {
resp := login(t, username, "definitely-wrong-password")
if resp.status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", resp.status, resp.body)
}
wrongPassBody = resp.body
})
t.Run("unknown username returns 401 with an identical body", func(t *testing.T) {
resp := login(t, "no-such-user-"+randomSuffix(), "whatever-password")
if resp.status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", resp.status, resp.body)
}
unknownUserBody = resp.body
if string(wrongPassBody) != string(unknownUserBody) {
t.Fatalf("expected identical 401 bodies, got %q vs %q", wrongPassBody, unknownUserBody)
}
})
}
func TestE2E_ProtectedEndpointsRequireToken(t *testing.T) {
cases := []struct {
name string
method string
path string
body any
}{
{"POST /snippets", http.MethodPost, "/snippets", map[string]string{"title": "t", "content": "c"}},
{"GET /snippets", http.MethodGet, "/snippets", nil},
{"DELETE /snippets/{id}", http.MethodDelete, "/snippets/some-id-" + randomSuffix(), nil},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name+" with no header", func(t *testing.T) {
resp := doRequest(t, tc.method, tc.path, tc.body, nil)
if resp.status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", resp.status, resp.body)
}
})
t.Run(tc.name+" with Bearer not-a-real-token", func(t *testing.T) {
resp := doRequest(t, tc.method, tc.path, tc.body, map[string]string{"Authorization": "Bearer not-a-real-token"})
if resp.status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", resp.status, resp.body)
}
})
}
}
func TestE2E_SnippetLifecycle(t *testing.T) {
username := uniqueUsername()
password := "password123"
if resp := register(t, username, password); resp.status != http.StatusCreated {
t.Fatalf("setup: register failed: %d %s", resp.status, resp.body)
}
loginResp := login(t, username, password)
if loginResp.status != http.StatusOK {
t.Fatalf("setup: login failed: %d %s", loginResp.status, loginResp.body)
}
token := sessionTokenFrom(t, loginResp)
authHeader := map[string]string{"Authorization": "Bearer " + token}
var snippetID string
t.Run("create snippet returns 201", func(t *testing.T) {
resp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "e2e title", "content": "e2e content"}, authHeader)
if resp.status != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.status, resp.body)
}
var body map[string]any
if err := json.Unmarshal(resp.body, &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
id, _ := body["id"].(string)
if id == "" {
t.Fatal("expected non-empty id in create response")
}
snippetID = id
})
t.Run("public GET matches what was created", func(t *testing.T) {
if snippetID == "" {
t.Fatal("previous subtest did not set snippetID")
}
resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
var body map[string]any
if err := json.Unmarshal(resp.body, &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
if body["title"] != "e2e title" || body["content"] != "e2e content" {
t.Fatalf("expected matching title/content, got %v", body)
}
})
t.Run("owner DELETE returns 204 with an empty body", func(t *testing.T) {
if snippetID == "" {
t.Fatal("previous subtest did not set snippetID")
}
resp := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, authHeader)
if resp.status != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", resp.status, resp.body)
}
if len(resp.body) != 0 {
t.Fatalf("expected empty body, got %q", resp.body)
}
})
t.Run("GET after delete returns 404", func(t *testing.T) {
if snippetID == "" {
t.Fatal("previous subtest did not set snippetID")
}
resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil)
if resp.status != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", resp.status, resp.body)
}
})
}
// registerAndLogin is a small setup helper shared by the tests below: it
// registers a fresh unique user, logs in, and returns an Authorization header
// map ready to use with doRequest.
func registerAndLogin(t *testing.T) (username string, authHeader map[string]string) {
t.Helper()
username = uniqueUsername()
password := "password123"
if resp := register(t, username, password); resp.status != http.StatusCreated {
t.Fatalf("setup: register failed: %d %s", resp.status, resp.body)
}
loginResp := login(t, username, password)
if loginResp.status != http.StatusOK {
t.Fatalf("setup: login failed: %d %s", loginResp.status, loginResp.body)
}
token := sessionTokenFrom(t, loginResp)
return username, map[string]string{"Authorization": "Bearer " + token}
}
func TestE2E_PublicRead(t *testing.T) {
_, authHeader := registerAndLogin(t)
var snippetID string
t.Run("user A creates a snippet", func(t *testing.T) {
resp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "public read title", "content": "public read content"}, authHeader)
if resp.status != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.status, resp.body)
}
var body map[string]any
if err := json.Unmarshal(resp.body, &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
id, _ := body["id"].(string)
if id == "" {
t.Fatal("expected non-empty id")
}
snippetID = id
})
t.Run("readable with no Authorization header at all", func(t *testing.T) {
if snippetID == "" {
t.Fatal("previous subtest did not set snippetID")
}
resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
var body map[string]any
if err := json.Unmarshal(resp.body, &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
if body["title"] != "public read title" || body["content"] != "public read content" {
t.Fatalf("expected matching title/content, got %v", body)
}
})
}
func TestE2E_LogoutInvalidatesToken(t *testing.T) {
_, authHeader := registerAndLogin(t)
t.Run("logout returns 204", func(t *testing.T) {
resp := doRequest(t, http.MethodPost, "/logout", nil, authHeader)
if resp.status != http.StatusNoContent {
t.Fatalf("expected 204, got %d: %s", resp.status, resp.body)
}
})
t.Run("same token on GET /snippets returns 401", func(t *testing.T) {
resp := doRequest(t, http.MethodGet, "/snippets", nil, authHeader)
if resp.status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", resp.status, resp.body)
}
})
}
func TestE2E_DataIsolation(t *testing.T) {
_, headerA := registerAndLogin(t)
_, headerB := registerAndLogin(t)
createA := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "A's snippet", "content": "belongs to A"}, headerA)
if createA.status != http.StatusCreated {
t.Fatalf("setup: create for A failed: %d %s", createA.status, createA.body)
}
var bodyA map[string]any
if err := json.Unmarshal(createA.body, &bodyA); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
idA, _ := bodyA["id"].(string)
if idA == "" {
t.Fatal("expected non-empty id for A's snippet")
}
createB := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "B's snippet", "content": "belongs to B"}, headerB)
if createB.status != http.StatusCreated {
t.Fatalf("setup: create for B failed: %d %s", createB.status, createB.body)
}
var bodyB map[string]any
if err := json.Unmarshal(createB.body, &bodyB); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
idB, _ := bodyB["id"].(string)
if idB == "" {
t.Fatal("expected non-empty id for B's snippet")
}
t.Run("user A's list contains exactly their own snippet", func(t *testing.T) {
resp := doRequest(t, http.MethodGet, "/snippets", nil, headerA)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
var list []map[string]any
if err := json.Unmarshal(resp.body, &list); err != nil {
t.Fatalf("expected JSON array: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected exactly 1 snippet for user A, got %d: %v", len(list), list)
}
if list[0]["id"] != idA {
t.Fatalf("expected A's own snippet id %q, got %v", idA, list[0])
}
})
t.Run("user B's list contains exactly their own snippet", func(t *testing.T) {
resp := doRequest(t, http.MethodGet, "/snippets", nil, headerB)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
var list []map[string]any
if err := json.Unmarshal(resp.body, &list); err != nil {
t.Fatalf("expected JSON array: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected exactly 1 snippet for user B, got %d: %v", len(list), list)
}
if list[0]["id"] != idB {
t.Fatalf("expected B's own snippet id %q, got %v", idB, list[0])
}
})
}
func TestE2E_DeleteOwnership(t *testing.T) {
_, ownerHeader := registerAndLogin(t)
_, otherHeader := registerAndLogin(t)
createResp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "owned by owner", "content": "do not delete me"}, ownerHeader)
if createResp.status != http.StatusCreated {
t.Fatalf("setup: create failed: %d %s", createResp.status, createResp.body)
}
var body map[string]any
if err := json.Unmarshal(createResp.body, &body); err != nil {
t.Fatalf("expected JSON body: %v", err)
}
snippetID, _ := body["id"].(string)
if snippetID == "" {
t.Fatal("expected non-empty id")
}
t.Run("non-owner delete returns 403 and the snippet is still readable afterward", func(t *testing.T) {
resp := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, otherHeader)
if resp.status != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", resp.status, resp.body)
}
getResp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil)
if getResp.status != http.StatusOK {
t.Fatalf("expected snippet to still be readable (200) after a forbidden delete attempt, got %d: %s", getResp.status, getResp.body)
}
})
t.Run("non-owner delete of a nonexistent id returns 404", func(t *testing.T) {
resp := doRequest(t, http.MethodDelete, "/snippets/no-such-id-"+randomSuffix(), nil, otherHeader)
if resp.status != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", resp.status, resp.body)
}
})
}
func TestE2E_EmptyListIsArray(t *testing.T) {
_, authHeader := registerAndLogin(t)
resp := doRequest(t, http.MethodGet, "/snippets", nil, authHeader)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
body := strings.TrimSpace(string(resp.body))
if body != "[]" {
t.Fatalf("expected a fresh user's empty snippet list to be the literal [], got %q", body)
}
}
func assertResponseJSONContentType(t *testing.T, resp apiResponse) {
t.Helper()
ct := resp.header.Get("Content-Type")
if !strings.HasPrefix(ct, "application/json") {
t.Fatalf("expected Content-Type application/json, got %q", ct)
}
}
func assertSnippetSchema(t *testing.T, resp apiResponse) {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(resp.body, &decoded); err != nil {
t.Fatalf("expected a JSON object body: %v (body=%s)", err, resp.body)
}
wantKeys := []string{"id", "title", "content"}
if len(decoded) != len(wantKeys) {
t.Fatalf("expected exactly the keys %v, got %v", wantKeys, decoded)
}
for _, k := range wantKeys {
if _, ok := decoded[k]; !ok {
t.Fatalf("missing key %q in %v", k, decoded)
}
}
}
func TestE2E_ContentTypeAndSchema(t *testing.T) {
_, authHeader := registerAndLogin(t)
var snippetID string
t.Run("201 on create has JSON content type and the exact snippet schema", func(t *testing.T) {
resp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "ct schema", "content": "ct schema content"}, authHeader)
if resp.status != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", resp.status, resp.body)
}
assertResponseJSONContentType(t, resp)
assertSnippetSchema(t, resp)
var body map[string]any
_ = json.Unmarshal(resp.body, &body)
snippetID, _ = body["id"].(string)
if snippetID == "" {
t.Fatal("expected non-empty id")
}
})
t.Run("200 on public get has JSON content type and the exact snippet schema", func(t *testing.T) {
if snippetID == "" {
t.Fatal("previous subtest did not set snippetID")
}
resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil)
if resp.status != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", resp.status, resp.body)
}
assertResponseJSONContentType(t, resp)
assertSnippetSchema(t, resp)
})
t.Run("400 on malformed body has JSON content type", func(t *testing.T) {
resp := doRequest(t, http.MethodPost, "/snippets", []byte("{not valid json"), authHeader)
if resp.status != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", resp.status, resp.body)
}
assertResponseJSONContentType(t, resp)
})
t.Run("401 on missing token has JSON content type", func(t *testing.T) {
resp := doRequest(t, http.MethodGet, "/snippets", nil, nil)
if resp.status != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d: %s", resp.status, resp.body)
}
assertResponseJSONContentType(t, resp)
})
t.Run("403 on non-owner delete has JSON content type", func(t *testing.T) {
if snippetID == "" {
t.Fatal("previous subtest did not set snippetID")
}
_, otherHeader := registerAndLogin(t)
resp := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, otherHeader)
if resp.status != http.StatusForbidden {
t.Fatalf("expected 403, got %d: %s", resp.status, resp.body)
}
assertResponseJSONContentType(t, resp)
})
t.Run("404 on nonexistent id has JSON content type", func(t *testing.T) {
resp := doRequest(t, http.MethodGet, "/snippets/no-such-id-"+randomSuffix(), nil, nil)
if resp.status != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", resp.status, resp.body)
}
assertResponseJSONContentType(t, resp)
})
}
func TestE2E_BadInput(t *testing.T) {
_, authHeader := registerAndLogin(t)
badBodies := []struct {
name string
body []byte
}{
{"malformed JSON", []byte("{not valid json")},
{"empty body", []byte("")},
{"JSON array body", []byte(`["unexpected", "array"]`)},
}
endpoints := []struct {
name string
method string
path string
headers map[string]string
}{
{"POST /register", http.MethodPost, "/register", nil},
{"POST /login", http.MethodPost, "/login", nil},
{"POST /snippets", http.MethodPost, "/snippets", authHeader},
}
for _, ep := range endpoints {
for _, bb := range badBodies {
ep, bb := ep, bb
t.Run(ep.name+" "+bb.name, func(t *testing.T) {
resp := doRequest(t, ep.method, ep.path, bb.body, ep.headers)
if resp.status != http.StatusBadRequest {
t.Fatalf("expected 400 (never 5xx, never a dropped connection), got %d: %s", resp.status, resp.body)
}
})
}
}
}