Compare commits

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