Initial commit

This commit is contained in:
2026-05-12 15:40:22 -06:00
parent 5b279865a1
commit 1eac72b3cd
31 changed files with 1192 additions and 45 deletions
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"encoding/json"
"net/http"
"go.mongodb.org/mongo-driver/v2/bson"
"logjensticks/internal/auth"
"logjensticks/internal/db"
)
type createLaneRequest struct {
LaneID *string `json:"lane_id"`
PickupState *string `json:"pickup_state"`
DropoffState *string `json:"dropoff_state"`
PickupAddress *string `json:"pickup_address"`
DropoffAddress *string `json:"dropoff_address"`
}
func handleCreateLane(dbc *db.Client) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := auth.SessionFromContext(r.Context())
if session.Role != "broker" {
writeError(w, http.StatusForbidden, "FORBIDDEN", "brokers only")
return
}
var req createLaneRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body")
return
}
hasLaneID := req.LaneID != nil && *req.LaneID != ""
hasStates := req.PickupState != nil && *req.PickupState != "" &&
req.DropoffState != nil && *req.DropoffState != ""
hasAddresses := req.PickupAddress != nil && *req.PickupAddress != "" &&
req.DropoffAddress != nil && *req.DropoffAddress != ""
if !hasLaneID && !hasStates && !hasAddresses {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR",
"provide at least a lane ID, both state codes, or both addresses")
return
}
var creatingUser struct {
ID bson.ObjectID `bson:"_id"`
}
err := dbc.Users().FindOne(r.Context(),
bson.D{{Key: "username", Value: session.Username}},
).Decode(&creatingUser)
if err != nil {
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "failed to resolve user")
return
}
doc := bson.D{
{Key: "lane_id", Value: req.LaneID},
{Key: "pickup_state", Value: req.PickupState},
{Key: "dropoff_state", Value: req.DropoffState},
{Key: "pickup_address", Value: req.PickupAddress},
{Key: "dropoff_address", Value: req.DropoffAddress},
{Key: "bidding_carriers", Value: bson.A{}},
{Key: "created_by", Value: session.Username},
{Key: "created_by_id", Value: creatingUser.ID},
}
result, err := dbc.Lanes().InsertOne(r.Context(), doc)
if err != nil {
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "failed to create lane")
return
}
writeSuccess(w, http.StatusCreated, map[string]any{
"id": result.InsertedID,
})
}
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"encoding/json"
"errors"
"net/http"
"time"
"logjensticks/internal/auth"
"logjensticks/internal/db"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func handleLogin(dbc *db.Client, secureCookie bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "username and password required")
return
}
token, session, err := auth.Login(r.Context(), dbc, req.Username, req.Password)
if errors.Is(err, auth.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid username or password")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "internal server error")
return
}
http.SetCookie(w, &http.Cookie{
Name: auth.CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: secureCookie,
SameSite: http.SameSiteStrictMode,
MaxAge: int(db.SessionTTL / time.Second),
})
writeSuccess(w, http.StatusOK, map[string]string{"role": session.Role})
}
}
func handleLogout(dbc *db.Client, secureCookie bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
if cookie, err := r.Cookie(auth.CookieName); err == nil {
auth.DeleteSession(r.Context(), dbc, cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: auth.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: secureCookie,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
writeSuccess(w, http.StatusOK, nil)
}
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// loginResponse mirrors the shape returned by handleLogin for assertion purposes.
type loginResponse struct {
Success bool `json:"success"`
Data *struct {
Role string `json:"role"`
} `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func TestHandleLogin_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
handleLogin(nil, false)(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleLogin_MissingFields(t *testing.T) {
body := bytes.NewBufferString(`{"username":""}`)
req := httptest.NewRequest(http.MethodPost, "/login", body)
w := httptest.NewRecorder()
handleLogin(nil, false)(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
var resp loginResponse
json.NewDecoder(w.Body).Decode(&resp)
if resp.Error == nil || resp.Error.Code != "BAD_REQUEST" {
t.Errorf("expected BAD_REQUEST error, got %+v", resp.Error)
}
}
func TestHandleLogout_MethodNotAllowed(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
w := httptest.NewRecorder()
handleLogout(nil, false)(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleLogout_NoCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
w := httptest.NewRecorder()
handleLogout(nil, false)(w, req)
// Logout with no cookie should still succeed (idempotent).
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"log"
"net/http"
"os"
"logjensticks/internal/auth"
"logjensticks/internal/db"
)
func main() {
mongoURI := os.Getenv("MONGO_URI")
if mongoURI == "" {
mongoURI = "mongodb://localhost:27017/logjensticks"
}
// In production set COOKIE_SECURE=true (requires HTTPS).
// Defaults to false for local dev over HTTP.
secureCookie := os.Getenv("COOKIE_SECURE") == "true"
ctx := context.Background()
dbClient, err := db.Connect(ctx, mongoURI)
if err != nil {
log.Fatalf("db: failed to connect: %v", err)
}
defer dbClient.Disconnect(ctx)
if err := dbClient.EnsureIndexes(ctx); err != nil {
log.Fatalf("db: failed to create indexes: %v", err)
}
seedUsername := os.Getenv("SEED_USERNAME")
seedPassword := os.Getenv("SEED_PASSWORD")
if seedUsername == "" {
seedUsername = "broker"
log.Println("WARNING: SEED_USERNAME not set, using dev default 'broker'")
}
if seedPassword == "" {
seedPassword = "changeme"
log.Println("WARNING: SEED_PASSWORD not set, using dev default 'changeme'")
}
if err := dbClient.SeedBroker(ctx, seedUsername, seedPassword); err != nil {
log.Fatalf("db: seed failed: %v", err)
}
protected := auth.Middleware(dbClient)
mux := http.NewServeMux()
mux.HandleFunc("/health", handleHealth)
mux.HandleFunc("/login", handleLogin(dbClient, secureCookie))
mux.HandleFunc("/logout", handleLogout(dbClient, secureCookie))
mux.Handle("/me", protected(http.HandlerFunc(handleMe)))
addr := ":8080"
log.Printf("server starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"success":true}`))
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
handleHealth(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if body != `{"success":true}` {
t.Errorf("unexpected body: %s", body)
}
}
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"net/http"
"logjensticks/internal/auth"
)
func handleMe(w http.ResponseWriter, r *http.Request) {
session := auth.SessionFromContext(r.Context())
writeSuccess(w, http.StatusOK, map[string]string{
"username": session.Username,
"role": session.Role,
})
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"encoding/json"
"net/http"
)
type apiResponse struct {
Success bool `json:"success"`
Data any `json:"data"`
Error *apiError `json:"error"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func writeSuccess(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(apiResponse{Success: true, Data: data, Error: nil})
}
func writeError(w http.ResponseWriter, status int, code, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(apiResponse{Success: false, Data: nil, Error: &apiError{Code: code, Message: message}})
}