From 004bf4e2921bc474156e95d2687707ccf2ce3206 Mon Sep 17 00:00:00 2001 From: Zagar Nashrullah Date: Sat, 3 Oct 2026 19:34:23 +0700 Subject: [PATCH] Assignment 4: tests, interface refactor, e2e suite --- .env.example | 11 + .gitignore | 3 +- README.md | 79 ++++ cmd/server/main.go | 2 +- e2e/api_test.go | 492 +++++++++++++++++++++++ internal/auth/password_test.go | 97 +++++ internal/auth/session.go | 7 +- internal/auth/session_test.go | 69 ++++ internal/handler/auth_handler.go | 93 +++-- internal/handler/auth_handler_test.go | 320 +++++++++++++++ internal/handler/fakes_test.go | 237 +++++++++++ internal/handler/interfaces.go | 28 ++ internal/handler/response.go | 17 + internal/handler/snippet_handler.go | 119 +++--- internal/handler/snippet_handler_test.go | 364 +++++++++++++++++ internal/middleware/require_auth.go | 70 ++-- internal/middleware/require_auth_test.go | 131 ++++++ internal/store/errors.go | 6 + internal/store/main_test.go | 75 ++++ internal/store/session_store.go | 4 + internal/store/session_store_test.go | 122 ++++++ internal/store/snippet_store.go | 39 +- internal/store/snippet_store_test.go | 224 +++++++++++ internal/store/user_store.go | 20 +- internal/store/user_store_test.go | 166 ++++++++ 25 files changed, 2644 insertions(+), 151 deletions(-) create mode 100644 README.md create mode 100644 e2e/api_test.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/session_test.go create mode 100644 internal/handler/auth_handler_test.go create mode 100644 internal/handler/fakes_test.go create mode 100644 internal/handler/interfaces.go create mode 100644 internal/handler/response.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.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 diff --git a/.env.example b/.env.example index e69de29..2263153 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,11 @@ +# Server config +PORT=8080 +ENVIRONMENT=dev + +# PostgreSQL (store tests need a database whose name ends in _test) +DB_HOST=localhost +DB_PORT=5432 +DB_USER= +DB_PASSWORD= +DB_NAME= +DB_SSLMODE=disable diff --git a/.gitignore b/.gitignore index 2eea525..0458c47 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +cover.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a06e62 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Snippet Sharing API + +A Go + PostgreSQL REST API for sharing code snippets (Assignment 3), with a full test suite (Assignment 4). + +**Name:** Zagar Nashrullah +**NRP:** 5803025048 + +Module: `5803025048/W04` + +## Run the server + +Set the database variables, then start the server. It listens on `PORT` (default `8080`). + +```powershell +$env:DB_HOST="..."; $env:DB_PORT="..." +$env:DB_USER="..."; $env:DB_PASSWORD="..."; $env:DB_NAME="..." +go run ./cmd/server/main.go +``` + +`PORT` is the HTTP port of the API. `DB_PORT` is the PostgreSQL port. + +## Run the tests + +Each layer has its own command: + +``` +go test ./internal/auth/... # unit tests +go test ./internal/middleware/... # unit tests, hand-written fake +go test ./internal/handler/... # unit tests, hand-written fakes +go test ./internal/store/... # integration tests, real PostgreSQL +go test ./e2e/... # black-box tests over HTTP +``` + +What each layer needs: + +- **auth, middleware, handler:** nothing. +- **store:** the `DB_*` variables, and `DB_NAME` **must end in `_test`**. +- **e2e:** a running server. It uses `API_BASE_URL` (default `http://localhost:8080`). + +To run everything the way the grader does (start the server first, because `./...` includes e2e): + +``` +go vet ./... +go test -race -shuffle=on -count=2 ./... +go test -coverpkg=./internal/... -coverprofile=cover.out ./internal/... +``` + +### Warning: store tests wipe the database + +The store tests `TRUNCATE` all tables at the start of every test. For safety, `TestMain` in `internal/store` refuses to run unless `DB_NAME` ends in `_test` (for example `w04_5803025048_test`). Never point them at a real database. + +If the `DB_*` variables are missing or the database is unreachable, the tests fail. They never skip. + +The e2e tests create their own unique usernames, so they work against a server that already has data. + +## Bugs My Tests Found + +Defects in my Assignment 3 code that these tests exposed, and how I fixed them: + +1. **Errors were not JSON.** Every 400/401/403/404/409/500 used `http.Error`, which sends `text/plain`. + *Fix:* a JSON error writer in the handlers and in the middleware. +2. **A token without the `Bearer` scheme was accepted.** The middleware and logout used the whole header as the token. + *Fix:* one strict `middleware.BearerToken` helper. A missing scheme, `Basic ...`, or an empty token now gives 401. +3. **Logout returned 204 for tokens that did not exist.** + *Fix:* logout checks that the session exists and returns 401 otherwise. +4. **"Not found" leaked the database driver.** Handlers compared against `pgx.ErrNoRows`, and `SnippetStore.Delete` returned `nil` for an unknown id. + *Fix:* added `store.ErrNotFound`, returned by every lookup. `Delete` returns it when no row was affected. +5. **Passwords longer than 72 bytes caused a 500 on `/register`** (bcrypt rejects them). + *Fix:* they now return 400. +6. **Username length was counted in bytes, not characters.** + *Fix:* it is now counted in runes. +7. **`GET /snippets` could return `null` for an empty list.** + *Fix:* the handler always returns `[]`, even if the store returns a nil slice. +8. **Any login lookup error was reported as 401.** + *Fix:* a real database failure is now 500. Only "user not found" and "wrong password" give the (identical) 401. + +## Refactor for testability + +No behavior change. Handlers depend on the `UserStore`, `SessionStore` and `SnippetStore` interfaces (package `handler`). The middleware depends on a small `SessionLookup` interface. The concrete types in `internal/store` satisfy them without importing `handler` or `middleware`. diff --git a/cmd/server/main.go b/cmd/server/main.go index 8f88f2e..bae8d83 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,7 +49,7 @@ 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))) port := os.Getenv("PORT") diff --git a/e2e/api_test.go b/e2e/api_test.go new file mode 100644 index 0000000..62b2898 --- /dev/null +++ b/e2e/api_test.go @@ -0,0 +1,492 @@ +package e2e + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "sort" + "strings" + "testing" + "time" +) + + +var baseURL = func() string { + if v := strings.TrimRight(os.Getenv("API_BASE_URL"), "/"); v != "" { + return v + } + return "http://localhost:8080" +}() + +var client = &http.Client{Timeout: 15 * time.Second} + +type response struct { + status int + header http.Header + body []byte +} + +func (r response) text() string { return string(r.body) } + +// send mengirim request mentah. Kalau server tidak terjangkau, tes GAGAL (tidak skip). +func send(t *testing.T, method, path, rawBody string, headers map[string]string) response { + t.Helper() + + var body io.Reader + if rawBody != "" { + body = strings.NewReader(rawBody) + } + req, err := http.NewRequest(method, baseURL+path, body) + if err != nil { + t.Fatalf("build request %s %s: %v", method, path, err) + } + if rawBody != "" { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + + res, err := client.Do(req) + if err != nil { + t.Fatalf("%s %s failed (is the server running at %s?): %v", method, path, baseURL, err) + } + defer res.Body.Close() + + data, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read response body of %s %s: %v", method, path, err) + } + return response{status: res.StatusCode, header: res.Header, body: data} +} + +func bearer(token string) map[string]string { + return map[string]string{"Authorization": "Bearer " + token} +} + +func jsonString(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} + +func randHex(t *testing.T, n int) string { + t.Helper() + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + t.Fatalf("random: %v", err) + } + return hex.EncodeToString(b) +} + +// uniqueName menghasilkan username unik per pemanggilan (3-32 karakter). +func uniqueName(t *testing.T) string { return "e2e_" + randHex(t, 8) } + +func uniquePassword(t *testing.T) string { return "Pw!" + randHex(t, 8) } + +func decodeObject(t *testing.T, r response) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(r.body, &m); err != nil { + t.Fatalf("body is not a JSON object: %v (status=%d body=%q)", err, r.status, r.text()) + } + return m +} + +func keysOf(m map[string]any) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + sort.Strings(ks) + return ks +} + +func requireExactKeys(t *testing.T, m map[string]any, want ...string) { + t.Helper() + got := keysOf(m) + sort.Strings(want) + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("keys = %v, want exactly %v", got, want) + } +} + +func requireStatus(t *testing.T, r response, want int) { + t.Helper() + if r.status != want { + t.Fatalf("status = %d, want %d (body=%q)", r.status, want, r.text()) + } +} + +func requireJSONContentType(t *testing.T, r response) { + t.Helper() + ct := strings.ToLower(r.header.Get("Content-Type")) + if !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json (status=%d)", ct, r.status) + } +} + +func register(t *testing.T, username, password string) response { + t.Helper() + return send(t, http.MethodPost, "/register", jsonString(t, map[string]string{ + "username": username, "password": password, + }), nil) +} + +func login(t *testing.T, username, password string) response { + t.Helper() + return send(t, http.MethodPost, "/login", jsonString(t, map[string]string{ + "username": username, "password": password, + }), nil) +} + +// newUser mendaftarkan user baru lalu login; mengembalikan username, password, token. +func newUser(t *testing.T) (username, password, token string) { + t.Helper() + username, password = uniqueName(t), uniquePassword(t) + requireStatus(t, register(t, username, password), http.StatusCreated) + + res := login(t, username, password) + requireStatus(t, res, http.StatusOK) + token, _ = decodeObject(t, res)["session_token"].(string) + if token == "" { + t.Fatalf("login did not return a session_token: %q", res.text()) + } + return username, password, token +} + +func createSnippet(t *testing.T, token, title, content string) (id string, res response) { + t.Helper() + res = send(t, http.MethodPost, "/snippets", jsonString(t, map[string]string{ + "title": title, "content": content, + }), bearer(token)) + if res.status == http.StatusCreated { + id, _ = decodeObject(t, res)["id"].(string) + } + return id, res +} + +func mustCreateSnippet(t *testing.T, token, title, content string) string { + t.Helper() + id, res := createSnippet(t, token, title, content) + requireStatus(t, res, http.StatusCreated) + if id == "" { + t.Fatalf("created snippet has no id: %q", res.text()) + } + return id +} + +func listSnippets(t *testing.T, token string) []map[string]any { + t.Helper() + res := send(t, http.MethodGet, "/snippets", "", bearer(token)) + requireStatus(t, res, http.StatusOK) + var items []map[string]any + if err := json.Unmarshal(res.body, &items); err != nil { + t.Fatalf("GET /snippets body is not a JSON array: %v (body=%q)", err, res.text()) + } + return items +} + +// --------------------------------------------------------------------------- +// Tes +// --------------------------------------------------------------------------- + +func TestE2E_RegisterDuplicate(t *testing.T) { + username, password := uniqueName(t), uniquePassword(t) + + t.Run("first registration returns 201", func(t *testing.T) { + requireStatus(t, register(t, username, password), http.StatusCreated) + }) + + t.Run("same username again returns 409", func(t *testing.T) { + requireStatus(t, register(t, username, password), http.StatusConflict) + }) +} + +func TestE2E_PasswordNeverLeaked(t *testing.T) { + username, password := uniqueName(t), uniquePassword(t) + + assertNoPassword := func(t *testing.T, r response) { + t.Helper() + if bytes.Contains(r.body, []byte(password)) { + t.Fatalf("response body contains the plaintext password: %q", r.text()) + } + var m map[string]any + if err := json.Unmarshal(r.body, &m); err == nil { + for k := range m { + if strings.Contains(strings.ToLower(k), "password") { + t.Fatalf("response has a password-like key %q", k) + } + } + } + } + + t.Run("register response", func(t *testing.T) { + res := register(t, username, password) + requireStatus(t, res, http.StatusCreated) + assertNoPassword(t, res) + }) + + t.Run("login response", func(t *testing.T) { + res := login(t, username, password) + requireStatus(t, res, http.StatusOK) + assertNoPassword(t, res) + }) +} + +func TestE2E_LoginFailuresIndistinguishable(t *testing.T) { + username, password := uniqueName(t), uniquePassword(t) + requireStatus(t, register(t, username, password), http.StatusCreated) + + var wrongPasswordBody []byte + + t.Run("wrong password returns 401", func(t *testing.T) { + res := login(t, username, password+"-wrong") + requireStatus(t, res, http.StatusUnauthorized) + wrongPasswordBody = res.body + }) + + t.Run("unknown username returns 401 with an identical body", func(t *testing.T) { + res := login(t, uniqueName(t), password) + requireStatus(t, res, http.StatusUnauthorized) + if !bytes.Equal(res.body, wrongPasswordBody) { + t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", string(wrongPasswordBody), res.text()) + } + }) +} + +func TestE2E_ProtectedEndpointsRequireToken(t *testing.T) { + validBody := jsonString(t, map[string]string{"title": "t", "content": "c"}) + + endpoints := []struct { + name string + method string + path string + body string + }{ + {"POST /snippets", http.MethodPost, "/snippets", validBody}, + {"GET /snippets", http.MethodGet, "/snippets", ""}, + {"DELETE /snippets/{id}", http.MethodDelete, "/snippets/some-id", ""}, + } + + for _, ep := range endpoints { + t.Run(ep.name+" without Authorization header", func(t *testing.T) { + requireStatus(t, send(t, ep.method, ep.path, ep.body, nil), http.StatusUnauthorized) + }) + t.Run(ep.name+" with a bogus bearer token", func(t *testing.T) { + requireStatus(t, send(t, ep.method, ep.path, ep.body, bearer("not-a-real-token")), http.StatusUnauthorized) + }) + } +} + +func TestE2E_SnippetLifecycle(t *testing.T) { + _, _, token := newUser(t) + title, content := "Lifecycle "+randHex(t, 4), "content-"+randHex(t, 4) + var id string + + t.Run("create returns 201 with an id", func(t *testing.T) { + var res response + id, res = createSnippet(t, token, title, content) + requireStatus(t, res, http.StatusCreated) + if id == "" { + t.Fatalf("no id in response: %q", res.text()) + } + }) + + t.Run("public GET returns the same snippet", func(t *testing.T) { + res := send(t, http.MethodGet, "/snippets/"+id, "", nil) + requireStatus(t, res, http.StatusOK) + m := decodeObject(t, res) + if m["id"] != id || m["title"] != title || m["content"] != content { + t.Fatalf("snippet mismatch: got %v", m) + } + }) + + t.Run("owner DELETE returns 204 with empty body", func(t *testing.T) { + res := send(t, http.MethodDelete, "/snippets/"+id, "", bearer(token)) + requireStatus(t, res, http.StatusNoContent) + if len(res.body) != 0 { + t.Fatalf("204 body must be empty, got %q", res.text()) + } + }) + + t.Run("GET after delete returns 404", func(t *testing.T) { + requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusNotFound) + }) +} + +func TestE2E_PublicRead(t *testing.T) { + _, _, tokenA := newUser(t) + title, content := "Public "+randHex(t, 4), "body-"+randHex(t, 4) + id := mustCreateSnippet(t, tokenA, title, content) + + t.Run("readable with no Authorization header", func(t *testing.T) { + requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusOK) + }) + + t.Run("returned fields match what the author stored", func(t *testing.T) { + res := send(t, http.MethodGet, "/snippets/"+id, "", nil) + requireStatus(t, res, http.StatusOK) + m := decodeObject(t, res) + if m["title"] != title || m["content"] != content { + t.Fatalf("got %v, want title=%q content=%q", m, title, content) + } + }) +} + +func TestE2E_LogoutInvalidatesToken(t *testing.T) { + _, _, token := newUser(t) + + t.Run("logout returns 204", func(t *testing.T) { + requireStatus(t, send(t, http.MethodPost, "/logout", "", bearer(token)), http.StatusNoContent) + }) + + t.Run("same token is rejected afterwards", func(t *testing.T) { + requireStatus(t, send(t, http.MethodGet, "/snippets", "", bearer(token)), http.StatusUnauthorized) + }) +} + +func TestE2E_DataIsolation(t *testing.T) { + _, _, tokenA := newUser(t) + _, _, tokenB := newUser(t) + idA := mustCreateSnippet(t, tokenA, "A "+randHex(t, 4), "content A") + idB := mustCreateSnippet(t, tokenB, "B "+randHex(t, 4), "content B") + + check := func(t *testing.T, token, wantID, otherID string) { + t.Helper() + items := listSnippets(t, token) + if len(items) != 1 { + t.Fatalf("list has %d snippets, want exactly 1", len(items)) + } + if items[0]["id"] != wantID { + t.Fatalf("list contains %v, want %s", items[0]["id"], wantID) + } + for _, it := range items { + if it["id"] == otherID { + t.Fatalf("list leaked another user's snippet %s", otherID) + } + } + } + + t.Run("user A sees only A's snippet", func(t *testing.T) { check(t, tokenA, idA, idB) }) + t.Run("user B sees only B's snippet", func(t *testing.T) { check(t, tokenB, idB, idA) }) +} + +func TestE2E_DeleteOwnership(t *testing.T) { + _, _, owner := newUser(t) + _, _, intruder := newUser(t) + id := mustCreateSnippet(t, owner, "Mine "+randHex(t, 4), "secret") + + t.Run("non-owner delete returns 403", func(t *testing.T) { + requireStatus(t, send(t, http.MethodDelete, "/snippets/"+id, "", bearer(intruder)), http.StatusForbidden) + }) + + t.Run("non-owner delete of a nonexistent id returns 404", func(t *testing.T) { + missing := "missing-" + randHex(t, 8) + requireStatus(t, send(t, http.MethodDelete, "/snippets/"+missing, "", bearer(intruder)), http.StatusNotFound) + }) + + t.Run("snippet is still readable after the failed deletes", func(t *testing.T) { + requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusOK) + }) +} + +func TestE2E_EmptyListIsArray(t *testing.T) { + _, _, token := newUser(t) + + t.Run("fresh user's list is exactly []", func(t *testing.T) { + res := send(t, http.MethodGet, "/snippets", "", bearer(token)) + requireStatus(t, res, http.StatusOK) + if got := strings.TrimSpace(res.text()); got != "[]" { + t.Fatalf("body = %q, want []", got) + } + }) +} + +func TestE2E_ContentTypeAndSchema(t *testing.T) { + _, _, owner := newUser(t) + _, _, other := newUser(t) + id := mustCreateSnippet(t, owner, "Schema "+randHex(t, 4), "content") + + t.Run("200 is JSON and snippet has exactly id, title, content", func(t *testing.T) { + res := send(t, http.MethodGet, "/snippets/"+id, "", nil) + requireStatus(t, res, http.StatusOK) + requireJSONContentType(t, res) + requireExactKeys(t, decodeObject(t, res), "id", "title", "content") + }) + + t.Run("201 is JSON and snippet has exactly id, title, content", func(t *testing.T) { + _, res := createSnippet(t, owner, "Another "+randHex(t, 4), "content") + requireStatus(t, res, http.StatusCreated) + requireJSONContentType(t, res) + requireExactKeys(t, decodeObject(t, res), "id", "title", "content") + }) + + t.Run("list elements have exactly id, title, content", func(t *testing.T) { + for _, it := range listSnippets(t, owner) { + requireExactKeys(t, it, "id", "title", "content") + } + }) + + t.Run("400 is JSON", func(t *testing.T) { + res := send(t, http.MethodPost, "/snippets", `{"title": "", "content": ""}`, bearer(owner)) + requireStatus(t, res, http.StatusBadRequest) + requireJSONContentType(t, res) + }) + + t.Run("401 is JSON", func(t *testing.T) { + res := send(t, http.MethodGet, "/snippets", "", nil) + requireStatus(t, res, http.StatusUnauthorized) + requireJSONContentType(t, res) + }) + + t.Run("403 is JSON", func(t *testing.T) { + res := send(t, http.MethodDelete, "/snippets/"+id, "", bearer(other)) + requireStatus(t, res, http.StatusForbidden) + requireJSONContentType(t, res) + }) + + t.Run("404 is JSON", func(t *testing.T) { + res := send(t, http.MethodGet, "/snippets/missing-"+randHex(t, 8), "", nil) + requireStatus(t, res, http.StatusNotFound) + requireJSONContentType(t, res) + }) +} + +func TestE2E_BadInput(t *testing.T) { + _, _, token := newUser(t) + + endpoints := []struct { + name string + path string + headers map[string]string + }{ + {"/register", "/register", nil}, + {"/login", "/login", nil}, + {"POST /snippets", "/snippets", bearer(token)}, + } + bodies := []struct { + name string + raw string + }{ + {"malformed JSON", `{"username": "abc", "password":`}, + {"empty body", ``}, + {"JSON array body", `["a", "b"]`}, + } + + for _, ep := range endpoints { + for _, b := range bodies { + t.Run(ep.name+" with "+b.name, func(t *testing.T) { + res := send(t, http.MethodPost, ep.path, b.raw, ep.headers) + requireStatus(t, res, http.StatusBadRequest) + }) + } + } +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..b90648d --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,97 @@ +package auth + +import ( + "strings" + "testing" +) + +func TestHashPassword(t *testing.T) { + const plain = "secret123" + + t.Run("hash never equals plaintext", func(t *testing.T) { + hash, err := HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + if hash == "" { + t.Fatal("hash must not be empty") + } + if hash == plain { + t.Fatal("hash must not equal the plaintext password") + } + if strings.Contains(hash, plain) { + t.Fatal("hash must not contain the plaintext password") + } + }) + + t.Run("same password hashed twice gives different hashes", func(t *testing.T) { + h1, err := HashPassword(plain) + if err != nil { + t.Fatalf("first HashPassword returned error: %v", err) + } + h2, err := HashPassword(plain) + if err != nil { + t.Fatalf("second HashPassword returned error: %v", err) + } + if h1 == h2 { + t.Fatal("two hashes of the same password must differ (salting)") + } + }) + + t.Run("hash verifies against original password", func(t *testing.T) { + hash, err := HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + if !CheckPassword(plain, hash) { + t.Fatal("CheckPassword must accept the original password") + } + }) + + t.Run("password longer than 72 bytes returns an error", func(t *testing.T) { + hash, err := HashPassword(strings.Repeat("a", 73)) + if err == nil { + t.Fatal("expected an error for a password longer than 72 bytes") + } + if hash != "" { + t.Fatalf("expected empty hash on error, got %q", hash) + } + }) +} + +func TestCheckPassword(t *testing.T) { + const plain = "secret123" + + hash, err := HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + + tests := []struct { + name string + password string + hash string + want bool + }{ + {name: "correct password", password: plain, hash: hash, want: true}, + {name: "wrong password", password: "wrong-password", hash: hash, want: false}, + {name: "empty password", password: "", hash: hash, want: false}, + {name: "garbage hash string", password: plain, hash: "this-is-not-a-bcrypt-hash", want: false}, + {name: "empty hash string", password: plain, hash: "", want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("CheckPassword panicked: %v", r) + } + }() + + got := CheckPassword(tc.password, tc.hash) + if got != tc.want { + t.Fatalf("CheckPassword(%q, hash) = %v, want %v", tc.password, got, tc.want) + } + }) + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go index 748c87a..ff24e53 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -3,11 +3,16 @@ package auth import ( "crypto/rand" "encoding/hex" + "io" ) func GenerateToken() (string, error) { + return generateToken(rand.Reader) +} + +func generateToken(r io.Reader) (string, error) { b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { + if _, err := io.ReadFull(r, b); err != nil { return "", err } return hex.EncodeToString(b), nil diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..7ed4206 --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,69 @@ +package auth + +import ( + "errors" + "strings" + "testing" + "unicode" +) + +func TestGenerateToken(t *testing.T) { + const n = 1000 + + tokens := make([]string, 0, n) + for i := 0; i < n; i++ { + tok, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken returned error on call %d: %v", i, err) + } + tokens = append(tokens, tok) + } + + t.Run("all tokens are non-empty", func(t *testing.T) { + for i, tok := range tokens { + if tok == "" { + t.Fatalf("token %d is empty", i) + } + } + }) + + t.Run("all tokens are distinct", func(t *testing.T) { + seen := make(map[string]struct{}, len(tokens)) + for i, tok := range tokens { + if _, dup := seen[tok]; dup { + t.Fatalf("token %d is a duplicate: %q", i, tok) + } + seen[tok] = struct{}{} + } + }) + + t.Run("no token contains whitespace", func(t *testing.T) { + for i, tok := range tokens { + if strings.IndexFunc(tok, unicode.IsSpace) >= 0 { + t.Fatalf("token %d contains whitespace: %q", i, tok) + } + } + }) + + t.Run("every token is at least 32 characters", func(t *testing.T) { + for i, tok := range tokens { + if len(tok) < 32 { + t.Fatalf("token %d has length %d, want >= 32", i, len(tok)) + } + } + }) +} + +type failingReader struct{} + +func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") } + +func TestGenerateTokenReadError(t *testing.T) { + tok, err := generateToken(failingReader{}) + if err == nil { + t.Fatal("expected an error when the random source fails") + } + if tok != "" { + t.Fatalf("expected empty token on error, got %q", tok) + } +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index ea94069..b4bb939 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -2,22 +2,28 @@ package handler import ( "encoding/json" + "errors" "net/http" "strings" "time" + "unicode/utf8" "5803025048/W04/internal/auth" + "5803025048/W04/internal/middleware" "5803025048/W04/internal/models" "5803025048/W04/internal/store" "github.com/google/uuid" ) +// bcrypt menolak password lebih dari 72 byte. +const maxPasswordBytes = 72 + type AuthHandler struct { - userStore *store.UserStore - sessionStore *store.SessionStore + userStore UserStore + sessionStore SessionStore } -func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler { +func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler { return &AuthHandler{ userStore: userStore, sessionStore: sessionStore, @@ -30,26 +36,22 @@ type authRequest struct { } func (h *AuthHandler) Register(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, "Bad Request") 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) + n := utf8.RuneCountInString(req.Username) + if n < 3 || n > 32 || req.Password == "" || len(req.Password) > maxPasswordBytes { + 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 Server Error") return } @@ -60,36 +62,41 @@ 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.ErrUsernameTaken) { + writeError(w, http.StatusConflict, "Username already exists") + return + } + writeError(w, http.StatusInternalServerError, "Internal Server Error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(user) + writeJSON(w, http.StatusCreated, user) } 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, "Bad Request") 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 { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusUnauthorized, "Invalid credentials") + return + } + writeError(w, http.StatusInternalServerError, "Internal Server Error") + return + } + if !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 Server Error") return } @@ -100,43 +107,33 @@ 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 Server Error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(session) + writeJSON(w, http.StatusOK, map[string]string{"session_token": session.Token}) } func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + token, ok := middleware.BearerToken(r) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") return } - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - 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 { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + writeError(w, http.StatusInternalServerError, "Internal Server Error") return } if err := h.sessionStore.Delete(r.Context(), token); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "Internal Server 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) } diff --git a/internal/handler/auth_handler_test.go b/internal/handler/auth_handler_test.go new file mode 100644 index 0000000..d2cb455 --- /dev/null +++ b/internal/handler/auth_handler_test.go @@ -0,0 +1,320 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "5803025048/W04/internal/auth" + "5803025048/W04/internal/models" +) + +func credsBody(username, password string) string { + b, _ := json.Marshal(map[string]string{"username": username, "password": password}) + return string(b) +} + +func TestRegister(t *testing.T) { + const password = "s3cret-pass-xyz" + + newHandler := func() (*AuthHandler, *fakeUserStore) { + us := newFakeUserStore() + return NewAuthHandler(us, newFakeSessionStore()), us + } + register := func(h *AuthHandler, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.Register(rec, newJSONRequest(http.MethodPost, "/register", body)) + return rec + } + + t.Run("success returns 201 with id and username", func(t *testing.T) { + h, _ := newHandler() + rec := register(h, credsBody("alice", password)) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201", rec.Code) + } + assertJSONContentType(t, rec) + m := decodeMap(t, rec.Body.Bytes()) + if id, ok := m["id"].(string); !ok || id == "" { + t.Fatalf("id must be a non-empty string, got %v", m["id"]) + } + if m["username"] != "alice" { + t.Fatalf("username = %v, want alice", m["username"]) + } + }) + + t.Run("duplicate username returns 409", func(t *testing.T) { + h, _ := newHandler() + if rec := register(h, credsBody("alice", password)); rec.Code != http.StatusCreated { + t.Fatalf("first register status = %d, want 201", rec.Code) + } + rec := register(h, credsBody("alice", password)) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", rec.Code) + } + assertJSONContentType(t, rec) + }) + + badCases := []struct { + name string + body string + }{ + {"empty username returns 400", credsBody("", password)}, + {"empty password returns 400", credsBody("alice", "")}, + {"username of 2 characters returns 400", credsBody("ab", password)}, + {"username of 33 characters returns 400", credsBody(strings.Repeat("a", 33), password)}, + {"malformed JSON returns 400", `{"username": "alice", "password":`}, + {"empty body returns 400", ``}, + {"JSON array body returns 400", `["alice","pw"]`}, + {"password longer than bcrypt limit returns 400", credsBody("alice", strings.Repeat("p", 73))}, + } + for _, tc := range badCases { + t.Run(tc.name, func(t *testing.T) { + h, us := newHandler() + rec := register(h, tc.body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertJSONContentType(t, rec) + if len(us.users) != 0 { + t.Fatal("no user must be stored for an invalid request") + } + }) + } + + t.Run("usernames of exactly 3 and 32 characters are accepted", func(t *testing.T) { + h, _ := newHandler() + for _, name := range []string{"abc", strings.Repeat("b", 32)} { + if rec := register(h, credsBody(name, password)); rec.Code != http.StatusCreated { + t.Fatalf("username %q: status = %d, want 201", name, rec.Code) + } + } + }) + + t.Run("success body contains no password material", func(t *testing.T) { + h, _ := newHandler() + rec := register(h, credsBody("alice", password)) + m := decodeMap(t, rec.Body.Bytes()) + for k := range m { + if strings.Contains(strings.ToLower(k), "password") { + t.Fatalf("response must not contain a password-like key, found %q", k) + } + } + if strings.Contains(rec.Body.String(), password) { + t.Fatal("response must not contain the plaintext password") + } + }) + + t.Run("store failure returns 500", func(t *testing.T) { + h, us := newHandler() + us.createErr = errors.New("db down") + rec := register(h, credsBody("alice", password)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + assertJSONContentType(t, rec) + }) +} + +func TestLogin(t *testing.T) { + const username = "alice" + const password = "correct-horse" + + setup := func(t *testing.T) (*AuthHandler, *fakeUserStore, *fakeSessionStore) { + t.Helper() + us, ss := newFakeUserStore(), newFakeSessionStore() + hash, err := auth.HashPassword(password) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := us.Create(context.Background(), &models.User{ID: "u1", Username: username, PasswordHash: hash}); err != nil { + t.Fatalf("seed user: %v", err) + } + return NewAuthHandler(us, ss), us, ss + } + login := func(h *AuthHandler, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.Login(rec, newJSONRequest(http.MethodPost, "/login", body)) + return rec + } + + t.Run("success returns 200 with session_token", func(t *testing.T) { + h, _, ss := setup(t) + rec := login(h, credsBody(username, password)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + assertJSONContentType(t, rec) + m := decodeMap(t, rec.Body.Bytes()) + tok, ok := m["session_token"].(string) + if !ok || tok == "" { + t.Fatalf("session_token must be a non-empty string, got %v", m["session_token"]) + } + if !ss.has(tok) { + t.Fatal("the returned token must be stored in the session store") + } + }) + + t.Run("wrong password returns 401", func(t *testing.T) { + h, _, _ := setup(t) + rec := login(h, credsBody(username, "wrong-password")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("unknown username returns 401", func(t *testing.T) { + h, _, _ := setup(t) + rec := login(h, credsBody("nobody", password)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("401 bodies for wrong password and unknown user are identical", func(t *testing.T) { + h, _, _ := setup(t) + wrong := login(h, credsBody(username, "wrong-password")) + unknown := login(h, credsBody("nobody", password)) + if wrong.Code != http.StatusUnauthorized || unknown.Code != http.StatusUnauthorized { + t.Fatalf("statuses = %d and %d, want 401 and 401", wrong.Code, unknown.Code) + } + if wrong.Body.String() != unknown.Body.String() { + t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", wrong.Body.String(), unknown.Body.String()) + } + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + h, _, _ := setup(t) + rec := login(h, `{"username":`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty body and JSON array return 400", func(t *testing.T) { + h, _, _ := setup(t) + for _, body := range []string{``, `[1,2,3]`} { + if rec := login(h, body); rec.Code != http.StatusBadRequest { + t.Fatalf("body %q: status = %d, want 400", body, rec.Code) + } + } + }) + + t.Run("user store failure returns 500", func(t *testing.T) { + h, us, _ := setup(t) + us.getErr = errors.New("db down") + rec := login(h, credsBody(username, password)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) + + t.Run("session store failure returns 500", func(t *testing.T) { + h, _, ss := setup(t) + ss.createErr = errors.New("db down") + rec := login(h, credsBody(username, password)) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} + +func TestLogout(t *testing.T) { + setup := func() (*AuthHandler, *fakeSessionStore) { + ss := newFakeSessionStore() + ss.sessions["tok-valid"] = &models.Session{Token: "tok-valid", UserID: "u1"} + ss.sessions["tok-other"] = &models.Session{Token: "tok-other", UserID: "u2"} + return NewAuthHandler(newFakeUserStore(), ss), ss + } + logout := func(h *AuthHandler, authz string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + if authz != "" { + req.Header.Set("Authorization", authz) + } + rec := httptest.NewRecorder() + h.Logout(rec, req) + return rec + } + + t.Run("valid token returns 204 and removes the session", func(t *testing.T) { + h, ss := setup() + rec := logout(h, "Bearer tok-valid") + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body must be empty, got %q", rec.Body.String()) + } + if ss.has("tok-valid") { + t.Fatal("session store must no longer contain the token") + } + if !ss.has("tok-other") { + t.Fatal("other sessions must not be deleted") + } + }) + + t.Run("missing token returns 401", func(t *testing.T) { + h, _ := setup() + rec := logout(h, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("unknown token returns 401", func(t *testing.T) { + h, _ := setup() + rec := logout(h, "Bearer not-a-real-token") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("second logout with the same token returns 401", func(t *testing.T) { + h, _ := setup() + if rec := logout(h, "Bearer tok-valid"); rec.Code != http.StatusNoContent { + t.Fatalf("first logout status = %d, want 204", rec.Code) + } + rec := logout(h, "Bearer tok-valid") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("second logout status = %d, want 401", rec.Code) + } + }) + + t.Run("Basic scheme returns 401 and keeps the session", func(t *testing.T) { + h, ss := setup() + rec := logout(h, "Basic tok-valid") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if !ss.has("tok-valid") { + t.Fatal("session must not be deleted by a non-Bearer header") + } + }) + + t.Run("session lookup failure returns 500", func(t *testing.T) { + h, ss := setup() + ss.getErr = errors.New("db down") + rec := logout(h, "Bearer tok-valid") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) + + t.Run("session delete failure returns 500", func(t *testing.T) { + h, ss := setup() + ss.deleteErr = errors.New("db down") + rec := logout(h, "Bearer tok-valid") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} diff --git a/internal/handler/fakes_test.go b/internal/handler/fakes_test.go new file mode 100644 index 0000000..b22496f --- /dev/null +++ b/internal/handler/fakes_test.go @@ -0,0 +1,237 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + "sync" + "testing" + + "5803025048/W04/internal/middleware" + "5803025048/W04/internal/models" + "5803025048/W04/internal/store" +) + +// ---------- fake stores (in-memory, buatan tangan) ---------- + +type fakeUserStore struct { + mu sync.Mutex + users map[string]*models.User // key: username + createErr error + getErr error +} + +func newFakeUserStore() *fakeUserStore { + return &fakeUserStore{users: map[string]*models.User{}} +} + +func (f *fakeUserStore) Create(_ context.Context, user *models.User) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + if _, exists := f.users[user.Username]; exists { + return store.ErrUsernameTaken + } + cp := *user + f.users[user.Username] = &cp + return nil +} + +func (f *fakeUserStore) GetByUsername(_ 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 +} + +type fakeSessionStore struct { + mu sync.Mutex + sessions map[string]*models.Session // key: token + createErr error + getErr error + deleteErr error +} + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{sessions: map[string]*models.Session{}} +} + +func (f *fakeSessionStore) Create(_ context.Context, session *models.Session) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + cp := *session + f.sessions[session.Token] = &cp + return nil +} + +func (f *fakeSessionStore) Get(_ 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(_ context.Context, token string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.sessions, token) + return nil +} + +func (f *fakeSessionStore) has(token string) bool { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.sessions[token] + return ok +} + +type fakeSnippetStore struct { + mu sync.Mutex + snippets map[string]*models.Snippet // key: id + createErr error + getErr error + listErr error + deleteErr error +} + +func newFakeSnippetStore() *fakeSnippetStore { + return &fakeSnippetStore{snippets: map[string]*models.Snippet{}} +} + +func (f *fakeSnippetStore) seed(id, title, content, userID string) { + f.mu.Lock() + defer f.mu.Unlock() + f.snippets[id] = &models.Snippet{ID: id, Title: title, Content: content, UserID: userID} +} + +func (f *fakeSnippetStore) Create(_ context.Context, snippet *models.Snippet) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + cp := *snippet + f.snippets[snippet.ID] = &cp + return nil +} + +func (f *fakeSnippetStore) GetByID(_ 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 +} + +// GetByUserID sengaja mengembalikan nil (bukan slice kosong) jika tidak ada hasil, +// supaya handler terbukti menghasilkan "[]" dan bukan "null". +func (f *fakeSnippetStore) GetByUserID(_ context.Context, userID string) ([]*models.Snippet, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.listErr != nil { + return nil, f.listErr + } + var out []*models.Snippet + for _, s := range f.snippets { + if s.UserID == userID { + cp := *s + out = append(out, &cp) + } + } + return out, nil +} + +func (f *fakeSnippetStore) Delete(_ 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) has(id string) bool { + f.mu.Lock() + defer f.mu.Unlock() + _, ok := f.snippets[id] + return ok +} + +// ---------- helper tes ---------- + +func newJSONRequest(method, target, body string) *http.Request { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func withUser(req *http.Request, userID string) *http.Request { + return req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, userID)) +} + +func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } +} + +func decodeMap(t *testing.T, raw []byte) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("body is not a JSON object: %v (body=%q)", err, string(raw)) + } + return m +} + +func assertKeys(t *testing.T, m map[string]any, want ...string) { + t.Helper() + got := make([]string, 0, len(m)) + for k := range m { + got = append(got, k) + } + sort.Strings(got) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("keys = %v, want exactly %v", got, want) + } +} + +func errNotFoundForTest() error { return store.ErrNotFound } diff --git a/internal/handler/interfaces.go b/internal/handler/interfaces.go new file mode 100644 index 0000000..ab8bf9f --- /dev/null +++ b/internal/handler/interfaces.go @@ -0,0 +1,28 @@ +package handler + +import ( + "context" + + "5803025048/W04/internal/models" +) + +// Interface didefinisikan di package consumer (handler), bukan di store. +// Tipe konkret di package store memenuhi interface ini tanpa meng-import handler. + +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, snippet *models.Snippet) error + GetByID(ctx context.Context, id string) (*models.Snippet, error) + GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) + Delete(ctx context.Context, id string) error +} diff --git a/internal/handler/response.go b/internal/handler/response.go new file mode 100644 index 0000000..2ac82c8 --- /dev/null +++ b/internal/handler/response.go @@ -0,0 +1,17 @@ +package handler + +import ( + "encoding/json" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +// writeError selalu mengirim error sebagai JSON (Content-Type: application/json). +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/handler/snippet_handler.go b/internal/handler/snippet_handler.go index 3cec71c..9d598a5 100644 --- a/internal/handler/snippet_handler.go +++ b/internal/handler/snippet_handler.go @@ -10,14 +10,13 @@ import ( "5803025048/W04/internal/models" "5803025048/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,50 +25,32 @@ type snippetRequest struct { Content string `json:"content"` } -func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - h.List(w, r) - case http.MethodPost: - h.Create(w, r) - default: - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - } -} - -func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - h.Get(w, r) - case http.MethodDelete: - h.Delete(w, r) - default: - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) +func snippetID(r *http.Request) string { + id := r.PathValue("id") + if id == "" { + path := strings.TrimPrefix(r.URL.Path, "/snippets/") + id = strings.TrimSuffix(path, "/") } + return id } 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, "Bad Request") 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,83 +62,87 @@ 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 Server Error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _ = json.NewEncoder(w).Encode(snippet) + writeJSON(w, http.StatusCreated, 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.GetByUserID(r.Context(), userID) if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + writeError(w, http.StatusInternalServerError, "Internal Server Error") return } + if snippets == nil { + // Pastikan selalu "[]", bukan "null". + snippets = []*models.Snippet{} + } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(snippets) + writeJSON(w, http.StatusOK, snippets) } 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 := snippetID(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, "Bad Request") 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 Server Error") return } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(snippet) + writeJSON(w, http.StatusOK, 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 := snippetID(r) if id == "" { - path := strings.TrimPrefix(r.URL.Path, "/snippets/") - id = strings.TrimSuffix(path, "/") + writeError(w, http.StatusBadRequest, "Bad Request") + 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 Server 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 Server Error") return } diff --git a/internal/handler/snippet_handler_test.go b/internal/handler/snippet_handler_test.go new file mode 100644 index 0000000..0231621 --- /dev/null +++ b/internal/handler/snippet_handler_test.go @@ -0,0 +1,364 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func snippetBody(title, content string) string { + b, _ := json.Marshal(map[string]string{"title": title, "content": content}) + return string(b) +} + +func TestCreateSnippet(t *testing.T) { + create := func(h *SnippetHandler, userID, body string) *httptest.ResponseRecorder { + req := newJSONRequest(http.MethodPost, "/snippets", body) + if userID != "" { + req = withUser(req, userID) + } + rec := httptest.NewRecorder() + h.Create(rec, req) + return rec + } + + t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) { + fs := newFakeSnippetStore() + h := NewSnippetHandler(fs) + rec := create(h, "u1", snippetBody("My title", "console.log('hi')")) + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201", rec.Code) + } + assertJSONContentType(t, rec) + m := decodeMap(t, rec.Body.Bytes()) + assertKeys(t, m, "id", "title", "content") + id, _ := m["id"].(string) + if id == "" { + t.Fatal("id must be a non-empty string") + } + if m["title"] != "My title" || m["content"] != "console.log('hi')" { + t.Fatalf("title/content not echoed correctly: %v", m) + } + if !fs.has(id) { + t.Fatal("snippet must be stored") + } + if fs.snippets[id].UserID != "u1" { + t.Fatalf("stored owner = %q, want u1", fs.snippets[id].UserID) + } + }) + + t.Run("empty title returns 400", func(t *testing.T) { + h := NewSnippetHandler(newFakeSnippetStore()) + rec := create(h, "u1", snippetBody("", "content")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty content returns 400", func(t *testing.T) { + h := NewSnippetHandler(newFakeSnippetStore()) + rec := create(h, "u1", snippetBody("title", "")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + h := NewSnippetHandler(newFakeSnippetStore()) + rec := create(h, "u1", `{"title": "x", "content":`) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty body and JSON array return 400", func(t *testing.T) { + h := NewSnippetHandler(newFakeSnippetStore()) + for _, body := range []string{``, `["a","b"]`} { + if rec := create(h, "u1", body); rec.Code != http.StatusBadRequest { + t.Fatalf("body %q: status = %d, want 400", body, rec.Code) + } + } + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + fs := newFakeSnippetStore() + h := NewSnippetHandler(fs) + rec := create(h, "", snippetBody("title", "content")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + if len(fs.snippets) != 0 { + t.Fatal("nothing must be stored for an unauthenticated request") + } + }) + + t.Run("store failure returns 500", func(t *testing.T) { + fs := newFakeSnippetStore() + fs.createErr = errors.New("db down") + h := NewSnippetHandler(fs) + rec := create(h, "u1", snippetBody("title", "content")) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} + +func TestGetSnippet(t *testing.T) { + setup := func() (*SnippetHandler, *fakeSnippetStore) { + fs := newFakeSnippetStore() + fs.seed("s1", "Title One", "Content One", "u1") + return NewSnippetHandler(fs), fs + } + get := func(h *SnippetHandler, target string) *httptest.ResponseRecorder { + // Sengaja tanpa header Authorization sama sekali. + rec := httptest.NewRecorder() + h.Get(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec + } + + t.Run("existing id returns 200 without Authorization header", func(t *testing.T) { + h, _ := setup() + rec := get(h, "/snippets/s1") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + assertJSONContentType(t, rec) + m := decodeMap(t, rec.Body.Bytes()) + if m["id"] != "s1" || m["title"] != "Title One" || m["content"] != "Content One" { + t.Fatalf("unexpected body: %v", m) + } + }) + + t.Run("response has exactly the keys id, title, content", func(t *testing.T) { + h, _ := setup() + rec := get(h, "/snippets/s1") + assertKeys(t, decodeMap(t, rec.Body.Bytes()), "id", "title", "content") + }) + + t.Run("missing id returns 404", func(t *testing.T) { + h, _ := setup() + rec := get(h, "/snippets/does-not-exist") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty id returns 400", func(t *testing.T) { + h, _ := setup() + rec := get(h, "/snippets/") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + }) + + t.Run("store failure returns 500", func(t *testing.T) { + h, fs := setup() + fs.getErr = errors.New("db down") + rec := get(h, "/snippets/s1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} + +func TestListSnippets(t *testing.T) { + setup := func() (*SnippetHandler, *fakeSnippetStore) { + fs := newFakeSnippetStore() + fs.seed("a1", "A one", "content a1", "alice") + fs.seed("a2", "A two", "content a2", "alice") + fs.seed("b1", "B one", "content b1", "bob") + return NewSnippetHandler(fs), fs + } + list := func(h *SnippetHandler, userID string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "/snippets", nil) + if userID != "" { + req = withUser(req, userID) + } + rec := httptest.NewRecorder() + h.List(rec, req) + return rec + } + + t.Run("only the caller's snippets are returned", func(t *testing.T) { + h, _ := setup() + rec := list(h, "alice") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + assertJSONContentType(t, rec) + var items []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("body is not a JSON array: %v", err) + } + if len(items) != 2 { + t.Fatalf("got %d snippets, want 2", len(items)) + } + for _, it := range items { + if id := it["id"]; id != "a1" && id != "a2" { + t.Fatalf("snippet %v does not belong to the caller", id) + } + } + }) + + t.Run("no snippets returns literally []", func(t *testing.T) { + h, _ := setup() + rec := list(h, "carol") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + assertJSONContentType(t, rec) + if got := strings.TrimSpace(rec.Body.String()); got != "[]" { + t.Fatalf("body = %q, want []", got) + } + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + h, _ := setup() + rec := list(h, "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("each element has exactly the three keys", func(t *testing.T) { + h, _ := setup() + rec := list(h, "alice") + var items []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("body is not a JSON array: %v", err) + } + if len(items) == 0 { + t.Fatal("expected at least one element") + } + for _, it := range items { + assertKeys(t, it, "id", "title", "content") + } + }) + + t.Run("store failure returns 500", func(t *testing.T) { + h, fs := setup() + fs.listErr = errors.New("db down") + rec := list(h, "alice") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) +} + +func TestDeleteSnippet(t *testing.T) { + setup := func() (*SnippetHandler, *fakeSnippetStore) { + fs := newFakeSnippetStore() + fs.seed("s1", "Title", "Content", "owner") + return NewSnippetHandler(fs), fs + } + del := func(h *SnippetHandler, userID, id string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodDelete, "/snippets/"+id, nil) + if userID != "" { + req = withUser(req, userID) + } + rec := httptest.NewRecorder() + h.Delete(rec, req) + return rec + } + + t.Run("owner gets 204 with empty body and snippet is gone", func(t *testing.T) { + h, fs := setup() + rec := del(h, "owner", "s1") + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204", rec.Code) + } + if rec.Body.Len() != 0 { + t.Fatalf("body must be empty, got %q", rec.Body.String()) + } + if fs.has("s1") { + t.Fatal("snippet must be removed from the store") + } + }) + + t.Run("non-owner gets 403 and snippet remains", func(t *testing.T) { + h, fs := setup() + rec := del(h, "intruder", "s1") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + assertJSONContentType(t, rec) + if !fs.has("s1") { + t.Fatal("snippet must still exist after a forbidden delete") + } + }) + + t.Run("nonexistent id returns 404", func(t *testing.T) { + h, _ := setup() + rec := del(h, "owner", "nope") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + h, fs := setup() + rec := del(h, "", "s1") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertJSONContentType(t, rec) + if !fs.has("s1") { + t.Fatal("snippet must still exist") + } + }) + + t.Run("non-owner on a nonexistent id returns 404 not 403", func(t *testing.T) { + h, _ := setup() + rec := del(h, "intruder", "nope") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + }) + + t.Run("empty id returns 400", func(t *testing.T) { + h, _ := setup() + req := withUser(httptest.NewRequest(http.MethodDelete, "/snippets/", nil), "owner") + rec := httptest.NewRecorder() + h.Delete(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + }) + + t.Run("lookup failure returns 500", func(t *testing.T) { + h, fs := setup() + fs.getErr = errors.New("db down") + rec := del(h, "owner", "s1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) + + t.Run("delete failure returns 500", func(t *testing.T) { + h, fs := setup() + fs.deleteErr = errors.New("db down") + rec := del(h, "owner", "s1") + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + }) + + t.Run("snippet removed between lookup and delete returns 404", func(t *testing.T) { + h, fs := setup() + fs.deleteErr = errNotFoundForTest() + rec := del(h, "owner", "s1") + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + }) +} diff --git a/internal/middleware/require_auth.go b/internal/middleware/require_auth.go index fe3265f..9a1a9cc 100644 --- a/internal/middleware/require_auth.go +++ b/internal/middleware/require_auth.go @@ -5,13 +5,14 @@ import ( "net/http" "strings" - "5803025048/W04/internal/store" + "5803025048/W04/internal/models" ) type contextKey string const UserIDKey contextKey = "user_id" +// GetUserID mengambil user id yang disimpan RequireAuth di context request. func GetUserID(ctx context.Context) string { if val, ok := ctx.Value(UserIDKey).(string); ok { return val @@ -19,35 +20,54 @@ func GetUserID(ctx context.Context) string { return "" } -type AuthMiddleware struct { - sessionStore *store.SessionStore +// SessionLookup adalah interface kecil yang dibutuhkan middleware: +// mencari session (dan user id-nya) dari sebuah token. +// *store.SessionStore memenuhi interface ini tanpa perubahan. +type SessionLookup interface { + Get(ctx context.Context, token string) (*models.Session, error) } -func NewAuthMiddleware(sessionStore *store.SessionStore) *AuthMiddleware { - return &AuthMiddleware{sessionStore: sessionStore} +type AuthMiddleware struct { + sessions SessionLookup +} + +func NewAuthMiddleware(sessions SessionLookup) *AuthMiddleware { + return &AuthMiddleware{sessions: sessions} +} + +const bearerPrefix = "Bearer " + +// BearerToken mengambil token dari header "Authorization: Bearer ". +// Mengembalikan false jika header tidak ada, skema bukan Bearer, atau token kosong. +func BearerToken(r *http.Request) (string, bool) { + h := r.Header.Get("Authorization") + if len(h) <= len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) { + return "", false + } + token := strings.TrimSpace(h[len(bearerPrefix):]) + if token == "" { + return "", false + } + return token, true +} + +func unauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"Unauthorized"}` + "\n")) } 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) + token, ok := BearerToken(r) + if !ok { + unauthorized(w) 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) - return - } - - session, err := m.sessionStore.Get(r.Context(), token) + session, err := m.sessions.Get(r.Context(), token) if err != nil || session == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + unauthorized(w) return } @@ -55,13 +75,3 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { next.ServeHTTP(w, r.WithContext(ctx)) }) } - -func (m *AuthMiddleware) RequireAuthFunc(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - m.RequireAuth(next).ServeHTTP(w, r) - } -} - -func RequireAuth(sessionStore *store.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..4323233 --- /dev/null +++ b/internal/middleware/require_auth_test.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "5803025048/W04/internal/models" +) + +// fakeLookup adalah fake SessionLookup buatan tangan. +type fakeLookup struct { + sessions map[string]string // token -> user id + nilToken string // token yang mengembalikan (nil, nil) +} + +func (f *fakeLookup) Get(_ context.Context, token string) (*models.Session, error) { + if token == f.nilToken && token != "" { + return nil, nil + } + uid, ok := f.sessions[token] + if !ok { + return nil, errors.New("session not found") + } + return &models.Session{Token: token, UserID: uid}, nil +} + +func TestRequireAuth(t *testing.T) { + lookup := &fakeLookup{ + sessions: map[string]string{"valid-token": "user-42"}, + nilToken: "nil-session-token", + } + + // run menjalankan middleware dan mencatat apakah next dipanggil + // (lewat closure, bukan dari status code). + run := func(authz string, setHeader bool) (rec *httptest.ResponseRecorder, called bool, gotUserID string) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + gotUserID = GetUserID(r.Context()) + w.WriteHeader(http.StatusOK) + }) + h := NewAuthMiddleware(lookup).RequireAuth(next) + + req := httptest.NewRequest(http.MethodGet, "/snippets", nil) + if setHeader { + req.Header.Set("Authorization", authz) + } + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec, called, gotUserID + } + + assert401 := func(t *testing.T, rec *httptest.ResponseRecorder, called bool) { + t.Helper() + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if called { + t.Fatal("next handler must not be called") + } + } + + t.Run("no Authorization header returns 401", func(t *testing.T) { + rec, called, _ := run("", false) + assert401(t, rec, called) + }) + + t.Run("Basic scheme returns 401", func(t *testing.T) { + rec, called, _ := run("Basic abc", true) + assert401(t, rec, called) + }) + + t.Run("Bearer with empty token returns 401", func(t *testing.T) { + rec, called, _ := run("Bearer ", true) + assert401(t, rec, called) + }) + + t.Run("Bearer with only spaces returns 401", func(t *testing.T) { + rec, called, _ := run("Bearer ", true) + assert401(t, rec, called) + }) + + t.Run("bare token without scheme returns 401", func(t *testing.T) { + rec, called, _ := run("valid-token", true) + assert401(t, rec, called) + }) + + t.Run("unknown token returns 401", func(t *testing.T) { + rec, called, _ := run("Bearer does-not-exist", true) + assert401(t, rec, called) + }) + + t.Run("lookup returning nil session returns 401", func(t *testing.T) { + rec, called, _ := run("Bearer nil-session-token", true) + assert401(t, rec, called) + }) + + t.Run("valid token calls next with user id in context", func(t *testing.T) { + rec, called, uid := run("Bearer valid-token", true) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !called { + t.Fatal("next handler must be called for a valid token") + } + if uid != "user-42" { + t.Fatalf("user id in context = %q, want %q", uid, "user-42") + } + }) + + t.Run("401 response is JSON with parsable body", func(t *testing.T) { + rec, _, _ := run("Bearer does-not-exist", true) + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Fatalf("Content-Type = %q, want application/json", ct) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("401 body is not valid JSON: %v (body=%q)", err, rec.Body.String()) + } + }) + + t.Run("GetUserID without value returns empty string", func(t *testing.T) { + if got := GetUserID(context.Background()); got != "" { + t.Fatalf("GetUserID = %q, want empty string", got) + } + }) +} diff --git a/internal/store/errors.go b/internal/store/errors.go new file mode 100644 index 0000000..29e9bb0 --- /dev/null +++ b/internal/store/errors.go @@ -0,0 +1,6 @@ +package store + +import "errors" + +// ErrNotFound dikembalikan saat data yang dicari (user, session, snippet) tidak ada. +var ErrNotFound = errors.New("not found") diff --git a/internal/store/main_test.go b/internal/store/main_test.go new file mode 100644 index 0000000..6cee1d1 --- /dev/null +++ b/internal/store/main_test.go @@ -0,0 +1,75 @@ +package store + +import ( + "context" + "log" + "os" + "strings" + "sync" + "testing" + + "5803025048/W04/internal/database" + "5803025048/W04/internal/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// testConn dipakai bersama oleh seluruh tes di package ini. +// Tes berjalan berurutan (tanpa t.Parallel), jadi aman. +var testConn *pgx.Conn + +func TestMain(m *testing.M) { + // Safety guard: tes ini men-TRUNCATE semua tabel. + name := os.Getenv("DB_NAME") + if !strings.HasSuffix(name, "_test") { + log.Fatalf("refusing to run store tests: DB_NAME must end in _test (got %q)", name) + } + + // Tidak boleh skip: kalau DB tidak ada / tidak terjangkau, tes GAGAL. + conn, err := database.Connect() + if err != nil { + log.Fatalf("store tests need a reachable PostgreSQL: %v", err) + } + + // Skema dibuat lewat kode yang sama dengan yang dipakai server saat startup. + if err := database.CreateTables(conn); err != nil { + log.Fatalf("could not create schema: %v", err) + } + + testConn = conn + code := m.Run() + _ = conn.Close(context.Background()) + os.Exit(code) +} + +// resetDB mengosongkan semua tabel. Dipanggil di awal setiap tes. +func resetDB(t *testing.T) { + t.Helper() + if _, err := testConn.Exec(context.Background(), `TRUNCATE TABLE sessions, snippets, users CASCADE`); err != nil { + t.Fatalf("truncate tables: %v", err) + } +} + +func newTestStores() (*UserStore, *SessionStore, *SnippetStore) { + mu := &sync.Mutex{} + return NewUserStore(testConn, mu), NewSessionStore(testConn, mu), NewSnippetStore(testConn, mu) +} + +func mustCreateUser(t *testing.T, us *UserStore, username string) *models.User { + t.Helper() + u := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-of-" + username} + if err := us.Create(context.Background(), u); err != nil { + t.Fatalf("create user %q: %v", username, err) + } + return u +} + +// countRows hanya dipanggil dengan nama tabel konstan dari tes ini. +func countRows(t *testing.T, table string) int { + t.Helper() + var n int + if err := testConn.QueryRow(context.Background(), `SELECT COUNT(*) FROM `+table).Scan(&n); err != nil { + t.Fatalf("count rows in %s: %v", table, err) + } + return n +} diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 1e4573d..608d953 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "sync" "time" @@ -45,6 +46,9 @@ 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 diff --git a/internal/store/session_store_test.go b/internal/store/session_store_test.go new file mode 100644 index 0000000..f4cb758 --- /dev/null +++ b/internal/store/session_store_test.go @@ -0,0 +1,122 @@ +package store + +import ( + "context" + "errors" + "testing" + + "5803025048/W04/internal/database" + "5803025048/W04/internal/models" +) + +func TestSessionStore(t *testing.T) { + resetDB(t) + ctx := context.Background() + + t.Run("create then look up token returns the user id", func(t *testing.T) { + resetDB(t) + us, ss, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + + if err := ss.Create(ctx, &models.Session{Token: "tok-1", UserID: u.ID}); err != nil { + t.Fatalf("Create: %v", err) + } + got, err := ss.Get(ctx, "tok-1") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.UserID != u.ID || got.Token != "tok-1" { + t.Fatalf("session = %+v, want user %s", got, u.ID) + } + if got.CreatedAt.IsZero() { + t.Fatal("CreatedAt must be set") + } + }) + + t.Run("unknown token returns ErrNotFound", func(t *testing.T) { + resetDB(t) + _, ss, _ := newTestStores() + if _, err := ss.Get(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("delete then look up returns ErrNotFound", func(t *testing.T) { + resetDB(t) + us, ss, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + if err := ss.Create(ctx, &models.Session{Token: "tok-1", UserID: u.ID}); err != nil { + t.Fatalf("Create: %v", err) + } + if err := ss.Delete(ctx, "tok-1"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := ss.Get(ctx, "tok-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("deleting an already-deleted token does not panic or touch other tokens", func(t *testing.T) { + resetDB(t) + us, ss, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + for _, tok := range []string{"tok-a", "tok-b"} { + if err := ss.Create(ctx, &models.Session{Token: tok, UserID: u.ID}); err != nil { + t.Fatalf("Create %s: %v", tok, err) + } + } + _ = ss.Delete(ctx, "tok-a") + _ = ss.Delete(ctx, "tok-a") // kedua kalinya: hanya tidak boleh panic + + if _, err := ss.Get(ctx, "tok-b"); err != nil { + t.Fatalf("tok-b must still be valid, got err = %v", err) + } + }) + + t.Run("two sessions for one user are independent", func(t *testing.T) { + resetDB(t) + us, ss, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + for _, tok := range []string{"tok-1", "tok-2"} { + if err := ss.Create(ctx, &models.Session{Token: tok, UserID: u.ID}); err != nil { + t.Fatalf("Create %s: %v", tok, err) + } + } + if err := ss.Delete(ctx, "tok-1"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := ss.Get(ctx, "tok-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("tok-1 err = %v, want ErrNotFound", err) + } + got, err := ss.Get(ctx, "tok-2") + if err != nil || got.UserID != u.ID { + t.Fatalf("tok-2 must remain valid, got %+v, err = %v", got, err) + } + }) +} + +func TestSessionSurvivesNewStore(t *testing.T) { + t.Run("token created by one store is found by a store on a new connection", func(t *testing.T) { + resetDB(t) + us, ss, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + if err := ss.Create(context.Background(), &models.Session{Token: "persist-tok", UserID: u.ID}); err != nil { + t.Fatalf("Create: %v", err) + } + + conn2, err := database.Connect() + if err != nil { + t.Fatalf("open second connection: %v", err) + } + defer conn2.Close(context.Background()) + + ss2 := NewSessionStore(conn2) + got, err := ss2.Get(context.Background(), "persist-tok") + if err != nil { + t.Fatalf("Get via new store: %v", err) + } + if got.UserID != u.ID { + t.Fatalf("user id = %q, want %q", got.UserID, u.ID) + } + }) +} diff --git a/internal/store/snippet_store.go b/internal/store/snippet_store.go index 758a28b..c139735 100644 --- a/internal/store/snippet_store.go +++ b/internal/store/snippet_store.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "sync" "5803025048/W04/internal/models" @@ -45,11 +46,39 @@ 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) GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) { + s.mu.Lock() + defer s.mu.Unlock() + + query := `SELECT id, title, content, user_id FROM snippets WHERE user_id = $1` + rows, err := s.conn.Query(ctx, query, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + snippets := make([]*models.Snippet, 0) + for rows.Next() { + var sn models.Snippet + if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil { + return nil, err + } + snippets = append(snippets, &sn) + } + if err := rows.Err(); err != nil { + return nil, err + } + return snippets, nil +} + func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) { s.mu.Lock() defer s.mu.Unlock() @@ -80,6 +109,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..c6fac8a --- /dev/null +++ b/internal/store/snippet_store_test.go @@ -0,0 +1,224 @@ +package store + +import ( + "context" + "errors" + "testing" + + "5803025048/W04/internal/models" + "github.com/google/uuid" +) + +func mustCreateSnippet(t *testing.T, sn *SnippetStore, ownerID, title, content string) *models.Snippet { + t.Helper() + s := &models.Snippet{ID: uuid.New().String(), Title: title, Content: content, UserID: ownerID} + if err := sn.Create(context.Background(), s); err != nil { + t.Fatalf("create snippet: %v", err) + } + return s +} + +func TestSnippetStore(t *testing.T) { + resetDB(t) + ctx := context.Background() + + t.Run("create then get by id", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + u := mustCreateUser(t, us, "alice") + s := mustCreateSnippet(t, sn, u.ID, "Title", "Content") + + got, err := sn.GetByID(ctx, s.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.ID != s.ID || got.Title != "Title" || got.Content != "Content" { + t.Fatalf("got %+v, want %+v", got, s) + } + }) + + t.Run("get unknown id returns ErrNotFound", func(t *testing.T) { + resetDB(t) + _, _, sn := newTestStores() + if _, err := sn.GetByID(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("list by owner returns only that owner's rows", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + alice := mustCreateUser(t, us, "alice") + bob := mustCreateUser(t, us, "bob") + a1 := mustCreateSnippet(t, sn, alice.ID, "a1", "c") + a2 := mustCreateSnippet(t, sn, alice.ID, "a2", "c") + mustCreateSnippet(t, sn, bob.ID, "b1", "c") + + got, err := sn.GetByUserID(ctx, alice.ID) + if err != nil { + t.Fatalf("GetByUserID: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d snippets, want 2", len(got)) + } + for _, s := range got { + if s.ID != a1.ID && s.ID != a2.ID { + t.Fatalf("snippet %s does not belong to alice", s.ID) + } + if s.UserID != alice.ID { + t.Fatalf("snippet owner = %s, want %s", s.UserID, alice.ID) + } + } + }) + + t.Run("list for an owner with no snippets returns a non-nil empty slice", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + u := mustCreateUser(t, us, "alice") + + got, err := sn.GetByUserID(ctx, u.ID) + if err != nil { + t.Fatalf("GetByUserID: %v", err) + } + if got == nil { + t.Fatal("slice must be non-nil") + } + if len(got) != 0 { + t.Fatalf("len = %d, want 0", len(got)) + } + }) + + t.Run("delete removes the row", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + u := mustCreateUser(t, us, "alice") + s := mustCreateSnippet(t, sn, u.ID, "t", "c") + + if err := sn.Delete(ctx, s.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := sn.GetByID(ctx, s.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("after delete err = %v, want ErrNotFound", err) + } + if n := countRows(t, "snippets"); n != 0 { + t.Fatalf("snippets rows = %d, want 0", n) + } + }) + + t.Run("delete unknown id returns ErrNotFound", func(t *testing.T) { + resetDB(t) + _, _, sn := newTestStores() + if err := sn.Delete(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("ownership of a snippet can be retrieved", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + owner := mustCreateUser(t, us, "owner") + other := mustCreateUser(t, us, "other") + s := mustCreateSnippet(t, sn, owner.ID, "t", "c") + + got, err := sn.GetByID(ctx, s.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.UserID != owner.ID { + t.Fatalf("owner = %q, want %q", got.UserID, owner.ID) + } + if got.UserID == other.ID { + t.Fatal("owner must not be the other user") + } + }) + + t.Run("GetAll returns snippets of every owner", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + alice := mustCreateUser(t, us, "alice") + bob := mustCreateUser(t, us, "bob") + mustCreateSnippet(t, sn, alice.ID, "a", "c") + mustCreateSnippet(t, sn, bob.ID, "b", "c") + + got, err := sn.GetAll(ctx) + if err != nil { + t.Fatalf("GetAll: %v", err) + } + if len(got) != 2 { + t.Fatalf("got %d snippets, want 2", len(got)) + } + }) + + t.Run("Create assigns an id when none is given", func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + u := mustCreateUser(t, us, "alice") + s := &models.Snippet{Title: "t", Content: "c", UserID: u.ID} + if err := sn.Create(ctx, s); err != nil { + t.Fatalf("Create: %v", err) + } + if s.ID == "" { + t.Fatal("Create must assign an id") + } + }) +} + +func TestSnippetStoreSQLInjection(t *testing.T) { + ctx := context.Background() + + payloads := []struct { + name string + payload string + }{ + {"always-true OR clause", `' OR '1'='1`}, + {"DROP TABLE statement", `'; DROP TABLE snippets; --`}, + {"title with a single quote", `O'Reilly's "quoted" title`}, + } + + for _, tc := range payloads { + t.Run(tc.name, func(t *testing.T) { + resetDB(t) + us, _, sn := newTestStores() + u := mustCreateUser(t, us, "alice") + + // Payload sebagai title DAN content: harus tersimpan apa adanya. + s := mustCreateSnippet(t, sn, u.ID, tc.payload, tc.payload) + + got, err := sn.GetByID(ctx, s.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if got.Title != tc.payload || got.Content != tc.payload { + t.Fatalf("round trip changed data: title=%q content=%q", got.Title, got.Content) + } + + list, err := sn.GetByUserID(ctx, u.ID) + if err != nil { + t.Fatalf("GetByUserID: %v", err) + } + if len(list) != 1 || list[0].Title != tc.payload { + t.Fatalf("list = %+v, want exactly the one stored snippet", list) + } + + // Payload sebagai id / user id: tidak boleh mengembalikan baris apa pun. + if _, err := sn.GetByID(ctx, tc.payload); !errors.Is(err, ErrNotFound) { + t.Fatalf("GetByID(payload) err = %v, want ErrNotFound", err) + } + other, err := sn.GetByUserID(ctx, tc.payload) + if err != nil { + t.Fatalf("GetByUserID(payload): %v", err) + } + if len(other) != 0 { + t.Fatalf("GetByUserID(payload) returned %d rows, want 0", len(other)) + } + if err := sn.Delete(ctx, tc.payload); !errors.Is(err, ErrNotFound) { + t.Fatalf("Delete(payload) err = %v, want ErrNotFound", err) + } + + // Tabel masih ada dan hanya berisi baris yang diharapkan. + if n := countRows(t, "snippets"); n != 1 { + t.Fatalf("snippets rows = %d, want 1", n) + } + }) + } +} diff --git a/internal/store/user_store.go b/internal/store/user_store.go index 901757a..ec6667d 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -2,13 +2,18 @@ package store import ( "context" + "errors" "sync" "5803025048/W04/internal/models" "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" ) +// ErrUsernameTaken dikembalikan saat Create() gagal karena username sudah dipakai (unique constraint). +var ErrUsernameTaken = errors.New("username already exists") + type UserStore struct { conn *pgx.Conn mu *sync.Mutex @@ -34,7 +39,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 ErrUsernameTaken + } + return err + } + return nil } func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { @@ -45,6 +57,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 +73,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..48bd959 --- /dev/null +++ b/internal/store/user_store_test.go @@ -0,0 +1,166 @@ +package store + +import ( + "context" + "errors" + "sync" + "testing" + + "5803025048/W04/internal/auth" + "5803025048/W04/internal/models" + "github.com/google/uuid" +) + +func TestUserStore(t *testing.T) { + resetDB(t) + ctx := context.Background() + + t.Run("create then fetch by username", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + + got, err := us.GetByUsername(ctx, "alice") + if err != nil { + t.Fatalf("GetByUsername: %v", err) + } + if got.ID != u.ID || got.Username != "alice" || got.PasswordHash != u.PasswordHash { + t.Fatalf("fetched user %+v does not match created user %+v", got, u) + } + }) + + t.Run("fetch unknown username returns ErrNotFound", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + if _, err := us.GetByUsername(ctx, "ghost"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("duplicate username returns ErrUsernameTaken", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + mustCreateUser(t, us, "alice") + + err := us.Create(ctx, &models.User{ID: uuid.New().String(), Username: "alice", PasswordHash: "other"}) + if !errors.Is(err, ErrUsernameTaken) { + t.Fatalf("err = %v, want ErrUsernameTaken", err) + } + if n := countRows(t, "users"); n != 1 { + t.Fatalf("users rows = %d, want 1", n) + } + }) + + t.Run("usernames are case-sensitive", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + upper := mustCreateUser(t, us, "Alice") + lower := mustCreateUser(t, us, "alice") + if upper.ID == lower.ID { + t.Fatal("expected two distinct users") + } + + gotUpper, err := us.GetByUsername(ctx, "Alice") + if err != nil || gotUpper.ID != upper.ID { + t.Fatalf("Alice lookup = %+v, err = %v", gotUpper, err) + } + gotLower, err := us.GetByUsername(ctx, "alice") + if err != nil || gotLower.ID != lower.ID { + t.Fatalf("alice lookup = %+v, err = %v", gotLower, err) + } + }) + + t.Run("stored password hash differs from plaintext", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + const plain = "plain-secret-123" + hash, err := auth.HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if err := us.Create(ctx, &models.User{ID: uuid.New().String(), Username: "alice", PasswordHash: hash}); err != nil { + t.Fatalf("Create: %v", err) + } + + got, err := us.GetByUsername(ctx, "alice") + if err != nil { + t.Fatalf("GetByUsername: %v", err) + } + if got.PasswordHash == plain { + t.Fatal("stored hash must not equal the plaintext password") + } + if !auth.CheckPassword(plain, got.PasswordHash) { + t.Fatal("stored hash must verify against the original password") + } + }) + + t.Run("GetByID returns the user and ErrNotFound for unknown id", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + u := mustCreateUser(t, us, "alice") + + got, err := us.GetByID(ctx, u.ID) + if err != nil || got.Username != "alice" { + t.Fatalf("GetByID = %+v, err = %v", got, err) + } + if _, err := us.GetByID(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } + }) + + t.Run("Create assigns an id when none is given", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + u := &models.User{Username: "noid", PasswordHash: "h"} + if err := us.Create(ctx, u); err != nil { + t.Fatalf("Create: %v", err) + } + if u.ID == "" { + t.Fatal("Create must assign an id") + } + }) +} + +func TestUserStoreConcurrentRegister(t *testing.T) { + t.Run("20 goroutines register the same username", func(t *testing.T) { + resetDB(t) + us, _, _ := newTestStores() + + const n = 20 + errs := make([]error, n) + start := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + errs[i] = us.Create(context.Background(), &models.User{ + ID: uuid.New().String(), + Username: "same-name", + PasswordHash: "h", + }) + }(i) + } + close(start) + wg.Wait() + + success, duplicate := 0, 0 + for _, err := range errs { + switch { + case err == nil: + success++ + case errors.Is(err, ErrUsernameTaken): + duplicate++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + if success != 1 || duplicate != n-1 { + t.Fatalf("successes = %d, duplicates = %d; want 1 and %d", success, duplicate, n-1) + } + if rows := countRows(t, "users"); rows != 1 { + t.Fatalf("users rows = %d, want 1", rows) + } + }) +}