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