Compare commits
3
Commits
47f490f5a3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3945920abb | ||
|
|
4ac21847fc | ||
|
|
5e9d934885 |
@@ -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
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("store: not found")
|
||||
ErrDuplicate = errors.New("store: duplicate")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user