Tuas W06
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"5803025020/w04/internal/config"
|
||||
"5803025020/w04/internal/database"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// testConn is the single shared connection used by every integration test in
|
||||
// this package. testMu serializes access to it the same way the production
|
||||
// server serializes access across UserStore/SessionStore/SnippetStore.
|
||||
var (
|
||||
testConn *pgx.Conn
|
||||
testMu = &sync.Mutex{}
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
config.LoadEnv()
|
||||
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
if !strings.HasSuffix(dbName, "_test") {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"refusing to run internal/store integration tests: DB_NAME=%q must end in \"_test\" (safety guard against running against a non-test database)\n",
|
||||
dbName)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
conn, err := database.Connect()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to connect to test database: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
testConn = conn
|
||||
|
||||
// Create the schema through the exact same code path the server uses on
|
||||
// startup — no second copy of the CREATE TABLE statements here.
|
||||
if err := database.CreateTables(testConn); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to create schema: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
testConn.Close(context.Background())
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// truncateAll wipes every application table so each test starts from a clean
|
||||
// slate. Call this at the start of every test (not just once in TestMain).
|
||||
func truncateAll(t *testing.T) {
|
||||
t.Helper()
|
||||
_, err := testConn.Exec(context.Background(),
|
||||
"TRUNCATE TABLE sessions, snippets, users RESTART IDENTITY CASCADE")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to truncate tables: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -9,6 +10,9 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrSessionNotFound dikembalikan saat token sesi tidak ditemukan (atau sudah dihapus).
|
||||
var ErrSessionNotFound = errors.New("session not found")
|
||||
|
||||
type SessionStore struct {
|
||||
conn *pgx.Conn
|
||||
mu *sync.Mutex
|
||||
@@ -45,16 +49,28 @@ func (s *SessionStore) Get(ctx context.Context, token string) (*models.Session,
|
||||
query := `SELECT token, user_id, created_at FROM sessions WHERE token = $1`
|
||||
err := s.conn.QueryRow(ctx, query, token).Scan(&sess.Token, &sess.UserID, &sess.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
// Delete menghapus token sesi. Jika token tidak ditemukan (sudah dihapus atau
|
||||
// tidak pernah ada), ia mengembalikan ErrSessionNotFound tanpa panic — memanggil
|
||||
// Delete dua kali dengan token yang sama aman dan tidak menghapus token lain.
|
||||
func (s *SessionStore) Delete(ctx context.Context, token string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
query := `DELETE FROM sessions WHERE token = $1`
|
||||
_, err := s.conn.Exec(ctx, query, token)
|
||||
return err
|
||||
tag, err := s.conn.Exec(ctx, query, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"5803025020/w04/internal/database"
|
||||
"5803025020/w04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestSessionStore(t *testing.T) (*SessionStore, *UserStore) {
|
||||
t.Helper()
|
||||
truncateAll(t)
|
||||
return NewSessionStore(testConn, testMu), NewUserStore(testConn, testMu)
|
||||
}
|
||||
|
||||
func seedUserForSession(t *testing.T, us *UserStore) *models.User {
|
||||
t.Helper()
|
||||
u := &models.User{ID: uuid.New().String(), Username: "sessuser_" + uuid.NewString()[:8], PasswordHash: "hash"}
|
||||
if err := us.Create(context.Background(), u); err != nil {
|
||||
t.Fatalf("failed to seed user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestSessionStore(t *testing.T) {
|
||||
t.Run("create then look up token returns correct user id", func(t *testing.T) {
|
||||
ss, us := newTestSessionStore(t)
|
||||
u := seedUserForSession(t, us)
|
||||
ctx := context.Background()
|
||||
token := "tok_" + uuid.NewString()
|
||||
if err := ss.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create session failed: %v", err)
|
||||
}
|
||||
got, err := ss.Get(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
if got.UserID != u.ID {
|
||||
t.Fatalf("expected user id %q, got %q", u.ID, got.UserID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown token returns ErrSessionNotFound", func(t *testing.T) {
|
||||
ss, _ := newTestSessionStore(t)
|
||||
_, err := ss.Get(context.Background(), "no-such-token")
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected ErrSessionNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete then look up returns ErrSessionNotFound", func(t *testing.T) {
|
||||
ss, us := newTestSessionStore(t)
|
||||
u := seedUserForSession(t, us)
|
||||
ctx := context.Background()
|
||||
token := "tok_" + uuid.NewString()
|
||||
if err := ss.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if err := ss.Delete(ctx, token); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
_, err := ss.Get(ctx, token)
|
||||
if !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected ErrSessionNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deleting an already-deleted token does not panic and does not delete other tokens", func(t *testing.T) {
|
||||
ss, us := newTestSessionStore(t)
|
||||
u := seedUserForSession(t, us)
|
||||
ctx := context.Background()
|
||||
token := "tok_" + uuid.NewString()
|
||||
other := "tok_" + uuid.NewString()
|
||||
if err := ss.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create token failed: %v", err)
|
||||
}
|
||||
if err := ss.Create(ctx, &models.Session{Token: other, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create other token failed: %v", err)
|
||||
}
|
||||
if err := ss.Delete(ctx, token); err != nil {
|
||||
t.Fatalf("first delete failed: %v", err)
|
||||
}
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("second delete of the same token panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
_ = ss.Delete(ctx, token) // return value is our choice; must not panic
|
||||
}()
|
||||
|
||||
if _, err := ss.Get(ctx, other); err != nil {
|
||||
t.Fatalf("expected the other token to remain valid, got err=%v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two sessions for one user are independent", func(t *testing.T) {
|
||||
ss, us := newTestSessionStore(t)
|
||||
u := seedUserForSession(t, us)
|
||||
ctx := context.Background()
|
||||
token1 := "tok_" + uuid.NewString()
|
||||
token2 := "tok_" + uuid.NewString()
|
||||
if err := ss.Create(ctx, &models.Session{Token: token1, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create token1 failed: %v", err)
|
||||
}
|
||||
if err := ss.Create(ctx, &models.Session{Token: token2, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create token2 failed: %v", err)
|
||||
}
|
||||
if err := ss.Delete(ctx, token1); err != nil {
|
||||
t.Fatalf("delete token1 failed: %v", err)
|
||||
}
|
||||
if _, err := ss.Get(ctx, token1); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("expected token1 to be gone, got err=%v", err)
|
||||
}
|
||||
if _, err := ss.Get(ctx, token2); err != nil {
|
||||
t.Fatalf("expected token2 to still be valid, got err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSessionSurvivesNewStore creates a session with one SessionStore
|
||||
// instance, then opens a brand-new connection and a brand-new SessionStore
|
||||
// instance against the same database, and confirms the token is still
|
||||
// readable — the closest a Go test can get to asserting survival across a
|
||||
// server restart.
|
||||
func TestSessionSurvivesNewStore(t *testing.T) {
|
||||
ss1, us := newTestSessionStore(t)
|
||||
u := seedUserForSession(t, us)
|
||||
ctx := context.Background()
|
||||
token := "tok_" + uuid.NewString()
|
||||
if err := ss1.Create(ctx, &models.Session{Token: token, UserID: u.ID, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
newConn, err := database.Connect()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open a brand-new connection: %v", err)
|
||||
}
|
||||
defer newConn.Close(ctx)
|
||||
|
||||
ss2 := NewSessionStore(newConn, &sync.Mutex{})
|
||||
got, err := ss2.Get(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("expected session to survive on a brand-new store instance, got err=%v", err)
|
||||
}
|
||||
if got.UserID != u.ID {
|
||||
t.Fatalf("expected user id %q, got %q", u.ID, got.UserID)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"5803025020/w04/internal/models"
|
||||
@@ -9,6 +10,9 @@ import (
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// ErrSnippetNotFound dikembalikan saat snippet dengan id yang diminta tidak ada.
|
||||
var ErrSnippetNotFound = errors.New("snippet not found")
|
||||
|
||||
type SnippetStore struct {
|
||||
conn *pgx.Conn
|
||||
mu *sync.Mutex
|
||||
@@ -45,6 +49,9 @@ func (s *SnippetStore) GetByID(ctx context.Context, id string) (*models.Snippet,
|
||||
query := `SELECT id, title, content, user_id FROM snippets WHERE id = $1`
|
||||
err := s.conn.QueryRow(ctx, query, id).Scan(&sn.ID, &sn.Title, &sn.Content, &sn.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSnippetNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sn, nil
|
||||
@@ -100,11 +107,19 @@ func (s *SnippetStore) GetAll(ctx context.Context) ([]*models.Snippet, error) {
|
||||
return snippets, nil
|
||||
}
|
||||
|
||||
// Delete menghapus snippet dengan id tersebut. Jika id tidak ditemukan, ia
|
||||
// mengembalikan ErrSnippetNotFound.
|
||||
func (s *SnippetStore) Delete(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
query := `DELETE FROM snippets WHERE id = $1`
|
||||
_, err := s.conn.Exec(ctx, query, id)
|
||||
return err
|
||||
tag, err := s.conn.Exec(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrSnippetNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"5803025020/w04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestSnippetStore(t *testing.T) (*SnippetStore, *UserStore) {
|
||||
t.Helper()
|
||||
truncateAll(t)
|
||||
return NewSnippetStore(testConn, testMu), NewUserStore(testConn, testMu)
|
||||
}
|
||||
|
||||
func seedUserForSnippet(t *testing.T, us *UserStore) *models.User {
|
||||
t.Helper()
|
||||
u := &models.User{ID: uuid.New().String(), Username: "snipuser_" + uuid.NewString()[:8], PasswordHash: "hash"}
|
||||
if err := us.Create(context.Background(), u); err != nil {
|
||||
t.Fatalf("failed to seed user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestSnippetStore(t *testing.T) {
|
||||
t.Run("create then get by id", func(t *testing.T) {
|
||||
sn, us := newTestSnippetStore(t)
|
||||
u := seedUserForSnippet(t, us)
|
||||
ctx := context.Background()
|
||||
snippet := &models.Snippet{ID: uuid.New().String(), Title: "hello", Content: "world", UserID: u.ID}
|
||||
if err := sn.Create(ctx, snippet); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
got, err := sn.GetByID(ctx, snippet.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
if got.Title != "hello" || got.Content != "world" {
|
||||
t.Fatalf("got %+v, want title=hello content=world", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get unknown id returns ErrSnippetNotFound", func(t *testing.T) {
|
||||
sn, _ := newTestSnippetStore(t)
|
||||
_, err := sn.GetByID(context.Background(), "no-such-id")
|
||||
if !errors.Is(err, ErrSnippetNotFound) {
|
||||
t.Fatalf("expected ErrSnippetNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list by owner returns only that owner's rows", func(t *testing.T) {
|
||||
sn, us := newTestSnippetStore(t)
|
||||
u1 := seedUserForSnippet(t, us)
|
||||
u2 := seedUserForSnippet(t, us)
|
||||
ctx := context.Background()
|
||||
if err := sn.Create(ctx, &models.Snippet{ID: uuid.New().String(), Title: "a", Content: "a", UserID: u1.ID}); err != nil {
|
||||
t.Fatalf("create for u1 failed: %v", err)
|
||||
}
|
||||
if err := sn.Create(ctx, &models.Snippet{ID: uuid.New().String(), Title: "b", Content: "b", UserID: u2.ID}); err != nil {
|
||||
t.Fatalf("create for u2 failed: %v", err)
|
||||
}
|
||||
got, err := sn.GetByUserID(ctx, u1.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].UserID != u1.ID {
|
||||
t.Fatalf("expected exactly 1 snippet owned by u1, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list for an owner with no snippets returns a non-nil empty slice", func(t *testing.T) {
|
||||
sn, us := newTestSnippetStore(t)
|
||||
u := seedUserForSnippet(t, us)
|
||||
got, err := sn.GetByUserID(context.Background(), u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil empty slice, got nil")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty slice, got %d items", len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete removes the row", func(t *testing.T) {
|
||||
sn, us := newTestSnippetStore(t)
|
||||
u := seedUserForSnippet(t, us)
|
||||
ctx := context.Background()
|
||||
snippet := &models.Snippet{ID: uuid.New().String(), Title: "x", Content: "y", UserID: u.ID}
|
||||
if err := sn.Create(ctx, snippet); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if err := sn.Delete(ctx, snippet.ID); err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
_, err := sn.GetByID(ctx, snippet.ID)
|
||||
if !errors.Is(err, ErrSnippetNotFound) {
|
||||
t.Fatalf("expected ErrSnippetNotFound after delete, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete unknown id returns ErrSnippetNotFound", func(t *testing.T) {
|
||||
sn, _ := newTestSnippetStore(t)
|
||||
err := sn.Delete(context.Background(), "no-such-id")
|
||||
if !errors.Is(err, ErrSnippetNotFound) {
|
||||
t.Fatalf("expected ErrSnippetNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ownership of a snippet can be retrieved to distinguish not found from not yours", func(t *testing.T) {
|
||||
sn, us := newTestSnippetStore(t)
|
||||
u1 := seedUserForSnippet(t, us)
|
||||
ctx := context.Background()
|
||||
snippet := &models.Snippet{ID: uuid.New().String(), Title: "x", Content: "y", UserID: u1.ID}
|
||||
if err := sn.Create(ctx, snippet); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
got, err := sn.GetByID(ctx, snippet.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
if got.UserID != u1.ID {
|
||||
t.Fatalf("expected to read back owner id %q, got %q", u1.ID, got.UserID)
|
||||
}
|
||||
// A handler compares got.UserID against the authenticated caller's id
|
||||
// to tell "not found" (GetByID itself errors) apart from "not yours"
|
||||
// (GetByID succeeds but UserID does not match the caller).
|
||||
})
|
||||
}
|
||||
|
||||
// TestSnippetStoreSQLInjection passes classic SQL-injection payloads through
|
||||
// Create/Get/List and confirms the strings round-trip unchanged and the
|
||||
// snippets table still exists and contains only the expected rows.
|
||||
func TestSnippetStoreSQLInjection(t *testing.T) {
|
||||
sn, us := newTestSnippetStore(t)
|
||||
u := seedUserForSnippet(t, us)
|
||||
ctx := context.Background()
|
||||
|
||||
payloads := []string{
|
||||
`' OR '1'='1`,
|
||||
`'; DROP TABLE snippets; --`,
|
||||
`a title containing a single quote: it's here`,
|
||||
}
|
||||
|
||||
for i, payload := range payloads {
|
||||
t.Run(payload, func(t *testing.T) {
|
||||
snippet := &models.Snippet{ID: uuid.New().String(), Title: payload, Content: payload, UserID: u.ID}
|
||||
if err := sn.Create(ctx, snippet); err != nil {
|
||||
t.Fatalf("create with payload %d failed: %v", i, err)
|
||||
}
|
||||
got, err := sn.GetByID(ctx, snippet.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get with payload %d failed: %v", i, err)
|
||||
}
|
||||
if got.Title != payload || got.Content != payload {
|
||||
t.Fatalf("payload %d did not round-trip: got title=%q content=%q, want %q", i, got.Title, got.Content, payload)
|
||||
}
|
||||
|
||||
list, err := sn.GetByUserID(ctx, u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list after payload %d failed: %v", i, err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Fatalf("expected the snippets table to still contain rows after payload %d", i)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := testConn.QueryRow(ctx, "SELECT COUNT(*) FROM snippets").Scan(&count); err != nil {
|
||||
t.Fatalf("snippets table appears to be gone: %v", err)
|
||||
}
|
||||
if count != len(payloads) {
|
||||
t.Fatalf("expected exactly %d rows (one per payload), got %d", len(payloads), count)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
// ErrUsernameTaken dikembalikan saat Create() gagal karena username sudah dipakai (unique constraint).
|
||||
var ErrUsernameTaken = errors.New("username already exists")
|
||||
|
||||
// ErrUserNotFound dikembalikan saat user dengan username/id yang diminta tidak ada.
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
|
||||
type UserStore struct {
|
||||
conn *pgx.Conn
|
||||
mu *sync.Mutex
|
||||
@@ -57,6 +60,9 @@ func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models
|
||||
query := `SELECT id, username, password_hash FROM users WHERE username = $1`
|
||||
err := s.conn.QueryRow(ctx, query, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
@@ -70,6 +76,9 @@ func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error
|
||||
query := `SELECT id, username, password_hash FROM users WHERE id = $1`
|
||||
err := s.conn.QueryRow(ctx, query, id).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"5803025020/w04/internal/auth"
|
||||
"5803025020/w04/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func newTestUserStore(t *testing.T) *UserStore {
|
||||
t.Helper()
|
||||
truncateAll(t)
|
||||
return NewUserStore(testConn, testMu)
|
||||
}
|
||||
|
||||
func TestUserStore(t *testing.T) {
|
||||
t.Run("create then fetch by username", func(t *testing.T) {
|
||||
us := newTestUserStore(t)
|
||||
ctx := context.Background()
|
||||
u := &models.User{ID: uuid.New().String(), Username: "alice_" + uuid.NewString()[:8], PasswordHash: "hash-1"}
|
||||
if err := us.Create(ctx, u); err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
got, err := us.GetByUsername(ctx, u.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByUsername failed: %v", err)
|
||||
}
|
||||
if got.ID != u.ID || got.Username != u.Username {
|
||||
t.Fatalf("got %+v, want id=%s username=%s", got, u.ID, u.Username)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetch unknown username returns ErrUserNotFound", func(t *testing.T) {
|
||||
us := newTestUserStore(t)
|
||||
_, err := us.GetByUsername(context.Background(), "no-such-user-xyz")
|
||||
if !errors.Is(err, ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate username returns ErrUsernameTaken from the database UNIQUE constraint", func(t *testing.T) {
|
||||
us := newTestUserStore(t)
|
||||
ctx := context.Background()
|
||||
username := "dupuser_" + uuid.NewString()[:8]
|
||||
u1 := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-1"}
|
||||
if err := us.Create(ctx, u1); err != nil {
|
||||
t.Fatalf("first create failed: %v", err)
|
||||
}
|
||||
u2 := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash-2"}
|
||||
err := us.Create(ctx, u2)
|
||||
if !errors.Is(err, ErrUsernameTaken) {
|
||||
t.Fatalf("expected ErrUsernameTaken, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("usernames are case sensitive", func(t *testing.T) {
|
||||
us := newTestUserStore(t)
|
||||
ctx := context.Background()
|
||||
suffix := uuid.NewString()[:8]
|
||||
lower := &models.User{ID: uuid.New().String(), Username: "case_" + suffix, PasswordHash: "hash-1"}
|
||||
upper := &models.User{ID: uuid.New().String(), Username: "CASE_" + suffix, PasswordHash: "hash-2"}
|
||||
if err := us.Create(ctx, lower); err != nil {
|
||||
t.Fatalf("create lower failed: %v", err)
|
||||
}
|
||||
if err := us.Create(ctx, upper); err != nil {
|
||||
t.Fatalf("create upper failed (expected a distinct user, usernames should be case sensitive): %v", err)
|
||||
}
|
||||
gotLower, err := us.GetByUsername(ctx, lower.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("get lower failed: %v", err)
|
||||
}
|
||||
gotUpper, err := us.GetByUsername(ctx, upper.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("get upper failed: %v", err)
|
||||
}
|
||||
if gotLower.ID == gotUpper.ID {
|
||||
t.Fatal("expected two distinct users for differently-cased usernames")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stored password_hash differs from the plaintext that was passed in", func(t *testing.T) {
|
||||
us := newTestUserStore(t)
|
||||
ctx := context.Background()
|
||||
plaintext := "correct horse battery staple"
|
||||
hash, err := auth.HashPassword(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to hash password: %v", err)
|
||||
}
|
||||
u := &models.User{ID: uuid.New().String(), Username: "hashcheck_" + uuid.NewString()[:8], PasswordHash: hash}
|
||||
if err := us.Create(ctx, u); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
got, err := us.GetByUsername(ctx, u.Username)
|
||||
if err != nil {
|
||||
t.Fatalf("get failed: %v", err)
|
||||
}
|
||||
if got.PasswordHash == plaintext {
|
||||
t.Fatal("expected stored password_hash to differ from the plaintext password")
|
||||
}
|
||||
if got.PasswordHash != hash {
|
||||
t.Fatalf("expected stored hash to round-trip unchanged, got %q want %q", got.PasswordHash, hash)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestUserStoreConcurrentRegister launches 20 goroutines that all try to
|
||||
// create the same username. Exactly one must succeed, the other 19 must
|
||||
// return ErrUsernameTaken, and exactly one row must exist afterward. Run
|
||||
// this test with `go test -race`.
|
||||
func TestUserStoreConcurrentRegister(t *testing.T) {
|
||||
us := newTestUserStore(t)
|
||||
ctx := context.Background()
|
||||
username := "concurrent_" + uuid.NewString()[:8]
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
u := &models.User{ID: uuid.New().String(), Username: username, PasswordHash: "hash"}
|
||||
results[i] = us.Create(ctx, u)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
successCount, dupCount := 0, 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
successCount++
|
||||
case errors.Is(err, ErrUsernameTaken):
|
||||
dupCount++
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if successCount != 1 {
|
||||
t.Fatalf("expected exactly 1 success, got %d", successCount)
|
||||
}
|
||||
if dupCount != n-1 {
|
||||
t.Fatalf("expected %d duplicate errors, got %d", n-1, dupCount)
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := testConn.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE username = $1", username).Scan(&count); err != nil {
|
||||
t.Fatalf("count query failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected exactly 1 row in the database, got %d", count)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user