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