Assignment 4: tests, interface refactor, e2e suite
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Server config
|
||||
PORT=8080
|
||||
ENVIRONMENT=dev
|
||||
|
||||
# PostgreSQL (store tests need a database whose name ends in _test)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_NAME=
|
||||
DB_SSLMODE=disable
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
.env
|
||||
cover.out
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Snippet Sharing API
|
||||
|
||||
A Go + PostgreSQL REST API for sharing code snippets (Assignment 3), with a full test suite (Assignment 4).
|
||||
|
||||
**Name:** Zagar Nashrullah
|
||||
**NRP:** 5803025048
|
||||
|
||||
Module: `5803025048/W04`
|
||||
|
||||
## Run the server
|
||||
|
||||
Set the database variables, then start the server. It listens on `PORT` (default `8080`).
|
||||
|
||||
```powershell
|
||||
$env:DB_HOST="..."; $env:DB_PORT="..."
|
||||
$env:DB_USER="..."; $env:DB_PASSWORD="..."; $env:DB_NAME="..."
|
||||
go run ./cmd/server/main.go
|
||||
```
|
||||
|
||||
`PORT` is the HTTP port of the API. `DB_PORT` is the PostgreSQL port.
|
||||
|
||||
## Run the tests
|
||||
|
||||
Each layer has its own command:
|
||||
|
||||
```
|
||||
go test ./internal/auth/... # unit tests
|
||||
go test ./internal/middleware/... # unit tests, hand-written fake
|
||||
go test ./internal/handler/... # unit tests, hand-written fakes
|
||||
go test ./internal/store/... # integration tests, real PostgreSQL
|
||||
go test ./e2e/... # black-box tests over HTTP
|
||||
```
|
||||
|
||||
What each layer needs:
|
||||
|
||||
- **auth, middleware, handler:** nothing.
|
||||
- **store:** the `DB_*` variables, and `DB_NAME` **must end in `_test`**.
|
||||
- **e2e:** a running server. It uses `API_BASE_URL` (default `http://localhost:8080`).
|
||||
|
||||
To run everything the way the grader does (start the server first, because `./...` includes e2e):
|
||||
|
||||
```
|
||||
go vet ./...
|
||||
go test -race -shuffle=on -count=2 ./...
|
||||
go test -coverpkg=./internal/... -coverprofile=cover.out ./internal/...
|
||||
```
|
||||
|
||||
### Warning: store tests wipe the database
|
||||
|
||||
The store tests `TRUNCATE` all tables at the start of every test. For safety, `TestMain` in `internal/store` refuses to run unless `DB_NAME` ends in `_test` (for example `w04_5803025048_test`). Never point them at a real database.
|
||||
|
||||
If the `DB_*` variables are missing or the database is unreachable, the tests fail. They never skip.
|
||||
|
||||
The e2e tests create their own unique usernames, so they work against a server that already has data.
|
||||
|
||||
## Bugs My Tests Found
|
||||
|
||||
Defects in my Assignment 3 code that these tests exposed, and how I fixed them:
|
||||
|
||||
1. **Errors were not JSON.** Every 400/401/403/404/409/500 used `http.Error`, which sends `text/plain`.
|
||||
*Fix:* a JSON error writer in the handlers and in the middleware.
|
||||
2. **A token without the `Bearer` scheme was accepted.** The middleware and logout used the whole header as the token.
|
||||
*Fix:* one strict `middleware.BearerToken` helper. A missing scheme, `Basic ...`, or an empty token now gives 401.
|
||||
3. **Logout returned 204 for tokens that did not exist.**
|
||||
*Fix:* logout checks that the session exists and returns 401 otherwise.
|
||||
4. **"Not found" leaked the database driver.** Handlers compared against `pgx.ErrNoRows`, and `SnippetStore.Delete` returned `nil` for an unknown id.
|
||||
*Fix:* added `store.ErrNotFound`, returned by every lookup. `Delete` returns it when no row was affected.
|
||||
5. **Passwords longer than 72 bytes caused a 500 on `/register`** (bcrypt rejects them).
|
||||
*Fix:* they now return 400.
|
||||
6. **Username length was counted in bytes, not characters.**
|
||||
*Fix:* it is now counted in runes.
|
||||
7. **`GET /snippets` could return `null` for an empty list.**
|
||||
*Fix:* the handler always returns `[]`, even if the store returns a nil slice.
|
||||
8. **Any login lookup error was reported as 401.**
|
||||
*Fix:* a real database failure is now 500. Only "user not found" and "wrong password" give the (identical) 401.
|
||||
|
||||
## Refactor for testability
|
||||
|
||||
No behavior change. Handlers depend on the `UserStore`, `SessionStore` and `SnippetStore` interfaces (package `handler`). The middleware depends on a small `SessionLookup` interface. The concrete types in `internal/store` satisfy them without importing `handler` or `middleware`.
|
||||
+1
-1
@@ -49,7 +49,7 @@ func main() {
|
||||
|
||||
mux.Handle("GET /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.List)))
|
||||
mux.Handle("POST /snippets", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Create)))
|
||||
mux.Handle("GET /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Get)))
|
||||
mux.HandleFunc("GET /snippets/{id}", snippetHandler.Get)
|
||||
mux.Handle("DELETE /snippets/{id}", authMiddleware.RequireAuth(http.HandlerFunc(snippetHandler.Delete)))
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
|
||||
+492
@@ -0,0 +1,492 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
var baseURL = func() string {
|
||||
if v := strings.TrimRight(os.Getenv("API_BASE_URL"), "/"); v != "" {
|
||||
return v
|
||||
}
|
||||
return "http://localhost:8080"
|
||||
}()
|
||||
|
||||
var client = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
type response struct {
|
||||
status int
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
func (r response) text() string { return string(r.body) }
|
||||
|
||||
// send mengirim request mentah. Kalau server tidak terjangkau, tes GAGAL (tidak skip).
|
||||
func send(t *testing.T, method, path, rawBody string, headers map[string]string) response {
|
||||
t.Helper()
|
||||
|
||||
var body io.Reader
|
||||
if rawBody != "" {
|
||||
body = strings.NewReader(rawBody)
|
||||
}
|
||||
req, err := http.NewRequest(method, baseURL+path, body)
|
||||
if err != nil {
|
||||
t.Fatalf("build request %s %s: %v", method, path, err)
|
||||
}
|
||||
if rawBody != "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s failed (is the server running at %s?): %v", method, path, baseURL, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read response body of %s %s: %v", method, path, err)
|
||||
}
|
||||
return response{status: res.StatusCode, header: res.Header, body: data}
|
||||
}
|
||||
|
||||
func bearer(token string) map[string]string {
|
||||
return map[string]string{"Authorization": "Bearer " + token}
|
||||
}
|
||||
|
||||
func jsonString(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func randHex(t *testing.T, n int) string {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
t.Fatalf("random: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// uniqueName menghasilkan username unik per pemanggilan (3-32 karakter).
|
||||
func uniqueName(t *testing.T) string { return "e2e_" + randHex(t, 8) }
|
||||
|
||||
func uniquePassword(t *testing.T) string { return "Pw!" + randHex(t, 8) }
|
||||
|
||||
func decodeObject(t *testing.T, r response) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r.body, &m); err != nil {
|
||||
t.Fatalf("body is not a JSON object: %v (status=%d body=%q)", err, r.status, r.text())
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
sort.Strings(ks)
|
||||
return ks
|
||||
}
|
||||
|
||||
func requireExactKeys(t *testing.T, m map[string]any, want ...string) {
|
||||
t.Helper()
|
||||
got := keysOf(m)
|
||||
sort.Strings(want)
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("keys = %v, want exactly %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func requireStatus(t *testing.T, r response, want int) {
|
||||
t.Helper()
|
||||
if r.status != want {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", r.status, want, r.text())
|
||||
}
|
||||
}
|
||||
|
||||
func requireJSONContentType(t *testing.T, r response) {
|
||||
t.Helper()
|
||||
ct := strings.ToLower(r.header.Get("Content-Type"))
|
||||
if !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json (status=%d)", ct, r.status)
|
||||
}
|
||||
}
|
||||
|
||||
func register(t *testing.T, username, password string) response {
|
||||
t.Helper()
|
||||
return send(t, http.MethodPost, "/register", jsonString(t, map[string]string{
|
||||
"username": username, "password": password,
|
||||
}), nil)
|
||||
}
|
||||
|
||||
func login(t *testing.T, username, password string) response {
|
||||
t.Helper()
|
||||
return send(t, http.MethodPost, "/login", jsonString(t, map[string]string{
|
||||
"username": username, "password": password,
|
||||
}), nil)
|
||||
}
|
||||
|
||||
// newUser mendaftarkan user baru lalu login; mengembalikan username, password, token.
|
||||
func newUser(t *testing.T) (username, password, token string) {
|
||||
t.Helper()
|
||||
username, password = uniqueName(t), uniquePassword(t)
|
||||
requireStatus(t, register(t, username, password), http.StatusCreated)
|
||||
|
||||
res := login(t, username, password)
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
token, _ = decodeObject(t, res)["session_token"].(string)
|
||||
if token == "" {
|
||||
t.Fatalf("login did not return a session_token: %q", res.text())
|
||||
}
|
||||
return username, password, token
|
||||
}
|
||||
|
||||
func createSnippet(t *testing.T, token, title, content string) (id string, res response) {
|
||||
t.Helper()
|
||||
res = send(t, http.MethodPost, "/snippets", jsonString(t, map[string]string{
|
||||
"title": title, "content": content,
|
||||
}), bearer(token))
|
||||
if res.status == http.StatusCreated {
|
||||
id, _ = decodeObject(t, res)["id"].(string)
|
||||
}
|
||||
return id, res
|
||||
}
|
||||
|
||||
func mustCreateSnippet(t *testing.T, token, title, content string) string {
|
||||
t.Helper()
|
||||
id, res := createSnippet(t, token, title, content)
|
||||
requireStatus(t, res, http.StatusCreated)
|
||||
if id == "" {
|
||||
t.Fatalf("created snippet has no id: %q", res.text())
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func listSnippets(t *testing.T, token string) []map[string]any {
|
||||
t.Helper()
|
||||
res := send(t, http.MethodGet, "/snippets", "", bearer(token))
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(res.body, &items); err != nil {
|
||||
t.Fatalf("GET /snippets body is not a JSON array: %v (body=%q)", err, res.text())
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestE2E_RegisterDuplicate(t *testing.T) {
|
||||
username, password := uniqueName(t), uniquePassword(t)
|
||||
|
||||
t.Run("first registration returns 201", func(t *testing.T) {
|
||||
requireStatus(t, register(t, username, password), http.StatusCreated)
|
||||
})
|
||||
|
||||
t.Run("same username again returns 409", func(t *testing.T) {
|
||||
requireStatus(t, register(t, username, password), http.StatusConflict)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PasswordNeverLeaked(t *testing.T) {
|
||||
username, password := uniqueName(t), uniquePassword(t)
|
||||
|
||||
assertNoPassword := func(t *testing.T, r response) {
|
||||
t.Helper()
|
||||
if bytes.Contains(r.body, []byte(password)) {
|
||||
t.Fatalf("response body contains the plaintext password: %q", r.text())
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(r.body, &m); err == nil {
|
||||
for k := range m {
|
||||
if strings.Contains(strings.ToLower(k), "password") {
|
||||
t.Fatalf("response has a password-like key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("register response", func(t *testing.T) {
|
||||
res := register(t, username, password)
|
||||
requireStatus(t, res, http.StatusCreated)
|
||||
assertNoPassword(t, res)
|
||||
})
|
||||
|
||||
t.Run("login response", func(t *testing.T) {
|
||||
res := login(t, username, password)
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
assertNoPassword(t, res)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_LoginFailuresIndistinguishable(t *testing.T) {
|
||||
username, password := uniqueName(t), uniquePassword(t)
|
||||
requireStatus(t, register(t, username, password), http.StatusCreated)
|
||||
|
||||
var wrongPasswordBody []byte
|
||||
|
||||
t.Run("wrong password returns 401", func(t *testing.T) {
|
||||
res := login(t, username, password+"-wrong")
|
||||
requireStatus(t, res, http.StatusUnauthorized)
|
||||
wrongPasswordBody = res.body
|
||||
})
|
||||
|
||||
t.Run("unknown username returns 401 with an identical body", func(t *testing.T) {
|
||||
res := login(t, uniqueName(t), password)
|
||||
requireStatus(t, res, http.StatusUnauthorized)
|
||||
if !bytes.Equal(res.body, wrongPasswordBody) {
|
||||
t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", string(wrongPasswordBody), res.text())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ProtectedEndpointsRequireToken(t *testing.T) {
|
||||
validBody := jsonString(t, map[string]string{"title": "t", "content": "c"})
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{"POST /snippets", http.MethodPost, "/snippets", validBody},
|
||||
{"GET /snippets", http.MethodGet, "/snippets", ""},
|
||||
{"DELETE /snippets/{id}", http.MethodDelete, "/snippets/some-id", ""},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.name+" without Authorization header", func(t *testing.T) {
|
||||
requireStatus(t, send(t, ep.method, ep.path, ep.body, nil), http.StatusUnauthorized)
|
||||
})
|
||||
t.Run(ep.name+" with a bogus bearer token", func(t *testing.T) {
|
||||
requireStatus(t, send(t, ep.method, ep.path, ep.body, bearer("not-a-real-token")), http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_SnippetLifecycle(t *testing.T) {
|
||||
_, _, token := newUser(t)
|
||||
title, content := "Lifecycle "+randHex(t, 4), "content-"+randHex(t, 4)
|
||||
var id string
|
||||
|
||||
t.Run("create returns 201 with an id", func(t *testing.T) {
|
||||
var res response
|
||||
id, res = createSnippet(t, token, title, content)
|
||||
requireStatus(t, res, http.StatusCreated)
|
||||
if id == "" {
|
||||
t.Fatalf("no id in response: %q", res.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("public GET returns the same snippet", func(t *testing.T) {
|
||||
res := send(t, http.MethodGet, "/snippets/"+id, "", nil)
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
m := decodeObject(t, res)
|
||||
if m["id"] != id || m["title"] != title || m["content"] != content {
|
||||
t.Fatalf("snippet mismatch: got %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("owner DELETE returns 204 with empty body", func(t *testing.T) {
|
||||
res := send(t, http.MethodDelete, "/snippets/"+id, "", bearer(token))
|
||||
requireStatus(t, res, http.StatusNoContent)
|
||||
if len(res.body) != 0 {
|
||||
t.Fatalf("204 body must be empty, got %q", res.text())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET after delete returns 404", func(t *testing.T) {
|
||||
requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusNotFound)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_PublicRead(t *testing.T) {
|
||||
_, _, tokenA := newUser(t)
|
||||
title, content := "Public "+randHex(t, 4), "body-"+randHex(t, 4)
|
||||
id := mustCreateSnippet(t, tokenA, title, content)
|
||||
|
||||
t.Run("readable with no Authorization header", func(t *testing.T) {
|
||||
requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("returned fields match what the author stored", func(t *testing.T) {
|
||||
res := send(t, http.MethodGet, "/snippets/"+id, "", nil)
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
m := decodeObject(t, res)
|
||||
if m["title"] != title || m["content"] != content {
|
||||
t.Fatalf("got %v, want title=%q content=%q", m, title, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_LogoutInvalidatesToken(t *testing.T) {
|
||||
_, _, token := newUser(t)
|
||||
|
||||
t.Run("logout returns 204", func(t *testing.T) {
|
||||
requireStatus(t, send(t, http.MethodPost, "/logout", "", bearer(token)), http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("same token is rejected afterwards", func(t *testing.T) {
|
||||
requireStatus(t, send(t, http.MethodGet, "/snippets", "", bearer(token)), http.StatusUnauthorized)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_DataIsolation(t *testing.T) {
|
||||
_, _, tokenA := newUser(t)
|
||||
_, _, tokenB := newUser(t)
|
||||
idA := mustCreateSnippet(t, tokenA, "A "+randHex(t, 4), "content A")
|
||||
idB := mustCreateSnippet(t, tokenB, "B "+randHex(t, 4), "content B")
|
||||
|
||||
check := func(t *testing.T, token, wantID, otherID string) {
|
||||
t.Helper()
|
||||
items := listSnippets(t, token)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("list has %d snippets, want exactly 1", len(items))
|
||||
}
|
||||
if items[0]["id"] != wantID {
|
||||
t.Fatalf("list contains %v, want %s", items[0]["id"], wantID)
|
||||
}
|
||||
for _, it := range items {
|
||||
if it["id"] == otherID {
|
||||
t.Fatalf("list leaked another user's snippet %s", otherID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("user A sees only A's snippet", func(t *testing.T) { check(t, tokenA, idA, idB) })
|
||||
t.Run("user B sees only B's snippet", func(t *testing.T) { check(t, tokenB, idB, idA) })
|
||||
}
|
||||
|
||||
func TestE2E_DeleteOwnership(t *testing.T) {
|
||||
_, _, owner := newUser(t)
|
||||
_, _, intruder := newUser(t)
|
||||
id := mustCreateSnippet(t, owner, "Mine "+randHex(t, 4), "secret")
|
||||
|
||||
t.Run("non-owner delete returns 403", func(t *testing.T) {
|
||||
requireStatus(t, send(t, http.MethodDelete, "/snippets/"+id, "", bearer(intruder)), http.StatusForbidden)
|
||||
})
|
||||
|
||||
t.Run("non-owner delete of a nonexistent id returns 404", func(t *testing.T) {
|
||||
missing := "missing-" + randHex(t, 8)
|
||||
requireStatus(t, send(t, http.MethodDelete, "/snippets/"+missing, "", bearer(intruder)), http.StatusNotFound)
|
||||
})
|
||||
|
||||
t.Run("snippet is still readable after the failed deletes", func(t *testing.T) {
|
||||
requireStatus(t, send(t, http.MethodGet, "/snippets/"+id, "", nil), http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_EmptyListIsArray(t *testing.T) {
|
||||
_, _, token := newUser(t)
|
||||
|
||||
t.Run("fresh user's list is exactly []", func(t *testing.T) {
|
||||
res := send(t, http.MethodGet, "/snippets", "", bearer(token))
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
if got := strings.TrimSpace(res.text()); got != "[]" {
|
||||
t.Fatalf("body = %q, want []", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_ContentTypeAndSchema(t *testing.T) {
|
||||
_, _, owner := newUser(t)
|
||||
_, _, other := newUser(t)
|
||||
id := mustCreateSnippet(t, owner, "Schema "+randHex(t, 4), "content")
|
||||
|
||||
t.Run("200 is JSON and snippet has exactly id, title, content", func(t *testing.T) {
|
||||
res := send(t, http.MethodGet, "/snippets/"+id, "", nil)
|
||||
requireStatus(t, res, http.StatusOK)
|
||||
requireJSONContentType(t, res)
|
||||
requireExactKeys(t, decodeObject(t, res), "id", "title", "content")
|
||||
})
|
||||
|
||||
t.Run("201 is JSON and snippet has exactly id, title, content", func(t *testing.T) {
|
||||
_, res := createSnippet(t, owner, "Another "+randHex(t, 4), "content")
|
||||
requireStatus(t, res, http.StatusCreated)
|
||||
requireJSONContentType(t, res)
|
||||
requireExactKeys(t, decodeObject(t, res), "id", "title", "content")
|
||||
})
|
||||
|
||||
t.Run("list elements have exactly id, title, content", func(t *testing.T) {
|
||||
for _, it := range listSnippets(t, owner) {
|
||||
requireExactKeys(t, it, "id", "title", "content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("400 is JSON", func(t *testing.T) {
|
||||
res := send(t, http.MethodPost, "/snippets", `{"title": "", "content": ""}`, bearer(owner))
|
||||
requireStatus(t, res, http.StatusBadRequest)
|
||||
requireJSONContentType(t, res)
|
||||
})
|
||||
|
||||
t.Run("401 is JSON", func(t *testing.T) {
|
||||
res := send(t, http.MethodGet, "/snippets", "", nil)
|
||||
requireStatus(t, res, http.StatusUnauthorized)
|
||||
requireJSONContentType(t, res)
|
||||
})
|
||||
|
||||
t.Run("403 is JSON", func(t *testing.T) {
|
||||
res := send(t, http.MethodDelete, "/snippets/"+id, "", bearer(other))
|
||||
requireStatus(t, res, http.StatusForbidden)
|
||||
requireJSONContentType(t, res)
|
||||
})
|
||||
|
||||
t.Run("404 is JSON", func(t *testing.T) {
|
||||
res := send(t, http.MethodGet, "/snippets/missing-"+randHex(t, 8), "", nil)
|
||||
requireStatus(t, res, http.StatusNotFound)
|
||||
requireJSONContentType(t, res)
|
||||
})
|
||||
}
|
||||
|
||||
func TestE2E_BadInput(t *testing.T) {
|
||||
_, _, token := newUser(t)
|
||||
|
||||
endpoints := []struct {
|
||||
name string
|
||||
path string
|
||||
headers map[string]string
|
||||
}{
|
||||
{"/register", "/register", nil},
|
||||
{"/login", "/login", nil},
|
||||
{"POST /snippets", "/snippets", bearer(token)},
|
||||
}
|
||||
bodies := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"malformed JSON", `{"username": "abc", "password":`},
|
||||
{"empty body", ``},
|
||||
{"JSON array body", `["a", "b"]`},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
for _, b := range bodies {
|
||||
t.Run(ep.name+" with "+b.name, func(t *testing.T) {
|
||||
res := send(t, http.MethodPost, ep.path, b.raw, ep.headers)
|
||||
requireStatus(t, res, http.StatusBadRequest)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
const plain = "secret123"
|
||||
|
||||
t.Run("hash never equals plaintext", func(t *testing.T) {
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword returned error: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("hash must not be empty")
|
||||
}
|
||||
if hash == plain {
|
||||
t.Fatal("hash must not equal the plaintext password")
|
||||
}
|
||||
if strings.Contains(hash, plain) {
|
||||
t.Fatal("hash must not contain the plaintext password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same password hashed twice gives different hashes", func(t *testing.T) {
|
||||
h1, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("first HashPassword returned error: %v", err)
|
||||
}
|
||||
h2, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("second HashPassword returned error: %v", err)
|
||||
}
|
||||
if h1 == h2 {
|
||||
t.Fatal("two hashes of the same password must differ (salting)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hash verifies against original password", func(t *testing.T) {
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword returned error: %v", err)
|
||||
}
|
||||
if !CheckPassword(plain, hash) {
|
||||
t.Fatal("CheckPassword must accept the original password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("password longer than 72 bytes returns an error", func(t *testing.T) {
|
||||
hash, err := HashPassword(strings.Repeat("a", 73))
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a password longer than 72 bytes")
|
||||
}
|
||||
if hash != "" {
|
||||
t.Fatalf("expected empty hash on error, got %q", hash)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCheckPassword(t *testing.T) {
|
||||
const plain = "secret123"
|
||||
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword returned error: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
hash string
|
||||
want bool
|
||||
}{
|
||||
{name: "correct password", password: plain, hash: hash, want: true},
|
||||
{name: "wrong password", password: "wrong-password", hash: hash, want: false},
|
||||
{name: "empty password", password: "", hash: hash, want: false},
|
||||
{name: "garbage hash string", password: plain, hash: "this-is-not-a-bcrypt-hash", want: false},
|
||||
{name: "empty hash string", password: plain, hash: "", want: 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,16 @@ package auth
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
)
|
||||
|
||||
func GenerateToken() (string, error) {
|
||||
return generateToken(rand.Reader)
|
||||
}
|
||||
|
||||
func generateToken(r io.Reader) (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
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 on call %d: %v", i, 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 %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 _, dup := seen[tok]; dup {
|
||||
t.Fatalf("token %d is a duplicate: %q", i, tok)
|
||||
}
|
||||
seen[tok] = struct{}{}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no token contains whitespace", func(t *testing.T) {
|
||||
for i, tok := range tokens {
|
||||
if strings.IndexFunc(tok, unicode.IsSpace) >= 0 {
|
||||
t.Fatalf("token %d contains whitespace: %q", i, tok)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every token is at least 32 characters", func(t *testing.T) {
|
||||
for i, tok := range tokens {
|
||||
if len(tok) < 32 {
|
||||
t.Fatalf("token %d has length %d, want >= 32", i, len(tok))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type failingReader struct{}
|
||||
|
||||
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read failed") }
|
||||
|
||||
func TestGenerateTokenReadError(t *testing.T) {
|
||||
tok, err := generateToken(failingReader{})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error when the random source fails")
|
||||
}
|
||||
if tok != "" {
|
||||
t.Fatalf("expected empty token on error, got %q", tok)
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,28 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"5803025048/W04/internal/auth"
|
||||
"5803025048/W04/internal/middleware"
|
||||
"5803025048/W04/internal/models"
|
||||
"5803025048/W04/internal/store"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// bcrypt menolak password lebih dari 72 byte.
|
||||
const maxPasswordBytes = 72
|
||||
|
||||
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,
|
||||
@@ -30,26 +36,22 @@ 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)
|
||||
return
|
||||
}
|
||||
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "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)
|
||||
n := utf8.RuneCountInString(req.Username)
|
||||
if n < 3 || n > 32 || req.Password == "" || len(req.Password) > maxPasswordBytes {
|
||||
writeError(w, http.StatusBadRequest, "Invalid username or password")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,36 +62,41 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.userStore.Create(r.Context(), user); err != nil {
|
||||
http.Error(w, "Username already exists or create failed", http.StatusBadRequest)
|
||||
if errors.Is(err, store.ErrUsernameTaken) {
|
||||
writeError(w, http.StatusConflict, "Username already exists")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(user)
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req authRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "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)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
writeError(w, http.StatusUnauthorized, "Invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,43 +107,33 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Create(r.Context(), session); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(session)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"session_token": session.Token})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
token, ok := middleware.BearerToken(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
if _, err := h.sessionStore.Get(r.Context(), token); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionStore.Delete(r.Context(), token); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"message": "logged out"})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/auth"
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
func credsBody(username, password string) string {
|
||||
b, _ := json.Marshal(map[string]string{"username": username, "password": password})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
const password = "s3cret-pass-xyz"
|
||||
|
||||
newHandler := func() (*AuthHandler, *fakeUserStore) {
|
||||
us := newFakeUserStore()
|
||||
return NewAuthHandler(us, newFakeSessionStore()), us
|
||||
}
|
||||
register := func(h *AuthHandler, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.Register(rec, newJSONRequest(http.MethodPost, "/register", body))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 201 with id and username", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
rec := register(h, credsBody("alice", password))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if id, ok := m["id"].(string); !ok || id == "" {
|
||||
t.Fatalf("id must be a non-empty string, got %v", m["id"])
|
||||
}
|
||||
if m["username"] != "alice" {
|
||||
t.Fatalf("username = %v, want alice", m["username"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate username returns 409", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
if rec := register(h, credsBody("alice", password)); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("first register status = %d, want 201", rec.Code)
|
||||
}
|
||||
rec := register(h, credsBody("alice", password))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
badCases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"empty username returns 400", credsBody("", password)},
|
||||
{"empty password returns 400", credsBody("alice", "")},
|
||||
{"username of 2 characters returns 400", credsBody("ab", password)},
|
||||
{"username of 33 characters returns 400", credsBody(strings.Repeat("a", 33), password)},
|
||||
{"malformed JSON returns 400", `{"username": "alice", "password":`},
|
||||
{"empty body returns 400", ``},
|
||||
{"JSON array body returns 400", `["alice","pw"]`},
|
||||
{"password longer than bcrypt limit returns 400", credsBody("alice", strings.Repeat("p", 73))},
|
||||
}
|
||||
for _, tc := range badCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h, us := newHandler()
|
||||
rec := register(h, tc.body)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if len(us.users) != 0 {
|
||||
t.Fatal("no user must be stored for an invalid request")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("usernames of exactly 3 and 32 characters are accepted", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
for _, name := range []string{"abc", strings.Repeat("b", 32)} {
|
||||
if rec := register(h, credsBody(name, password)); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("username %q: status = %d, want 201", name, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success body contains no password material", func(t *testing.T) {
|
||||
h, _ := newHandler()
|
||||
rec := register(h, credsBody("alice", password))
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
for k := range m {
|
||||
if strings.Contains(strings.ToLower(k), "password") {
|
||||
t.Fatalf("response must not contain a password-like key, found %q", k)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), password) {
|
||||
t.Fatal("response must not contain the plaintext password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, us := newHandler()
|
||||
us.createErr = errors.New("db down")
|
||||
rec := register(h, credsBody("alice", password))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
const username = "alice"
|
||||
const password = "correct-horse"
|
||||
|
||||
setup := func(t *testing.T) (*AuthHandler, *fakeUserStore, *fakeSessionStore) {
|
||||
t.Helper()
|
||||
us, ss := newFakeUserStore(), newFakeSessionStore()
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := us.Create(context.Background(), &models.User{ID: "u1", Username: username, PasswordHash: hash}); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return NewAuthHandler(us, ss), us, ss
|
||||
}
|
||||
login := func(h *AuthHandler, body string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
h.Login(rec, newJSONRequest(http.MethodPost, "/login", body))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 200 with session_token", func(t *testing.T) {
|
||||
h, _, ss := setup(t)
|
||||
rec := login(h, credsBody(username, password))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
tok, ok := m["session_token"].(string)
|
||||
if !ok || tok == "" {
|
||||
t.Fatalf("session_token must be a non-empty string, got %v", m["session_token"])
|
||||
}
|
||||
if !ss.has(tok) {
|
||||
t.Fatal("the returned token must be stored in the session store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wrong password returns 401", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
rec := login(h, credsBody(username, "wrong-password"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unknown username returns 401", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
rec := login(h, credsBody("nobody", password))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("401 bodies for wrong password and unknown user are identical", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
wrong := login(h, credsBody(username, "wrong-password"))
|
||||
unknown := login(h, credsBody("nobody", password))
|
||||
if wrong.Code != http.StatusUnauthorized || unknown.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("statuses = %d and %d, want 401 and 401", wrong.Code, unknown.Code)
|
||||
}
|
||||
if wrong.Body.String() != unknown.Body.String() {
|
||||
t.Fatalf("bodies differ:\n wrong password: %q\n unknown user: %q", wrong.Body.String(), unknown.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
rec := login(h, `{"username":`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty body and JSON array return 400", func(t *testing.T) {
|
||||
h, _, _ := setup(t)
|
||||
for _, body := range []string{``, `[1,2,3]`} {
|
||||
if rec := login(h, body); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("user store failure returns 500", func(t *testing.T) {
|
||||
h, us, _ := setup(t)
|
||||
us.getErr = errors.New("db down")
|
||||
rec := login(h, credsBody(username, password))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session store failure returns 500", func(t *testing.T) {
|
||||
h, _, ss := setup(t)
|
||||
ss.createErr = errors.New("db down")
|
||||
rec := login(h, credsBody(username, password))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogout(t *testing.T) {
|
||||
setup := func() (*AuthHandler, *fakeSessionStore) {
|
||||
ss := newFakeSessionStore()
|
||||
ss.sessions["tok-valid"] = &models.Session{Token: "tok-valid", UserID: "u1"}
|
||||
ss.sessions["tok-other"] = &models.Session{Token: "tok-other", UserID: "u2"}
|
||||
return NewAuthHandler(newFakeUserStore(), ss), ss
|
||||
}
|
||||
logout := func(h *AuthHandler, authz string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
if authz != "" {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Logout(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("valid token returns 204 and removes the session", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("body must be empty, got %q", rec.Body.String())
|
||||
}
|
||||
if ss.has("tok-valid") {
|
||||
t.Fatal("session store must no longer contain the token")
|
||||
}
|
||||
if !ss.has("tok-other") {
|
||||
t.Fatal("other sessions must not be deleted")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing token returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := logout(h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unknown token returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := logout(h, "Bearer not-a-real-token")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("second logout with the same token returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
if rec := logout(h, "Bearer tok-valid"); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("first logout status = %d, want 204", rec.Code)
|
||||
}
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("second logout status = %d, want 401", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Basic scheme returns 401 and keeps the session", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
rec := logout(h, "Basic tok-valid")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if !ss.has("tok-valid") {
|
||||
t.Fatal("session must not be deleted by a non-Bearer header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session lookup failure returns 500", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
ss.getErr = errors.New("db down")
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("session delete failure returns 500", func(t *testing.T) {
|
||||
h, ss := setup()
|
||||
ss.deleteErr = errors.New("db down")
|
||||
rec := logout(h, "Bearer tok-valid")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/middleware"
|
||||
"5803025048/W04/internal/models"
|
||||
"5803025048/W04/internal/store"
|
||||
)
|
||||
|
||||
// ---------- fake stores (in-memory, buatan tangan) ----------
|
||||
|
||||
type fakeUserStore struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*models.User // key: username
|
||||
createErr error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func newFakeUserStore() *fakeUserStore {
|
||||
return &fakeUserStore{users: map[string]*models.User{}}
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) Create(_ context.Context, user *models.User) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
if _, exists := f.users[user.Username]; exists {
|
||||
return store.ErrUsernameTaken
|
||||
}
|
||||
cp := *user
|
||||
f.users[user.Username] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeUserStore) GetByUsername(_ context.Context, username string) (*models.User, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
u, ok := f.users[username]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
type fakeSessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*models.Session // key: token
|
||||
createErr error
|
||||
getErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func newFakeSessionStore() *fakeSessionStore {
|
||||
return &fakeSessionStore{sessions: map[string]*models.Session{}}
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Create(_ context.Context, session *models.Session) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
cp := *session
|
||||
f.sessions[session.Token] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Get(_ context.Context, token string) (*models.Session, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
s, ok := f.sessions[token]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) Delete(_ context.Context, token string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
type fakeSnippetStore struct {
|
||||
mu sync.Mutex
|
||||
snippets map[string]*models.Snippet // key: id
|
||||
createErr error
|
||||
getErr error
|
||||
listErr error
|
||||
deleteErr error
|
||||
}
|
||||
|
||||
func newFakeSnippetStore() *fakeSnippetStore {
|
||||
return &fakeSnippetStore{snippets: map[string]*models.Snippet{}}
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) seed(id, title, content, userID string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.snippets[id] = &models.Snippet{ID: id, Title: title, Content: content, UserID: userID}
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Create(_ context.Context, snippet *models.Snippet) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
cp := *snippet
|
||||
f.snippets[snippet.ID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) GetByID(_ context.Context, id string) (*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
s, ok := f.snippets[id]
|
||||
if !ok {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
cp := *s
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// GetByUserID sengaja mengembalikan nil (bukan slice kosong) jika tidak ada hasil,
|
||||
// supaya handler terbukti menghasilkan "[]" dan bukan "null".
|
||||
func (f *fakeSnippetStore) GetByUserID(_ context.Context, userID string) ([]*models.Snippet, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
var out []*models.Snippet
|
||||
for _, s := range f.snippets {
|
||||
if s.UserID == userID {
|
||||
cp := *s
|
||||
out = append(out, &cp)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) Delete(_ context.Context, id string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.deleteErr != nil {
|
||||
return f.deleteErr
|
||||
}
|
||||
if _, ok := f.snippets[id]; !ok {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
delete(f.snippets, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSnippetStore) has(id string) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, ok := f.snippets[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ---------- helper tes ----------
|
||||
|
||||
func newJSONRequest(method, target, body string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
func withUser(req *http.Request, userID string) *http.Request {
|
||||
return req.WithContext(context.WithValue(req.Context(), middleware.UserIDKey, userID))
|
||||
}
|
||||
|
||||
func assertJSONContentType(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMap(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("body is not a JSON object: %v (body=%q)", err, string(raw))
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func assertKeys(t *testing.T, m map[string]any, want ...string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
got = append(got, k)
|
||||
}
|
||||
sort.Strings(got)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("keys = %v, want exactly %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func errNotFoundForTest() error { return store.ErrNotFound }
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
// Interface didefinisikan di package consumer (handler), bukan di store.
|
||||
// Tipe konkret di package store memenuhi interface ini tanpa meng-import handler.
|
||||
|
||||
type UserStore interface {
|
||||
Create(ctx context.Context, user *models.User) error
|
||||
GetByUsername(ctx context.Context, username string) (*models.User, error)
|
||||
}
|
||||
|
||||
type SessionStore interface {
|
||||
Create(ctx context.Context, session *models.Session) error
|
||||
Get(ctx context.Context, token string) (*models.Session, error)
|
||||
Delete(ctx context.Context, token string) error
|
||||
}
|
||||
|
||||
type SnippetStore interface {
|
||||
Create(ctx context.Context, 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,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// writeError selalu mengirim error sebagai JSON (Content-Type: application/json).
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -10,14 +10,13 @@ import (
|
||||
"5803025048/W04/internal/models"
|
||||
"5803025048/W04/internal/store"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type SnippetHandler struct {
|
||||
snippetStore *store.SnippetStore
|
||||
snippetStore SnippetStore
|
||||
}
|
||||
|
||||
func NewSnippetHandler(snippetStore *store.SnippetStore) *SnippetHandler {
|
||||
func NewSnippetHandler(snippetStore SnippetStore) *SnippetHandler {
|
||||
return &SnippetHandler{snippetStore: snippetStore}
|
||||
}
|
||||
|
||||
@@ -26,50 +25,32 @@ type snippetRequest struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Snippets(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.List(w, r)
|
||||
case http.MethodPost:
|
||||
h.Create(w, r)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) SnippetByID(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.Get(w, r)
|
||||
case http.MethodDelete:
|
||||
h.Delete(w, r)
|
||||
default:
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
func snippetID(r *http.Request) string {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req snippetRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
req.Content = strings.TrimSpace(req.Content)
|
||||
if req.Title == "" || req.Content == "" {
|
||||
http.Error(w, "Title and content are required", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Title and content are required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -81,83 +62,87 @@ func (h *SnippetHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := h.snippetStore.Create(r.Context(), snippet); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(snippet)
|
||||
writeJSON(w, http.StatusCreated, snippet)
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
snippets, err := h.snippetStore.GetAll(r.Context())
|
||||
snippets, err := h.snippetStore.GetByUserID(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
if snippets == nil {
|
||||
// Pastikan selalu "[]", bukan "null".
|
||||
snippets = []*models.Snippet{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(snippets)
|
||||
writeJSON(w, http.StatusOK, snippets)
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
id := snippetID(r)
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Snippet not found", http.StatusNotFound)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Snippet not found")
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(snippet)
|
||||
writeJSON(w, http.StatusOK, snippet)
|
||||
}
|
||||
|
||||
func (h *SnippetHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
userID := middleware.GetUserID(r.Context())
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
id := r.PathValue("id")
|
||||
id := snippetID(r)
|
||||
if id == "" {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/snippets/")
|
||||
id = strings.TrimSuffix(path, "/")
|
||||
writeError(w, http.StatusBadRequest, "Bad Request")
|
||||
return
|
||||
}
|
||||
|
||||
if id == "" {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
snippet, err := h.snippetStore.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Snippet not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
if snippet.UserID != userID {
|
||||
writeError(w, http.StatusForbidden, "Forbidden")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.snippetStore.Delete(r.Context(), id); err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "Snippet not found")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func snippetBody(title, content string) string {
|
||||
b, _ := json.Marshal(map[string]string{"title": title, "content": content})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestCreateSnippet(t *testing.T) {
|
||||
create := func(h *SnippetHandler, userID, body string) *httptest.ResponseRecorder {
|
||||
req := newJSONRequest(http.MethodPost, "/snippets", body)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Create(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("success returns 201 with exactly id, title, content", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "u1", snippetBody("My title", "console.log('hi')"))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
assertKeys(t, m, "id", "title", "content")
|
||||
id, _ := m["id"].(string)
|
||||
if id == "" {
|
||||
t.Fatal("id must be a non-empty string")
|
||||
}
|
||||
if m["title"] != "My title" || m["content"] != "console.log('hi')" {
|
||||
t.Fatalf("title/content not echoed correctly: %v", m)
|
||||
}
|
||||
if !fs.has(id) {
|
||||
t.Fatal("snippet must be stored")
|
||||
}
|
||||
if fs.snippets[id].UserID != "u1" {
|
||||
t.Fatalf("stored owner = %q, want u1", fs.snippets[id].UserID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty title returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", snippetBody("", "content"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty content returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", snippetBody("title", ""))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("malformed JSON returns 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
rec := create(h, "u1", `{"title": "x", "content":`)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty body and JSON array return 400", func(t *testing.T) {
|
||||
h := NewSnippetHandler(newFakeSnippetStore())
|
||||
for _, body := range []string{``, `["a","b"]`} {
|
||||
if rec := create(h, "u1", body); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("body %q: status = %d, want 400", body, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "", snippetBody("title", "content"))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if len(fs.snippets) != 0 {
|
||||
t.Fatal("nothing must be stored for an unauthenticated request")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.createErr = errors.New("db down")
|
||||
h := NewSnippetHandler(fs)
|
||||
rec := create(h, "u1", snippetBody("title", "content"))
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSnippet(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("s1", "Title One", "Content One", "u1")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
get := func(h *SnippetHandler, target string) *httptest.ResponseRecorder {
|
||||
// Sengaja tanpa header Authorization sama sekali.
|
||||
rec := httptest.NewRecorder()
|
||||
h.Get(rec, httptest.NewRequest(http.MethodGet, target, nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("existing id returns 200 without Authorization header", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/s1")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
m := decodeMap(t, rec.Body.Bytes())
|
||||
if m["id"] != "s1" || m["title"] != "Title One" || m["content"] != "Content One" {
|
||||
t.Fatalf("unexpected body: %v", m)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("response has exactly the keys id, title, content", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/s1")
|
||||
assertKeys(t, decodeMap(t, rec.Body.Bytes()), "id", "title", "content")
|
||||
})
|
||||
|
||||
t.Run("missing id returns 404", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/does-not-exist")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("empty id returns 400", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := get(h, "/snippets/")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.getErr = errors.New("db down")
|
||||
rec := get(h, "/snippets/s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSnippets(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("a1", "A one", "content a1", "alice")
|
||||
fs.seed("a2", "A two", "content a2", "alice")
|
||||
fs.seed("b1", "B one", "content b1", "bob")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
list := func(h *SnippetHandler, userID string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.List(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("only the caller's snippets are returned", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "alice")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("body is not a JSON array: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("got %d snippets, want 2", len(items))
|
||||
}
|
||||
for _, it := range items {
|
||||
if id := it["id"]; id != "a1" && id != "a2" {
|
||||
t.Fatalf("snippet %v does not belong to the caller", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no snippets returns literally []", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "carol")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != "[]" {
|
||||
t.Fatalf("body = %q, want []", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("each element has exactly the three keys", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := list(h, "alice")
|
||||
var items []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("body is not a JSON array: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected at least one element")
|
||||
}
|
||||
for _, it := range items {
|
||||
assertKeys(t, it, "id", "title", "content")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("store failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.listErr = errors.New("db down")
|
||||
rec := list(h, "alice")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteSnippet(t *testing.T) {
|
||||
setup := func() (*SnippetHandler, *fakeSnippetStore) {
|
||||
fs := newFakeSnippetStore()
|
||||
fs.seed("s1", "Title", "Content", "owner")
|
||||
return NewSnippetHandler(fs), fs
|
||||
}
|
||||
del := func(h *SnippetHandler, userID, id string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodDelete, "/snippets/"+id, nil)
|
||||
if userID != "" {
|
||||
req = withUser(req, userID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
t.Run("owner gets 204 with empty body and snippet is gone", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Fatalf("body must be empty, got %q", rec.Body.String())
|
||||
}
|
||||
if fs.has("s1") {
|
||||
t.Fatal("snippet must be removed from the store")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner gets 403 and snippet remains", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "intruder", "s1")
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !fs.has("s1") {
|
||||
t.Fatal("snippet must still exist after a forbidden delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonexistent id returns 404", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := del(h, "owner", "nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated returns 401", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
rec := del(h, "", "s1")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
assertJSONContentType(t, rec)
|
||||
if !fs.has("s1") {
|
||||
t.Fatal("snippet must still exist")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-owner on a nonexistent id returns 404 not 403", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
rec := del(h, "intruder", "nope")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty id returns 400", func(t *testing.T) {
|
||||
h, _ := setup()
|
||||
req := withUser(httptest.NewRequest(http.MethodDelete, "/snippets/", nil), "owner")
|
||||
rec := httptest.NewRecorder()
|
||||
h.Delete(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lookup failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.getErr = errors.New("db down")
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete failure returns 500", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.deleteErr = errors.New("db down")
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("snippet removed between lookup and delete returns 404", func(t *testing.T) {
|
||||
h, fs := setup()
|
||||
fs.deleteErr = errNotFoundForTest()
|
||||
rec := del(h, "owner", "s1")
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"5803025048/W04/internal/store"
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserIDKey contextKey = "user_id"
|
||||
|
||||
// GetUserID mengambil user id yang disimpan RequireAuth di context request.
|
||||
func GetUserID(ctx context.Context) string {
|
||||
if val, ok := ctx.Value(UserIDKey).(string); ok {
|
||||
return val
|
||||
@@ -19,35 +20,54 @@ func GetUserID(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type AuthMiddleware struct {
|
||||
sessionStore *store.SessionStore
|
||||
// SessionLookup adalah interface kecil yang dibutuhkan middleware:
|
||||
// mencari session (dan user id-nya) dari sebuah token.
|
||||
// *store.SessionStore memenuhi interface ini tanpa perubahan.
|
||||
type SessionLookup interface {
|
||||
Get(ctx context.Context, token string) (*models.Session, error)
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(sessionStore *store.SessionStore) *AuthMiddleware {
|
||||
return &AuthMiddleware{sessionStore: sessionStore}
|
||||
type AuthMiddleware struct {
|
||||
sessions SessionLookup
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(sessions SessionLookup) *AuthMiddleware {
|
||||
return &AuthMiddleware{sessions: sessions}
|
||||
}
|
||||
|
||||
const bearerPrefix = "Bearer "
|
||||
|
||||
// BearerToken mengambil token dari header "Authorization: Bearer <token>".
|
||||
// Mengembalikan false jika header tidak ada, skema bukan Bearer, atau token kosong.
|
||||
func BearerToken(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) <= len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(h[len(bearerPrefix):])
|
||||
if token == "" {
|
||||
return "", false
|
||||
}
|
||||
return token, true
|
||||
}
|
||||
|
||||
func unauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"Unauthorized"}` + "\n"))
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
token, ok := BearerToken(r)
|
||||
if !ok {
|
||||
unauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
token := authHeader
|
||||
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||
token = authHeader[7:]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := m.sessionStore.Get(r.Context(), token)
|
||||
session, err := m.sessions.Get(r.Context(), token)
|
||||
if err != nil || session == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
unauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,13 +75,3 @@ func (m *AuthMiddleware) RequireAuth(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAuthFunc(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
m.RequireAuth(next).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAuth(sessionStore *store.SessionStore) func(http.Handler) http.Handler {
|
||||
return NewAuthMiddleware(sessionStore).RequireAuth
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
// fakeLookup adalah fake SessionLookup buatan tangan.
|
||||
type fakeLookup struct {
|
||||
sessions map[string]string // token -> user id
|
||||
nilToken string // token yang mengembalikan (nil, nil)
|
||||
}
|
||||
|
||||
func (f *fakeLookup) Get(_ context.Context, token string) (*models.Session, error) {
|
||||
if token == f.nilToken && token != "" {
|
||||
return nil, nil
|
||||
}
|
||||
uid, ok := f.sessions[token]
|
||||
if !ok {
|
||||
return nil, errors.New("session not found")
|
||||
}
|
||||
return &models.Session{Token: token, UserID: uid}, nil
|
||||
}
|
||||
|
||||
func TestRequireAuth(t *testing.T) {
|
||||
lookup := &fakeLookup{
|
||||
sessions: map[string]string{"valid-token": "user-42"},
|
||||
nilToken: "nil-session-token",
|
||||
}
|
||||
|
||||
// run menjalankan middleware dan mencatat apakah next dipanggil
|
||||
// (lewat closure, bukan dari status code).
|
||||
run := func(authz string, setHeader bool) (rec *httptest.ResponseRecorder, called bool, gotUserID string) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
gotUserID = GetUserID(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
h := NewAuthMiddleware(lookup).RequireAuth(next)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/snippets", nil)
|
||||
if setHeader {
|
||||
req.Header.Set("Authorization", authz)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec, called, gotUserID
|
||||
}
|
||||
|
||||
assert401 := func(t *testing.T, rec *httptest.ResponseRecorder, called bool) {
|
||||
t.Helper()
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("next handler must not be called")
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("no Authorization header returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("", false)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("Basic scheme returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("Basic abc", true)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("Bearer with empty token returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("Bearer ", true)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("Bearer with only spaces returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("Bearer ", true)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("bare token without scheme returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("valid-token", true)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("unknown token returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("Bearer does-not-exist", true)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("lookup returning nil session returns 401", func(t *testing.T) {
|
||||
rec, called, _ := run("Bearer nil-session-token", true)
|
||||
assert401(t, rec, called)
|
||||
})
|
||||
|
||||
t.Run("valid token calls next with user id in context", func(t *testing.T) {
|
||||
rec, called, uid := run("Bearer valid-token", true)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("next handler must be called for a valid token")
|
||||
}
|
||||
if uid != "user-42" {
|
||||
t.Fatalf("user id in context = %q, want %q", uid, "user-42")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("401 response is JSON with parsable body", func(t *testing.T) {
|
||||
rec, _, _ := run("Bearer does-not-exist", true)
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("401 body is not valid JSON: %v (body=%q)", err, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetUserID without value returns empty string", func(t *testing.T) {
|
||||
if got := GetUserID(context.Background()); got != "" {
|
||||
t.Fatalf("GetUserID = %q, want empty string", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package store
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrNotFound dikembalikan saat data yang dicari (user, session, snippet) tidak ada.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
@@ -0,0 +1,75 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/database"
|
||||
"5803025048/W04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// testConn dipakai bersama oleh seluruh tes di package ini.
|
||||
// Tes berjalan berurutan (tanpa t.Parallel), jadi aman.
|
||||
var testConn *pgx.Conn
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Safety guard: tes ini men-TRUNCATE semua tabel.
|
||||
name := os.Getenv("DB_NAME")
|
||||
if !strings.HasSuffix(name, "_test") {
|
||||
log.Fatalf("refusing to run store tests: DB_NAME must end in _test (got %q)", name)
|
||||
}
|
||||
|
||||
// Tidak boleh skip: kalau DB tidak ada / tidak terjangkau, tes GAGAL.
|
||||
conn, err := database.Connect()
|
||||
if err != nil {
|
||||
log.Fatalf("store tests need a reachable PostgreSQL: %v", err)
|
||||
}
|
||||
|
||||
// Skema dibuat lewat kode yang sama dengan yang dipakai server saat startup.
|
||||
if err := database.CreateTables(conn); err != nil {
|
||||
log.Fatalf("could not create schema: %v", err)
|
||||
}
|
||||
|
||||
testConn = conn
|
||||
code := m.Run()
|
||||
_ = conn.Close(context.Background())
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// resetDB mengosongkan semua tabel. Dipanggil di awal setiap tes.
|
||||
func resetDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := testConn.Exec(context.Background(), `TRUNCATE TABLE sessions, snippets, users CASCADE`); err != nil {
|
||||
t.Fatalf("truncate tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestStores() (*UserStore, *SessionStore, *SnippetStore) {
|
||||
mu := &sync.Mutex{}
|
||||
return NewUserStore(testConn, mu), NewSessionStore(testConn, mu), NewSnippetStore(testConn, mu)
|
||||
}
|
||||
|
||||
func mustCreateUser(t *testing.T, us *UserStore, username string) *models.User {
|
||||
t.Helper()
|
||||
u := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-of-" + username}
|
||||
if err := us.Create(context.Background(), u); err != nil {
|
||||
t.Fatalf("create user %q: %v", username, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// countRows hanya dipanggil dengan nama tabel konstan dari tes ini.
|
||||
func countRows(t *testing.T, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := testConn.QueryRow(context.Background(), `SELECT COUNT(*) FROM `+table).Scan(&n); err != nil {
|
||||
t.Fatalf("count rows in %s: %v", table, err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -45,6 +46,9 @@ func (s *SessionStore) Get(ctx context.Context, token string) (*models.Session,
|
||||
query := `SELECT token, user_id, created_at FROM sessions WHERE token = $1`
|
||||
err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/database"
|
||||
"5803025048/W04/internal/models"
|
||||
)
|
||||
|
||||
func TestSessionStore(t *testing.T) {
|
||||
resetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create then look up token returns the user id", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, ss, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
|
||||
if err := ss.Create(ctx, &models.Session{Token: "tok-1", UserID: u.ID}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
got, err := ss.Get(ctx, "tok-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.UserID != u.ID || got.Token != "tok-1" {
|
||||
t.Fatalf("session = %+v, want user %s", got, u.ID)
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Fatal("CreatedAt must be set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown token returns ErrNotFound", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
_, ss, _ := newTestStores()
|
||||
if _, err := ss.Get(ctx, "nope"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete then look up returns ErrNotFound", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, ss, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
if err := ss.Create(ctx, &models.Session{Token: "tok-1", UserID: u.ID}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := ss.Delete(ctx, "tok-1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := ss.Get(ctx, "tok-1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deleting an already-deleted token does not panic or touch other tokens", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, ss, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
for _, tok := range []string{"tok-a", "tok-b"} {
|
||||
if err := ss.Create(ctx, &models.Session{Token: tok, UserID: u.ID}); err != nil {
|
||||
t.Fatalf("Create %s: %v", tok, err)
|
||||
}
|
||||
}
|
||||
_ = ss.Delete(ctx, "tok-a")
|
||||
_ = ss.Delete(ctx, "tok-a") // kedua kalinya: hanya tidak boleh panic
|
||||
|
||||
if _, err := ss.Get(ctx, "tok-b"); err != nil {
|
||||
t.Fatalf("tok-b must still be valid, got err = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two sessions for one user are independent", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, ss, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
for _, tok := range []string{"tok-1", "tok-2"} {
|
||||
if err := ss.Create(ctx, &models.Session{Token: tok, UserID: u.ID}); err != nil {
|
||||
t.Fatalf("Create %s: %v", tok, err)
|
||||
}
|
||||
}
|
||||
if err := ss.Delete(ctx, "tok-1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := ss.Get(ctx, "tok-1"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("tok-1 err = %v, want ErrNotFound", err)
|
||||
}
|
||||
got, err := ss.Get(ctx, "tok-2")
|
||||
if err != nil || got.UserID != u.ID {
|
||||
t.Fatalf("tok-2 must remain valid, got %+v, err = %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionSurvivesNewStore(t *testing.T) {
|
||||
t.Run("token created by one store is found by a store on a new connection", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, ss, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
if err := ss.Create(context.Background(), &models.Session{Token: "persist-tok", UserID: u.ID}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
conn2, err := database.Connect()
|
||||
if err != nil {
|
||||
t.Fatalf("open second connection: %v", err)
|
||||
}
|
||||
defer conn2.Close(context.Background())
|
||||
|
||||
ss2 := NewSessionStore(conn2)
|
||||
got, err := ss2.Get(context.Background(), "persist-tok")
|
||||
if err != nil {
|
||||
t.Fatalf("Get via new store: %v", err)
|
||||
}
|
||||
if got.UserID != u.ID {
|
||||
t.Fatalf("user id = %q, want %q", got.UserID, u.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"5803025048/W04/internal/models"
|
||||
@@ -45,11 +46,39 @@ func (s *SnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet,
|
||||
query := `SELECT id, title, content, user_id FROM snippets WHERE id = $1`
|
||||
err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sn, nil
|
||||
}
|
||||
|
||||
func (s *SnippetStore) GetByUserID(ctx context.Context, userID string) ([]*models.Snippet, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
query := `SELECT id, title, content, user_id FROM snippets WHERE user_id = $1`
|
||||
rows, err := s.conn.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
snippets := make([]*models.Snippet, 0)
|
||||
for rows.Next() {
|
||||
var sn models.Snippet
|
||||
if err := rows.Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snippets = append(snippets, &sn)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snippets, nil
|
||||
}
|
||||
|
||||
func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -80,6 +109,12 @@ func (s *SnippetStore) Delete(ctx context.Context, id string) error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
query := `DELETE FROM snippets WHERE id = $1`
|
||||
_, err := s.conn.Exec(ctx, query, id)
|
||||
return err
|
||||
tag, err := s.conn.Exec(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func mustCreateSnippet(t *testing.T, sn *SnippetStore, ownerID, title, content string) *models.Snippet {
|
||||
t.Helper()
|
||||
s := &models.Snippet{ID: uuid.New().String(), Title: title, Content: content, UserID: ownerID}
|
||||
if err := sn.Create(context.Background(), s); err != nil {
|
||||
t.Fatalf("create snippet: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSnippetStore(t *testing.T) {
|
||||
resetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create then get by id", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
s := mustCreateSnippet(t, sn, u.ID, "Title", "Content")
|
||||
|
||||
got, err := sn.GetByID(ctx, s.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.ID != s.ID || got.Title != "Title" || got.Content != "Content" {
|
||||
t.Fatalf("got %+v, want %+v", got, s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get unknown id returns ErrNotFound", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
_, _, sn := newTestStores()
|
||||
if _, err := sn.GetByID(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list by owner returns only that owner's rows", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
alice := mustCreateUser(t, us, "alice")
|
||||
bob := mustCreateUser(t, us, "bob")
|
||||
a1 := mustCreateSnippet(t, sn, alice.ID, "a1", "c")
|
||||
a2 := mustCreateSnippet(t, sn, alice.ID, "a2", "c")
|
||||
mustCreateSnippet(t, sn, bob.ID, "b1", "c")
|
||||
|
||||
got, err := sn.GetByUserID(ctx, alice.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUserID: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d snippets, want 2", len(got))
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.ID != a1.ID && s.ID != a2.ID {
|
||||
t.Fatalf("snippet %s does not belong to alice", s.ID)
|
||||
}
|
||||
if s.UserID != alice.ID {
|
||||
t.Fatalf("snippet owner = %s, want %s", s.UserID, alice.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list for an owner with no snippets returns a non-nil empty slice", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
|
||||
got, err := sn.GetByUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUserID: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("slice must be non-nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("len = %d, want 0", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete removes the row", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
s := mustCreateSnippet(t, sn, u.ID, "t", "c")
|
||||
|
||||
if err := sn.Delete(ctx, s.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := sn.GetByID(ctx, s.ID); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("after delete err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if n := countRows(t, "snippets"); n != 0 {
|
||||
t.Fatalf("snippets rows = %d, want 0", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete unknown id returns ErrNotFound", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
_, _, sn := newTestStores()
|
||||
if err := sn.Delete(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ownership of a snippet can be retrieved", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
owner := mustCreateUser(t, us, "owner")
|
||||
other := mustCreateUser(t, us, "other")
|
||||
s := mustCreateSnippet(t, sn, owner.ID, "t", "c")
|
||||
|
||||
got, err := sn.GetByID(ctx, s.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.UserID != owner.ID {
|
||||
t.Fatalf("owner = %q, want %q", got.UserID, owner.ID)
|
||||
}
|
||||
if got.UserID == other.ID {
|
||||
t.Fatal("owner must not be the other user")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetAll returns snippets of every owner", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
alice := mustCreateUser(t, us, "alice")
|
||||
bob := mustCreateUser(t, us, "bob")
|
||||
mustCreateSnippet(t, sn, alice.ID, "a", "c")
|
||||
mustCreateSnippet(t, sn, bob.ID, "b", "c")
|
||||
|
||||
got, err := sn.GetAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAll: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d snippets, want 2", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create assigns an id when none is given", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
s := &models.Snippet{Title: "t", Content: "c", UserID: u.ID}
|
||||
if err := sn.Create(ctx, s); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if s.ID == "" {
|
||||
t.Fatal("Create must assign an id")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSnippetStoreSQLInjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
payloads := []struct {
|
||||
name string
|
||||
payload string
|
||||
}{
|
||||
{"always-true OR clause", `' OR '1'='1`},
|
||||
{"DROP TABLE statement", `'; DROP TABLE snippets; --`},
|
||||
{"title with a single quote", `O'Reilly's "quoted" title`},
|
||||
}
|
||||
|
||||
for _, tc := range payloads {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, sn := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
|
||||
// Payload sebagai title DAN content: harus tersimpan apa adanya.
|
||||
s := mustCreateSnippet(t, sn, u.ID, tc.payload, tc.payload)
|
||||
|
||||
got, err := sn.GetByID(ctx, s.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID: %v", err)
|
||||
}
|
||||
if got.Title != tc.payload || got.Content != tc.payload {
|
||||
t.Fatalf("round trip changed data: title=%q content=%q", got.Title, got.Content)
|
||||
}
|
||||
|
||||
list, err := sn.GetByUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUserID: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Title != tc.payload {
|
||||
t.Fatalf("list = %+v, want exactly the one stored snippet", list)
|
||||
}
|
||||
|
||||
// Payload sebagai id / user id: tidak boleh mengembalikan baris apa pun.
|
||||
if _, err := sn.GetByID(ctx, tc.payload); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("GetByID(payload) err = %v, want ErrNotFound", err)
|
||||
}
|
||||
other, err := sn.GetByUserID(ctx, tc.payload)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUserID(payload): %v", err)
|
||||
}
|
||||
if len(other) != 0 {
|
||||
t.Fatalf("GetByUserID(payload) returned %d rows, want 0", len(other))
|
||||
}
|
||||
if err := sn.Delete(ctx, tc.payload); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Delete(payload) err = %v, want ErrNotFound", err)
|
||||
}
|
||||
|
||||
// Tabel masih ada dan hanya berisi baris yang diharapkan.
|
||||
if n := countRows(t, "snippets"); n != 1 {
|
||||
t.Fatalf("snippets rows = %d, want 1", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,18 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"5803025048/W04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// ErrUsernameTaken dikembalikan saat Create() gagal karena username sudah dipakai (unique constraint).
|
||||
var ErrUsernameTaken = errors.New("username already exists")
|
||||
|
||||
type UserStore struct {
|
||||
conn *pgx.Conn
|
||||
mu *sync.Mutex
|
||||
@@ -34,7 +39,14 @@ func (s *UserStore) Create(ctx context.Context, user *models.User) error {
|
||||
|
||||
query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)`
|
||||
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
|
||||
return err
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return ErrUsernameTaken
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||
@@ -45,6 +57,9 @@ func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models
|
||||
query := `SELECT id, username, password_hash FROM users WHERE username = $1`
|
||||
err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
@@ -58,6 +73,9 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error
|
||||
query := `SELECT id, username, password_hash FROM users WHERE id = $1`
|
||||
err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"5803025048/W04/internal/auth"
|
||||
"5803025048/W04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestUserStore(t *testing.T) {
|
||||
resetDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("create then fetch by username", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
|
||||
got, err := us.GetByUsername(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUsername: %v", err)
|
||||
}
|
||||
if got.ID != u.ID || got.Username != "alice" || got.PasswordHash != u.PasswordHash {
|
||||
t.Fatalf("fetched user %+v does not match created user %+v", got, u)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetch unknown username returns ErrNotFound", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
if _, err := us.GetByUsername(ctx, "ghost"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate username returns ErrUsernameTaken", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
mustCreateUser(t, us, "alice")
|
||||
|
||||
err := us.Create(ctx, &models.User{ID: uuid.New().String(), Username: "alice", PasswordHash: "other"})
|
||||
if !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("err = %v, want ErrUsernameTaken", err)
|
||||
}
|
||||
if n := countRows(t, "users"); n != 1 {
|
||||
t.Fatalf("users rows = %d, want 1", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("usernames are case-sensitive", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
upper := mustCreateUser(t, us, "Alice")
|
||||
lower := mustCreateUser(t, us, "alice")
|
||||
if upper.ID == lower.ID {
|
||||
t.Fatal("expected two distinct users")
|
||||
}
|
||||
|
||||
gotUpper, err := us.GetByUsername(ctx, "Alice")
|
||||
if err != nil || gotUpper.ID != upper.ID {
|
||||
t.Fatalf("Alice lookup = %+v, err = %v", gotUpper, err)
|
||||
}
|
||||
gotLower, err := us.GetByUsername(ctx, "alice")
|
||||
if err != nil || gotLower.ID != lower.ID {
|
||||
t.Fatalf("alice lookup = %+v, err = %v", gotLower, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stored password hash differs from plaintext", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
const plain = "plain-secret-123"
|
||||
hash, err := auth.HashPassword(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if err := us.Create(ctx, &models.User{ID: uuid.New().String(), Username: "alice", PasswordHash: hash}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
got, err := us.GetByUsername(ctx, "alice")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUsername: %v", err)
|
||||
}
|
||||
if got.PasswordHash == plain {
|
||||
t.Fatal("stored hash must not equal the plaintext password")
|
||||
}
|
||||
if !auth.CheckPassword(plain, got.PasswordHash) {
|
||||
t.Fatal("stored hash must verify against the original password")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetByID returns the user and ErrNotFound for unknown id", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
u := mustCreateUser(t, us, "alice")
|
||||
|
||||
got, err := us.GetByID(ctx, u.ID)
|
||||
if err != nil || got.Username != "alice" {
|
||||
t.Fatalf("GetByID = %+v, err = %v", got, err)
|
||||
}
|
||||
if _, err := us.GetByID(ctx, "no-such-id"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create assigns an id when none is given", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
u := &models.User{Username: "noid", PasswordHash: "h"}
|
||||
if err := us.Create(ctx, u); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if u.ID == "" {
|
||||
t.Fatal("Create must assign an id")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserStoreConcurrentRegister(t *testing.T) {
|
||||
t.Run("20 goroutines register the same username", func(t *testing.T) {
|
||||
resetDB(t)
|
||||
us, _, _ := newTestStores()
|
||||
|
||||
const n = 20
|
||||
errs := make([]error, n)
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs[i] = us.Create(context.Background(), &models.User{
|
||||
ID: uuid.New().String(),
|
||||
Username: "same-name",
|
||||
PasswordHash: "h",
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
success, duplicate := 0, 0
|
||||
for _, err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
success++
|
||||
case errors.Is(err, ErrUsernameTaken):
|
||||
duplicate++
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if success != 1 || duplicate != n-1 {
|
||||
t.Fatalf("successes = %d, duplicates = %d; want 1 and %d", success, duplicate, n-1)
|
||||
}
|
||||
if rows := countRows(t, "users"); rows != 1 {
|
||||
t.Fatalf("users rows = %d, want 1", rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user