From 650b24619f117541bf2c565a488ebad750c219d6 Mon Sep 17 00:00:00 2001 From: Yovan Date: Sat, 3 Oct 2026 20:28:25 +0700 Subject: [PATCH] Tuas W06 --- .env.example | 15 + .gitignore | 3 +- e2e/api_test.go | 614 +++++++++++++++++++++++ internal/auth/password_test.go | 76 +++ internal/auth/session_test.go | 52 ++ internal/config/config.go | 3 +- internal/database/database.go | 8 +- internal/handler/auth_handler.go | 43 +- internal/handler/auth_handler_test.go | 265 ++++++++++ internal/handler/fakes_test.go | 135 +++++ internal/handler/interfaces.go | 28 ++ internal/handler/snippet_handler_test.go | 327 ++++++++++++ internal/httpjson/httpjson.go | 12 + internal/middleware/interfaces.go | 15 + internal/middleware/require_auth_test.go | 162 ++++++ internal/store/main_test.go | 64 +++ internal/store/session_store.go | 20 +- internal/store/session_store_test.go | 155 ++++++ internal/store/snippet_store.go | 19 +- internal/store/snippet_store_test.go | 179 +++++++ internal/store/user_store.go | 9 + internal/store/user_store_test.go | 157 ++++++ readme.md | 42 ++ 23 files changed, 2373 insertions(+), 30 deletions(-) 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/snippet_handler_test.go create mode 100644 internal/httpjson/httpjson.go create mode 100644 internal/middleware/interfaces.go create mode 100644 internal/middleware/require_auth_test.go create mode 100644 internal/store/main_test.go create mode 100644 internal/store/session_store_test.go create mode 100644 internal/store/snippet_store_test.go create mode 100644 internal/store/user_store_test.go create mode 100644 readme.md diff --git a/.env.example b/.env.example index e69de29..14a2255 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,15 @@ +# Server config +# +PORT=4000 +ENVIRONMENT=dev # option: dev, stage, prod + +# PostgreSQL Database Config +# +DB_HOST=localhost +DB_PORT=5432 +DB_USER=your_user +DB_PASSWORD=your_secure_password +DB_NAME=your_database_name +DB_SSLMODE=disable # 'disable' or 'require' + +DATABASE_URL=your_database_url \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2eea525..c16a7a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.env \ No newline at end of file +.env +.env.test \ No newline at end of file diff --git a/e2e/api_test.go b/e2e/api_test.go new file mode 100644 index 0000000..88c8d3d --- /dev/null +++ b/e2e/api_test.go @@ -0,0 +1,614 @@ +package e2e + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" + "time" +) + +var baseURL = func() string { + if v := os.Getenv("API_BASE_URL"); v != "" { + return strings.TrimRight(v, "/") + } + return "http://localhost:8080" +}() + +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// randomSuffix returns a short random hex string so every test run uses +// unique usernames — the grader truncates tables between runs, not between +// tests, and may run this suite against several servers back to back. +func randomSuffix() string { + b := make([]byte, 6) + if _, err := rand.Read(b); err != nil { + // crypto/rand failing is exceptional enough to just panic; every + // caller is inside a test anyway. + panic(err) + } + return hex.EncodeToString(b) +} + +func uniqueUsername() string { + return "e2e_" + randomSuffix() +} + +type apiResponse struct { + status int + header http.Header + body []byte +} + +// doRequest fails the test loudly (never skips) if the server cannot be +// reached at all, per the suite's "fail loudly if unreachable" requirement. +func doRequest(t *testing.T, method, path string, body any, headers map[string]string) apiResponse { + t.Helper() + + var reader io.Reader + switch v := body.(type) { + case nil: + reader = nil + case []byte: + reader = bytes.NewReader(v) + default: + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + reader = bytes.NewReader(b) + } + + req, err := http.NewRequest(method, baseURL+path, reader) + if err != nil { + t.Fatalf("failed to build request: %v", err) + } + if reader != nil { + req.Header.Set("Content-Type", "application/json") + } + for k, v := range headers { + req.Header.Set(k, v) + } + + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("request %s %s failed (is the server running at %s?): %v", method, path, baseURL, err) + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + return apiResponse{status: resp.StatusCode, header: resp.Header, body: b} +} + +func register(t *testing.T, username, password string) apiResponse { + t.Helper() + return doRequest(t, http.MethodPost, "/register", map[string]string{"username": username, "password": password}, nil) +} + +func login(t *testing.T, username, password string) apiResponse { + t.Helper() + return doRequest(t, http.MethodPost, "/login", map[string]string{"username": username, "password": password}, nil) +} + +func sessionTokenFrom(t *testing.T, resp apiResponse) string { + t.Helper() + var body map[string]any + if err := json.Unmarshal(resp.body, &body); err != nil { + t.Fatalf("expected JSON login response, got %q: %v", resp.body, err) + } + tok, _ := body["session_token"].(string) + if tok == "" { + t.Fatalf("expected non-empty session_token in %q", resp.body) + } + return tok +} + +func TestE2E_RegisterDuplicate(t *testing.T) { + username := uniqueUsername() + password := "password123" + + t.Run("first registration succeeds with 201", func(t *testing.T) { + resp := register(t, username, password) + if resp.status != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.status, resp.body) + } + }) + + t.Run("second registration with the same username returns 409", func(t *testing.T) { + resp := register(t, username, password) + if resp.status != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", resp.status, resp.body) + } + }) +} + +func TestE2E_PasswordNeverLeaked(t *testing.T) { + username := uniqueUsername() + password := "s3cr3t-" + randomSuffix() + + t.Run("register response has no password key and no plaintext password", func(t *testing.T) { + resp := register(t, username, password) + if resp.status != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.status, resp.body) + } + lower := strings.ToLower(string(resp.body)) + if strings.Contains(lower, "password") { + t.Fatalf("register response must not contain any password-related key: %s", resp.body) + } + if strings.Contains(string(resp.body), password) { + t.Fatalf("register response must not leak the plaintext password: %s", resp.body) + } + }) + + t.Run("login response has no password key and no plaintext password", func(t *testing.T) { + resp := login(t, username, password) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + lower := strings.ToLower(string(resp.body)) + if strings.Contains(lower, "password") { + t.Fatalf("login response must not contain any password-related key: %s", resp.body) + } + if strings.Contains(string(resp.body), password) { + t.Fatalf("login response must not leak the plaintext password: %s", resp.body) + } + }) +} + +func TestE2E_LoginFailuresIndistinguishable(t *testing.T) { + username := uniqueUsername() + password := "password123" + if reg := register(t, username, password); reg.status != http.StatusCreated { + t.Fatalf("setup: expected 201 registering, got %d: %s", reg.status, reg.body) + } + + var wrongPassBody, unknownUserBody []byte + + t.Run("wrong password returns 401", func(t *testing.T) { + resp := login(t, username, "definitely-wrong-password") + if resp.status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", resp.status, resp.body) + } + wrongPassBody = resp.body + }) + + t.Run("unknown username returns 401 with an identical body", func(t *testing.T) { + resp := login(t, "no-such-user-"+randomSuffix(), "whatever-password") + if resp.status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", resp.status, resp.body) + } + unknownUserBody = resp.body + if string(wrongPassBody) != string(unknownUserBody) { + t.Fatalf("expected identical 401 bodies, got %q vs %q", wrongPassBody, unknownUserBody) + } + }) +} + +func TestE2E_ProtectedEndpointsRequireToken(t *testing.T) { + cases := []struct { + name string + method string + path string + body any + }{ + {"POST /snippets", http.MethodPost, "/snippets", map[string]string{"title": "t", "content": "c"}}, + {"GET /snippets", http.MethodGet, "/snippets", nil}, + {"DELETE /snippets/{id}", http.MethodDelete, "/snippets/some-id-" + randomSuffix(), nil}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name+" with no header", func(t *testing.T) { + resp := doRequest(t, tc.method, tc.path, tc.body, nil) + if resp.status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", resp.status, resp.body) + } + }) + t.Run(tc.name+" with Bearer not-a-real-token", func(t *testing.T) { + resp := doRequest(t, tc.method, tc.path, tc.body, map[string]string{"Authorization": "Bearer not-a-real-token"}) + if resp.status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", resp.status, resp.body) + } + }) + } +} + +func TestE2E_SnippetLifecycle(t *testing.T) { + username := uniqueUsername() + password := "password123" + if resp := register(t, username, password); resp.status != http.StatusCreated { + t.Fatalf("setup: register failed: %d %s", resp.status, resp.body) + } + loginResp := login(t, username, password) + if loginResp.status != http.StatusOK { + t.Fatalf("setup: login failed: %d %s", loginResp.status, loginResp.body) + } + token := sessionTokenFrom(t, loginResp) + authHeader := map[string]string{"Authorization": "Bearer " + token} + + var snippetID string + + t.Run("create snippet returns 201", func(t *testing.T) { + resp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "e2e title", "content": "e2e content"}, authHeader) + if resp.status != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.status, resp.body) + } + var body map[string]any + if err := json.Unmarshal(resp.body, &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + id, _ := body["id"].(string) + if id == "" { + t.Fatal("expected non-empty id in create response") + } + snippetID = id + }) + + t.Run("public GET matches what was created", func(t *testing.T) { + if snippetID == "" { + t.Fatal("previous subtest did not set snippetID") + } + resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + var body map[string]any + if err := json.Unmarshal(resp.body, &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + if body["title"] != "e2e title" || body["content"] != "e2e content" { + t.Fatalf("expected matching title/content, got %v", body) + } + }) + + t.Run("owner DELETE returns 204 with an empty body", func(t *testing.T) { + if snippetID == "" { + t.Fatal("previous subtest did not set snippetID") + } + resp := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, authHeader) + if resp.status != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", resp.status, resp.body) + } + if len(resp.body) != 0 { + t.Fatalf("expected empty body, got %q", resp.body) + } + }) + + t.Run("GET after delete returns 404", func(t *testing.T) { + if snippetID == "" { + t.Fatal("previous subtest did not set snippetID") + } + resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + if resp.status != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", resp.status, resp.body) + } + }) +} + +// registerAndLogin is a small setup helper shared by the tests below: it +// registers a fresh unique user, logs in, and returns an Authorization header +// map ready to use with doRequest. +func registerAndLogin(t *testing.T) (username string, authHeader map[string]string) { + t.Helper() + username = uniqueUsername() + password := "password123" + if resp := register(t, username, password); resp.status != http.StatusCreated { + t.Fatalf("setup: register failed: %d %s", resp.status, resp.body) + } + loginResp := login(t, username, password) + if loginResp.status != http.StatusOK { + t.Fatalf("setup: login failed: %d %s", loginResp.status, loginResp.body) + } + token := sessionTokenFrom(t, loginResp) + return username, map[string]string{"Authorization": "Bearer " + token} +} + +func TestE2E_PublicRead(t *testing.T) { + _, authHeader := registerAndLogin(t) + + var snippetID string + + t.Run("user A creates a snippet", func(t *testing.T) { + resp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "public read title", "content": "public read content"}, authHeader) + if resp.status != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.status, resp.body) + } + var body map[string]any + if err := json.Unmarshal(resp.body, &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + id, _ := body["id"].(string) + if id == "" { + t.Fatal("expected non-empty id") + } + snippetID = id + }) + + t.Run("readable with no Authorization header at all", func(t *testing.T) { + if snippetID == "" { + t.Fatal("previous subtest did not set snippetID") + } + resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + var body map[string]any + if err := json.Unmarshal(resp.body, &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + if body["title"] != "public read title" || body["content"] != "public read content" { + t.Fatalf("expected matching title/content, got %v", body) + } + }) +} + +func TestE2E_LogoutInvalidatesToken(t *testing.T) { + _, authHeader := registerAndLogin(t) + + t.Run("logout returns 204", func(t *testing.T) { + resp := doRequest(t, http.MethodPost, "/logout", nil, authHeader) + if resp.status != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", resp.status, resp.body) + } + }) + + t.Run("same token on GET /snippets returns 401", func(t *testing.T) { + resp := doRequest(t, http.MethodGet, "/snippets", nil, authHeader) + if resp.status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", resp.status, resp.body) + } + }) +} + +func TestE2E_DataIsolation(t *testing.T) { + _, headerA := registerAndLogin(t) + _, headerB := registerAndLogin(t) + + createA := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "A's snippet", "content": "belongs to A"}, headerA) + if createA.status != http.StatusCreated { + t.Fatalf("setup: create for A failed: %d %s", createA.status, createA.body) + } + var bodyA map[string]any + if err := json.Unmarshal(createA.body, &bodyA); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + idA, _ := bodyA["id"].(string) + if idA == "" { + t.Fatal("expected non-empty id for A's snippet") + } + + createB := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "B's snippet", "content": "belongs to B"}, headerB) + if createB.status != http.StatusCreated { + t.Fatalf("setup: create for B failed: %d %s", createB.status, createB.body) + } + var bodyB map[string]any + if err := json.Unmarshal(createB.body, &bodyB); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + idB, _ := bodyB["id"].(string) + if idB == "" { + t.Fatal("expected non-empty id for B's snippet") + } + + t.Run("user A's list contains exactly their own snippet", func(t *testing.T) { + resp := doRequest(t, http.MethodGet, "/snippets", nil, headerA) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + var list []map[string]any + if err := json.Unmarshal(resp.body, &list); err != nil { + t.Fatalf("expected JSON array: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected exactly 1 snippet for user A, got %d: %v", len(list), list) + } + if list[0]["id"] != idA { + t.Fatalf("expected A's own snippet id %q, got %v", idA, list[0]) + } + }) + + t.Run("user B's list contains exactly their own snippet", func(t *testing.T) { + resp := doRequest(t, http.MethodGet, "/snippets", nil, headerB) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + var list []map[string]any + if err := json.Unmarshal(resp.body, &list); err != nil { + t.Fatalf("expected JSON array: %v", err) + } + if len(list) != 1 { + t.Fatalf("expected exactly 1 snippet for user B, got %d: %v", len(list), list) + } + if list[0]["id"] != idB { + t.Fatalf("expected B's own snippet id %q, got %v", idB, list[0]) + } + }) +} + +func TestE2E_DeleteOwnership(t *testing.T) { + _, ownerHeader := registerAndLogin(t) + _, otherHeader := registerAndLogin(t) + + createResp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "owned by owner", "content": "do not delete me"}, ownerHeader) + if createResp.status != http.StatusCreated { + t.Fatalf("setup: create failed: %d %s", createResp.status, createResp.body) + } + var body map[string]any + if err := json.Unmarshal(createResp.body, &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + snippetID, _ := body["id"].(string) + if snippetID == "" { + t.Fatal("expected non-empty id") + } + + t.Run("non-owner delete returns 403 and the snippet is still readable afterward", func(t *testing.T) { + resp := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, otherHeader) + if resp.status != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", resp.status, resp.body) + } + getResp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + if getResp.status != http.StatusOK { + t.Fatalf("expected snippet to still be readable (200) after a forbidden delete attempt, got %d: %s", getResp.status, getResp.body) + } + }) + + t.Run("non-owner delete of a nonexistent id returns 404", func(t *testing.T) { + resp := doRequest(t, http.MethodDelete, "/snippets/no-such-id-"+randomSuffix(), nil, otherHeader) + if resp.status != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", resp.status, resp.body) + } + }) +} + +func TestE2E_EmptyListIsArray(t *testing.T) { + _, authHeader := registerAndLogin(t) + + resp := doRequest(t, http.MethodGet, "/snippets", nil, authHeader) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + body := strings.TrimSpace(string(resp.body)) + if body != "[]" { + t.Fatalf("expected a fresh user's empty snippet list to be the literal [], got %q", body) + } +} + +func assertResponseJSONContentType(t *testing.T, resp apiResponse) { + t.Helper() + ct := resp.header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Fatalf("expected Content-Type application/json, got %q", ct) + } +} + +func assertSnippetSchema(t *testing.T, resp apiResponse) { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal(resp.body, &decoded); err != nil { + t.Fatalf("expected a JSON object body: %v (body=%s)", err, resp.body) + } + wantKeys := []string{"id", "title", "content"} + if len(decoded) != len(wantKeys) { + t.Fatalf("expected exactly the keys %v, got %v", wantKeys, decoded) + } + for _, k := range wantKeys { + if _, ok := decoded[k]; !ok { + t.Fatalf("missing key %q in %v", k, decoded) + } + } +} + +func TestE2E_ContentTypeAndSchema(t *testing.T) { + _, authHeader := registerAndLogin(t) + + var snippetID string + + t.Run("201 on create has JSON content type and the exact snippet schema", func(t *testing.T) { + resp := doRequest(t, http.MethodPost, "/snippets", map[string]string{"title": "ct schema", "content": "ct schema content"}, authHeader) + if resp.status != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", resp.status, resp.body) + } + assertResponseJSONContentType(t, resp) + assertSnippetSchema(t, resp) + + var body map[string]any + _ = json.Unmarshal(resp.body, &body) + snippetID, _ = body["id"].(string) + if snippetID == "" { + t.Fatal("expected non-empty id") + } + }) + + t.Run("200 on public get has JSON content type and the exact snippet schema", func(t *testing.T) { + if snippetID == "" { + t.Fatal("previous subtest did not set snippetID") + } + resp := doRequest(t, http.MethodGet, "/snippets/"+snippetID, nil, nil) + if resp.status != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", resp.status, resp.body) + } + assertResponseJSONContentType(t, resp) + assertSnippetSchema(t, resp) + }) + + t.Run("400 on malformed body has JSON content type", func(t *testing.T) { + resp := doRequest(t, http.MethodPost, "/snippets", []byte("{not valid json"), authHeader) + if resp.status != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", resp.status, resp.body) + } + assertResponseJSONContentType(t, resp) + }) + + t.Run("401 on missing token has JSON content type", func(t *testing.T) { + resp := doRequest(t, http.MethodGet, "/snippets", nil, nil) + if resp.status != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", resp.status, resp.body) + } + assertResponseJSONContentType(t, resp) + }) + + t.Run("403 on non-owner delete has JSON content type", func(t *testing.T) { + if snippetID == "" { + t.Fatal("previous subtest did not set snippetID") + } + _, otherHeader := registerAndLogin(t) + resp := doRequest(t, http.MethodDelete, "/snippets/"+snippetID, nil, otherHeader) + if resp.status != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", resp.status, resp.body) + } + assertResponseJSONContentType(t, resp) + }) + + t.Run("404 on nonexistent id has JSON content type", func(t *testing.T) { + resp := doRequest(t, http.MethodGet, "/snippets/no-such-id-"+randomSuffix(), nil, nil) + if resp.status != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", resp.status, resp.body) + } + assertResponseJSONContentType(t, resp) + }) +} + +func TestE2E_BadInput(t *testing.T) { + _, authHeader := registerAndLogin(t) + + badBodies := []struct { + name string + body []byte + }{ + {"malformed JSON", []byte("{not valid json")}, + {"empty body", []byte("")}, + {"JSON array body", []byte(`["unexpected", "array"]`)}, + } + + endpoints := []struct { + name string + method string + path string + headers map[string]string + }{ + {"POST /register", http.MethodPost, "/register", nil}, + {"POST /login", http.MethodPost, "/login", nil}, + {"POST /snippets", http.MethodPost, "/snippets", authHeader}, + } + + for _, ep := range endpoints { + for _, bb := range badBodies { + ep, bb := ep, bb + t.Run(ep.name+" "+bb.name, func(t *testing.T) { + resp := doRequest(t, ep.method, ep.path, bb.body, ep.headers) + if resp.status != http.StatusBadRequest { + t.Fatalf("expected 400 (never 5xx, never a dropped connection), got %d: %s", resp.status, resp.body) + } + }) + } + } +} \ No newline at end of file diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..efdcc18 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,76 @@ +package auth + +import "testing" + +func TestHashPassword(t *testing.T) { + t.Run("hash never equals the plaintext", func(t *testing.T) { + pw := "secret123" + hash, err := HashPassword(pw) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + if hash == pw { + t.Fatal("expected hash to differ from plaintext") + } + }) + + t.Run("hashing the same password twice yields two different hashes", func(t *testing.T) { + pw := "secret123" + h1, err := HashPassword(pw) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + h2, err := HashPassword(pw) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + if h1 == h2 { + t.Fatal("expected two different hashes for the same password (salting)") + } + }) + + t.Run("the hash verifies against the original password", func(t *testing.T) { + pw := "secret123" + hash, err := HashPassword(pw) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + if !CheckPassword(pw, hash) { + t.Fatal("expected CheckPassword to succeed against its own hash") + } + }) +} + +func TestCheckPassword(t *testing.T) { + pw := "secret123" + hash, err := HashPassword(pw) + if err != nil { + t.Fatalf("HashPassword returned error: %v", err) + } + + tests := []struct { + name string + password string + hash string + want bool + }{ + {"correct password returns true", pw, hash, true}, + {"wrong password returns false", "wrongpass", hash, false}, + {"empty password returns false", "", hash, false}, + {"garbage non-bcrypt hash returns false without panicking", pw, "this-is-not-a-bcrypt-hash", 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_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000..e2e437d --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "strings" + "testing" +) + +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: %v", 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 at index %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 _, ok := seen[tok]; ok { + t.Fatalf("duplicate token found at index %d: %s", i, tok) + } + seen[tok] = struct{}{} + } + }) + + t.Run("no token contains whitespace", func(t *testing.T) { + for i, tok := range tokens { + if strings.ContainsAny(tok, " \t\n\r\v\f") { + t.Fatalf("token at index %d contains whitespace: %q", i, tok) + } + } + }) + + t.Run("every token has length at least 32 characters", func(t *testing.T) { + for i, tok := range tokens { + if len(tok) < 32 { + t.Fatalf("token at index %d has length %d, want >= 32", i, len(tok)) + } + } + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index fe5cc75..a20fd4d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,8 +4,7 @@ import ( "github.com/joho/godotenv" ) -// LoadEnv memuat .env file jika ada, tapi tidak error jika tidak ditemukan. -// Grader akan export env vars langsung tanpa .env file. + func LoadEnv() { _ = godotenv.Load() // ignore error — .env } diff --git a/internal/database/database.go b/internal/database/database.go index 76c70c3..004eaad 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -76,10 +76,10 @@ func SeedSampleData(conn *pgx.Conn) error { username string password string }{ - {username: "yovan", password: "password123"}, - {username: "kiwkiw", password: "password12345"}, - {username: "broski", password: "password12345678"}, - {username: "oit", password: "password12345678910"}, + {username: "Budianto", password: "password123"}, + {username: "Megalodon", password: "password12345"}, + {username: "Bruh", password: "password12345678"}, + {username: "KiwKiw", password: "password12345678910"}, } fmt.Println("✅ INSERT DATA SAMPLE KE DATABASE (lebih dari 2 USERS):") diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index bfa7a4a..306fd0e 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -8,17 +8,18 @@ import ( "time" "5803025020/w04/internal/auth" + "5803025020/w04/internal/httpjson" "5803025020/w04/internal/models" "5803025020/w04/internal/store" "github.com/google/uuid" ) type AuthHandler struct { - userStore *store.UserStore - sessionStore *store.SessionStore + userStore UserStore + sessionStore SessionStore } -func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler { +func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler { return &AuthHandler{ userStore: userStore, sessionStore: sessionStore, @@ -32,25 +33,25 @@ 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) + httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed") return } var req authRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) + httpjson.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) + httpjson.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) + httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error") return } @@ -62,10 +63,10 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { if err := h.userStore.Create(r.Context(), user); err != nil { if errors.Is(err, store.ErrUsernameTaken) { - http.Error(w, "Username already exists", http.StatusConflict) + httpjson.WriteError(w, http.StatusConflict, "Username already exists") return } - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error") return } @@ -76,25 +77,25 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed") return } var req authRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Bad Request", http.StatusBadRequest) + httpjson.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) + httpjson.WriteError(w, http.StatusUnauthorized, "Invalid credentials") return } token, err := auth.GenerateToken() if err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error") return } @@ -105,7 +106,7 @@ 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) + httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error") return } @@ -116,28 +117,32 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + httpjson.WriteError(w, http.StatusMethodNotAllowed, "Method Not Allowed") return } authHeader := r.Header.Get("Authorization") if authHeader == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized") return } token := authHeader if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { - token = authHeader[7:] + token = authHeader[len("Bearer "):] } token = strings.TrimSpace(token) if token == "" { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized") return } if err := h.sessionStore.Delete(r.Context(), token); err != nil { - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + if errors.Is(err, store.ErrSessionNotFound) { + httpjson.WriteError(w, http.StatusUnauthorized, "Unauthorized") + return + } + httpjson.WriteError(w, http.StatusInternalServerError, "Internal Server Error") return } diff --git a/internal/handler/auth_handler_test.go b/internal/handler/auth_handler_test.go new file mode 100644 index 0000000..fa64d5b --- /dev/null +++ b/internal/handler/auth_handler_test.go @@ -0,0 +1,265 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "5803025020/w04/internal/auth" + "5803025020/w04/internal/models" +) + +func newTestAuthHandler() (*AuthHandler, *fakeUserStore, *fakeSessionStore) { + us := newFakeUserStore() + ss := newFakeSessionStore() + return NewAuthHandler(us, ss), us, ss +} + +// doJSONRequest builds a request whose body is either a Go value (marshalled +// to JSON) or a raw string (used as-is, to exercise malformed-JSON cases). +func doJSONRequest(h http.HandlerFunc, method, path string, body any) *httptest.ResponseRecorder { + var raw []byte + switch v := body.(type) { + case string: + raw = []byte(v) + default: + raw, _ = json.Marshal(v) + } + req := httptest.NewRequest(method, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h(rec, req) + return rec +} + +// assertJSONContentType is used on every handler test subtest that inspects +// a non-204 response, per the spec's blanket requirement. +func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Fatalf("expected Content-Type application/json, got %q", ct) + } +} + +func TestRegister(t *testing.T) { + t.Run("success returns 201 with id and echoed username", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{ + "username": "alice", "password": "password123", + }) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + assertJSONContentType(t, rec) + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + if id, _ := body["id"].(string); id == "" { + t.Fatal("expected non-empty id in response") + } + if body["username"] != "alice" { + t.Fatalf("expected username %q, got %v", "alice", body["username"]) + } + }) + + t.Run("duplicate username returns 409", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "bob", "password": "password123"}) + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "bob", "password": "anotherpass"}) + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d: %s", rec.Code, rec.Body.String()) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty username returns 400", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "", "password": "password123"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty password returns 400", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "charlie", "password": ""}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("username of 2 characters returns 400", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "ab", "password": "password123"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("username of 33 characters returns 400", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + longUsername := strings.Repeat("a", 33) + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": longUsername, "password": "password123"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + rec := doJSONRequest(h.Register, http.MethodPost, "/register", "{not valid json") + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("success body contains no password or password_hash key, nor the plaintext", func(t *testing.T) { + h, _, _ := newTestAuthHandler() + rec := doJSONRequest(h.Register, http.MethodPost, "/register", map[string]string{"username": "dana", "password": "supersecretpw"}) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d", rec.Code) + } + assertJSONContentType(t, rec) + lower := strings.ToLower(rec.Body.String()) + if strings.Contains(lower, "password") { + t.Fatalf("response body must not contain any password-related key: %s", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "supersecretpw") { + t.Fatal("response body must not contain the plaintext password") + } + }) +} + +func TestLogin(t *testing.T) { + h, us, _ := newTestAuthHandler() + ctx := context.Background() + hash, err := auth.HashPassword("correcthorse") + if err != nil { + t.Fatalf("failed to hash seed password: %v", err) + } + if err := us.Create(ctx, &models.User{ID: "user-1", Username: "eve", PasswordHash: hash}); err != nil { + t.Fatalf("failed to seed user: %v", err) + } + + var wrongPasswordBody, unknownUserBody string + + t.Run("success returns 200 with non-empty session_token", func(t *testing.T) { + rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "eve", "password": "correcthorse"}) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + assertJSONContentType(t, rec) + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + tok, _ := body["session_token"].(string) + if tok == "" { + t.Fatal("expected non-empty session_token") + } + }) + + t.Run("wrong password returns 401", func(t *testing.T) { + rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "eve", "password": "wrongpass"}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + wrongPasswordBody = rec.Body.String() + }) + + t.Run("unknown username returns 401", func(t *testing.T) { + rec := doJSONRequest(h.Login, http.MethodPost, "/login", map[string]string{"username": "ghost", "password": "whatever"}) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + unknownUserBody = rec.Body.String() + }) + + t.Run("401 bodies for wrong password and unknown user are byte-for-byte identical", func(t *testing.T) { + if wrongPasswordBody == "" || unknownUserBody == "" { + t.Fatal("prior subtests did not populate response bodies") + } + if wrongPasswordBody != unknownUserBody { + t.Fatalf("expected identical bodies, got %q vs %q", wrongPasswordBody, unknownUserBody) + } + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + rec := doJSONRequest(h.Login, http.MethodPost, "/login", "{bad json") + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) +} + +func TestLogout(t *testing.T) { + h, _, ss := newTestAuthHandler() + ctx := context.Background() + validToken := "validtoken1234567890123456789012" + if err := ss.Create(ctx, &models.Session{Token: validToken, UserID: "user-1"}); err != nil { + t.Fatalf("failed to seed session: %v", err) + } + + t.Run("valid token returns 204 with empty body and removes the session", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.Header.Set("Authorization", "Bearer "+validToken) + rec := httptest.NewRecorder() + h.Logout(rec, req) + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + // 204 is exempt from the Content-Type:application/json requirement. + if rec.Body.Len() != 0 { + t.Fatalf("expected empty body, got %q", rec.Body.String()) + } + if ss.has(validToken) { + t.Fatal("expected session to be removed from the fake store") + } + }) + + t.Run("missing token returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + rec := httptest.NewRecorder() + h.Logout(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("unknown token returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.Header.Set("Authorization", "Bearer not-a-real-token-000000000000") + rec := httptest.NewRecorder() + h.Logout(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("second logout with the same token returns 401", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + req.Header.Set("Authorization", "Bearer "+validToken) + rec := httptest.NewRecorder() + h.Logout(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 on second logout, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) +} diff --git a/internal/handler/fakes_test.go b/internal/handler/fakes_test.go new file mode 100644 index 0000000..824e82b --- /dev/null +++ b/internal/handler/fakes_test.go @@ -0,0 +1,135 @@ +package handler + +import ( + "context" + "sync" + + "5803025020/w04/internal/models" + "5803025020/w04/internal/store" +) + +// fakeUserStore is a hand-written in-memory implementation of UserStore. +type fakeUserStore struct { + mu sync.Mutex + byName map[string]*models.User +} + +func newFakeUserStore() *fakeUserStore { + return &fakeUserStore{byName: map[string]*models.User{}} +} + +func (f *fakeUserStore) Create(ctx context.Context, user *models.User) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, exists := f.byName[user.Username]; exists { + return store.ErrUsernameTaken + } + cp := *user + f.byName[user.Username] = &cp + return nil +} + +func (f *fakeUserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { + f.mu.Lock() + defer f.mu.Unlock() + u, ok := f.byName[username] + if !ok { + return nil, store.ErrUserNotFound + } + cp := *u + return &cp, nil +} + +// fakeSessionStore is a hand-written in-memory implementation of SessionStore. +type fakeSessionStore struct { + mu sync.Mutex + sessions map[string]*models.Session +} + +func newFakeSessionStore() *fakeSessionStore { + return &fakeSessionStore{sessions: map[string]*models.Session{}} +} + +func (f *fakeSessionStore) Create(ctx context.Context, session *models.Session) error { + f.mu.Lock() + defer f.mu.Unlock() + cp := *session + f.sessions[session.Token] = &cp + return nil +} + +func (f *fakeSessionStore) Delete(ctx context.Context, token string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.sessions[token]; !ok { + return store.ErrSessionNotFound + } + 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 +} + +// fakeSnippetStore is a hand-written in-memory implementation of SnippetStore. +type fakeSnippetStore struct { + mu sync.Mutex + snippets map[string]*models.Snippet +} + +func newFakeSnippetStore() *fakeSnippetStore { + return &fakeSnippetStore{snippets: map[string]*models.Snippet{}} +} + +func (f *fakeSnippetStore) Create(ctx context.Context, snippet *models.Snippet) error { + f.mu.Lock() + defer f.mu.Unlock() + cp := *snippet + f.snippets[snippet.ID] = &cp + return nil +} + +func (f *fakeSnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet, error) { + f.mu.Lock() + defer f.mu.Unlock() + sn, ok := f.snippets[id] + if !ok { + return nil, store.ErrSnippetNotFound + } + cp := *sn + return &cp, nil +} + +func (f *fakeSnippetStore) GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) { + f.mu.Lock() + defer f.mu.Unlock() + result := make([]*models.Snippet, 0) + for _, sn := range f.snippets { + if sn.UserID == userID { + cp := *sn + result = append(result, &cp) + } + } + return result, nil +} + +func (f *fakeSnippetStore) Delete(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.snippets[id]; !ok { + return store.ErrSnippetNotFound + } + 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 +} diff --git a/internal/handler/interfaces.go b/internal/handler/interfaces.go new file mode 100644 index 0000000..04ab6bc --- /dev/null +++ b/internal/handler/interfaces.go @@ -0,0 +1,28 @@ +package handler + +import ( + "context" + + "5803025020/w04/internal/models" +) + +type UserStore interface { + Create(ctx context.Context, user *models.User) error + GetByUsername(ctx context.Context, username string) (*models.User, error) +} + +type SessionStore interface { + Create(ctx context.Context, session *models.Session) error + + 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/snippet_handler_test.go b/internal/handler/snippet_handler_test.go new file mode 100644 index 0000000..0cd437d --- /dev/null +++ b/internal/handler/snippet_handler_test.go @@ -0,0 +1,327 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "5803025020/w04/internal/middleware" + "5803025020/w04/internal/models" +) + +func mustJSONBody(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal request body: %v", err) + } + return b +} + +// authedRequest builds a request that already carries an authenticated user +// id in its context, simulating what RequireAuth does before calling the +// handler. This lets snippet handler tests run without any middleware. +func authedRequest(method, path string, body []byte, userID string) *http.Request { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, path, nil) + } + ctx := context.WithValue(req.Context(), middleware.UserIDKey, userID) + return req.WithContext(ctx) +} + +func TestCreateSnippet(t *testing.T) { + t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "hello", "content": "world"}), "user-1") + rec := httptest.NewRecorder() + h.Create(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + assertJSONContentType(t, rec) + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + wantKeys := []string{"id", "title", "content"} + if len(body) != len(wantKeys) { + t.Fatalf("expected exactly keys %v, got %v", wantKeys, body) + } + for _, k := range wantKeys { + if _, ok := body[k]; !ok { + t.Fatalf("missing key %q in response: %v", k, body) + } + } + }) + + t.Run("empty title returns 400", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "", "content": "world"}), "user-1") + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("empty content returns 400", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodPost, "/snippets", mustJSONBody(t, map[string]string{"title": "hello", "content": ""}), "user-1") + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodPost, "/snippets", []byte("{bad"), "user-1") + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := httptest.NewRequest(http.MethodPost, "/snippets", bytes.NewReader(mustJSONBody(t, map[string]string{"title": "hello", "content": "world"}))) + rec := httptest.NewRecorder() + h.Create(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) +} + +func TestGetSnippet(t *testing.T) { + newSeeded := func() (*SnippetHandler, *fakeSnippetStore) { + ss := newFakeSnippetStore() + _ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "hello", Content: "world", UserID: "owner-1"}) + return NewSnippetHandler(ss), ss + } + + t.Run("existing id returns 200 without any Authorization header", func(t *testing.T) { + h, _ := newSeeded() + req := httptest.NewRequest(http.MethodGet, "/snippets/s1", nil) + req.SetPathValue("id", "s1") + rec := httptest.NewRecorder() + h.Get(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + } + assertJSONContentType(t, rec) + }) + + t.Run("missing id returns 404", func(t *testing.T) { + h, _ := newSeeded() + req := httptest.NewRequest(http.MethodGet, "/snippets/does-not-exist", nil) + req.SetPathValue("id", "does-not-exist") + rec := httptest.NewRecorder() + h.Get(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("response has exactly the keys id, title, content", func(t *testing.T) { + h, _ := newSeeded() + req := httptest.NewRequest(http.MethodGet, "/snippets/s1", nil) + req.SetPathValue("id", "s1") + rec := httptest.NewRecorder() + h.Get(rec, req) + assertJSONContentType(t, rec) + + var decoded map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { + t.Fatalf("expected JSON body: %v", err) + } + wantKeys := []string{"id", "title", "content"} + if len(decoded) != len(wantKeys) { + t.Fatalf("expected exactly keys %v, got %v", wantKeys, decoded) + } + for _, k := range wantKeys { + if _, ok := decoded[k]; !ok { + t.Fatalf("missing key %q in response: %v", k, decoded) + } + } + }) +} + +func TestListSnippets(t *testing.T) { + t.Run("only the caller's snippets are returned", func(t *testing.T) { + ss := newFakeSnippetStore() + ctx := context.Background() + _ = ss.Create(ctx, &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "user-1"}) + _ = ss.Create(ctx, &models.Snippet{ID: "s2", Title: "t2", Content: "c2", UserID: "user-2"}) + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodGet, "/snippets", nil, "user-1") + rec := httptest.NewRecorder() + h.List(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + assertJSONContentType(t, rec) + var got []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("expected JSON array: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected exactly 1 snippet, got %d: %v", len(got), got) + } + if got[0]["id"] != "s1" { + t.Fatalf("expected snippet s1, got %v", got[0]) + } + }) + + t.Run("no snippets returns a literal empty array, not null", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodGet, "/snippets", nil, "user-with-nothing") + rec := httptest.NewRecorder() + h.List(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + assertJSONContentType(t, rec) + body := strings.TrimSpace(rec.Body.String()) + if body != "[]" { + t.Fatalf("expected literal [], got %q", body) + } + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + ss := newFakeSnippetStore() + h := NewSnippetHandler(ss) + req := httptest.NewRequest(http.MethodGet, "/snippets", nil) + rec := httptest.NewRecorder() + h.List(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("each element has exactly the three keys", func(t *testing.T) { + ss := newFakeSnippetStore() + _ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "user-1"}) + h := NewSnippetHandler(ss) + req := authedRequest(http.MethodGet, "/snippets", nil, "user-1") + rec := httptest.NewRecorder() + h.List(rec, req) + assertJSONContentType(t, rec) + + var got []map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("expected JSON array: %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 element, got %d", len(got)) + } + wantKeys := []string{"id", "title", "content"} + if len(got[0]) != len(wantKeys) { + t.Fatalf("expected exactly keys %v, got %v", wantKeys, got[0]) + } + }) +} + +func TestDeleteSnippet(t *testing.T) { + newSeeded := func() (*SnippetHandler, *fakeSnippetStore) { + ss := newFakeSnippetStore() + _ = ss.Create(context.Background(), &models.Snippet{ID: "s1", Title: "t1", Content: "c1", UserID: "owner-1"}) + return NewSnippetHandler(ss), ss + } + + t.Run("owner deleting returns 204 with empty body and snippet is gone", func(t *testing.T) { + h, ss := newSeeded() + req := authedRequest(http.MethodDelete, "/snippets/s1", nil, "owner-1") + req.SetPathValue("id", "s1") + rec := httptest.NewRecorder() + h.Delete(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d: %s", rec.Code, rec.Body.String()) + } + // 204 is exempt from the Content-Type:application/json requirement. + if rec.Body.Len() != 0 { + t.Fatalf("expected empty body, got %q", rec.Body.String()) + } + if ss.has("s1") { + t.Fatal("expected snippet to be removed from the fake store") + } + }) + + t.Run("non-owner returns 403 and snippet still present", func(t *testing.T) { + h, ss := newSeeded() + req := authedRequest(http.MethodDelete, "/snippets/s1", nil, "someone-else") + req.SetPathValue("id", "s1") + rec := httptest.NewRecorder() + h.Delete(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } + assertJSONContentType(t, rec) + if !ss.has("s1") { + t.Fatal("expected snippet to still be present") + } + }) + + t.Run("nonexistent id returns 404", func(t *testing.T) { + h, _ := newSeeded() + req := authedRequest(http.MethodDelete, "/snippets/does-not-exist", nil, "owner-1") + req.SetPathValue("id", "does-not-exist") + rec := httptest.NewRecorder() + h.Delete(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("unauthenticated returns 401", func(t *testing.T) { + h, _ := newSeeded() + req := httptest.NewRequest(http.MethodDelete, "/snippets/s1", nil) + req.SetPathValue("id", "s1") + rec := httptest.NewRecorder() + h.Delete(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) + + t.Run("non-owner on a nonexistent id returns 404, not 403", func(t *testing.T) { + h, _ := newSeeded() + req := authedRequest(http.MethodDelete, "/snippets/does-not-exist", nil, "someone-else") + req.SetPathValue("id", "does-not-exist") + rec := httptest.NewRecorder() + h.Delete(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + assertJSONContentType(t, rec) + }) +} diff --git a/internal/httpjson/httpjson.go b/internal/httpjson/httpjson.go new file mode 100644 index 0000000..0697a3e --- /dev/null +++ b/internal/httpjson/httpjson.go @@ -0,0 +1,12 @@ +package httpjson + +import ( + "encoding/json" + "net/http" +) + +func WriteError(w http.ResponseWriter, status int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"message": message}) +} diff --git a/internal/middleware/interfaces.go b/internal/middleware/interfaces.go new file mode 100644 index 0000000..806b0bb --- /dev/null +++ b/internal/middleware/interfaces.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "context" + + "5803025020/w04/internal/models" +) + +// SessionLookup is the small interface RequireAuth needs to resolve a bearer +// token to the session (and therefore the user id) it belongs to. Defined +// here (the consumer) so internal/store never needs to import middleware. +// *store.SessionStore satisfies this implicitly via its existing Get method. +type SessionLookup interface { + Get(ctx context.Context, token string) (*models.Session, error) +} diff --git a/internal/middleware/require_auth_test.go b/internal/middleware/require_auth_test.go new file mode 100644 index 0000000..c92c21f --- /dev/null +++ b/internal/middleware/require_auth_test.go @@ -0,0 +1,162 @@ +package middleware + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "5803025020/w04/internal/models" +) + +// fakeSessionLookup is a hand-written in-memory fake satisfying SessionLookup. +type fakeSessionLookup struct { + sessions map[string]*models.Session +} + +func newFakeSessionLookup() *fakeSessionLookup { + return &fakeSessionLookup{sessions: make(map[string]*models.Session)} +} + +func (f *fakeSessionLookup) add(token, userID string) { + f.sessions[token] = &models.Session{Token: token, UserID: userID, CreatedAt: time.Now()} +} + +var errFakeSessionNotFound = errors.New("fake: session not found") + +func (f *fakeSessionLookup) Get(ctx context.Context, token string) (*models.Session, error) { + sess, ok := f.sessions[token] + if !ok { + return nil, errFakeSessionNotFound + } + return sess, nil +} + +// newRecordingNext returns a next handler that records whether it was called +// and what user id was readable from the request context inside it. +func newRecordingNext(called *bool, gotUserID *string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *called = true + *gotUserID = GetUserID(r.Context()) + w.WriteHeader(http.StatusOK) + }) +} + +func assertJSONErrorResponse(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + ct := rec.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Fatalf("expected Content-Type application/json, got %q", ct) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("expected response body to parse as JSON: %v (body=%s)", err, rec.Body.String()) + } +} + +func TestRequireAuth(t *testing.T) { + fake := newFakeSessionLookup() + fake.add("validtoken1234567890123456789012", "user-123") + mw := NewAuthMiddleware(fake) + + t.Run("no Authorization header returns 401 and next is not called", func(t *testing.T) { + called := false + var gotUserID string + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + mw.RequireAuth(newRecordingNext(&called, &gotUserID)).ServeHTTP(rec, req) + + if called { + t.Fatal("expected next handler NOT to be called") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONErrorResponse(t, rec) + }) + + t.Run("Basic scheme returns 401", func(t *testing.T) { + called := false + var gotUserID string + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Basic abc") + mw.RequireAuth(newRecordingNext(&called, &gotUserID)).ServeHTTP(rec, req) + + if called { + t.Fatal("expected next handler NOT to be called") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + }) + + t.Run("Bearer with empty token returns 401", func(t *testing.T) { + called := false + var gotUserID string + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer ") + mw.RequireAuth(newRecordingNext(&called, &gotUserID)).ServeHTTP(rec, req) + + if called { + t.Fatal("expected next handler NOT to be called") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + }) + + t.Run("unknown token returns 401", func(t *testing.T) { + called := false + var gotUserID string + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer not-a-real-token-000000000000") + mw.RequireAuth(newRecordingNext(&called, &gotUserID)).ServeHTTP(rec, req) + + if called { + t.Fatal("expected next handler NOT to be called") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + }) + + t.Run("valid token calls next handler with the authenticated user id in context", func(t *testing.T) { + called := false + var gotUserID string + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer validtoken1234567890123456789012") + mw.RequireAuth(newRecordingNext(&called, &gotUserID)).ServeHTTP(rec, req) + + if !called { + t.Fatal("expected next handler to be called") + } + if gotUserID != "user-123" { + t.Fatalf("expected user id %q inside next handler, got %q", "user-123", gotUserID) + } + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 from next handler, got %d", rec.Code) + } + }) + + t.Run("401 responses carry Content-Type application/json and a body that parses as JSON", func(t *testing.T) { + called := false + var gotUserID string + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer not-a-real-token-000000000000") + mw.RequireAuth(newRecordingNext(&called, &gotUserID)).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rec.Code) + } + assertJSONErrorResponse(t, rec) + }) +} diff --git a/internal/store/main_test.go b/internal/store/main_test.go new file mode 100644 index 0000000..d3cb5e1 --- /dev/null +++ b/internal/store/main_test.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "testing" + + "5803025020/w04/internal/config" + "5803025020/w04/internal/database" + "github.com/jackc/pgx/v5" +) + +// testConn is the single shared connection used by every integration test in +// this package. testMu serializes access to it the same way the production +// server serializes access across UserStore/SessionStore/SnippetStore. +var ( + testConn *pgx.Conn + testMu = &sync.Mutex{} +) + +func TestMain(m *testing.M) { + config.LoadEnv() + + dbName := os.Getenv("DB_NAME") + if !strings.HasSuffix(dbName, "_test") { + fmt.Fprintf(os.Stderr, + "refusing to run internal/store integration tests: DB_NAME=%q must end in \"_test\" (safety guard against running against a non-test database)\n", + dbName) + os.Exit(1) + } + + conn, err := database.Connect() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to connect to test database: %v\n", err) + os.Exit(1) + } + testConn = conn + + // Create the schema through the exact same code path the server uses on + // startup — no second copy of the CREATE TABLE statements here. + if err := database.CreateTables(testConn); err != nil { + fmt.Fprintf(os.Stderr, "failed to create schema: %v\n", err) + os.Exit(1) + } + + code := m.Run() + + testConn.Close(context.Background()) + os.Exit(code) +} + +// truncateAll wipes every application table so each test starts from a clean +// slate. Call this at the start of every test (not just once in TestMain). +func truncateAll(t *testing.T) { + t.Helper() + _, err := testConn.Exec(context.Background(), + "TRUNCATE TABLE sessions, snippets, users RESTART IDENTITY CASCADE") + if err != nil { + t.Fatalf("failed to truncate tables: %v", err) + } +} diff --git a/internal/store/session_store.go b/internal/store/session_store.go index 247d019..5ab1b84 100644 --- a/internal/store/session_store.go +++ b/internal/store/session_store.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "sync" "time" @@ -9,6 +10,9 @@ import ( "github.com/jackc/pgx/v5" ) +// ErrSessionNotFound dikembalikan saat token sesi tidak ditemukan (atau sudah dihapus). +var ErrSessionNotFound = errors.New("session not found") + type SessionStore struct { conn *pgx.Conn mu *sync.Mutex @@ -45,16 +49,28 @@ 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, ErrSessionNotFound + } return nil, err } return &sess, nil } +// Delete menghapus token sesi. Jika token tidak ditemukan (sudah dihapus atau +// tidak pernah ada), ia mengembalikan ErrSessionNotFound tanpa panic — memanggil +// Delete dua kali dengan token yang sama aman dan tidak menghapus token lain. func (s *SessionStore) Delete(ctx context.Context, token string) error { s.mu.Lock() defer s.mu.Unlock() query := `DELETE FROM sessions WHERE token = $1` - _, err := s.conn.Exec(ctx, query, token) - return err + tag, err := s.conn.Exec(ctx, query, token) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrSessionNotFound + } + return nil } diff --git a/internal/store/session_store_test.go b/internal/store/session_store_test.go new file mode 100644 index 0000000..2f87784 --- /dev/null +++ b/internal/store/session_store_test.go @@ -0,0 +1,155 @@ +package store + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "5803025020/w04/internal/database" + "5803025020/w04/internal/models" + "github.com/google/uuid" +) + +func newTestSessionStore(t *testing.T) (*SessionStore, *UserStore) { + t.Helper() + truncateAll(t) + return NewSessionStore(testConn, testMu), NewUserStore(testConn, testMu) +} + +func seedUserForSession(t *testing.T, us *UserStore) *models.User { + t.Helper() + u := &models.User{ID: uuid.New().String(), Username: "sessuser_" + uuid.NewString()[:8], PasswordHash: "hash"} + if err := us.Create(context.Background(), u); err != nil { + t.Fatalf("failed to seed user: %v", err) + } + return u +} + +func TestSessionStore(t *testing.T) { + t.Run("create then look up token returns correct user id", func(t *testing.T) { + ss, us := newTestSessionStore(t) + u := seedUserForSession(t, us) + ctx := context.Background() + token := "tok_" + uuid.NewString() + if err := ss.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create session failed: %v", err) + } + got, err := ss.Get(ctx, token) + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got.UserID != u.ID { + t.Fatalf("expected user id %q, got %q", u.ID, got.UserID) + } + }) + + t.Run("unknown token returns ErrSessionNotFound", func(t *testing.T) { + ss, _ := newTestSessionStore(t) + _, err := ss.Get(context.Background(), "no-such-token") + if !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("expected ErrSessionNotFound, got %v", err) + } + }) + + t.Run("delete then look up returns ErrSessionNotFound", func(t *testing.T) { + ss, us := newTestSessionStore(t) + u := seedUserForSession(t, us) + ctx := context.Background() + token := "tok_" + uuid.NewString() + if err := ss.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create failed: %v", err) + } + if err := ss.Delete(ctx, token); err != nil { + t.Fatalf("delete failed: %v", err) + } + _, err := ss.Get(ctx, token) + if !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("expected ErrSessionNotFound after delete, got %v", err) + } + }) + + t.Run("deleting an already-deleted token does not panic and does not delete other tokens", func(t *testing.T) { + ss, us := newTestSessionStore(t) + u := seedUserForSession(t, us) + ctx := context.Background() + token := "tok_" + uuid.NewString() + other := "tok_" + uuid.NewString() + if err := ss.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create token failed: %v", err) + } + if err := ss.Create(ctx, &models.Session{Token: other, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create other token failed: %v", err) + } + if err := ss.Delete(ctx, token); err != nil { + t.Fatalf("first delete failed: %v", err) + } + + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("second delete of the same token panicked: %v", r) + } + }() + _ = ss.Delete(ctx, token) // return value is our choice; must not panic + }() + + if _, err := ss.Get(ctx, other); err != nil { + t.Fatalf("expected the other token to remain valid, got err=%v", err) + } + }) + + t.Run("two sessions for one user are independent", func(t *testing.T) { + ss, us := newTestSessionStore(t) + u := seedUserForSession(t, us) + ctx := context.Background() + token1 := "tok_" + uuid.NewString() + token2 := "tok_" + uuid.NewString() + if err := ss.Create(ctx, &models.Session{Token: token1, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create token1 failed: %v", err) + } + if err := ss.Create(ctx, &models.Session{Token: token2, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create token2 failed: %v", err) + } + if err := ss.Delete(ctx, token1); err != nil { + t.Fatalf("delete token1 failed: %v", err) + } + if _, err := ss.Get(ctx, token1); !errors.Is(err, ErrSessionNotFound) { + t.Fatalf("expected token1 to be gone, got err=%v", err) + } + if _, err := ss.Get(ctx, token2); err != nil { + t.Fatalf("expected token2 to still be valid, got err=%v", err) + } + }) +} + +// TestSessionSurvivesNewStore creates a session with one SessionStore +// instance, then opens a brand-new connection and a brand-new SessionStore +// instance against the same database, and confirms the token is still +// readable — the closest a Go test can get to asserting survival across a +// server restart. +func TestSessionSurvivesNewStore(t *testing.T) { + ss1, us := newTestSessionStore(t) + u := seedUserForSession(t, us) + ctx := context.Background() + token := "tok_" + uuid.NewString() + if err := ss1.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil { + t.Fatalf("create failed: %v", err) + } + + newConn, err := database.Connect() + if err != nil { + t.Fatalf("failed to open a brand-new connection: %v", err) + } + defer newConn.Close(ctx) + + ss2 := NewSessionStore(newConn, &sync.Mutex{}) + got, err := ss2.Get(ctx, token) + if err != nil { + t.Fatalf("expected session to survive on a brand-new store instance, got err=%v", err) + } + if got.UserID != u.ID { + t.Fatalf("expected user id %q, got %q", u.ID, got.UserID) + } +} diff --git a/internal/store/snippet_store.go b/internal/store/snippet_store.go index bfa0cca..d31c800 100644 --- a/internal/store/snippet_store.go +++ b/internal/store/snippet_store.go @@ -2,6 +2,7 @@ package store import ( "context" + "errors" "sync" "5803025020/w04/internal/models" @@ -9,6 +10,9 @@ import ( "github.com/jackc/pgx/v5" ) +// ErrSnippetNotFound dikembalikan saat snippet dengan id yang diminta tidak ada. +var ErrSnippetNotFound = errors.New("snippet not found") + type SnippetStore struct { conn *pgx.Conn mu *sync.Mutex @@ -45,6 +49,9 @@ 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, ErrSnippetNotFound + } return nil, err } return &sn, nil @@ -100,11 +107,19 @@ func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) { return snippets, nil } +// Delete menghapus snippet dengan id tersebut. Jika id tidak ditemukan, ia +// mengembalikan ErrSnippetNotFound. func (s *SnippetStore) Delete(ctx context.Context, id string) error { s.mu.Lock() 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 ErrSnippetNotFound + } + return nil } diff --git a/internal/store/snippet_store_test.go b/internal/store/snippet_store_test.go new file mode 100644 index 0000000..e2fa1b9 --- /dev/null +++ b/internal/store/snippet_store_test.go @@ -0,0 +1,179 @@ +package store + +import ( + "context" + "errors" + "testing" + + "5803025020/w04/internal/models" + "github.com/google/uuid" +) + +func newTestSnippetStore(t *testing.T) (*SnippetStore, *UserStore) { + t.Helper() + truncateAll(t) + return NewSnippetStore(testConn, testMu), NewUserStore(testConn, testMu) +} + +func seedUserForSnippet(t *testing.T, us *UserStore) *models.User { + t.Helper() + u := &models.User{ID: uuid.New().String(), Username: "snipuser_" + uuid.NewString()[:8], PasswordHash: "hash"} + if err := us.Create(context.Background(), u); err != nil { + t.Fatalf("failed to seed user: %v", err) + } + return u +} + +func TestSnippetStore(t *testing.T) { + t.Run("create then get by id", func(t *testing.T) { + sn, us := newTestSnippetStore(t) + u := seedUserForSnippet(t, us) + ctx := context.Background() + snippet := &models.Snippet{ID: uuid.New().String(), Title: "hello", Content: "world", UserID: u.ID} + if err := sn.Create(ctx, snippet); err != nil { + t.Fatalf("create failed: %v", err) + } + got, err := sn.GetByID(ctx, snippet.ID) + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got.Title != "hello" || got.Content != "world" { + t.Fatalf("got %+v, want title=hello content=world", got) + } + }) + + t.Run("get unknown id returns ErrSnippetNotFound", func(t *testing.T) { + sn, _ := newTestSnippetStore(t) + _, err := sn.GetByID(context.Background(), "no-such-id") + if !errors.Is(err, ErrSnippetNotFound) { + t.Fatalf("expected ErrSnippetNotFound, got %v", err) + } + }) + + t.Run("list by owner returns only that owner's rows", func(t *testing.T) { + sn, us := newTestSnippetStore(t) + u1 := seedUserForSnippet(t, us) + u2 := seedUserForSnippet(t, us) + ctx := context.Background() + if err := sn.Create(ctx, &models.Snippet{ID: uuid.New().String(), Title: "a", Content: "a", UserID: u1.ID}); err != nil { + t.Fatalf("create for u1 failed: %v", err) + } + if err := sn.Create(ctx, &models.Snippet{ID: uuid.New().String(), Title: "b", Content: "b", UserID: u2.ID}); err != nil { + t.Fatalf("create for u2 failed: %v", err) + } + got, err := sn.GetByUserID(ctx, u1.ID) + if err != nil { + t.Fatalf("list failed: %v", err) + } + if len(got) != 1 || got[0].UserID != u1.ID { + t.Fatalf("expected exactly 1 snippet owned by u1, got %+v", got) + } + }) + + t.Run("list for an owner with no snippets returns a non-nil empty slice", func(t *testing.T) { + sn, us := newTestSnippetStore(t) + u := seedUserForSnippet(t, us) + got, err := sn.GetByUserID(context.Background(), u.ID) + if err != nil { + t.Fatalf("list failed: %v", err) + } + if got == nil { + t.Fatal("expected non-nil empty slice, got nil") + } + if len(got) != 0 { + t.Fatalf("expected empty slice, got %d items", len(got)) + } + }) + + t.Run("delete removes the row", func(t *testing.T) { + sn, us := newTestSnippetStore(t) + u := seedUserForSnippet(t, us) + ctx := context.Background() + snippet := &models.Snippet{ID: uuid.New().String(), Title: "x", Content: "y", UserID: u.ID} + if err := sn.Create(ctx, snippet); err != nil { + t.Fatalf("create failed: %v", err) + } + if err := sn.Delete(ctx, snippet.ID); err != nil { + t.Fatalf("delete failed: %v", err) + } + _, err := sn.GetByID(ctx, snippet.ID) + if !errors.Is(err, ErrSnippetNotFound) { + t.Fatalf("expected ErrSnippetNotFound after delete, got %v", err) + } + }) + + t.Run("delete unknown id returns ErrSnippetNotFound", func(t *testing.T) { + sn, _ := newTestSnippetStore(t) + err := sn.Delete(context.Background(), "no-such-id") + if !errors.Is(err, ErrSnippetNotFound) { + t.Fatalf("expected ErrSnippetNotFound, got %v", err) + } + }) + + t.Run("ownership of a snippet can be retrieved to distinguish not found from not yours", func(t *testing.T) { + sn, us := newTestSnippetStore(t) + u1 := seedUserForSnippet(t, us) + ctx := context.Background() + snippet := &models.Snippet{ID: uuid.New().String(), Title: "x", Content: "y", UserID: u1.ID} + if err := sn.Create(ctx, snippet); err != nil { + t.Fatalf("create failed: %v", err) + } + got, err := sn.GetByID(ctx, snippet.ID) + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got.UserID != u1.ID { + t.Fatalf("expected to read back owner id %q, got %q", u1.ID, got.UserID) + } + // A handler compares got.UserID against the authenticated caller's id + // to tell "not found" (GetByID itself errors) apart from "not yours" + // (GetByID succeeds but UserID does not match the caller). + }) +} + +// TestSnippetStoreSQLInjection passes classic SQL-injection payloads through +// Create/Get/List and confirms the strings round-trip unchanged and the +// snippets table still exists and contains only the expected rows. +func TestSnippetStoreSQLInjection(t *testing.T) { + sn, us := newTestSnippetStore(t) + u := seedUserForSnippet(t, us) + ctx := context.Background() + + payloads := []string{ + `' OR '1'='1`, + `'; DROP TABLE snippets; --`, + `a title containing a single quote: it's here`, + } + + for i, payload := range payloads { + t.Run(payload, func(t *testing.T) { + snippet := &models.Snippet{ID: uuid.New().String(), Title: payload, Content: payload, UserID: u.ID} + if err := sn.Create(ctx, snippet); err != nil { + t.Fatalf("create with payload %d failed: %v", i, err) + } + got, err := sn.GetByID(ctx, snippet.ID) + if err != nil { + t.Fatalf("get with payload %d failed: %v", i, err) + } + if got.Title != payload || got.Content != payload { + t.Fatalf("payload %d did not round-trip: got title=%q content=%q, want %q", i, got.Title, got.Content, payload) + } + + list, err := sn.GetByUserID(ctx, u.ID) + if err != nil { + t.Fatalf("list after payload %d failed: %v", i, err) + } + if len(list) == 0 { + t.Fatalf("expected the snippets table to still contain rows after payload %d", i) + } + }) + } + + var count int + if err := testConn.QueryRow(ctx, "SELECT COUNT(*) FROM snippets").Scan(&count); err != nil { + t.Fatalf("snippets table appears to be gone: %v", err) + } + if count != len(payloads) { + t.Fatalf("expected exactly %d rows (one per payload), got %d", len(payloads), count) + } +} diff --git a/internal/store/user_store.go b/internal/store/user_store.go index a659f72..d5a1b8c 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -14,6 +14,9 @@ import ( // ErrUsernameTaken dikembalikan saat Create() gagal karena username sudah dipakai (unique constraint). var ErrUsernameTaken = errors.New("username already exists") +// ErrUserNotFound dikembalikan saat user dengan username/id yang diminta tidak ada. +var ErrUserNotFound = errors.New("user not found") + type UserStore struct { conn *pgx.Conn mu *sync.Mutex @@ -57,6 +60,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, ErrUserNotFound + } return nil, err } return &u, nil @@ -70,6 +76,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, ErrUserNotFound + } 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..fdc7028 --- /dev/null +++ b/internal/store/user_store_test.go @@ -0,0 +1,157 @@ +package store + +import ( + "context" + "errors" + "sync" + "testing" + + "5803025020/w04/internal/auth" + "5803025020/w04/internal/models" + "github.com/google/uuid" +) + +func newTestUserStore(t *testing.T) *UserStore { + t.Helper() + truncateAll(t) + return NewUserStore(testConn, testMu) +} + +func TestUserStore(t *testing.T) { + t.Run("create then fetch by username", func(t *testing.T) { + us := newTestUserStore(t) + ctx := context.Background() + u := &models.User{ID: uuid.New().String(), Username: "alice_" + uuid.NewString()[:8], PasswordHash: "hash-1"} + if err := us.Create(ctx, u); err != nil { + t.Fatalf("Create failed: %v", err) + } + got, err := us.GetByUsername(ctx, u.Username) + if err != nil { + t.Fatalf("GetByUsername failed: %v", err) + } + if got.ID != u.ID || got.Username != u.Username { + t.Fatalf("got %+v, want id=%s username=%s", got, u.ID, u.Username) + } + }) + + t.Run("fetch unknown username returns ErrUserNotFound", func(t *testing.T) { + us := newTestUserStore(t) + _, err := us.GetByUsername(context.Background(), "no-such-user-xyz") + if !errors.Is(err, ErrUserNotFound) { + t.Fatalf("expected ErrUserNotFound, got %v", err) + } + }) + + t.Run("duplicate username returns ErrUsernameTaken from the database UNIQUE constraint", func(t *testing.T) { + us := newTestUserStore(t) + ctx := context.Background() + username := "dupuser_" + uuid.NewString()[:8] + u1 := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-1"} + if err := us.Create(ctx, u1); err != nil { + t.Fatalf("first create failed: %v", err) + } + u2 := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-2"} + err := us.Create(ctx, u2) + if !errors.Is(err, ErrUsernameTaken) { + t.Fatalf("expected ErrUsernameTaken, got %v", err) + } + }) + + t.Run("usernames are case sensitive", func(t *testing.T) { + us := newTestUserStore(t) + ctx := context.Background() + suffix := uuid.NewString()[:8] + lower := &models.User{ID: uuid.New().String(), Username: "case_" + suffix, PasswordHash: "hash-1"} + upper := &models.User{ID: uuid.New().String(), Username: "CASE_" + suffix, PasswordHash: "hash-2"} + if err := us.Create(ctx, lower); err != nil { + t.Fatalf("create lower failed: %v", err) + } + if err := us.Create(ctx, upper); err != nil { + t.Fatalf("create upper failed (expected a distinct user, usernames should be case sensitive): %v", err) + } + gotLower, err := us.GetByUsername(ctx, lower.Username) + if err != nil { + t.Fatalf("get lower failed: %v", err) + } + gotUpper, err := us.GetByUsername(ctx, upper.Username) + if err != nil { + t.Fatalf("get upper failed: %v", err) + } + if gotLower.ID == gotUpper.ID { + t.Fatal("expected two distinct users for differently-cased usernames") + } + }) + + t.Run("stored password_hash differs from the plaintext that was passed in", func(t *testing.T) { + us := newTestUserStore(t) + ctx := context.Background() + plaintext := "correct horse battery staple" + hash, err := auth.HashPassword(plaintext) + if err != nil { + t.Fatalf("failed to hash password: %v", err) + } + u := &models.User{ID: uuid.New().String(), Username: "hashcheck_" + uuid.NewString()[:8], PasswordHash: hash} + if err := us.Create(ctx, u); err != nil { + t.Fatalf("create failed: %v", err) + } + got, err := us.GetByUsername(ctx, u.Username) + if err != nil { + t.Fatalf("get failed: %v", err) + } + if got.PasswordHash == plaintext { + t.Fatal("expected stored password_hash to differ from the plaintext password") + } + if got.PasswordHash != hash { + t.Fatalf("expected stored hash to round-trip unchanged, got %q want %q", got.PasswordHash, hash) + } + }) +} + +// TestUserStoreConcurrentRegister launches 20 goroutines that all try to +// create the same username. Exactly one must succeed, the other 19 must +// return ErrUsernameTaken, and exactly one row must exist afterward. Run +// this test with `go test -race`. +func TestUserStoreConcurrentRegister(t *testing.T) { + us := newTestUserStore(t) + ctx := context.Background() + username := "concurrent_" + uuid.NewString()[:8] + + const n = 20 + var wg sync.WaitGroup + results := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + u := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash"} + results[i] = us.Create(ctx, u) + }(i) + } + wg.Wait() + + successCount, dupCount := 0, 0 + for _, err := range results { + switch { + case err == nil: + successCount++ + case errors.Is(err, ErrUsernameTaken): + dupCount++ + default: + t.Fatalf("unexpected error: %v", err) + } + } + if successCount != 1 { + t.Fatalf("expected exactly 1 success, got %d", successCount) + } + if dupCount != n-1 { + t.Fatalf("expected %d duplicate errors, got %d", n-1, dupCount) + } + + var count int + if err := testConn.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count); err != nil { + t.Fatalf("count query failed: %v", err) + } + if count != 1 { + t.Fatalf("expected exactly 1 row in the database, got %d", count) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..9be3a20 --- /dev/null +++ b/readme.md @@ -0,0 +1,42 @@ +# Assignment 4: + +**Name:** Yovan Budianto Tanujaya
+**NRP:** 5803025020
+ +--- + +## Overview + +Proyek ini adalah **RESTful Web API** yang dibuat menggunakan **Go** dengan **PostgreSQL** sebagai penyimpanan database utama. Proyek ini mencakup **manajemen autentikasi, operasi pada store layer, serta pengujian otomatis** yang meliputi **unit test, integration test, dan E2E test**. + + +--- + +## Prerequisites + +* **Go:** Version 1.22 higher +* **PostgreSQL:** Local +* **OS:** Windows / Linux / macOS + +--- + +## Environment Variables + +The application relies on the following environment variables for database configuration and testing: + +| Variable | Description | Default / Example | +| :--- | :--- | :--- | +| `DB_HOST` | Database host address | `localhost` | +| `DB_PORT` | Database port | `5432` | +| `DB_USER` | Database username | `postgres` | +| `DB_PASSWORD` | Database password | *Your local database password from postgresql* | +| `DB_NAME` | Database name (**must end with `_test`** during testing) | `w04_5803025020` | + +--- + +## Database Setup + +Before running integration tests, create a PostgreSQL test database with a name ending in `_test` as a safety guard[cite: 3, 4]: + +```sql +CREATE DATABASE w04_5803025020; \ No newline at end of file