Assignment 4: add unit, integration, and e2e tests; refactor for testability
This commit is contained in:
+2
-1
@@ -49,8 +49,9 @@ func main() {
|
|||||||
|
|
||||||
mux.Handle("GET /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.List)))
|
mux.Handle("GET /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.List)))
|
||||||
mux.Handle("POST /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Create)))
|
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("DELETE /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Delete)))
|
||||||
|
mux.Handle("GET /user", authMiddleware.RequireAuth(http.HandlerFunc(authHandler.GetProfile)))
|
||||||
|
|
||||||
port := os.Getenv("PORT")
|
port := os.Getenv("PORT")
|
||||||
if 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)
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
+125
-20
@@ -1,25 +1,130 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestPasswordAndToken(t *testing.T) {
|
func TestHashPassword(t *testing.T) {
|
||||||
pass := "secret123"
|
t.Run("hash tidak sama dengan plaintext", func(t *testing.T) {
|
||||||
hash, err := HashPassword(pass)
|
password := "secret123"
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error hashing: %v", err)
|
|
||||||
}
|
|
||||||
if !CheckPassword(pass, hash) {
|
|
||||||
t.Fatal("expected password check to succeed")
|
|
||||||
}
|
|
||||||
if CheckPassword("wrongpass", hash) {
|
|
||||||
t.Fatal("expected wrong password to fail")
|
|
||||||
}
|
|
||||||
|
|
||||||
tok, err := GenerateToken()
|
hash, err := HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error generating token: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if len(tok) != 64 {
|
|
||||||
t.Fatalf("expected 64 hex chars, got %d", len(tok))
|
if hash == password {
|
||||||
}
|
t.Fatal("hash should not equal plaintext")
|
||||||
|
}
|
||||||
|
t.Run("password terlalu panjang menyebabkan error", func(t *testing.T) {
|
||||||
|
// bcrypt rejects passwords > 72 bytes
|
||||||
|
tooLong := strings.Repeat("a", 100)
|
||||||
|
_, err := HashPassword(tooLong)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for password > 72 bytes")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("password yang sama menghasilkan hash berbeda", func(t *testing.T) {
|
||||||
|
password := "secret123"
|
||||||
|
|
||||||
|
hash1, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash2, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hash1 == hash2 {
|
||||||
|
t.Fatal("hashes should be different because of salting")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hash dapat memverifikasi password asli", func(t *testing.T) {
|
||||||
|
password := "secret123"
|
||||||
|
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !CheckPassword(password, hash) {
|
||||||
|
t.Fatal("expected password check to succeed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckPassword(t *testing.T) {
|
||||||
|
hash, err := HashPassword("secret123")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("password benar", func(t *testing.T) {
|
||||||
|
if !CheckPassword("secret123", hash) {
|
||||||
|
t.Fatal("expected correct password to return true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("password salah", func(t *testing.T) {
|
||||||
|
if CheckPassword("wrongpass", hash) {
|
||||||
|
t.Fatal("expected wrong password to return false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("password kosong", func(t *testing.T) {
|
||||||
|
if CheckPassword("", hash) {
|
||||||
|
t.Fatal("expected empty password to return false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hash rusak tidak panic", func(t *testing.T) {
|
||||||
|
if CheckPassword("secret123", "ini-bukan-hash-bcrypt") {
|
||||||
|
t.Fatal("expected garbage hash to return false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateToken(t *testing.T) {
|
||||||
|
const N = 1000
|
||||||
|
tokens := make([]string, 0, N)
|
||||||
|
|
||||||
|
t.Run("menghasilkan token non-empty", func(t *testing.T) {
|
||||||
|
for i := 0; i < N; i++ {
|
||||||
|
token, err := GenerateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatalf("token should not be empty at i=%d", i)
|
||||||
|
}
|
||||||
|
tokens = append(tokens, token)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("semua token distinct", func(t *testing.T) {
|
||||||
|
seen := make(map[string]bool, len(tokens))
|
||||||
|
for i, token := range tokens {
|
||||||
|
if seen[token] {
|
||||||
|
t.Fatalf("duplicate token at i=%d", i)
|
||||||
|
}
|
||||||
|
seen[token] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("token tidak mengandung whitespace dan panjang >= 32", func(t *testing.T) {
|
||||||
|
for i, token := range tokens {
|
||||||
|
if len(token) < 32 {
|
||||||
|
t.Fatalf("token too short (%d chars) at i=%d", len(token), i)
|
||||||
|
}
|
||||||
|
if strings.IndexAny(token, " \t\r\n") != -1 {
|
||||||
|
t.Fatalf("token contains whitespace at i=%d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Connect membuat koneksi ke PostgreSQL menggunakan env vars individual
|
|
||||||
func Connect() (*pgx.Conn, error) {
|
func Connect() (*pgx.Conn, error) {
|
||||||
host := getEnvOrDefault("DB_HOST", "localhost")
|
host := getEnvOrDefault("DB_HOST", "localhost")
|
||||||
port := getEnvOrDefault("DB_PORT", "5432")
|
port := getEnvOrDefault("DB_PORT", "5432")
|
||||||
@@ -33,7 +32,6 @@ func Connect() (*pgx.Conn, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTables membuat tabel users, sessions, dan snippets jika belum ada
|
|
||||||
func CreateTables(conn *pgx.Conn) error {
|
func CreateTables(conn *pgx.Conn) error {
|
||||||
queries := []string{
|
queries := []string{
|
||||||
`CREATE TABLE IF NOT EXISTS users (
|
`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 {
|
||||||
if err := SeedSampleData(conn); err != nil {
|
// fmt.Fprintf(os.Stderr, "Warning: failed to seed sample data: %v\n", err)
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to seed sample data: %v\n", err)
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
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 {
|
func SeedSampleData(conn *pgx.Conn) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,26 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"5803025015/w04/internal/auth"
|
"5803025015/w04/internal/auth"
|
||||||
|
"5803025015/w04/internal/middleware"
|
||||||
"5803025015/w04/internal/models"
|
"5803025015/w04/internal/models"
|
||||||
"5803025015/w04/internal/store"
|
"5803025015/w04/internal/store"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
userStore *store.UserStore
|
userStore UserStore
|
||||||
sessionStore *store.SessionStore
|
sessionStore SessionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(userStore *store.UserStore, sessionStore *store.SessionStore) *AuthHandler {
|
func NewAuthHandler(userStore UserStore, sessionStore SessionStore) *AuthHandler {
|
||||||
return &AuthHandler{
|
return &AuthHandler{userStore: userStore, sessionStore: sessionStore}
|
||||||
userStore: userStore,
|
|
||||||
sessionStore: sessionStore,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type authRequest struct {
|
type authRequest struct {
|
||||||
@@ -29,27 +29,38 @@ type authRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
// ---------- shared JSON helpers (dipakai juga oleh snippet_handler.go) ----------
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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
|
var req authRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Username = strings.TrimSpace(req.Username)
|
req.Username = strings.TrimSpace(req.Username)
|
||||||
if len(req.Username) < 3 || len(req.Username) > 32 || req.Password == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hash, err := auth.HashPassword(req.Password)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
w.WriteHeader(http.StatusCreated)
|
"id": user.ID,
|
||||||
_ = json.NewEncoder(w).Encode(user)
|
"username": user.Username,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req authRequest
|
var req authRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
|
user, err := h.userStore.GetByUsername(r.Context(), req.Username)
|
||||||
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
if err != nil || user == nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := auth.GenerateToken()
|
token, err := auth.GenerateToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
w.WriteHeader(http.StatusOK)
|
"session_token": token,
|
||||||
_ = json.NewEncoder(w).Encode(session)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := authHeader
|
if _, err := h.sessionStore.Get(r.Context(), token); err != nil {
|
||||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
token = authHeader[7:]
|
|
||||||
}
|
|
||||||
token = strings.TrimSpace(token)
|
|
||||||
if token == "" {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.WriteHeader(http.StatusNoContent)
|
||||||
w.WriteHeader(http.StatusOK)
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "logged out"})
|
|
||||||
|
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/middleware"
|
||||||
"5803025015/w04/internal/models"
|
"5803025015/w04/internal/models"
|
||||||
"5803025015/w04/internal/store"
|
"5803025015/w04/internal/store"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SnippetHandler struct {
|
type SnippetHandler struct {
|
||||||
snippetStore *store.SnippetStore
|
snippetStore SnippetStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSnippetHandler(snippetStore *store.SnippetStore) *SnippetHandler {
|
func NewSnippetHandler(snippetStore SnippetStore) *SnippetHandler {
|
||||||
return &SnippetHandler{snippetStore: snippetStore}
|
return &SnippetHandler{snippetStore: snippetStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,16 @@ type snippetRequest struct {
|
|||||||
Content string `json:"content"`
|
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) {
|
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@@ -33,7 +43,7 @@ func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
|
|||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
h.Create(w, r)
|
h.Create(w, r)
|
||||||
default:
|
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:
|
case http.MethodDelete:
|
||||||
h.Delete(w, r)
|
h.Delete(w, r)
|
||||||
default:
|
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) {
|
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())
|
userID := middleware.GetUserID(r.Context())
|
||||||
if userID == "" {
|
if userID == "" {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req snippetRequest
|
var req snippetRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Title = strings.TrimSpace(req.Title)
|
req.Title = strings.TrimSpace(req.Title)
|
||||||
req.Content = strings.TrimSpace(req.Content)
|
req.Content = strings.TrimSpace(req.Content)
|
||||||
if req.Title == "" || 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
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeJSON(w, http.StatusCreated, toSnippetResponse(snippet))
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(snippet)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) {
|
func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
userID := middleware.GetUserID(r.Context())
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
if userID == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
snippets, err := h.snippetStore.GetAll(r.Context())
|
snippets, err := h.snippetStore.ListByOwner(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
out := make([]snippetResponse, 0, len(snippets))
|
||||||
w.WriteHeader(http.StatusOK)
|
for _, s := range snippets {
|
||||||
_ = json.NewEncoder(w).Encode(snippets)
|
out = append(out, toSnippetResponse(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
id := extractID(r)
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
id := r.PathValue("id")
|
|
||||||
if id == "" {
|
if id == "" {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
writeError(w, http.StatusBadRequest, "missing id")
|
||||||
id = strings.TrimSuffix(path, "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
if id == "" {
|
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
http.Error(w, "Snippet not found", http.StatusNotFound)
|
writeError(w, http.StatusNotFound, "snippet not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeJSON(w, http.StatusOK, toSnippetResponse(snippet))
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_ = json.NewEncoder(w).Encode(snippet)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodDelete {
|
userID := middleware.GetUserID(r.Context())
|
||||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
if userID == "" {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := r.PathValue("id")
|
id := extractID(r)
|
||||||
if id == "" {
|
if id == "" {
|
||||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
writeError(w, http.StatusBadRequest, "missing id")
|
||||||
id = strings.TrimSuffix(path, "/")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if id == "" {
|
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.snippetStore.Delete(r.Context(), id); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"5803025015/w04/internal/store"
|
"5803025015/w04/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type contextKey string
|
type contextKey string
|
||||||
@@ -19,35 +20,46 @@ func GetUserID(ctx context.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthMiddleware struct {
|
type SessionStore interface {
|
||||||
sessionStore *store.SessionStore
|
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}
|
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 {
|
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := authHeader
|
const bearerPrefix = "bearer "
|
||||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
if !strings.HasPrefix(strings.ToLower(authHeader), bearerPrefix) {
|
||||||
token = authHeader[7:]
|
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
token = strings.TrimSpace(token)
|
token := strings.TrimSpace(authHeader[len(bearerPrefix):])
|
||||||
if token == "" {
|
if token == "" {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := m.sessionStore.Get(r.Context(), token)
|
session, err := m.sessionStore.Get(r.Context(), token)
|
||||||
if err != nil || session == nil {
|
if err != nil || session == nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
writeJSONError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
return
|
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
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"5803025015/w04/internal/models"
|
"5803025015/w04/internal/models"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"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`
|
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)
|
err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &sess, nil
|
return &sess, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"5803025015/w04/internal/models"
|
"5803025015/w04/internal/models"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"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`
|
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)
|
err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &sn, nil
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
query := `SELECT id, title, content, user_id FROM snippets`
|
query := `SELECT id, title, content, user_id FROM snippets WHERE user_id = $1`
|
||||||
rows, err := s.conn.Query(ctx, query)
|
rows, err := s.conn.Query(ctx, query, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
snippets := make([]*models.Snippet, 0)
|
snippets := make([]*models.Snippet, 0) // non-nil!
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sn models.Snippet
|
var sn models.Snippet
|
||||||
if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil {
|
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()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
query := `DELETE FROM snippets WHERE id = $1`
|
query := `DELETE FROM snippets WHERE id = $1`
|
||||||
_, err := s.conn.Exec(ctx, query, id)
|
tag, err := s.conn.Exec(ctx, query, id)
|
||||||
return err
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"5803025015/w04/internal/models"
|
"5803025015/w04/internal/models"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserStore struct {
|
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)`
|
query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)`
|
||||||
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
|
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
|
||||||
return err
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return ErrDuplicate
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
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`
|
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)
|
err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &u, nil
|
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`
|
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)
|
err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &u, nil
|
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