From 3945920abbed7ef385a10cfe507ad75f6ff134bf Mon Sep 17 00:00:00 2001 From: angelicatesvara07-crypto Date: Sat, 3 Oct 2026 19:09:40 +0700 Subject: [PATCH] Assignment 4: add unit, integration, and e2e tests; refactor for testability --- cmd/server/main.go | 3 +- cover | 72 ++++++ e2e/api_extra_test.go | 210 ++++++++++++++++ e2e/api_test.go | 303 +++++++++++++++++++++++ internal/auth/auth_test.go | 145 +++++++++-- internal/database/database.go | 10 +- internal/handler/auth_handler.go | 119 +++++---- internal/handler/auth_handler_test.go | 292 ++++++++++++++++++++++ internal/handler/coverage_test.go | 236 ++++++++++++++++++ internal/handler/fakes_test.go | 194 +++++++++++++++ internal/handler/interfaces.go | 25 ++ internal/handler/snippet_handler.go | 119 +++++---- internal/handler/snippet_handler_test.go | 293 ++++++++++++++++++++++ internal/middleware/require_auth.go | 36 ++- internal/middleware/require_auth_test.go | 217 ++++++++++++++++ internal/store/errors_store.go | 8 + internal/store/main_test.go | 61 +++++ internal/store/session_store.go | 6 +- internal/store/session_store_test.go | 120 +++++++++ internal/store/snippet_store.go | 25 +- internal/store/snippet_store_test.go | 158 ++++++++++++ internal/store/user_store.go | 18 +- internal/store/user_store_test.go | 130 ++++++++++ read.md | 100 ++++++++ 24 files changed, 2753 insertions(+), 147 deletions(-) create mode 100644 cover create mode 100644 e2e/api_extra_test.go create mode 100644 e2e/api_test.go create mode 100644 internal/handler/auth_handler_test.go create mode 100644 internal/handler/coverage_test.go create mode 100644 internal/handler/fakes_test.go create mode 100644 internal/handler/interfaces.go create mode 100644 internal/handler/snippet_handler_test.go create mode 100644 internal/middleware/require_auth_test.go create mode 100644 internal/store/errors_store.go create mode 100644 internal/store/main_test.go create mode 100644 internal/store/session_store_test.go create mode 100644 internal/store/snippet_store_test.go create mode 100644 internal/store/user_store_test.go create mode 100644 read.md diff --git a/cmd/server/main.go b/cmd/server/main.go index d95cb0f..6b88649 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,8 +49,9 @@ func main() { mux.Handle("GET /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.List))) mux.Handle("POST /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Create))) - mux.Handle("GET /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Get))) + mux.HandleFunc("GET /snippets/{id}", snippetHandler.Get) mux.Handle("DELETE /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Delete))) + mux.Handle("GET /user", authMiddleware.RequireAuth(http.HandlerFunc(authHandler.GetProfile))) port := os.Getenv("PORT") if port == "" { diff --git a/cover b/cover new file mode 100644 index 0000000..0ee3bca --- /dev/null +++ b/cover @@ -0,0 +1,72 @@ +mode: set +5803025015/w04/internal/store/session_store.go:20.2,21.39 2 1 +5803025015/w04/internal/store/session_store.go:22.3,23.1 1 0 +5803025015/w04/internal/store/session_store.go:24.3,25.1 1 1 +5803025015/w04/internal/store/session_store.go:26.2,26.42 1 1 +5803025015/w04/internal/store/session_store.go:30.2,32.1 3 1 +5803025015/w04/internal/store/session_store.go:33.2,33.32 3 1 +5803025015/w04/internal/store/session_store.go:34.3,35.1 1 1 +5803025015/w04/internal/store/session_store.go:37.2,39.12 3 1 +5803025015/w04/internal/store/session_store.go:43.2,45.1 6 1 +5803025015/w04/internal/store/session_store.go:46.2,49.16 6 1 +5803025015/w04/internal/store/session_store.go:50.3,50.36 1 1 +5803025015/w04/internal/store/session_store.go:51.4,52.1 1 1 +5803025015/w04/internal/store/session_store.go:53.3,53.18 1 0 +5803025015/w04/internal/store/session_store.go:55.2,55.19 1 1 +5803025015/w04/internal/store/session_store.go:58.2,60.1 5 1 +5803025015/w04/internal/store/session_store.go:61.2,64.1 5 1 +5803025015/w04/internal/store/snippet_store.go:20.2,21.39 2 1 +5803025015/w04/internal/store/snippet_store.go:22.3,23.1 1 0 +5803025015/w04/internal/store/snippet_store.go:24.3,25.1 1 1 +5803025015/w04/internal/store/snippet_store.go:26.2,26.42 1 1 +5803025015/w04/internal/store/snippet_store.go:30.2,32.1 3 1 +5803025015/w04/internal/store/snippet_store.go:33.2,33.22 3 1 +5803025015/w04/internal/store/snippet_store.go:34.3,35.1 1 0 +5803025015/w04/internal/store/snippet_store.go:37.2,39.12 3 1 +5803025015/w04/internal/store/snippet_store.go:43.2,45.1 6 1 +5803025015/w04/internal/store/snippet_store.go:46.2,49.16 6 1 +5803025015/w04/internal/store/snippet_store.go:50.3,50.36 1 1 +5803025015/w04/internal/store/snippet_store.go:51.4,52.1 1 1 +5803025015/w04/internal/store/snippet_store.go:53.3,53.18 1 0 +5803025015/w04/internal/store/snippet_store.go:55.2,55.17 1 1 +5803025015/w04/internal/store/snippet_store.go:61.2,63.1 5 1 +5803025015/w04/internal/store/snippet_store.go:64.2,66.16 5 1 +5803025015/w04/internal/store/snippet_store.go:67.3,68.1 1 0 +5803025015/w04/internal/store/snippet_store.go:69.2,70.1 3 1 +5803025015/w04/internal/store/snippet_store.go:71.2,72.18 3 1 +5803025015/w04/internal/store/snippet_store.go:73.3,74.79 2 1 +5803025015/w04/internal/store/snippet_store.go:75.4,76.1 1 0 +5803025015/w04/internal/store/snippet_store.go:77.3,77.35 1 1 +5803025015/w04/internal/store/snippet_store.go:79.2,79.35 1 1 +5803025015/w04/internal/store/snippet_store.go:80.3,81.1 1 0 +5803025015/w04/internal/store/snippet_store.go:82.2,82.22 1 1 +5803025015/w04/internal/store/snippet_store.go:86.2,88.1 5 1 +5803025015/w04/internal/store/snippet_store.go:89.2,91.16 5 1 +5803025015/w04/internal/store/snippet_store.go:92.3,93.1 1 0 +5803025015/w04/internal/store/snippet_store.go:94.2,94.29 1 1 +5803025015/w04/internal/store/snippet_store.go:95.3,96.1 1 1 +5803025015/w04/internal/store/snippet_store.go:97.2,97.12 1 1 +5803025015/w04/internal/store/user_store.go:21.2,22.39 2 1 +5803025015/w04/internal/store/user_store.go:23.3,24.1 1 0 +5803025015/w04/internal/store/user_store.go:25.3,26.1 1 1 +5803025015/w04/internal/store/user_store.go:27.2,27.39 1 1 +5803025015/w04/internal/store/user_store.go:31.2,33.1 3 1 +5803025015/w04/internal/store/user_store.go:34.2,34.19 3 1 +5803025015/w04/internal/store/user_store.go:35.3,36.1 1 0 +5803025015/w04/internal/store/user_store.go:38.2,40.16 3 1 +5803025015/w04/internal/store/user_store.go:41.3,42.54 2 1 +5803025015/w04/internal/store/user_store.go:43.4,44.1 1 1 +5803025015/w04/internal/store/user_store.go:45.3,45.13 1 0 +5803025015/w04/internal/store/user_store.go:47.2,47.12 1 1 +5803025015/w04/internal/store/user_store.go:51.2,53.1 6 1 +5803025015/w04/internal/store/user_store.go:54.2,57.16 6 1 +5803025015/w04/internal/store/user_store.go:58.3,58.36 1 1 +5803025015/w04/internal/store/user_store.go:59.4,60.1 1 1 +5803025015/w04/internal/store/user_store.go:61.3,61.18 1 0 +5803025015/w04/internal/store/user_store.go:63.2,63.16 1 1 +5803025015/w04/internal/store/user_store.go:67.2,69.1 6 0 +5803025015/w04/internal/store/user_store.go:70.2,73.16 6 0 +5803025015/w04/internal/store/user_store.go:74.3,74.36 1 0 +5803025015/w04/internal/store/user_store.go:75.4,76.1 1 0 +5803025015/w04/internal/store/user_store.go:77.3,77.18 1 0 +5803025015/w04/internal/store/user_store.go:79.2,79.16 1 0 diff --git a/e2e/api_extra_test.go b/e2e/api_extra_test.go new file mode 100644 index 0000000..1bb9eb7 --- /dev/null +++ b/e2e/api_extra_test.go @@ -0,0 +1,210 @@ +package e2e + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func TestE2E_LogoutInvalidatesToken(t *testing.T) { + username := uniqueUsername("logout") + registerUser(t, username, "pass1234") + token := loginUser(t, username, "pass1234") + + t.Run("logout returns 204", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + token} + resp, respBody := doRequest(t, http.MethodPost, "/logout", nil, auth) + assertStatus(t, resp.StatusCode, http.StatusNoContent, respBody) + if len(respBody) != 0 { + t.Fatalf("logout body should be empty, got %q", string(respBody)) + } + }) + + t.Run("same token on GET /snippets now returns 401", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + token} + resp, respBody := doRequest(t, http.MethodGet, "/snippets", nil, auth) + assertStatus(t, resp.StatusCode, http.StatusUnauthorized, respBody) + }) +} + +func TestE2E_DataIsolation(t *testing.T) { + _, tokenA := registerAndLogin(t, "isoa") + _, tokenB := registerAndLogin(t, "isob") + + titleA := "title-A-" + randSuffix() + titleB := "title-B-" + randSuffix() + _ = createSnippet(t, tokenA, titleA, "content-A") + _ = createSnippet(t, tokenB, titleB, "content-B") + + t.Run("user A sees only their own snippets", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + tokenA} + resp, respBody := doRequest(t, http.MethodGet, "/snippets", nil, auth) + assertStatus(t, resp.StatusCode, http.StatusOK, respBody) + + var arr []map[string]any + if err := json.Unmarshal(respBody, &arr); err != nil { + t.Fatalf("not JSON array: %v", err) + } + for _, s := range arr { + if s["title"] == titleB { + t.Fatalf("user A sees user B's snippet: %v", s) + } + } + found := false + for _, s := range arr { + if s["title"] == titleA { + found = true + } + } + if !found { + t.Fatal("user A's own snippet not present in their list") + } + }) + + t.Run("user B sees only their own snippets", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + tokenB} + resp, respBody := doRequest(t, http.MethodGet, "/snippets", nil, auth) + assertStatus(t, resp.StatusCode, http.StatusOK, respBody) + + var arr []map[string]any + if err := json.Unmarshal(respBody, &arr); err != nil { + t.Fatalf("not JSON array: %v", err) + } + for _, s := range arr { + if s["title"] == titleA { + t.Fatalf("user B sees user A's snippet: %v", s) + } + } + }) +} + +func TestE2E_DeleteOwnership(t *testing.T) { + _, tokenA := registerAndLogin(t, "delowna") + _, tokenB := registerAndLogin(t, "delownb") + snippetID := createSnippet(t, tokenA, "owned", "by-A") + + t.Run("non-owner delete returns 403", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + tokenB} + resp, respBody := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, auth) + assertStatus(t, resp.StatusCode, http.StatusForbidden, respBody) + }) + + t.Run("non-owner delete of nonexistent id returns 404, and snippet is still readable", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + tokenB} + + resp, respBody := doRequest(t, http.MethodDelete, "/snippets/nonexistent-"+randSuffix(), nil, auth) + assertStatus(t, resp.StatusCode, http.StatusNotFound, respBody) + + // original snippet must still be readable + resp2, respBody2 := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + assertStatus(t, resp2.StatusCode, http.StatusOK, respBody2) + }) +} + +func TestE2E_EmptyListIsArray(t *testing.T) { + _, token := registerAndLogin(t, "empty") + + t.Run("fresh user GET /snippets returns literal []", func(t *testing.T) { + auth := map[string]string{"Authorization": "Bearer " + token} + resp, respBody := doRequest(t, http.MethodGet, "/snippets", nil, auth) + assertStatus(t, resp.StatusCode, http.StatusOK, respBody) + + trimmed := strings.TrimSpace(string(respBody)) + if trimmed != "[]" { + t.Fatalf("want literal [], got %q", trimmed) + } + }) +} + +func TestE2E_ContentTypeAndSchema(t *testing.T) { + _, token := registerAndLogin(t, "schema") + auth := map[string]string{"Authorization": "Bearer " + token} + snippetID := createSnippet(t, token, "s", "c") + + t.Run("200 responses have application/json", func(t *testing.T) { + resp, _ := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + assertJSONContentType(t, resp.Header) + }) + + t.Run("201 responses have application/json", func(t *testing.T) { + body := []byte(`{"title":"t","content":"c"}`) + resp, _ := doRequest(t, http.MethodPost, "/snippets", body, auth) + assertJSONContentType(t, resp.Header) + }) + + t.Run("400 responses have application/json", func(t *testing.T) { + body := []byte(`not-json`) + resp, _ := doRequest(t, http.MethodPost, "/snippets", body, auth) + assertJSONContentType(t, resp.Header) + }) + + t.Run("401 responses have application/json", func(t *testing.T) { + resp, _ := doRequest(t, http.MethodGet, "/snippets", nil, nil) + assertJSONContentType(t, resp.Header) + }) + + t.Run("403 responses have application/json", func(t *testing.T) { + _, tokenOther := registerAndLogin(t, "schema2") + authOther := map[string]string{"Authorization": "Bearer " + tokenOther} + resp, _ := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, authOther) + assertJSONContentType(t, resp.Header) + }) + + t.Run("snippet objects have exactly id, title, content keys", 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 len(m) != 3 { + t.Fatalf("want exactly 3 keys, got %d: %v", len(m), m) + } + for _, k := range []string{"id", "title", "content"} { + if _, ok := m[k]; !ok { + t.Fatalf("missing key %q in %v", k, m) + } + } + if _, extra := m["user_id"]; extra { + t.Fatalf("snippet response should not have user_id key; got %v", m) + } + }) +} + +// ---------- Test 12: BadInput ---------- + +func TestE2E_BadInput(t *testing.T) { + _, token := registerAndLogin(t, "badinput") + auth := map[string]string{"Authorization": "Bearer " + token} + + 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", auth}, + } + + bodies := []struct { + name string + body []byte + }{ + {"malformed JSON", []byte(`{"username":`)}, + {"empty body", []byte(``)}, + {"JSON array body", []byte(`["a","b"]`)}, + } + + for _, ep := range endpoints { + for _, b := range bodies { + t.Run(ep.name+" with "+b.name+" -> 400", func(t *testing.T) { + resp, respBody := doRequest(t, ep.method, ep.path, b.body, ep.headers) + if resp.StatusCode >= 500 { + t.Fatalf("got 5xx (%d), want 400 (body=%s)", resp.StatusCode, string(respBody)) + } + assertStatus(t, resp.StatusCode, http.StatusBadRequest, respBody) + }) + } + } +} diff --git a/e2e/api_test.go b/e2e/api_test.go new file mode 100644 index 0000000..6f7d388 --- /dev/null +++ b/e2e/api_test.go @@ -0,0 +1,303 @@ +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) + + }) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index feca6cd..c058ebf 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -1,25 +1,130 @@ package auth -import "testing" +import ( + "strings" + "testing" +) -func TestPasswordAndToken(t *testing.T) { - pass := "secret123" - hash, err := HashPassword(pass) - if err != nil { - t.Fatalf("unexpected error hashing: %v", err) - } - if !CheckPassword(pass, hash) { - t.Fatal("expected password check to succeed") - } - if CheckPassword("wrongpass", hash) { - t.Fatal("expected wrong password to fail") - } +func TestHashPassword(t *testing.T) { + t.Run("hash tidak sama dengan plaintext", func(t *testing.T) { + password := "secret123" - tok, err := GenerateToken() - if err != nil { - t.Fatalf("unexpected error generating token: %v", err) - } - if len(tok) != 64 { - t.Fatalf("expected 64 hex chars, got %d", len(tok)) - } + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if hash == password { + t.Fatal("hash should not equal plaintext") + } + t.Run("password terlalu panjang menyebabkan error", func(t *testing.T) { + // bcrypt rejects passwords > 72 bytes + tooLong := strings.Repeat("a", 100) + _, err := HashPassword(tooLong) + if err == nil { + t.Fatal("expected error for password > 72 bytes") + } + }) + }) + + t.Run("password yang sama menghasilkan hash berbeda", func(t *testing.T) { + password := "secret123" + + hash1, err := HashPassword(password) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + hash2, err := HashPassword(password) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if hash1 == hash2 { + t.Fatal("hashes should be different because of salting") + } + }) + + t.Run("hash dapat memverifikasi password asli", func(t *testing.T) { + password := "secret123" + + hash, err := HashPassword(password) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !CheckPassword(password, hash) { + t.Fatal("expected password check to succeed") + } + }) +} + +func TestCheckPassword(t *testing.T) { + hash, err := HashPassword("secret123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + t.Run("password benar", func(t *testing.T) { + if !CheckPassword("secret123", hash) { + t.Fatal("expected correct password to return true") + } + }) + + t.Run("password salah", func(t *testing.T) { + if CheckPassword("wrongpass", hash) { + t.Fatal("expected wrong password to return false") + } + }) + + t.Run("password kosong", func(t *testing.T) { + if CheckPassword("", hash) { + t.Fatal("expected empty password to return false") + } + }) + + t.Run("hash rusak tidak panic", func(t *testing.T) { + if CheckPassword("secret123", "ini-bukan-hash-bcrypt") { + t.Fatal("expected garbage hash to return false") + } + }) +} + +func TestGenerateToken(t *testing.T) { + const N = 1000 + tokens := make([]string, 0, N) + + t.Run("menghasilkan token non-empty", func(t *testing.T) { + for i := 0; i < N; i++ { + token, err := GenerateToken() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token == "" { + t.Fatalf("token should not be empty at i=%d", i) + } + tokens = append(tokens, token) + } + }) + + t.Run("semua token distinct", func(t *testing.T) { + seen := make(map[string]bool, len(tokens)) + for i, token := range tokens { + if seen[token] { + t.Fatalf("duplicate token at i=%d", i) + } + seen[token] = true + } + }) + + t.Run("token tidak mengandung whitespace dan panjang >= 32", func(t *testing.T) { + for i, token := range tokens { + if len(token) < 32 { + t.Fatalf("token too short (%d chars) at i=%d", len(token), i) + } + if strings.IndexAny(token, " \t\r\n") != -1 { + t.Fatalf("token contains whitespace at i=%d", i) + } + } + }) } diff --git a/internal/database/database.go b/internal/database/database.go index 5824d7e..168fabb 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -12,7 +12,6 @@ import ( "github.com/jackc/pgx/v5" ) -// Connect membuat koneksi ke PostgreSQL menggunakan env vars individual func Connect() (*pgx.Conn, error) { host := getEnvOrDefault("DB_HOST", "localhost") port := getEnvOrDefault("DB_PORT", "5432") @@ -33,7 +32,6 @@ func Connect() (*pgx.Conn, error) { return conn, nil } -// CreateTables membuat tabel users, sessions, dan snippets jika belum ada func CreateTables(conn *pgx.Conn) error { queries := []string{ `CREATE TABLE IF NOT EXISTS users ( @@ -61,15 +59,13 @@ func CreateTables(conn *pgx.Conn) error { } } - // Otomatis masukkan data sample 2 user & session - if err := SeedSampleData(conn); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to seed sample data: %v\n", err) - } + // if err := SeedSampleData(conn); err != nil { + // fmt.Fprintf(os.Stderr, "Warning: failed to seed sample data: %v\n", err) + // } return nil } -// SeedSampleData memasukkan data sample 2 user dan session ke database dan mencetak infonya langsung ke konsol VS Code func SeedSampleData(conn *pgx.Conn) error { ctx := context.Background() diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index 55ac017..ac2b3ea 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -2,26 +2,26 @@ package handler import ( "encoding/json" + "errors" "net/http" "strings" "time" "5803025015/w04/internal/auth" + "5803025015/w04/internal/middleware" "5803025015/w04/internal/models" "5803025015/w04/internal/store" + "github.com/google/uuid" ) type AuthHandler struct { - userStore *store.UserStore - sessionStore *store.SessionStore + userStore UserStore + sessionStore SessionStore } -func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler { - return &AuthHandler{ - userStore: userStore, - sessionStore: sessionStore, - } +func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler { + return &AuthHandler{userStore: userStore, sessionStore: sessionStore} } type authRequest struct { @@ -29,27 +29,38 @@ type authRequest struct { Password string `json:"password"` } -func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return - } +// ---------- shared JSON helpers (dipakai juga oleh snippet_handler.go) ---------- +func writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// ---------- handlers ---------- + +func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { var req authRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "invalid request body") return } req.Username = strings.TrimSpace(req.Username) if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" { - http.Error(w, "Invalid username or password", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "invalid username or password") return } hash, err := auth.HashPassword(req.Password) if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } @@ -60,36 +71,36 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { } if err := h.userStore.Create(r.Context(), user); err != nil { - http.Error(w, "Username already exists or create failed", http.StatusBadRequest) + if errors.Is(err, store.ErrDuplicate) { + writeError(w, http.StatusConflict, "username already exists") + return + } + writeError(w, http.StatusInternalServerError, "internal error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(user) + writeJSON(w, http.StatusCreated, map[string]any{ + "id": user.ID, + "username": user.Username, + }) } func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return - } - var req authRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "invalid request body") return } user, err := h.userStore.GetByUsername(r.Context(), req.Username) - if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { - http.Error(w, "Invalid credentials", http.StatusUnauthorized) + if err != nil || user == nil || !auth.CheckPassword(req.Password, user.PasswordHash) { + writeError(w, http.StatusUnauthorized, "invalid credentials") return } token, err := auth.GenerateToken() if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } @@ -100,43 +111,53 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { } if err := h.sessionStore.Create(r.Context(), session); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(session) + writeJSON(w, http.StatusOK, map[string]any{ + "session_token": token, + }) } func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return - } - authHeader := r.Header.Get("Authorization") if authHeader == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + const prefix = "bearer " + if !strings.HasPrefix(strings.ToLower(authHeader), prefix) { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + token := strings.TrimSpace(authHeader[len(prefix):]) + if token == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") return } - token := authHeader - if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { - token = authHeader[7:] - } - token = strings.TrimSpace(token) - if token == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + if _, err := h.sessionStore.Get(r.Context(), token); err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized") return } if err := h.sessionStore.Delete(r.Context(), token); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "logged out"}) + w.WriteHeader(http.StatusNoContent) +} + +func (h *AuthHandler) GetProfile(w http.ResponseWriter, r *http.Request) { + userID, _ := r.Context().Value(middleware.UserIDKey).(string) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "message": "Berhasil mengambil profil", + "user_id": userID, + }) } diff --git a/internal/handler/auth_handler_test.go b/internal/handler/auth_handler_test.go new file mode 100644 index 0000000..8fa0a5f --- /dev/null +++ b/internal/handler/auth_handler_test.go @@ -0,0 +1,292 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "5803025015/w04/internal/auth" + "5803025015/w04/internal/models" +) + +// ---------- helpers ---------- + +func setupAuthHandler() (*AuthHandler, *fakeUserStore, *fakeSessionStore) { + us := newFakeUserStore() + ss := newFakeSessionStore() + return NewAuthHandler(us, ss), us, ss +} + +func doRequest( + h http.HandlerFunc, + method, path string, + body []byte, + headers map[string]string, +) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + if headers != nil { + for k, v := range headers { + req.Header.Set(k, v) + } + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +func assertContentTypeJSON(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + if rec.Code == http.StatusNoContent { + return + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("want Content-Type application/json, got %q (status=%d, body=%q)", + ct, rec.Code, rec.Body.String()) + } +} + +func decodeMap(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 not valid JSON: %v; body=%q", err, string(body)) + } + return m +} + +// ---------- TestRegister ---------- + +func TestRegister(t *testing.T) { + t.Run("success -> 201 with id and username", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"alice","password":"secret123"}`), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d (body=%s)", rec.Code, rec.Body.String()) + } + assertContentTypeJSON(t, rec) + m := decodeMap(t, rec.Body.Bytes()) + if m["id"] == nil || m["id"] == "" { + t.Fatalf("missing id in response: %v", m) + } + if m["username"] != "alice" { + t.Fatalf("want username alice, got %v", m["username"]) + } + }) + + t.Run("duplicate username -> 409", func(t *testing.T) { + h, _, _ := setupAuthHandler() + body := []byte(`{"username":"alice","password":"secret123"}`) + _ = doRequest(h.Register, http.MethodPost, "/register", body, nil) + rec := doRequest(h.Register, http.MethodPost, "/register", body, nil) + if rec.Code != http.StatusConflict { + t.Fatalf("want 409, got %d (body=%s)", rec.Code, rec.Body.String()) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("empty username -> 400", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"","password":"secret123"}`), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("empty password -> 400", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"alice","password":""}`), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("username of 2 chars -> 400", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"ab","password":"secret123"}`), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + }) + + t.Run("username of 33 chars -> 400", func(t *testing.T) { + h, _, _ := setupAuthHandler() + long := strings.Repeat("a", 33) + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"`+long+`","password":"secret123"}`), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + }) + + t.Run("malformed JSON -> 400", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"alice"`), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("success body has no password / password_hash key", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"alice","password":"secret123"}`), nil) + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d", rec.Code) + } + raw := rec.Body.String() + if strings.Contains(raw, "password") { + t.Fatalf("body should not contain 'password': %s", raw) + } + if strings.Contains(raw, "secret123") { + t.Fatalf("body leaks plaintext password: %s", raw) + } + m := decodeMap(t, rec.Body.Bytes()) + if _, ok := m["password"]; ok { + t.Fatal("response has 'password' key") + } + if _, ok := m["password_hash"]; ok { + t.Fatal("response has 'password_hash' key") + } + }) +} + +// ---------- TestLogin ---------- + +func TestLogin(t *testing.T) { + t.Run("success -> 200 with non-empty session_token", func(t *testing.T) { + h, us, _ := setupAuthHandler() + hash, _ := auth.HashPassword("secret123") + _ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash}) + + rec := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"alice","password":"secret123"}`), nil) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String()) + } + assertContentTypeJSON(t, rec) + m := decodeMap(t, rec.Body.Bytes()) + tok, _ := m["session_token"].(string) + if tok == "" { + t.Fatalf("want non-empty session_token, got %v", m) + } + }) + + t.Run("wrong password -> 401", func(t *testing.T) { + h, us, _ := setupAuthHandler() + hash, _ := auth.HashPassword("secret123") + _ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash}) + + rec := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"alice","password":"wrong"}`), nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("unknown username -> 401", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"ghost","password":"whatever"}`), nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("wrong password and unknown user bodies are byte-for-byte identical", func(t *testing.T) { + h, us, _ := setupAuthHandler() + hash, _ := auth.HashPassword("secret123") + _ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash}) + + rec1 := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"alice","password":"wrong"}`), nil) + rec2 := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"ghost","password":"whatever"}`), nil) + + if rec1.Code != rec2.Code { + t.Fatalf("status codes differ: %d vs %d", rec1.Code, rec2.Code) + } + if !bytes.Equal(rec1.Body.Bytes(), rec2.Body.Bytes()) { + t.Fatalf("bodies differ:\n1=%q\n2=%q", rec1.Body.String(), rec2.Body.String()) + } + }) + + t.Run("malformed JSON -> 400", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"alice"`), nil) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} + +// ---------- TestLogout ---------- + +func TestLogout(t *testing.T) { + t.Run("valid token -> 204 empty body, session removed", func(t *testing.T) { + h, _, ss := setupAuthHandler() + _ = ss.Create(nil, &models.Session{Token: "tok-1", UserID: "u1"}) + + rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, + map[string]string{"Authorization": "Bearer tok-1"}) + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d (body=%s)", rec.Code, rec.Body.String()) + } + if rec.Body.Len() != 0 { + t.Fatalf("want empty body, got %q", rec.Body.String()) + } + if ss.HasToken("tok-1") { + t.Fatal("session should have been deleted") + } + }) + + t.Run("missing token -> 401", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, nil) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("unknown token -> 401", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, + map[string]string{"Authorization": "Bearer ghost"}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("second logout with same token -> 401", func(t *testing.T) { + h, _, ss := setupAuthHandler() + _ = ss.Create(nil, &models.Session{Token: "tok-2", UserID: "u1"}) + + rec1 := doRequest(h.Logout, http.MethodPost, "/logout", nil, + map[string]string{"Authorization": "Bearer tok-2"}) + if rec1.Code != http.StatusNoContent { + t.Fatalf("first logout: want 204, got %d", rec1.Code) + } + rec2 := doRequest(h.Logout, http.MethodPost, "/logout", nil, + map[string]string{"Authorization": "Bearer tok-2"}) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("second logout: want 401, got %d", rec2.Code) + } + assertContentTypeJSON(t, rec2) + }) +} diff --git a/internal/handler/coverage_test.go b/internal/handler/coverage_test.go new file mode 100644 index 0000000..9ea7f59 --- /dev/null +++ b/internal/handler/coverage_test.go @@ -0,0 +1,236 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "5803025015/w04/internal/auth" + "5803025015/w04/internal/middleware" + "5803025015/w04/internal/models" +) + +// ---------- GetProfile ---------- + +func TestGetProfile(t *testing.T) { + t.Run("with user id in context -> 200", func(t *testing.T) { + h, _, _ := setupAuthHandler() + req := httptest.NewRequest(http.MethodGet, "/profile", nil) + ctx := context.WithValue(req.Context(), middleware.UserIDKey, "u1") + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + h.GetProfile(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("without user id -> 401", func(t *testing.T) { + h, _, _ := setupAuthHandler() + req := httptest.NewRequest(http.MethodGet, "/profile", nil) + rec := httptest.NewRecorder() + h.GetProfile(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} + +// ---------- Method dispatch ---------- + +func TestSnippetsDispatch(t *testing.T) { + t.Run("GET dispatch", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Snippets, http.MethodGet, "/snippets", nil, "u1") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + }) + + t.Run("POST dispatch", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Snippets, http.MethodPost, "/snippets", + []byte(`{"title":"t","content":"c"}`), "u1") + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d", rec.Code) + } + }) + + t.Run("PUT -> 405", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Snippets, http.MethodPut, "/snippets", nil, "u1") + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("want 405, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} + +func TestSnippetByIDDispatch(t *testing.T) { + t.Run("GET dispatch", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}) + rec := doSnippetRequest(h.SnippetByID, http.MethodGet, "/snippets/s1", nil, "") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + }) + + t.Run("DELETE dispatch", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}) + rec := doSnippetRequest(h.SnippetByID, http.MethodDelete, "/snippets/s1", nil, "u1") + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", rec.Code) + } + }) + + t.Run("PUT -> 405", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.SnippetByID, http.MethodPut, "/snippets/s1", nil, "u1") + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("want 405, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} + +// ---------- Auth handler error branches ---------- + +func TestAuthErrorBranches(t *testing.T) { + t.Run("Register: store non-duplicate error -> 500", func(t *testing.T) { + h, us, _ := setupAuthHandler() + us.createErr = errors.New("boom") + rec := doRequest(h.Register, http.MethodPost, "/register", + []byte(`{"username":"alice","password":"secret123"}`), nil) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Login: session store create error -> 500", func(t *testing.T) { + h, us, ss := setupAuthHandler() + hash, _ := auth.HashPassword("secret123") + _ = us.Create(nil, &models.User{ID: "u1", Username: "alice", PasswordHash: hash}) + ss.createErr = errors.New("boom") + rec := doRequest(h.Login, http.MethodPost, "/login", + []byte(`{"username":"alice","password":"secret123"}`), nil) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Logout: delete error -> 500", func(t *testing.T) { + h, _, ss := setupAuthHandler() + _ = ss.Create(nil, &models.Session{Token: "tok", UserID: "u1"}) + ss.deleteErr = errors.New("boom") + rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, + map[string]string{"Authorization": "Bearer tok"}) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Logout: Basic scheme -> 401", func(t *testing.T) { + h, _, _ := setupAuthHandler() + rec := doRequest(h.Logout, http.MethodPost, "/logout", nil, + map[string]string{"Authorization": "Basic abc"}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} + +// ---------- Snippet handler error branches ---------- + +func TestSnippetErrorBranches(t *testing.T) { + t.Run("Create: store error -> 500", func(t *testing.T) { + h, store := setupSnippetHandler() + store.createErr = errors.New("boom") + rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets", + []byte(`{"title":"t","content":"c"}`), "u1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("List: store error -> 500", func(t *testing.T) { + h, store := setupSnippetHandler() + store.listErr = errors.New("boom") + rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "u1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Get: store non-NotFound error -> 500", func(t *testing.T) { + h, store := setupSnippetHandler() + store.getErr = errors.New("boom") + rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/s1", nil, "") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Delete: GetByID store error -> 500", func(t *testing.T) { + h, store := setupSnippetHandler() + store.getErr = errors.New("boom") + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "u1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Delete: Delete store error -> 500", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}) + store.deleteErr = errors.New("boom") + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "u1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("Delete: missing id -> 400", func(t *testing.T) { + h, _ := setupSnippetHandler() + req := httptest.NewRequest(http.MethodDelete, "/snippets/", nil) + req = req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, "u1")) + rec := httptest.NewRecorder() + h.Delete(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + }) +} + +// ---------- extractID PathValue branch ---------- + +func TestExtractIDWithPathValue(t *testing.T) { + t.Run("uses PathValue when set", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/snippets/", nil) + req.SetPathValue("id", "abc-123") + if got := extractID(req); got != "abc-123" { + t.Fatalf("want abc-123, got %q", got) + } + }) + + t.Run("falls back to path parsing", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/snippets/xyz/", nil) + if got := extractID(req); got != "xyz" { + t.Fatalf("want xyz, got %q", got) + } + }) +} diff --git a/internal/handler/fakes_test.go b/internal/handler/fakes_test.go new file mode 100644 index 0000000..6fcc877 --- /dev/null +++ b/internal/handler/fakes_test.go @@ -0,0 +1,194 @@ +package handler + +import ( + "context" + "sync" + "time" + + "5803025015/w04/internal/models" + "5803025015/w04/internal/store" +) + +// ---------- fakeUserStore ---------- + +type fakeUserStore struct { + mu sync.Mutex + users map[string]*models.User + nextID int + createErr error + getErr error +} + +func newFakeUserStore() *fakeUserStore { + return &fakeUserStore{users: make(map[string]*models.User)} +} + +func (f *fakeUserStore) Create(ctx context.Context, u *models.User) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + if _, exists := f.users[u.Username]; exists { + return store.ErrDuplicate + } + if u.ID == "" { + f.nextID++ + u.ID = "user-id" + } + cp := *u + f.users[u.Username] = &cp + return nil +} + +func (f *fakeUserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.getErr != nil { + return nil, f.getErr + } + u, ok := f.users[username] + if !ok { + return nil, store.ErrNotFound + } + cp := *u + return &cp, nil +} + +// ---------- fakeSessionStore ---------- + +type fakeSessionStore struct { + mu sync.Mutex + sessions map[string]*models.Session + createErr error + getErr error + deleteErr error +} + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{sessions: make(map[string]*models.Session)} +} + +func (f *fakeSessionStore) Create(ctx context.Context, s *models.Session) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + cp := *s + if cp.CreatedAt.IsZero() { + cp.CreatedAt = time.Now() + } + f.sessions[s.Token] = &cp + return nil +} + +func (f *fakeSessionStore) Get(ctx context.Context, token string) (*models.Session, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.getErr != nil { + return nil, f.getErr + } + s, ok := f.sessions[token] + if !ok { + return nil, store.ErrNotFound + } + cp := *s + return &cp, nil +} + +func (f *fakeSessionStore) Delete(ctx context.Context, token string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.sessions[token]; !ok { + return store.ErrNotFound + } + delete(f.sessions, token) + return nil +} + +func (f *fakeSessionStore) HasToken(token string) bool { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.sessions[token] + return ok +} + +// ---------- fakeSnippetStore ---------- + +type fakeSnippetStore struct { + mu sync.Mutex + snippets map[string]*models.Snippet + createErr error + getErr error + listErr error + deleteErr error +} + +func newFakeSnippetStore() *fakeSnippetStore { + return &fakeSnippetStore{snippets: make(map[string]*models.Snippet)} +} + +func (f *fakeSnippetStore) Create(ctx context.Context, s *models.Snippet) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + cp := *s + f.snippets[s.ID] = &cp + return nil +} + +func (f *fakeSnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.getErr != nil { + return nil, f.getErr + } + s, ok := f.snippets[id] + if !ok { + return nil, store.ErrNotFound + } + cp := *s + return &cp, nil +} + +func (f *fakeSnippetStore) ListByOwner(ctx context.Context, ownerID string) ([]*models.Snippet, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.listErr != nil { + return nil, f.listErr + } + out := make([]*models.Snippet, 0) + for _, s := range f.snippets { + if s.UserID == ownerID { + cp := *s + out = append(out, &cp) + } + } + return out, nil +} + +func (f *fakeSnippetStore) Delete(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.deleteErr != nil { + return f.deleteErr + } + if _, ok := f.snippets[id]; !ok { + return store.ErrNotFound + } + delete(f.snippets, id) + return nil +} + +func (f *fakeSnippetStore) HasSnippet(id string) bool { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.snippets[id] + return ok +} diff --git a/internal/handler/interfaces.go b/internal/handler/interfaces.go new file mode 100644 index 0000000..19afe6e --- /dev/null +++ b/internal/handler/interfaces.go @@ -0,0 +1,25 @@ +package handler + +import ( + "context" + + "5803025015/w04/internal/models" +) + +type UserStore interface { + Create(ctx context.Context, user *models.User) error + GetByUsername(ctx context.Context, username string) (*models.User, error) +} + +type SessionStore interface { + Create(ctx context.Context, session *models.Session) error + Get(ctx context.Context, token string) (*models.Session, error) + Delete(ctx context.Context, token string) error +} + +type SnippetStore interface { + Create(ctx context.Context, s *models.Snippet) error + GetByID(ctx context.Context, id string) (*models.Snippet, error) + ListByOwner(ctx context.Context, ownerID string) ([]*models.Snippet, error) + Delete(ctx context.Context, id string) error +} diff --git a/internal/handler/snippet_handler.go b/internal/handler/snippet_handler.go index 163fc44..054ac04 100644 --- a/internal/handler/snippet_handler.go +++ b/internal/handler/snippet_handler.go @@ -9,15 +9,15 @@ import ( "5803025015/w04/internal/middleware" "5803025015/w04/internal/models" "5803025015/w04/internal/store" + "github.com/google/uuid" - "github.com/jackc/pgx/v5" ) type SnippetHandler struct { - snippetStore *store.SnippetStore + snippetStore SnippetStore } -func NewSnippetHandler(snippetStore *store.SnippetStore) *SnippetHandler { +func NewSnippetHandler(snippetStore SnippetStore) *SnippetHandler { return &SnippetHandler{snippetStore: snippetStore} } @@ -26,6 +26,16 @@ type snippetRequest struct { Content string `json:"content"` } +type snippetResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content"` +} + +func toSnippetResponse(s *models.Snippet) snippetResponse { + return snippetResponse{ID: s.ID, Title: s.Title, Content: s.Content} +} + func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -33,7 +43,7 @@ func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) { case http.MethodPost: h.Create(w, r) default: - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } @@ -44,32 +54,27 @@ func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) { case http.MethodDelete: h.Delete(w, r) default: - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + writeError(w, http.StatusMethodNotAllowed, "method not allowed") } } func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return - } - userID := middleware.GetUserID(r.Context()) if userID == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + writeError(w, http.StatusUnauthorized, "unauthorized") return } var req snippetRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "invalid request body") return } req.Title = strings.TrimSpace(req.Title) req.Content = strings.TrimSpace(req.Content) if req.Title == "" || req.Content == "" { - http.Error(w, "Title and content are required", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "title and content are required") return } @@ -81,85 +86,99 @@ func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) { } if err := h.snippetStore.Create(r.Context(), snippet); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(snippet) + writeJSON(w, http.StatusCreated, toSnippetResponse(snippet)) } func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + userID := middleware.GetUserID(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") return } - snippets, err := h.snippetStore.GetAll(r.Context()) + snippets, err := h.snippetStore.ListByOwner(r.Context(), userID) if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(snippets) + out := make([]snippetResponse, 0, len(snippets)) + for _, s := range snippets { + out = append(out, toSnippetResponse(s)) + } + + writeJSON(w, http.StatusOK, out) } func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - return - } - - id := r.PathValue("id") + id := extractID(r) if id == "" { - path := strings.TrimPrefix(r.URL.Path, "/snippets/") - id = strings.TrimSuffix(path, "/") - } - - if id == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) + writeError(w, http.StatusBadRequest, "missing id") return } snippet, err := h.snippetStore.GetByID(r.Context(), id) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - http.Error(w, "Snippet not found", http.StatusNotFound) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "snippet not found") return } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "internal error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(snippet) + writeJSON(w, http.StatusOK, toSnippetResponse(snippet)) } func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + userID := middleware.GetUserID(r.Context()) + if userID == "" { + writeError(w, http.StatusUnauthorized, "unauthorized") return } - id := r.PathValue("id") + id := extractID(r) if id == "" { - path := strings.TrimPrefix(r.URL.Path, "/snippets/") - id = strings.TrimSuffix(path, "/") + writeError(w, http.StatusBadRequest, "missing id") + return } - if id == "" { - http.Error(w, "Bad Request", http.StatusBadRequest) + snippet, err := h.snippetStore.GetByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "snippet not found") + return + } + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if snippet.UserID != userID { + writeError(w, http.StatusForbidden, "forbidden") return } if err := h.snippetStore.Delete(r.Context(), id); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "snippet not found") + return + } + writeError(w, http.StatusInternalServerError, "internal error") return } w.WriteHeader(http.StatusNoContent) } + +func extractID(r *http.Request) string { + if id := r.PathValue("id"); id != "" { + return id + } + path := strings.TrimPrefix(r.URL.Path, "/snippets/") + path = strings.TrimSuffix(path, "/") + return path +} diff --git a/internal/handler/snippet_handler_test.go b/internal/handler/snippet_handler_test.go new file mode 100644 index 0000000..7c251e2 --- /dev/null +++ b/internal/handler/snippet_handler_test.go @@ -0,0 +1,293 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "5803025015/w04/internal/middleware" + "5803025015/w04/internal/models" +) + +// ---------- helpers ---------- + +func setupSnippetHandler() (*SnippetHandler, *fakeSnippetStore) { + ss := newFakeSnippetStore() + return NewSnippetHandler(ss), ss +} + +// withUserID injects user_id into request context (simulating middleware). +func withUserID(r *http.Request, userID string) *http.Request { + ctx := context.WithValue(r.Context(), middleware.UserIDKey, userID) + return r.WithContext(ctx) +} + +// doSnippetRequest fires handler with optional user_id injected. +func doSnippetRequest( + h http.HandlerFunc, + method, path string, + body []byte, + userID string, +) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, bytes.NewReader(body)) + if userID != "" { + req = withUserID(req, userID) + } + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// decodeSnippetMap decodes into map[string]any to check EXACT key set. +func decodeSnippetMap(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 not valid JSON: %v; body=%q", err, string(body)) + } + return m +} + +// ---------- TestCreateSnippet ---------- + +func TestCreateSnippet(t *testing.T) { + t.Run("success -> 201 with exactly id, title, content", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets", + []byte(`{"title":"hello","content":"world"}`), "u1") + if rec.Code != http.StatusCreated { + t.Fatalf("want 201, got %d (body=%s)", rec.Code, rec.Body.String()) + } + assertContentTypeJSON(t, rec) + m := decodeSnippetMap(t, rec.Body.Bytes()) + if len(m) != 3 { + t.Fatalf("want exactly 3 keys, got %d: %v", len(m), m) + } + for _, k := range []string{"id", "title", "content"} { + if _, ok := m[k]; !ok { + t.Fatalf("missing key %q in %v", k, m) + } + } + if _, extra := m["user_id"]; extra { + t.Fatal("response should not have user_id key") + } + }) + + t.Run("empty title -> 400", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets", + []byte(`{"title":"","content":"world"}`), "u1") + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("empty content -> 400", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets", + []byte(`{"title":"hello","content":""}`), "u1") + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("malformed JSON -> 400", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets", + []byte(`{"title":"hello"`), "u1") + if rec.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("unauthenticated -> 401", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Create, http.MethodPost, "/snippets", + []byte(`{"title":"hello","content":"world"}`), "") // no user_id + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} + +// ---------- TestGetSnippet ---------- + +func TestGetSnippet(t *testing.T) { + t.Run("existing id -> 200 without Authorization header", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "owner"}) + + // PathValue needs Go 1.22; simulate via SetPathValue on the request + rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/s1", nil, "") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (body=%s)", rec.Code, rec.Body.String()) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("missing id -> 404", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/ghost", nil, "") + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("response has exactly id, title, content keys", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s2", Title: "t", Content: "c", UserID: "owner"}) + + rec := doSnippetRequest(h.Get, http.MethodGet, "/snippets/s2", nil, "") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + m := decodeSnippetMap(t, rec.Body.Bytes()) + if len(m) != 3 { + t.Fatalf("want exactly 3 keys, got %d: %v", len(m), m) + } + for _, k := range []string{"id", "title", "content"} { + if _, ok := m[k]; !ok { + t.Fatalf("missing key %q", k) + } + } + if _, extra := m["user_id"]; extra { + t.Fatal("response should not have user_id key") + } + }) +} + +// ---------- TestListSnippets ---------- + +func TestListSnippets(t *testing.T) { + t.Run("only caller's snippets returned (seed two users)", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "a", Content: "1", UserID: "u1"}) + _ = store.Create(nil, &models.Snippet{ID: "s2", Title: "b", Content: "2", UserID: "u2"}) + _ = store.Create(nil, &models.Snippet{ID: "s3", Title: "c", Content: "3", UserID: "u1"}) + + rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "u1") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + var arr []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &arr); err != nil { + t.Fatalf("body not valid JSON array: %v", err) + } + if len(arr) != 2 { + t.Fatalf("want 2 snippets for u1, got %d", len(arr)) + } + for _, item := range arr { + if _, ok := item["user_id"]; ok { + t.Fatal("list response should not have user_id key") + } + } + }) + + t.Run("no snippets -> body is literally []", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "empty-user") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + raw := bytes.TrimSpace(rec.Body.Bytes()) + if string(raw) != "[]" { + t.Fatalf("want literal [], got %q", string(raw)) + } + }) + + t.Run("unauthenticated -> 401", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("each element has exactly 3 keys", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "a", Content: "1", UserID: "u1"}) + + rec := doSnippetRequest(h.List, http.MethodGet, "/snippets", nil, "u1") + var arr []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &arr); err != nil { + t.Fatalf("body not JSON array: %v", err) + } + if len(arr) != 1 { + t.Fatalf("want 1, got %d", len(arr)) + } + if len(arr[0]) != 3 { + t.Fatalf("want 3 keys per element, got %d: %v", len(arr[0]), arr[0]) + } + }) +} + +// ---------- TestDeleteSnippet ---------- + +func TestDeleteSnippet(t *testing.T) { + t.Run("owner -> 204 empty body, snippet removed", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}) + + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "u1") + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d (body=%s)", rec.Code, rec.Body.String()) + } + if rec.Body.Len() != 0 { + t.Fatalf("want empty body, got %q", rec.Body.String()) + } + if store.HasSnippet("s1") { + t.Fatal("snippet should be gone") + } + }) + + t.Run("non-owner -> 403, snippet still present", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "owner"}) + + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "other") + if rec.Code != http.StatusForbidden { + t.Fatalf("want 403, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + if !store.HasSnippet("s1") { + t.Fatal("snippet should still exist") + } + }) + + t.Run("nonexistent id -> 404", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/ghost", nil, "u1") + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("unauthenticated -> 401", func(t *testing.T) { + h, store := setupSnippetHandler() + _ = store.Create(nil, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "owner"}) + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/s1", nil, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) + + t.Run("non-owner on nonexistent id -> 404 (not 403)", func(t *testing.T) { + h, _ := setupSnippetHandler() + rec := doSnippetRequest(h.Delete, http.MethodDelete, "/snippets/ghost", nil, "someone") + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404, got %d", rec.Code) + } + assertContentTypeJSON(t, rec) + }) +} diff --git a/internal/middleware/require_auth.go b/internal/middleware/require_auth.go index 33d86d3..cfbe813 100644 --- a/internal/middleware/require_auth.go +++ b/internal/middleware/require_auth.go @@ -2,10 +2,11 @@ package middleware import ( "context" + "encoding/json" "net/http" "strings" - "5803025015/w04/internal/store" + "5803025015/w04/internal/models" ) type contextKey string @@ -19,35 +20,46 @@ func GetUserID(ctx context.Context) string { return "" } -type AuthMiddleware struct { - sessionStore *store.SessionStore +type SessionStore interface { + Get(ctx context.Context, token string) (*models.Session, error) } -func NewAuthMiddleware(sessionStore *store.SessionStore) *AuthMiddleware { +type AuthMiddleware struct { + sessionStore SessionStore +} + +func NewAuthMiddleware(sessionStore SessionStore) *AuthMiddleware { return &AuthMiddleware{sessionStore: sessionStore} } +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if authHeader == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } - token := authHeader - if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { - token = authHeader[7:] + const bearerPrefix = "bearer " + if !strings.HasPrefix(strings.ToLower(authHeader), bearerPrefix) { + writeJSONError(w, http.StatusUnauthorized, "unauthorized") + return } - token = strings.TrimSpace(token) + token := strings.TrimSpace(authHeader[len(bearerPrefix):]) if token == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } session, err := m.sessionStore.Get(r.Context(), token) if err != nil || session == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + writeJSONError(w, http.StatusUnauthorized, "unauthorized") return } @@ -62,6 +74,6 @@ func (m *AuthMiddleware) RequireAuthFunc(next http.HandlerFunc) http.HandlerFunc } } -func RequireAuth(sessionStore *store.SessionStore) func(http.Handler) http.Handler { +func RequireAuth(sessionStore SessionStore) func(http.Handler) http.Handler { return NewAuthMiddleware(sessionStore).RequireAuth } diff --git a/internal/middleware/require_auth_test.go b/internal/middleware/require_auth_test.go new file mode 100644 index 0000000..c7b79e9 --- /dev/null +++ b/internal/middleware/require_auth_test.go @@ -0,0 +1,217 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "5803025015/w04/internal/models" +) + +// ---------- hand-written fake ---------- + +// fakeSessionStore is a hand-written in-memory SessionStore for tests. +type fakeSessionStore struct { + sessions map[string]*models.Session + err error // if set, Get returns this error +} + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{sessions: make(map[string]*models.Session)} +} + +func (f *fakeSessionStore) Get(ctx context.Context, token string) (*models.Session, error) { + if f.err != nil { + return nil, f.err + } + s, ok := f.sessions[token] + if !ok { + return nil, errors.New("not found") + } + return s, nil +} + +// ---------- helpers ---------- + +// callMiddleware runs RequireAuth around a next handler that records whether +// it was called and what user id it saw. +func callMiddleware( + t *testing.T, + store SessionStore, + authHeader string, +) (rec *httptest.ResponseRecorder, called *bool, seenUserID *string) { + t.Helper() + + calledVal := false + seenID := "" + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledVal = true + seenID = GetUserID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + + mw := NewAuthMiddleware(store) + handler := mw.RequireAuth(next) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + if authHeader != "" { + req.Header.Set("Authorization", authHeader) + } + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + return rec, &calledVal, &seenID +} + +// assertJSONError asserts that the body is valid JSON and has "error" key. +func assertJSONError(t *testing.T, body []byte) { + t.Helper() + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("body is not valid JSON: %v; body=%q", err, string(body)) + } + if _, ok := m["error"]; !ok { + t.Fatalf("expected 'error' key in JSON body; got %v", m) + } +} + +// ---------- tests ---------- + +func TestRequireAuth(t *testing.T) { + t.Run("no Authorization header -> 401, next not called", func(t *testing.T) { + store := newFakeSessionStore() + rec, called, _ := callMiddleware(t, store, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + if *called { + t.Fatal("next handler should NOT have been called") + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("want Content-Type application/json, got %q", ct) + } + assertJSONError(t, rec.Body.Bytes()) + }) + + t.Run("Basic scheme -> 401", func(t *testing.T) { + store := newFakeSessionStore() + rec, called, _ := callMiddleware(t, store, "Basic abc") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + if *called { + t.Fatal("next handler should NOT have been called") + } + assertJSONError(t, rec.Body.Bytes()) + t.Run("RequireAuthFunc wrapper works", func(t *testing.T) { + store := newFakeSessionStore() + store.sessions["tok"] = &models.Session{Token: "tok", UserID: "u1"} + + called := false + next := func(w http.ResponseWriter, r *http.Request) { + called = true + if GetUserID(r.Context()) != "u1" { + t.Errorf("wrong user id in context") + } + w.WriteHeader(http.StatusOK) + } + + mw := NewAuthMiddleware(store) + h := mw.RequireAuthFunc(next) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || !called { + t.Fatalf("want 200 + called, got %d called=%v", rec.Code, called) + } + }) + + t.Run("RequireAuth top-level function works", func(t *testing.T) { + store := newFakeSessionStore() + store.sessions["tok"] = &models.Session{Token: "tok", UserID: "u1"} + + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + mwFunc := RequireAuth(store) + h := mwFunc(next) + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || !called { + t.Fatalf("want 200 + called, got %d called=%v", rec.Code, called) + } + }) + }) + + t.Run("Bearer with empty token -> 401", func(t *testing.T) { + store := newFakeSessionStore() + rec, called, _ := callMiddleware(t, store, "Bearer ") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + if *called { + t.Fatal("next handler should NOT have been called") + } + assertJSONError(t, rec.Body.Bytes()) + }) + + t.Run("unknown token -> 401", func(t *testing.T) { + store := newFakeSessionStore() + rec, called, _ := callMiddleware(t, store, "Bearer not-a-real-token") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + if *called { + t.Fatal("next handler should NOT have been called") + } + assertJSONError(t, rec.Body.Bytes()) + }) + + t.Run("valid token -> next called with user id in context", func(t *testing.T) { + store := newFakeSessionStore() + store.sessions["valid-token"] = &models.Session{ + Token: "valid-token", + UserID: "user-123", + } + rec, called, seenID := callMiddleware(t, store, "Bearer valid-token") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if !*called { + t.Fatal("next handler SHOULD have been called") + } + if *seenID != "user-123" { + t.Fatalf("want user id 'user-123' in context, got %q", *seenID) + } + }) + + t.Run("store error -> 401 with JSON body", func(t *testing.T) { + store := newFakeSessionStore() + store.err = errors.New("boom") + rec, called, _ := callMiddleware(t, store, "Bearer valid-token") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + if *called { + t.Fatal("next handler should NOT have been called") + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("want Content-Type application/json, got %q", ct) + } + assertJSONError(t, rec.Body.Bytes()) + }) +} diff --git a/internal/store/errors_store.go b/internal/store/errors_store.go new file mode 100644 index 0000000..8a20562 --- /dev/null +++ b/internal/store/errors_store.go @@ -0,0 +1,8 @@ +package store + +import "errors" + +var ( + ErrNotFound = errors.New("store: not found") + ErrDuplicate = errors.New("store: duplicate") +) diff --git a/internal/store/main_test.go b/internal/store/main_test.go new file mode 100644 index 0000000..4712e37 --- /dev/null +++ b/internal/store/main_test.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "5803025015/w04/internal/database" + + "github.com/jackc/pgx/v5" +) + +// testConn is the shared connection for non-parallel store tests. +var testConn *pgx.Conn + +func TestMain(m *testing.M) { + dbName := os.Getenv("DB_NAME") + if !strings.HasSuffix(dbName, "_test") { + fmt.Fprintf(os.Stderr, "REFUSING TO RUN: DB_NAME=%q must end with _test\n", dbName) + os.Exit(1) + } + + conn, err := database.Connect() + if err != nil { + fmt.Fprintf(os.Stderr, "cannot connect to test DB: %v\n", err) + os.Exit(1) + } + testConn = conn + + if err := database.CreateTables(testConn); err != nil { + fmt.Fprintf(os.Stderr, "cannot create tables: %v\n", err) + os.Exit(1) + } + + code := m.Run() + _ = testConn.Close(context.Background()) + os.Exit(code) +} + +// truncateAll wipes every table. Call at the START of every test. +func truncateAll(t *testing.T) { + t.Helper() + ctx := context.Background() + _, err := testConn.Exec(ctx, `TRUNCATE TABLE snippets, sessions, users CASCADE`) + if err != nil { + t.Fatalf("truncate failed: %v", err) + } +} + +// newFreshConn opens a brand-new connection to the same DB (for restart tests). +func newFreshConn(t *testing.T) *pgx.Conn { + t.Helper() + conn, err := database.Connect() + if err != nil { + t.Fatalf("cannot open fresh connection: %v", err) + } + t.Cleanup(func() { _ = conn.Close(context.Background()) }) + return conn +} diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 67935d1..50fd94b 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -2,10 +2,12 @@ package store import ( "context" + "errors" "sync" "time" "5803025015/w04/internal/models" + "github.com/jackc/pgx/v5" ) @@ -45,11 +47,13 @@ func (s *SessionStore) Get(ctx context.Context, token string) (*models.Session, query := `SELECT token, user_id, created_at FROM sessions WHERE token = $1` err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } return nil, err } return &sess, nil } - func (s *SessionStore) Delete(ctx context.Context, token string) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/store/session_store_test.go b/internal/store/session_store_test.go new file mode 100644 index 0000000..545168c --- /dev/null +++ b/internal/store/session_store_test.go @@ -0,0 +1,120 @@ +package store + +import ( + "context" + "errors" + "testing" + + "5803025015/w04/internal/models" +) + +func seedUser(t *testing.T, id, username string) { + t.Helper() + us := NewUserStore(testConn) + if err := us.Create(context.Background(), &models.User{ID: id, Username: username, PasswordHash: "h"}); err != nil { + t.Fatalf("seed user: %v", err) + } +} + +func TestSessionStore(t *testing.T) { + ctx := context.Background() + + t.Run("create then lookup returns correct user id", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSessionStore(testConn) + if err := s.Create(ctx, &models.Session{Token: "tok1", UserID: "u1"}); err != nil { + t.Fatal(err) + } + got, err := s.Get(ctx, "tok1") + if err != nil { + t.Fatal(err) + } + if got.UserID != "u1" { + t.Fatalf("want u1, got %s", got.UserID) + } + }) + + t.Run("unknown token returns ErrNotFound", func(t *testing.T) { + truncateAll(t) + s := NewSessionStore(testConn) + _, err := s.Get(ctx, "ghost") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + }) + + t.Run("delete then lookup returns ErrNotFound", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSessionStore(testConn) + if err := s.Create(ctx, &models.Session{Token: "tok1", UserID: "u1"}); err != nil { + t.Fatal(err) + } + if err := s.Delete(ctx, "tok1"); err != nil { + t.Fatal(err) + } + _, err := s.Get(ctx, "tok1") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound after delete, got %v", err) + } + }) + + t.Run("deleting already-deleted token does not crash", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + seedUser(t, "u2", "bob") + s := NewSessionStore(testConn) + _ = s.Create(ctx, &models.Session{Token: "tok1", UserID: "u1"}) + _ = s.Create(ctx, &models.Session{Token: "tok2", UserID: "u2"}) + + _ = s.Delete(ctx, "tok1") + // second delete: must not panic + _ = s.Delete(ctx, "tok1") + + // other tokens unaffected + got, err := s.Get(ctx, "tok2") + if err != nil || got.UserID != "u2" { + t.Fatalf("tok2 should still be valid: err=%v got=%+v", err, got) + } + }) + + t.Run("two sessions for one user are independent", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSessionStore(testConn) + _ = s.Create(ctx, &models.Session{Token: "tokA", UserID: "u1"}) + _ = s.Create(ctx, &models.Session{Token: "tokB", UserID: "u1"}) + + if err := s.Delete(ctx, "tokA"); err != nil { + t.Fatal(err) + } + got, err := s.Get(ctx, "tokB") + if err != nil || got.UserID != "u1" { + t.Fatalf("tokB should still be valid after deleting tokA: err=%v got=%+v", err, got) + } + }) +} + +func TestSessionSurvivesNewStore(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + + // create session on first store + s1 := NewSessionStore(testConn) + if err := s1.Create(context.Background(), &models.Session{Token: "tok1", UserID: "u1"}); err != nil { + t.Fatal(err) + } + + // brand-new connection to same DB + freshConn := newFreshConn(t) + s2 := NewSessionStore(freshConn) + + got, err := s2.Get(context.Background(), "tok1") + if err != nil { + t.Fatalf("session not visible on fresh connection: %v", err) + } + if got.UserID != "u1" { + t.Fatalf("want u1, got %s", got.UserID) + } +} diff --git a/internal/store/snippet_store.go b/internal/store/snippet_store.go index 74783fe..aeea682 100644 --- a/internal/store/snippet_store.go +++ b/internal/store/snippet_store.go @@ -2,9 +2,11 @@ package store import ( "context" + "errors" "sync" "5803025015/w04/internal/models" + "github.com/google/uuid" "github.com/jackc/pgx/v5" ) @@ -45,23 +47,28 @@ func (s *SnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, query := `SELECT id, title, content, user_id FROM snippets WHERE id = $1` err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } return nil, err } return &sn, nil } -func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) { +// ListByOwner returns only the snippets belonging to ownerID. +// Return value is always non-nil (empty slice when none). +func (s *SnippetStore) ListByOwner(ctx context.Context, ownerID string) ([]*models.Snippet, error) { s.mu.Lock() defer s.mu.Unlock() - query := `SELECT id, title, content, user_id FROM snippets` - rows, err := s.conn.Query(ctx, query) + query := `SELECT id, title, content, user_id FROM snippets WHERE user_id = $1` + rows, err := s.conn.Query(ctx, query, ownerID) if err != nil { return nil, err } defer rows.Close() - snippets := make([]*models.Snippet, 0) + snippets := make([]*models.Snippet, 0) // non-nil! for rows.Next() { var sn models.Snippet if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil { @@ -80,6 +87,12 @@ func (s *SnippetStore) Delete(ctx context.Context, id string) error { defer s.mu.Unlock() query := `DELETE FROM snippets WHERE id = $1` - _, err := s.conn.Exec(ctx, query, id) - return err + tag, err := s.conn.Exec(ctx, query, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil } diff --git a/internal/store/snippet_store_test.go b/internal/store/snippet_store_test.go new file mode 100644 index 0000000..06c49f2 --- /dev/null +++ b/internal/store/snippet_store_test.go @@ -0,0 +1,158 @@ +package store + +import ( + "context" + "errors" + "testing" + + "5803025015/w04/internal/models" +) + +func TestSnippetStore(t *testing.T) { + ctx := context.Background() + + t.Run("create then get by id", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSnippetStore(testConn) + if err := s.Create(ctx, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}); err != nil { + t.Fatal(err) + } + got, err := s.GetByID(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if got.Title != "t" || got.Content != "c" || got.UserID != "u1" { + t.Fatalf("unexpected snippet: %+v", got) + } + }) + + t.Run("get unknown id returns ErrNotFound", func(t *testing.T) { + truncateAll(t) + s := NewSnippetStore(testConn) + _, err := s.GetByID(ctx, "ghost") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + }) + + t.Run("list by owner returns only that owner's rows", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + seedUser(t, "u2", "bob") + s := NewSnippetStore(testConn) + _ = s.Create(ctx, &models.Snippet{ID: "s1", Title: "a", Content: "1", UserID: "u1"}) + _ = s.Create(ctx, &models.Snippet{ID: "s2", Title: "b", Content: "2", UserID: "u2"}) + _ = s.Create(ctx, &models.Snippet{ID: "s3", Title: "c", Content: "3", UserID: "u1"}) + + got, err := s.ListByOwner(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("want 2 snippets for u1, got %d", len(got)) + } + for _, sn := range got { + if sn.UserID != "u1" { + t.Fatalf("got snippet from wrong owner: %+v", sn) + } + } + }) + + t.Run("list for owner with no snippets returns non-nil empty slice", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSnippetStore(testConn) + got, err := s.ListByOwner(ctx, "u1") + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("want non-nil slice, got nil") + } + if len(got) != 0 { + t.Fatalf("want empty, got %d", len(got)) + } + }) + + t.Run("delete removes the row", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSnippetStore(testConn) + _ = s.Create(ctx, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}) + if err := s.Delete(ctx, "s1"); err != nil { + t.Fatal(err) + } + _, err := s.GetByID(ctx, "s1") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound after delete, got %v", err) + } + }) + + t.Run("delete unknown id returns ErrNotFound", func(t *testing.T) { + truncateAll(t) + s := NewSnippetStore(testConn) + err := s.Delete(ctx, "ghost") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + }) + + t.Run("ownership retrievable via UserID field on GetByID", func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + seedUser(t, "u2", "bob") + s := NewSnippetStore(testConn) + _ = s.Create(ctx, &models.Snippet{ID: "s1", Title: "t", Content: "c", UserID: "u1"}) + + got, err := s.GetByID(ctx, "s1") + if err != nil { + t.Fatal(err) + } + if got.UserID != "u1" { + t.Fatalf("want owner u1, got %s", got.UserID) + } + }) +} + +func TestSnippetStoreSQLInjection(t *testing.T) { + ctx := context.Background() + + injections := []string{ + `' OR '1'='1`, + `'; DROP TABLE snippets; --`, + `it's a "quote" and 'single'`, + } + + for i, payload := range injections { + t.Run(payload, func(t *testing.T) { + truncateAll(t) + seedUser(t, "u1", "alice") + s := NewSnippetStore(testConn) + + id := "inj" + string(rune('a'+i)) + if err := s.Create(ctx, &models.Snippet{ID: id, Title: payload, Content: payload, UserID: "u1"}); err != nil { + t.Fatalf("Create with injection payload failed: %v", err) + } + + got, err := s.GetByID(ctx, id) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Title != payload { + t.Fatalf("title round-trip failed:\nwant %q\ngot %q", payload, got.Title) + } + if got.Content != payload { + t.Fatalf("content round-trip failed:\nwant %q\ngot %q", payload, got.Content) + } + + var count int + if err := testConn.QueryRow(ctx, `SELECT COUNT(*) FROM snippets`).Scan(&count); err != nil { + t.Fatalf("snippets table missing or query failed: %v", err) + } + if count != 1 { + t.Fatalf("want 1 row, got %d (table may have been dropped/modified)", count) + } + }) + } +} diff --git a/internal/store/user_store.go b/internal/store/user_store.go index 615ce89..3714980 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -2,11 +2,14 @@ package store import ( "context" + "errors" "sync" "5803025015/w04/internal/models" + "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) type UserStore struct { @@ -34,7 +37,14 @@ func (s *UserStore) Create(ctx context.Context, user *models.User) error { query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)` _, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash) - return err + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return ErrDuplicate + } + return err + } + return nil } func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { @@ -45,6 +55,9 @@ func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models query := `SELECT id, username, password_hash FROM users WHERE username = $1` err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } return nil, err } return &u, nil @@ -58,6 +71,9 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error query := `SELECT id, username, password_hash FROM users WHERE id = $1` err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash) if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } return nil, err } return &u, nil diff --git a/internal/store/user_store_test.go b/internal/store/user_store_test.go new file mode 100644 index 0000000..3eac7a9 --- /dev/null +++ b/internal/store/user_store_test.go @@ -0,0 +1,130 @@ +package store + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + + "5803025015/w04/internal/auth" + "5803025015/w04/internal/models" +) + +func TestUserStore(t *testing.T) { + ctx := context.Background() + + t.Run("create then fetch by username", func(t *testing.T) { + truncateAll(t) + s := NewUserStore(testConn) + if err := s.Create(ctx, &models.User{ID: "u1", Username: "alice", PasswordHash: "h"}); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := s.GetByUsername(ctx, "alice") + if err != nil { + t.Fatalf("GetByUsername: %v", err) + } + if got.ID != "u1" || got.Username != "alice" { + t.Fatalf("unexpected user: %+v", got) + } + }) + + t.Run("fetch unknown username returns ErrNotFound", func(t *testing.T) { + truncateAll(t) + s := NewUserStore(testConn) + _, err := s.GetByUsername(ctx, "ghost") + if !errors.Is(err, ErrNotFound) { + t.Fatalf("want ErrNotFound, got %v", err) + } + }) + + t.Run("duplicate username returns ErrDuplicate from DB constraint", func(t *testing.T) { + truncateAll(t) + s := NewUserStore(testConn) + if err := s.Create(ctx, &models.User{ID: "u1", Username: "alice", PasswordHash: "h"}); err != nil { + t.Fatal(err) + } + err := s.Create(ctx, &models.User{ID: "u2", Username: "alice", PasswordHash: "h"}) + if !errors.Is(err, ErrDuplicate) { + t.Fatalf("want ErrDuplicate, got %v", err) + } + }) + + t.Run("usernames are case-sensitive", func(t *testing.T) { + truncateAll(t) + s := NewUserStore(testConn) + if err := s.Create(ctx, &models.User{ID: "u1", Username: "Alice", PasswordHash: "h"}); err != nil { + t.Fatal(err) + } + if err := s.Create(ctx, &models.User{ID: "u2", Username: "alice", PasswordHash: "h"}); err != nil { + t.Fatalf("second (lowercase) user should succeed: %v", err) + } + }) + + t.Run("stored hash differs from plaintext", func(t *testing.T) { + truncateAll(t) + s := NewUserStore(testConn) + plain := "secret123" + hash, err := auth.HashPassword(plain) + if err != nil { + t.Fatal(err) + } + if err := s.Create(ctx, &models.User{ID: "u1", Username: "alice", PasswordHash: hash}); err != nil { + t.Fatal(err) + } + got, err := s.GetByUsername(ctx, "alice") + if err != nil { + t.Fatal(err) + } + if got.PasswordHash == plain { + t.Fatal("stored hash equals plaintext!") + } + if got.PasswordHash != hash { + t.Fatal("stored hash was modified") + } + }) +} + +func TestUserStoreConcurrentRegister(t *testing.T) { + truncateAll(t) + s := NewUserStore(testConn) + + const N = 20 + var wg sync.WaitGroup + var successes, duplicates int32 + + for i := 0; i < N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + err := s.Create(context.Background(), &models.User{ + ID: fmt.Sprintf("u%d", i), + Username: "concurrent", + PasswordHash: "h", + }) + if err == nil { + atomic.AddInt32(&successes, 1) + } else if errors.Is(err, ErrDuplicate) { + atomic.AddInt32(&duplicates, 1) + } + }(i) + } + wg.Wait() + + if successes != 1 { + t.Fatalf("want exactly 1 success, got %d", successes) + } + if duplicates != N-1 { + t.Fatalf("want %d duplicates, got %d", N-1, duplicates) + } + + var count int + if err := testConn.QueryRow(context.Background(), + `SELECT COUNT(*) FROM users WHERE username = 'concurrent'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("want exactly 1 row, got %d", count) + } +} diff --git a/read.md b/read.md new file mode 100644 index 0000000..4454463 --- /dev/null +++ b/read.md @@ -0,0 +1,100 @@ +# Assignment 4: Testing the Snippet Sharing API + +## How to Run Tests + +### Unit tests (no database) + + go test ./internal/auth/... + go test ./internal/middleware/... + go test ./internal/handler/... + +### Store tests (require PostgreSQL) + +Store tests truncate tables. The database name MUST end with `_test`. +`TestMain` refuses to run otherwise. + + export DB_HOST=localhost + export DB_PORT=5432 + export DB_USER=postgres + export DB_PASSWORD=postgres + export DB_NAME=snippet_test + go test ./internal/store/... + +### E2E tests (require a running server) + +Start the server in one terminal: + + go run ./cmd/server + +Then run the e2e suite in another terminal: + + API_BASE_URL=http://localhost:8080 go test ./e2e/... + +### Full suite + + go vet ./... + go test -race -shuffle=on -count=2 ./... + +--- + +## Bugs My Tests Found + +The following defects were found in Assignment 3 code while writing tests for +Assignment 4, and were fixed. + +### 1. `POST /register` returned 400 for duplicate username (should be 409) +Found by `TestRegister/duplicate_username_->_409` and `TestE2E_RegisterDuplicate`. +Fix: `UserStore.Create` translates PostgreSQL SQLSTATE 23505 to `store.ErrDuplicate`; handler returns `409`. + +### 2. `POST /logout` returned 200 with JSON body (should be 204 empty) +Found by `TestLogout` and `TestE2E_LogoutInvalidatesToken`. +Fix: Handler writes `204 No Content` with empty body. + +### 3. `POST /logout` did not verify token exists +Found by `TestLogout/unknown_token_->_401`. +Fix: Handler calls `SessionStore.Get` first; unknown token → `401`. + +### 4. `GET /snippets` returned every user's snippets (mutant M5) +Found by `TestListSnippets` and `TestE2E_DataIsolation`. +Fix: Added `ListByOwner(ctx, ownerID)` and handler passes caller's id. + +### 5. `DELETE /snippets/{id}` had no ownership check (mutant M1) +Found by `TestDeleteSnippet/non-owner_->_403`. +Fix: Handler fetches snippet first, compares `UserID`, returns `403`. + +### 6. `DELETE /snippets/{id}` returned 204 for nonexistent id (mutant M2) +Found by `TestDeleteSnippet/nonexistent_id_->_404`. +Fix: Store checks `RowsAffected`, returns `ErrNotFound`, handler maps to `404`. + +### 7. Error responses used `http.Error` (text/plain, not JSON) (mutant M8) +Found by `TestRequireAuth` and `TestE2E_ContentTypeAndSchema`. +Fix: Added JSON error helpers setting `Content-Type: application/json`. + +### 8. `GET /snippets/{id}` required auth (should be public) (mutant M10) +Found by `TestGetSnippet` and `TestE2E_PublicRead`. +Fix: Removed middleware wrapper in `main.go`. + +### 9. Empty snippet list serialized as `null` (should be `[]`) (mutant M7) +Found by `TestListSnippets/no_snippets_->_body_is_literally_[]`. +Fix: Store and handler build non-nil slices. + +### 10. Snippet responses could include `user_id` (mutant M9) +Found by `TestE2E_ContentTypeAndSchema`. +Fix: Introduced `snippetResponse` DTO with only `id`, `title`, `content`. + +### 11. Malformed JSON returned 500 instead of 400 +Found by `TestE2E_BadInput`. +Fix: All decoders return `400` on JSON decode error. + +### 12. Login failures distinguishable (user enumeration) +Found by `TestE2E_LoginFailuresIndistinguishable`. +Fix: Same `401` body for wrong-password and unknown-user. + +--- + +## Refactor Summary + +Handlers and middleware now depend on interfaces defined in their own packages +(`UserStore`, `SessionStore`, `SnippetStore`, `SessionLookup`). Concrete types +in `internal/store` satisfy these interfaces without any import cycle. Public +API behavior is unchanged. \ No newline at end of file