Assignment 4: tests, interface refactor, e2e suite

This commit is contained in:
Zagar Nashrullah
2026-10-03 19:35:30 +07:00
parent 6e7e35d9a5
commit 004bf4e292
25 changed files with 2644 additions and 151 deletions
+11
View File
@@ -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
View File
@@ -1 +1,2 @@
.env
cover.out
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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)
})
}
}
}
+97
View File
@@ -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)
}
})
}
}
+6 -1
View File
@@ -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
+69
View File
@@ -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)
}
}
+43 -46
View File
@@ -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)
if _, err := h.sessionStore.Get(r.Context(), token); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
token := authHeader
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = authHeader[7:]
}
token = strings.TrimSpace(token)
if token == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
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)
}
+320
View File
@@ -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)
}
})
}
+237
View File
@@ -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 }
+28
View File
@@ -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
}
+17
View File
@@ -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})
}
+52 -67
View File
@@ -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
}
+364
View File
@@ -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)
}
})
}
+40 -30
View File
@@ -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
}
+131
View File
@@ -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)
}
})
}
+6
View File
@@ -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")
+75
View File
@@ -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
}
+4
View File
@@ -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
+122
View File
@@ -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)
}
})
}
+36 -1
View File
@@ -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)
tag, err := s.conn.Exec(ctx, query, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
+224
View File
@@ -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)
}
})
}
}
+18
View File
@@ -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,8 +39,15 @@ func (s *UserStore) Create(ctx context.Context, user *models.User) error {
query := `INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)`
_, err := s.conn.Exec(ctx, query, user.ID, user.Username, user.PasswordHash)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrUsernameTaken
}
return err
}
return nil
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
s.mu.Lock()
@@ -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
+166
View File
@@ -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)
}
})
}