refactor(api): enforce protocol-neutral service boundaries
- refactor: move HTTP status and DTO concerns out of model and service layers into API and handler code. - feat: add admin service boundary and route auth through services instead of direct repository access. - test: add architecture and error tests covering package boundaries, redaction, and service behavior. - docs: record the protocol-neutral boundary decision and update architecture and roadmap notes.
This commit is contained in:
+70
-16
@@ -21,48 +21,102 @@ type ErrorResponse struct {
|
||||
// ErrorBody contains human-readable error details.
|
||||
type ErrorBody struct {
|
||||
Message string `json:"message"`
|
||||
LogID string `json:"log_id,omitempty"`
|
||||
}
|
||||
|
||||
// Error writes a JSON error response.
|
||||
func Error(c *gin.Context, status int, message string) {
|
||||
writeError(c, status, message, "")
|
||||
}
|
||||
|
||||
func writeError(c *gin.Context, status int, message, logID string) {
|
||||
c.JSON(status, ErrorResponse{
|
||||
Error: ErrorBody{
|
||||
Message: message,
|
||||
LogID: logID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RespondError unpacks an error from the service layer and writes a JSON
|
||||
// error response. It expects *model.AppError; unexpected non-AppError
|
||||
// values are treated as 500 with a safe message. For 500+ errors, a
|
||||
// random reference hash is included in the response so operators can
|
||||
// correlate it with server logs.
|
||||
// error response. It maps protocol-neutral domain error kinds to HTTP status
|
||||
// codes at the API boundary. Unexpected non-AppError values are treated as 500
|
||||
// with a safe message. Recorded errors return a log_id for correlation.
|
||||
func RespondError(c *gin.Context, err error) {
|
||||
var ae *model.AppError
|
||||
if !errors.As(err, &ae) {
|
||||
ref := randomHex(8)
|
||||
logID := randomHex(8)
|
||||
slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler",
|
||||
"ref", ref, "error", err, "type", fmt.Sprintf("%T", err))
|
||||
Error(c, http.StatusInternalServerError, "internal server error (ref: "+ref+")")
|
||||
"log_id", logID, "error", err, "type", fmt.Sprintf("%T", err))
|
||||
writeError(c, http.StatusInternalServerError, "internal server error", logID)
|
||||
return
|
||||
}
|
||||
|
||||
// 500+ errors: log full detail with a reference hash, return sanitized message.
|
||||
if ae.Status >= 500 {
|
||||
ref := randomHex(8)
|
||||
status := StatusForErrorKind(ae.Kind)
|
||||
message := ae.Message
|
||||
if status >= http.StatusInternalServerError {
|
||||
message = "internal server error"
|
||||
}
|
||||
|
||||
if status >= http.StatusInternalServerError {
|
||||
logID := randomHex(8)
|
||||
slog.ErrorContext(c.Request.Context(), "internal server error",
|
||||
"ref", ref, slog.Any("error", ae.Err), "message", ae.Message)
|
||||
Error(c, ae.Status, "internal server error (ref: "+ref+")")
|
||||
"log_id", logID, slog.Any("error", ae.Err), "message", ae.Message)
|
||||
writeError(c, status, message, logID)
|
||||
return
|
||||
}
|
||||
|
||||
// 4xx errors with internal cause: log at WARN for diagnostics.
|
||||
if ae.Err != nil {
|
||||
// 4xx errors with unexpected causes are recorded for diagnostics and return log_id.
|
||||
if isLoggableCause(ae.Err) {
|
||||
logID := randomHex(8)
|
||||
slog.WarnContext(c.Request.Context(), ae.Message,
|
||||
"status", ae.Status, slog.Any("error", ae.Err))
|
||||
"log_id", logID, "status", status, slog.Any("error", ae.Err))
|
||||
writeError(c, status, message, logID)
|
||||
return
|
||||
}
|
||||
|
||||
Error(c, ae.Status, ae.Message)
|
||||
writeError(c, status, message, "")
|
||||
}
|
||||
|
||||
func isLoggableCause(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, expected := range []error{
|
||||
model.ErrNotFound,
|
||||
model.ErrDuplicate,
|
||||
model.ErrUnauthorized,
|
||||
model.ErrForbidden,
|
||||
model.ErrUploadTooLarge,
|
||||
} {
|
||||
if errors.Is(err, expected) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// StatusForErrorKind maps a protocol-neutral error kind to a REST status code.
|
||||
func StatusForErrorKind(kind model.ErrorKind) int {
|
||||
switch kind {
|
||||
case model.KindInvalidArgument:
|
||||
return http.StatusBadRequest
|
||||
case model.KindUnauthenticated:
|
||||
return http.StatusUnauthorized
|
||||
case model.KindPermissionDenied:
|
||||
return http.StatusForbidden
|
||||
case model.KindNotFound:
|
||||
return http.StatusNotFound
|
||||
case model.KindConflict:
|
||||
return http.StatusConflict
|
||||
case model.KindPayloadTooLarge:
|
||||
return http.StatusRequestEntityTooLarge
|
||||
case model.KindInternal:
|
||||
return http.StatusInternalServerError
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
|
||||
@@ -2,11 +2,14 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
@@ -33,4 +36,66 @@ func TestError(t *testing.T) {
|
||||
if body.Error.Message != "invalid request" {
|
||||
t.Errorf("message = %q, want %q", body.Error.Message, "invalid request")
|
||||
}
|
||||
if body.Error.LogID != "" {
|
||||
t.Errorf("log_id = %q, want empty", body.Error.LogID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRespondErrorMapsDomainKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantStatus int
|
||||
wantLogID bool
|
||||
}{
|
||||
{"invalid argument", model.NewInvalidArgumentError("bad input"), http.StatusBadRequest, false},
|
||||
{"unauthenticated", model.NewUnauthenticatedError("invalid token"), http.StatusUnauthorized, false},
|
||||
{"permission denied", model.NewPermissionDeniedError("access denied", nil), http.StatusForbidden, false},
|
||||
{"not found", model.NewNotFoundError("missing", nil), http.StatusNotFound, false},
|
||||
{"conflict", model.NewConflictError("duplicate"), http.StatusConflict, false},
|
||||
{"payload too large", model.NewPayloadTooLargeError("too large", nil), http.StatusRequestEntityTooLarge, false},
|
||||
{"internal", model.NewInternalError("lookup", errors.New("database offline")), http.StatusInternalServerError, true},
|
||||
{"unknown", errors.New("escaped error"), http.StatusInternalServerError, true},
|
||||
{"permission denied sentinel cause", model.NewPermissionDeniedError("access denied", model.ErrForbidden), http.StatusForbidden, false},
|
||||
{"not found sentinel cause", model.NewNotFoundError("missing", model.ErrNotFound), http.StatusNotFound, false},
|
||||
{"payload too large sentinel cause", model.NewPayloadTooLargeError("too large", model.ErrUploadTooLarge), http.StatusRequestEntityTooLarge, false},
|
||||
{"client with diagnostic cause", model.NewPermissionDeniedError("access denied", errors.New("policy backend failed")), http.StatusForbidden, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rec := exerciseRespondError(tt.err)
|
||||
if rec.Code != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body = %s", rec.Code, tt.wantStatus, rec.Body.String())
|
||||
}
|
||||
|
||||
var body ErrorResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if tt.wantLogID && body.Error.LogID == "" {
|
||||
t.Fatalf("log_id is empty, want populated; body = %s", rec.Body.String())
|
||||
}
|
||||
if !tt.wantLogID && body.Error.LogID != "" {
|
||||
t.Fatalf("log_id = %q, want empty", body.Error.LogID)
|
||||
}
|
||||
if rec.Code >= http.StatusInternalServerError && body.Error.Message != "internal server error" {
|
||||
t.Fatalf("message = %q, want sanitized internal message", body.Error.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func exerciseRespondError(err error) *httptest.ResponseRecorder {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/error", func(c *gin.Context) {
|
||||
RespondError(c, err)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/error", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type WebApp struct {
|
||||
FileRepo repository.FileRepository
|
||||
CredentialRepo repository.CredentialRepository
|
||||
AuthService *service.AuthService
|
||||
AdminService *service.AdminService
|
||||
FileService *service.FileService
|
||||
Storage storage.StorageBackend
|
||||
}
|
||||
@@ -58,6 +59,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
||||
}
|
||||
|
||||
fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default())
|
||||
adminService := service.NewAdminService(userRepo)
|
||||
|
||||
return &WebApp{
|
||||
Config: cfg,
|
||||
@@ -68,6 +70,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
||||
FileRepo: fileRepo,
|
||||
CredentialRepo: credentialRepo,
|
||||
AuthService: authService,
|
||||
AdminService: adminService,
|
||||
FileService: fileService,
|
||||
Storage: store,
|
||||
}, nil
|
||||
@@ -80,6 +83,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||
fileRepo repository.FileRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
authService *service.AuthService,
|
||||
adminService *service.AdminService,
|
||||
fileService *service.FileService,
|
||||
store storage.StorageBackend,
|
||||
) *WebApp {
|
||||
@@ -92,6 +96,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
|
||||
FileRepo: fileRepo,
|
||||
CredentialRepo: credentialRepo,
|
||||
AuthService: authService,
|
||||
AdminService: adminService,
|
||||
FileService: fileService,
|
||||
Storage: store,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func TestNewWebApp(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
|
||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
webApp := NewWebApp(cfg, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
if webApp.Config != cfg {
|
||||
t.Fatal("Config was not assigned")
|
||||
@@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCloseNilDB(t *testing.T) {
|
||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
webApp := NewWebApp(&config.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err := webApp.Close(); err != nil {
|
||||
t.Errorf("Close with nil DB should not error: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package internal_test
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArchitecture_ModelAndServiceAreProtocolNeutral(t *testing.T) {
|
||||
for _, dir := range []string{"model", "service"} {
|
||||
imports := packageImports(t, dir)
|
||||
for _, forbidden := range []string{
|
||||
"net/http",
|
||||
"github.com/gin-gonic/gin",
|
||||
"github.com/dhao2001/mygo/internal/api",
|
||||
} {
|
||||
if imports[forbidden] {
|
||||
t.Fatalf("internal/%s imports forbidden package %q", dir, forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchitecture_HTTPDoesNotImportRepositories(t *testing.T) {
|
||||
for _, dir := range []string{"handler", "middleware"} {
|
||||
imports := packageImports(t, dir)
|
||||
if imports["github.com/dhao2001/mygo/internal/repository"] {
|
||||
t.Fatalf("internal/%s imports internal/repository; use service boundaries instead", dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func packageImports(t *testing.T, dir string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
imports := map[string]bool{}
|
||||
pkgs := parsePackage(t, dir)
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, spec := range file.Imports {
|
||||
path, err := strconv.Unquote(spec.Path.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("unquote import path %s: %v", spec.Path.Value, err)
|
||||
}
|
||||
imports[path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return imports
|
||||
}
|
||||
|
||||
func parsePackage(t *testing.T, dir string) map[string]*ast.Package {
|
||||
t.Helper()
|
||||
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool {
|
||||
name := info.Name()
|
||||
return !strings.HasSuffix(name, "_test.go")
|
||||
}, parser.ParseComments)
|
||||
if err != nil {
|
||||
t.Fatalf("parse internal/%s: %v", dir, err)
|
||||
}
|
||||
return pkgs
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -26,10 +27,29 @@ type createPasskeyRequest struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
}
|
||||
|
||||
type accountResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type passkeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type createdPasskeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
Raw string `json:"raw"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// GetAccount handles GET /api/v1/account.
|
||||
func (h *AccountHandler) GetAccount(c *gin.Context) {
|
||||
userID := middleware.MustGetUserID(c)
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": userID})
|
||||
c.JSON(http.StatusOK, accountResponse{UserID: userID})
|
||||
}
|
||||
|
||||
// ListPasskeys handles GET /api/v1/account/passkeys.
|
||||
@@ -46,7 +66,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) {
|
||||
creds = []model.Credential{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, creds)
|
||||
c.JSON(http.StatusOK, toPasskeyResponses(creds))
|
||||
}
|
||||
|
||||
// CreatePasskey handles POST /api/v1/account/passkeys.
|
||||
@@ -66,7 +86,11 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, pk)
|
||||
c.JSON(http.StatusCreated, createdPasskeyResponse{
|
||||
ID: pk.ID,
|
||||
Raw: pk.Raw,
|
||||
Label: pk.Label,
|
||||
})
|
||||
}
|
||||
|
||||
// RevokePasskey handles DELETE /api/v1/account/passkeys/:id.
|
||||
@@ -86,3 +110,18 @@ func (h *AccountHandler) RevokePasskey(c *gin.Context) {
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func toPasskeyResponses(creds []model.Credential) []passkeyResponse {
|
||||
items := make([]passkeyResponse, 0, len(creds))
|
||||
for i := range creds {
|
||||
items = append(items, passkeyResponse{
|
||||
ID: creds[i].ID,
|
||||
UserID: creds[i].UserID,
|
||||
Type: creds[i].Type,
|
||||
Label: creds[i].Label,
|
||||
LastUsedAt: creds[i].LastUsedAt,
|
||||
CreatedAt: creds[i].CreatedAt,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/middleware"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
||||
@@ -31,7 +29,7 @@ func setupAccountRouter(t *testing.T) (*gin.Engine, []byte) {
|
||||
}
|
||||
|
||||
protected := r.Group("/api/v1")
|
||||
protected.Use(middleware.AuthRequired(secret))
|
||||
protected.Use(middleware.AuthRequired(svc))
|
||||
{
|
||||
account := protected.Group("/account")
|
||||
{
|
||||
@@ -65,7 +63,7 @@ func TestAccountEndpoint(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||
|
||||
// Get /account
|
||||
@@ -107,7 +105,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||
authHeader := "Bearer " + pair.AccessToken
|
||||
|
||||
@@ -134,7 +132,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
// Revoke passkey
|
||||
var creds []model.Credential
|
||||
var creds []passkeyResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &creds)
|
||||
if len(creds) != 1 {
|
||||
t.Fatalf("expected 1 passkey, got %d", len(creds))
|
||||
|
||||
@@ -9,17 +9,17 @@ import (
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
// AdminHandler handles admin-only endpoints for user management.
|
||||
type AdminHandler struct {
|
||||
userRepo repository.UserRepository
|
||||
adminService *service.AdminService
|
||||
}
|
||||
|
||||
// NewAdminHandler creates an AdminHandler.
|
||||
func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler {
|
||||
return &AdminHandler{userRepo: userRepo}
|
||||
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
|
||||
return &AdminHandler{adminService: adminService}
|
||||
}
|
||||
|
||||
// adminUserResponse exposes all user fields, including status, for admin views.
|
||||
@@ -55,7 +55,7 @@ func toAdminResponse(u *model.User) adminUserResponse {
|
||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.userRepo.Delete(c.Request.Context(), id); err != nil {
|
||||
if err := h.adminService.DeleteUser(c.Request.Context(), id); err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||
func (h *AdminHandler) GetUser(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
user, err := h.userRepo.FindByIDIncludeDeleted(c.Request.Context(), id)
|
||||
user, err := h.adminService.GetUser(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
@@ -93,7 +93,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
users, total, err := h.userRepo.ListIncludeDeleted(c.Request.Context(), offset, limit)
|
||||
users, total, err := h.adminService.ListUsers(c.Request.Context(), offset, limit)
|
||||
if err != nil {
|
||||
api.RespondError(c, err)
|
||||
return
|
||||
|
||||
@@ -42,7 +42,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
)
|
||||
|
||||
authHandler := NewAuthHandler(authService)
|
||||
adminHandler := NewAdminHandler(userRepo)
|
||||
adminService := service.NewAdminService(userRepo)
|
||||
adminHandler := NewAdminHandler(adminService)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
@@ -54,8 +55,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
||||
}
|
||||
|
||||
admin := r.Group("/api/v1/admin")
|
||||
admin.Use(middleware.AuthRequired(secret))
|
||||
admin.Use(middleware.AdminRequired(userRepo))
|
||||
admin.Use(middleware.AuthRequired(authService))
|
||||
admin.Use(middleware.AdminRequired())
|
||||
{
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.GET("/users/:id", adminHandler.GetUser)
|
||||
@@ -104,7 +105,7 @@ func loginHelper(t *testing.T, r *gin.Engine, email, password string) string {
|
||||
t.Fatalf("login %s: status = %d, body = %s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("unmarshal login response: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package handler
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
@@ -35,6 +37,20 @@ type tokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type userResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type tokenPairResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/v1/auth/register.
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
@@ -50,7 +66,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, user)
|
||||
c.JSON(http.StatusCreated, toUserResponse(user))
|
||||
}
|
||||
|
||||
// Login handles POST /api/v1/auth/login.
|
||||
@@ -68,7 +84,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pair)
|
||||
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||
}
|
||||
|
||||
// Refresh handles POST /api/v1/auth/refresh.
|
||||
@@ -86,7 +102,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, pair)
|
||||
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||
}
|
||||
|
||||
// Logout handles POST /api/v1/auth/logout.
|
||||
@@ -105,3 +121,21 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func toUserResponse(user *model.User) userResponse {
|
||||
return userResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
IsAdmin: user.IsAdmin,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toTokenPairResponse(pair *service.TokenPair) tokenPairResponse {
|
||||
return tokenPairResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
RefreshToken: pair.RefreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
type testTokenPairResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) {
|
||||
t.Helper()
|
||||
|
||||
@@ -140,7 +145,7 @@ func TestLoginHandler(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
@@ -196,7 +201,7 @@ func TestRefreshHandler(t *testing.T) {
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var pair service.TokenPair
|
||||
var pair testTokenPairResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||
|
||||
// Refresh
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -52,6 +53,24 @@ type updateFileRequest struct {
|
||||
ParentID *string `json:"parent_id"`
|
||||
}
|
||||
|
||||
type fileInfoResponse struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type fileListResponse struct {
|
||||
Files []fileInfoResponse `json:"files"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// Upload handles POST /api/v1/files.
|
||||
// If the content type is multipart/form-data, it uploads a file.
|
||||
// If the content type is application/json, it creates a directory.
|
||||
@@ -81,7 +100,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, dir)
|
||||
c.JSON(http.StatusCreated, toFileInfoResponse(dir))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +149,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, info)
|
||||
c.JSON(http.StatusCreated, toFileInfoResponse(info))
|
||||
}
|
||||
|
||||
func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) {
|
||||
@@ -217,7 +236,7 @@ func (h *FileHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, list)
|
||||
c.JSON(http.StatusOK, toFileListResponse(list))
|
||||
}
|
||||
|
||||
// Get handles GET /api/v1/files/:id.
|
||||
@@ -231,7 +250,7 @@ func (h *FileHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||
}
|
||||
|
||||
// Download handles GET /api/v1/files/:id/content.
|
||||
@@ -299,7 +318,7 @@ func (h *FileHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||
}
|
||||
|
||||
// Delete handles DELETE /api/v1/files/:id.
|
||||
@@ -314,3 +333,29 @@ func (h *FileHandler) Delete(c *gin.Context) {
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func toFileInfoResponse(info *service.FileInfo) fileInfoResponse {
|
||||
return fileInfoResponse{
|
||||
ID: info.ID,
|
||||
UserID: info.UserID,
|
||||
ParentID: info.ParentID,
|
||||
Name: info.Name,
|
||||
Size: info.Size,
|
||||
MimeType: info.MimeType,
|
||||
IsDir: info.IsDir,
|
||||
Hash: info.Hash,
|
||||
CreatedAt: info.CreatedAt,
|
||||
UpdatedAt: info.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toFileListResponse(list *service.FileList) fileListResponse {
|
||||
items := make([]fileInfoResponse, 0, len(list.Files))
|
||||
for i := range list.Files {
|
||||
items = append(items, toFileInfoResponse(&list.Files[i]))
|
||||
}
|
||||
return fileListResponse{
|
||||
Files: items,
|
||||
Total: list.Total,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func TestFileHandler_Upload(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
var info fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -318,7 +318,7 @@ func TestFileHandler_CreateDir(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
var info fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -353,7 +353,7 @@ func TestFileHandler_List(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var list service.FileList
|
||||
var list fileListResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func TestFileHandler_Get(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Get metadata.
|
||||
@@ -391,7 +391,7 @@ func TestFileHandler_Get(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
var info fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -430,7 +430,7 @@ func TestFileHandler_Download(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Download.
|
||||
@@ -466,7 +466,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil {
|
||||
t.Fatalf("unmarshal upload: %v", err)
|
||||
}
|
||||
@@ -502,7 +502,7 @@ func TestFileHandler_Update(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Rename.
|
||||
@@ -517,7 +517,7 @@ func TestFileHandler_Update(t *testing.T) {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var updated service.FileInfo
|
||||
var updated fileInfoResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
@@ -542,7 +542,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
updateBody, _ := json.Marshal(gin.H{})
|
||||
@@ -573,7 +573,7 @@ func TestFileHandler_Delete(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Delete.
|
||||
@@ -613,7 +613,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
var uploaded fileInfoResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Try to access as user2.
|
||||
|
||||
+42
-30
@@ -1,21 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/api"
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
const userIDKey = "user_id"
|
||||
const (
|
||||
userIDKey = "user_id"
|
||||
principalKey = "principal"
|
||||
)
|
||||
|
||||
type accessAuthenticator interface {
|
||||
AuthenticateAccessToken(ctx context.Context, token string) (*service.Principal, error)
|
||||
}
|
||||
|
||||
// AuthRequired returns a Gin middleware that validates JWT access tokens.
|
||||
// On success, it injects the user ID into the context via c.Get("user_id").
|
||||
func AuthRequired(jwtSecret []byte) gin.HandlerFunc {
|
||||
// On success, it injects the principal and user ID into the context.
|
||||
func AuthRequired(authenticator accessAuthenticator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" {
|
||||
@@ -31,20 +38,15 @@ func AuthRequired(jwtSecret []byte) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := auth.ParseToken(parts[1], jwtSecret)
|
||||
principal, err := authenticator.AuthenticateAccessToken(c.Request.Context(), parts[1])
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusUnauthorized, "invalid or expired token")
|
||||
api.RespondError(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Type != auth.TokenAccess {
|
||||
api.Error(c, http.StatusUnauthorized, "invalid token type")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(userIDKey, claims.UserID)
|
||||
c.Set(principalKey, *principal)
|
||||
c.Set(userIDKey, principal.UserID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -65,32 +67,42 @@ func GetUserID(c *gin.Context) string {
|
||||
func MustGetUserID(c *gin.Context) string {
|
||||
userID := GetUserID(c)
|
||||
if userID == "" {
|
||||
panic("user_id not found in context — is AuthRequired middleware applied?")
|
||||
panic("user_id not found in context - is AuthRequired middleware applied?")
|
||||
}
|
||||
return userID
|
||||
}
|
||||
|
||||
// AdminRequired returns a Gin middleware that gates access to admin-only
|
||||
// endpoints. It must be applied AFTER AuthRequired. It fetches the user from
|
||||
// the repository, and returns 403 if the user is not an admin. Soft-deleted
|
||||
// users are not found by FindByID and will receive a 401 response.
|
||||
func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc {
|
||||
// GetPrincipal extracts the authenticated principal injected by AuthRequired.
|
||||
func GetPrincipal(c *gin.Context) (service.Principal, bool) {
|
||||
v, ok := c.Get(principalKey)
|
||||
if !ok || v == nil {
|
||||
return service.Principal{}, false
|
||||
}
|
||||
principal, ok := v.(service.Principal)
|
||||
return principal, ok
|
||||
}
|
||||
|
||||
// MustGetPrincipal extracts the authenticated principal injected by AuthRequired.
|
||||
func MustGetPrincipal(c *gin.Context) service.Principal {
|
||||
principal, ok := GetPrincipal(c)
|
||||
if !ok {
|
||||
panic("principal not found in context - is AuthRequired middleware applied?")
|
||||
}
|
||||
return principal
|
||||
}
|
||||
|
||||
// AdminRequired returns a Gin middleware that gates access to admin-only endpoints.
|
||||
// It must be applied after AuthRequired.
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := GetUserID(c)
|
||||
if userID == "" {
|
||||
principal, ok := GetPrincipal(c)
|
||||
if !ok {
|
||||
api.Error(c, http.StatusUnauthorized, "missing user context")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userRepo.FindByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
api.Error(c, http.StatusUnauthorized, "user not found")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsAdmin {
|
||||
if !principal.IsAdmin {
|
||||
api.Error(c, http.StatusForbidden, "admin access required")
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
@@ -7,29 +7,47 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
)
|
||||
|
||||
func setupTestRouter(secret []byte) *gin.Engine {
|
||||
type stubAccessAuthenticator struct {
|
||||
wantToken string
|
||||
principal service.Principal
|
||||
err error
|
||||
called bool
|
||||
}
|
||||
|
||||
func (s *stubAccessAuthenticator) AuthenticateAccessToken(_ context.Context, token string) (*service.Principal, error) {
|
||||
s.called = true
|
||||
if s.wantToken != "" && token != s.wantToken {
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &s.principal, nil
|
||||
}
|
||||
|
||||
func setupTestRouter(authenticator accessAuthenticator) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(AuthRequired(secret))
|
||||
r.Use(AuthRequired(authenticator))
|
||||
r.GET("/protected", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"user_id": MustGetUserID(c)})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user_id": MustGetUserID(c),
|
||||
"is_admin": MustGetPrincipal(c).IsAdmin,
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAuthRequiredNoHeader(t *testing.T) {
|
||||
r := setupTestRouter([]byte("test-secret"))
|
||||
authenticator := &stubAccessAuthenticator{}
|
||||
r := setupTestRouter(authenticator)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -38,10 +56,14 @@ func TestAuthRequiredNoHeader(t *testing.T) {
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if authenticator.called {
|
||||
t.Fatal("authenticator should not be called without a bearer token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredInvalidFormat(t *testing.T) {
|
||||
r := setupTestRouter([]byte("test-secret"))
|
||||
authenticator := &stubAccessAuthenticator{}
|
||||
r := setupTestRouter(authenticator)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "invalid")
|
||||
@@ -51,10 +73,14 @@ func TestAuthRequiredInvalidFormat(t *testing.T) {
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if authenticator.called {
|
||||
t.Fatal("authenticator should not be called for malformed authorization header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredNotBearer(t *testing.T) {
|
||||
r := setupTestRouter([]byte("test-secret"))
|
||||
authenticator := &stubAccessAuthenticator{}
|
||||
r := setupTestRouter(authenticator)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
||||
@@ -64,105 +90,51 @@ func TestAuthRequiredNotBearer(t *testing.T) {
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if authenticator.called {
|
||||
t.Fatal("authenticator should not be called for non-bearer authorization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredExpiredToken(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := auth.GenerateAccessToken("user-1", secret, -1*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
func TestAuthRequiredServiceRejectsToken(t *testing.T) {
|
||||
authenticator := &stubAccessAuthenticator{err: model.NewUnauthenticatedError("invalid or expired token")}
|
||||
r := setupTestRouter(authenticator)
|
||||
|
||||
r := setupTestRouter(secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Authorization", "Bearer expired")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if !authenticator.called {
|
||||
t.Fatal("authenticator was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredValidToken(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
authenticator := &stubAccessAuthenticator{
|
||||
wantToken: "valid",
|
||||
principal: service.Principal{
|
||||
UserID: "user-1",
|
||||
IsAdmin: true,
|
||||
},
|
||||
}
|
||||
r := setupTestRouter(authenticator)
|
||||
|
||||
r := setupTestRouter(secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Authorization", "Bearer valid")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRequiredRefreshTokenRejected(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := auth.GenerateRefreshToken("user-1", secret, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken = %v", err)
|
||||
if !strings.Contains(rec.Body.String(), "user-1") {
|
||||
t.Errorf("response body %q does not contain user id", rec.Body.String())
|
||||
}
|
||||
|
||||
r := setupTestRouter(secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d (refresh token should be rejected)", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserID(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := auth.GenerateAccessToken("alice-42", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
|
||||
r := setupTestRouter(secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "alice-42") {
|
||||
t.Errorf("response body %q does not contain user id", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustGetUserID(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := auth.GenerateAccessToken("bob-99", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
|
||||
r := setupTestRouter(secret)
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rec.Code)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "bob-99") {
|
||||
t.Errorf("response body %q does not contain user id", body)
|
||||
if !strings.Contains(rec.Body.String(), "true") {
|
||||
t.Errorf("response body %q does not contain admin flag", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +142,7 @@ func TestMustGetUserIDPanics(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/naked", func(c *gin.Context) {
|
||||
MustGetUserID(c) // should panic — no AuthRequired middleware applied
|
||||
MustGetUserID(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/naked", nil)
|
||||
@@ -184,75 +156,27 @@ func TestMustGetUserIDPanics(t *testing.T) {
|
||||
r.ServeHTTP(rec, req)
|
||||
}
|
||||
|
||||
func TestAuthRequiredWrongSecret(t *testing.T) {
|
||||
secret := []byte("test-secret")
|
||||
token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateAccessToken = %v", err)
|
||||
}
|
||||
|
||||
// Use a different secret for the middleware
|
||||
r := setupTestRouter([]byte("different-secret"))
|
||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// --- AdminRequired tests ---
|
||||
|
||||
// errorBody extracts the error.message field from a JSON response body.
|
||||
type errorBody struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func setupAdminTestDB(t *testing.T) repository.UserRepository {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
return repository.NewUserRepository(db)
|
||||
}
|
||||
|
||||
// injectUserIDMiddleware simulates AuthRequired by injecting a user_id into the context.
|
||||
func injectUserIDMiddleware(userID string) gin.HandlerFunc {
|
||||
func injectPrincipalMiddleware(principal *service.Principal) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set(userIDKey, userID)
|
||||
if principal != nil {
|
||||
c.Set(principalKey, *principal)
|
||||
c.Set(userIDKey, principal.UserID)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequired_AdminPasses(t *testing.T) {
|
||||
repo := setupAdminTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{
|
||||
ID: "admin-1",
|
||||
Username: "admin",
|
||||
Email: "admin@example.com",
|
||||
IsAdmin: true,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(injectUserIDMiddleware("admin-1"))
|
||||
r.Use(AdminRequired(repo))
|
||||
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "admin-1", IsAdmin: true}))
|
||||
r.Use(AdminRequired())
|
||||
r.GET("/admin", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
@@ -267,24 +191,10 @@ func TestAdminRequired_AdminPasses(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRequired_NonAdminForbidden(t *testing.T) {
|
||||
repo := setupAdminTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{
|
||||
ID: "user-1",
|
||||
Username: "regular",
|
||||
Email: "regular@example.com",
|
||||
IsAdmin: false,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(injectUserIDMiddleware("user-1"))
|
||||
r.Use(AdminRequired(repo))
|
||||
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "user-1", IsAdmin: false}))
|
||||
r.Use(AdminRequired())
|
||||
r.GET("/admin", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
@@ -306,49 +216,10 @@ func TestAdminRequired_NonAdminForbidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequired_SoftDeletedAdmin(t *testing.T) {
|
||||
repo := setupAdminTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user := &model.User{
|
||||
ID: "admin-2",
|
||||
Username: "deleted_admin",
|
||||
Email: "deleted_admin@example.com",
|
||||
IsAdmin: true,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
if err := repo.Create(ctx, user); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
// Soft-delete the admin user
|
||||
if err := repo.Delete(ctx, "admin-2"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
func TestAdminRequired_NoPrincipal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(injectUserIDMiddleware("admin-2"))
|
||||
r.Use(AdminRequired(repo))
|
||||
r.GET("/admin", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminRequired_NoUserID(t *testing.T) {
|
||||
repo := setupAdminTestDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(AdminRequired(repo))
|
||||
r.Use(AdminRequired())
|
||||
r.GET("/admin", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
// The primary password is stored on the User model; additional credentials
|
||||
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
||||
type Credential struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
||||
Type string `gorm:"index;type:varchar(32);not null" json:"type"`
|
||||
Label string `gorm:"type:varchar(128)" json:"label"`
|
||||
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||
Type string `gorm:"index;type:varchar(32);not null"`
|
||||
Label string `gorm:"type:varchar(128)"`
|
||||
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
+46
-19
@@ -3,7 +3,6 @@ package model
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,48 +13,76 @@ var (
|
||||
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
|
||||
)
|
||||
|
||||
// AppError is a service-layer error that carries an HTTP status, a user-safe
|
||||
// message, and an optional internal cause for logging. Handlers unpack it
|
||||
// transparently via api.RespondError.
|
||||
// ErrorKind classifies domain errors without tying them to a transport.
|
||||
type ErrorKind string
|
||||
|
||||
// Protocol-neutral error kinds.
|
||||
const (
|
||||
KindInvalidArgument ErrorKind = "invalid_argument"
|
||||
KindUnauthenticated ErrorKind = "unauthenticated"
|
||||
KindPermissionDenied ErrorKind = "permission_denied"
|
||||
KindNotFound ErrorKind = "not_found"
|
||||
KindConflict ErrorKind = "conflict"
|
||||
KindPayloadTooLarge ErrorKind = "payload_too_large"
|
||||
KindInternal ErrorKind = "internal"
|
||||
)
|
||||
|
||||
// AppError is a service-layer error that carries a protocol-neutral kind, a
|
||||
// user-safe message, and an optional internal cause for logging.
|
||||
type AppError struct {
|
||||
Status int // HTTP status code
|
||||
Message string // safe for API response
|
||||
Err error // internal cause (nil = user-caused, skip logging)
|
||||
Kind ErrorKind
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string { return e.Message }
|
||||
func (e *AppError) Unwrap() error { return e.Err }
|
||||
|
||||
// NewBadRequestError creates a 400 AppError.
|
||||
// NewInvalidArgumentError creates an invalid-argument AppError.
|
||||
func NewInvalidArgumentError(message string) *AppError {
|
||||
return &AppError{Kind: KindInvalidArgument, Message: message}
|
||||
}
|
||||
|
||||
// NewBadRequestError creates an invalid-argument AppError.
|
||||
func NewBadRequestError(message string) *AppError {
|
||||
return &AppError{Status: http.StatusBadRequest, Message: message}
|
||||
return NewInvalidArgumentError(message)
|
||||
}
|
||||
|
||||
// NewConflictError creates a 409 AppError.
|
||||
// NewUnauthenticatedError creates an unauthenticated AppError.
|
||||
func NewUnauthenticatedError(message string) *AppError {
|
||||
return &AppError{Kind: KindUnauthenticated, Message: message}
|
||||
}
|
||||
|
||||
// NewConflictError creates a conflict AppError.
|
||||
func NewConflictError(message string) *AppError {
|
||||
return &AppError{Status: http.StatusConflict, Message: message}
|
||||
return &AppError{Kind: KindConflict, Message: message}
|
||||
}
|
||||
|
||||
// NewNotFoundError creates a 404 AppError.
|
||||
// NewNotFoundError creates a not-found AppError.
|
||||
func NewNotFoundError(message string, cause error) *AppError {
|
||||
return &AppError{Status: http.StatusNotFound, Message: message, Err: cause}
|
||||
return &AppError{Kind: KindNotFound, Message: message, Err: cause}
|
||||
}
|
||||
|
||||
// NewForbiddenError creates a 403 AppError.
|
||||
// NewPermissionDeniedError creates a permission-denied AppError.
|
||||
func NewPermissionDeniedError(message string, cause error) *AppError {
|
||||
return &AppError{Kind: KindPermissionDenied, Message: message, Err: cause}
|
||||
}
|
||||
|
||||
// NewForbiddenError creates a permission-denied AppError.
|
||||
func NewForbiddenError(message string, cause error) *AppError {
|
||||
return &AppError{Status: http.StatusForbidden, Message: message, Err: cause}
|
||||
return NewPermissionDeniedError(message, cause)
|
||||
}
|
||||
|
||||
// NewPayloadTooLargeError creates a 413 AppError.
|
||||
// NewPayloadTooLargeError creates a payload-too-large AppError.
|
||||
func NewPayloadTooLargeError(message string, cause error) *AppError {
|
||||
return &AppError{Status: http.StatusRequestEntityTooLarge, Message: message, Err: cause}
|
||||
return &AppError{Kind: KindPayloadTooLarge, Message: message, Err: cause}
|
||||
}
|
||||
|
||||
// NewInternalError creates a 500 AppError with a wrapped internal cause.
|
||||
// NewInternalError creates an internal AppError with a wrapped internal cause.
|
||||
// msg describes the operation that failed; cause is the underlying error.
|
||||
func NewInternalError(msg string, cause error) *AppError {
|
||||
return &AppError{
|
||||
Status: http.StatusInternalServerError,
|
||||
Kind: KindInternal,
|
||||
Message: "internal server error",
|
||||
Err: fmt.Errorf("%s: %w", msg, cause),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppErrorConstructors(t *testing.T) {
|
||||
cause := errors.New("database offline")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AppError
|
||||
kind ErrorKind
|
||||
message string
|
||||
cause error
|
||||
}{
|
||||
{
|
||||
name: "invalid argument",
|
||||
err: NewInvalidArgumentError("bad input"),
|
||||
kind: KindInvalidArgument,
|
||||
message: "bad input",
|
||||
},
|
||||
{
|
||||
name: "unauthenticated",
|
||||
err: NewUnauthenticatedError("invalid token"),
|
||||
kind: KindUnauthenticated,
|
||||
message: "invalid token",
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
err: NewPermissionDeniedError("access denied", ErrForbidden),
|
||||
kind: KindPermissionDenied,
|
||||
message: "access denied",
|
||||
cause: ErrForbidden,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
err: NewNotFoundError("missing", ErrNotFound),
|
||||
kind: KindNotFound,
|
||||
message: "missing",
|
||||
cause: ErrNotFound,
|
||||
},
|
||||
{
|
||||
name: "conflict",
|
||||
err: NewConflictError("duplicate"),
|
||||
kind: KindConflict,
|
||||
message: "duplicate",
|
||||
},
|
||||
{
|
||||
name: "payload too large",
|
||||
err: NewPayloadTooLargeError("too large", ErrUploadTooLarge),
|
||||
kind: KindPayloadTooLarge,
|
||||
message: "too large",
|
||||
cause: ErrUploadTooLarge,
|
||||
},
|
||||
{
|
||||
name: "internal",
|
||||
err: NewInternalError("load record", cause),
|
||||
kind: KindInternal,
|
||||
message: "internal server error",
|
||||
cause: cause,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Kind != tt.kind {
|
||||
t.Fatalf("Kind = %q, want %q", tt.err.Kind, tt.kind)
|
||||
}
|
||||
if tt.err.Message != tt.message {
|
||||
t.Fatalf("Message = %q, want %q", tt.err.Message, tt.message)
|
||||
}
|
||||
if tt.cause != nil && !errors.Is(tt.err, tt.cause) {
|
||||
t.Fatalf("errors.Is(%v, %v) = false", tt.err, tt.cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -9,16 +9,16 @@ const StatusUserDeleted = "user_deleted"
|
||||
|
||||
// File represents a file or directory entry in the virtual filesystem.
|
||||
type File struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"`
|
||||
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)" json:"parent_id"`
|
||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3" json:"name"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
MimeType string `gorm:"type:varchar(127)" json:"mime_type"`
|
||||
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
|
||||
Hash string `gorm:"type:varchar(64)" json:"-"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||
IsDir bool `gorm:"default:false" json:"is_dir"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null"`
|
||||
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)"`
|
||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3"`
|
||||
Size int64 `gorm:"default:0"`
|
||||
MimeType string `gorm:"type:varchar(127)"`
|
||||
StoragePath string `gorm:"type:varchar(512)" json:"-"`
|
||||
Hash string `gorm:"type:varchar(64)" json:"-"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||
IsDir bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
+18
-18
@@ -6,18 +6,20 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
||||
func TestFileJSONOmitsInternalFields(t *testing.T) {
|
||||
f := &File{
|
||||
ID: "1",
|
||||
UserID: "u1",
|
||||
ParentID: nil,
|
||||
Name: "test.txt",
|
||||
Size: 100,
|
||||
MimeType: "text/plain",
|
||||
Status: StatusActive,
|
||||
IsDir: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ID: "1",
|
||||
UserID: "u1",
|
||||
ParentID: nil,
|
||||
Name: "test.txt",
|
||||
Size: 100,
|
||||
MimeType: "text/plain",
|
||||
StoragePath: "users/u1/files/1",
|
||||
Hash: "abc123",
|
||||
Status: StatusActive,
|
||||
IsDir: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(f)
|
||||
@@ -30,13 +32,11 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := result["status"]; ok {
|
||||
t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
||||
}
|
||||
|
||||
if _, ok := result["hash"]; ok {
|
||||
t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
||||
}
|
||||
assertJSONKeysAbsent(t, result,
|
||||
"StoragePath", "storage_path",
|
||||
"Hash", "hash",
|
||||
"Status", "status",
|
||||
)
|
||||
}
|
||||
|
||||
func TestStatusConstants(t *testing.T) {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
|
||||
// Session stores a refresh token for a user session.
|
||||
type Session struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
|
||||
// User represents a registered account.
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;type:varchar(64);not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
|
||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
Username string `gorm:"uniqueIndex;type:varchar(64);not null"`
|
||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null"`
|
||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
IsAdmin bool `gorm:"default:false"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// User status constants.
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserJSONOmitsStatusField(t *testing.T) {
|
||||
func TestUserJSONOmitsPasswordHash(t *testing.T) {
|
||||
u := User{
|
||||
ID: "user-1",
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Status: StatusActive,
|
||||
ID: "user-1",
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
PasswordHash: "hash-secret",
|
||||
Status: StatusActive,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(u)
|
||||
@@ -23,7 +24,57 @@ func TestUserJSONOmitsStatusField(t *testing.T) {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := m["status"]; ok {
|
||||
t.Error("Status field should not appear in JSON output")
|
||||
assertJSONKeysAbsent(t, m, "PasswordHash", "password_hash")
|
||||
}
|
||||
|
||||
func TestSessionJSONOmitsTokenHash(t *testing.T) {
|
||||
s := Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
TokenHash: "token-hash-secret",
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
assertJSONKeysAbsent(t, m, "TokenHash", "token_hash")
|
||||
}
|
||||
|
||||
func TestCredentialJSONOmitsSecretHash(t *testing.T) {
|
||||
c := Credential{
|
||||
ID: "credential-1",
|
||||
UserID: "user-1",
|
||||
Type: "app_passkey",
|
||||
Label: "Phone",
|
||||
SecretHash: "credential-secret",
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
assertJSONKeysAbsent(t, m, "SecretHash", "secret_hash")
|
||||
}
|
||||
|
||||
func assertJSONKeysAbsent(t *testing.T, m map[string]interface{}, keys ...string) {
|
||||
t.Helper()
|
||||
|
||||
for _, key := range keys {
|
||||
if _, ok := m[key]; ok {
|
||||
t.Errorf("%s field should not appear in JSON output", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,11 @@ import (
|
||||
)
|
||||
|
||||
func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||
jwtSecret := []byte(webApp.Config.JWT.Secret)
|
||||
accountHandler := handler.NewAccountHandler(webApp.AuthService)
|
||||
fileHandler := handler.NewFileHandler(webApp.FileService)
|
||||
adminHandler := handler.NewAdminHandler(webApp.UserRepo)
|
||||
adminHandler := handler.NewAdminHandler(webApp.AdminService)
|
||||
|
||||
rg.Use(middleware.AuthRequired(jwtSecret))
|
||||
rg.Use(middleware.AuthRequired(webApp.AuthService))
|
||||
|
||||
account := rg.Group("/account")
|
||||
{
|
||||
@@ -39,7 +38,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||
}
|
||||
|
||||
admin := rg.Group("/admin")
|
||||
admin.Use(middleware.AdminRequired(webApp.UserRepo))
|
||||
admin.Use(middleware.AdminRequired())
|
||||
{
|
||||
admin.GET("/users", adminHandler.ListUsers)
|
||||
admin.GET("/users/:id", adminHandler.GetUser)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestVersionRoute(t *testing.T) {
|
||||
},
|
||||
}
|
||||
authService := service.NewAuthService(nil, nil, nil, nil, 15*time.Minute, 7*24*time.Hour)
|
||||
webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil)
|
||||
webApp := app.NewWebApp(cfg, nil, nil, nil, nil, nil, authService, nil, nil, nil)
|
||||
router := NewRouter(webApp)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||
@@ -84,6 +84,7 @@ func TestAdminRoutes(t *testing.T) {
|
||||
refreshTTL := 168 * time.Hour
|
||||
|
||||
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
|
||||
adminService := service.NewAdminService(userRepo)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWT: config.JWTConfig{
|
||||
@@ -93,7 +94,7 @@ func TestAdminRoutes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, nil, nil)
|
||||
webApp := app.NewWebApp(cfg, db, userRepo, sessionRepo, nil, credentialRepo, authService, adminService, nil, nil)
|
||||
router := NewRouter(webApp)
|
||||
|
||||
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
)
|
||||
|
||||
// AdminService handles administrator user-management operations.
|
||||
type AdminService struct {
|
||||
userRepo repository.UserRepository
|
||||
}
|
||||
|
||||
// NewAdminService creates an AdminService.
|
||||
func NewAdminService(userRepo repository.UserRepository) *AdminService {
|
||||
return &AdminService{userRepo: userRepo}
|
||||
}
|
||||
|
||||
// GetUser returns a user by ID, including deleted users.
|
||||
func (s *AdminService) GetUser(ctx context.Context, id string) (*model.User, error) {
|
||||
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewNotFoundError("user not found", model.ErrNotFound)
|
||||
}
|
||||
return nil, model.NewInternalError("find admin user", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ListUsers returns all users, including deleted users, with pagination.
|
||||
func (s *AdminService) ListUsers(ctx context.Context, offset, limit int) ([]model.User, int64, error) {
|
||||
users, total, err := s.userRepo.ListIncludeDeleted(ctx, offset, limit)
|
||||
if err != nil {
|
||||
return nil, 0, model.NewInternalError("list admin users", err)
|
||||
}
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
// DeleteUser soft-deletes a user.
|
||||
func (s *AdminService) DeleteUser(ctx context.Context, id string) error {
|
||||
if _, err := s.GetUser(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.userRepo.Delete(ctx, id); err != nil {
|
||||
return model.NewInternalError("delete admin user", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
)
|
||||
|
||||
func setupAdminService(t *testing.T) (*AdminService, repository.UserRepository) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
return NewAdminService(userRepo), userRepo
|
||||
}
|
||||
|
||||
func TestAdminServiceGetUserMissingReturnsNotFound(t *testing.T) {
|
||||
svc, _ := setupAdminService(t)
|
||||
|
||||
_, err := svc.GetUser(context.Background(), "missing")
|
||||
var ae *model.AppError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||
}
|
||||
if ae.Kind != model.KindNotFound {
|
||||
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminServiceListIncludesDeletedUsers(t *testing.T) {
|
||||
svc, repo := setupAdminService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
active := &model.User{ID: "active-1", Username: "active", Email: "active@example.com", Status: model.StatusActive}
|
||||
deleted := &model.User{ID: "deleted-1", Username: "deleted", Email: "deleted@example.com", Status: model.StatusActive}
|
||||
if err := repo.Create(ctx, active); err != nil {
|
||||
t.Fatalf("create active: %v", err)
|
||||
}
|
||||
if err := repo.Create(ctx, deleted); err != nil {
|
||||
t.Fatalf("create deleted: %v", err)
|
||||
}
|
||||
if err := repo.Delete(ctx, deleted.ID); err != nil {
|
||||
t.Fatalf("delete user: %v", err)
|
||||
}
|
||||
|
||||
users, total, err := svc.ListUsers(ctx, 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers = %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Fatalf("total = %d, want 2", total)
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("len(users) = %d, want 2", len(users))
|
||||
}
|
||||
}
|
||||
+56
-23
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,15 +15,21 @@ import (
|
||||
|
||||
// TokenPair contains the access and refresh tokens returned after authentication.
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
// CreatedPasskey contains the raw token for a newly created app passkey.
|
||||
type CreatedPasskey struct {
|
||||
ID string `json:"id"`
|
||||
Raw string `json:"raw"`
|
||||
Label string `json:"label"`
|
||||
ID string
|
||||
Raw string
|
||||
Label string
|
||||
}
|
||||
|
||||
// Principal is the authenticated user identity used by transport layers.
|
||||
type Principal struct {
|
||||
UserID string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// AuthService handles user authentication and session management.
|
||||
@@ -59,7 +64,7 @@ func NewAuthService(
|
||||
// Register creates a new user account.
|
||||
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
||||
if username == "" || email == "" || password == "" {
|
||||
return nil, &model.AppError{Status: http.StatusBadRequest, Message: "username, email, and password are required"}
|
||||
return nil, model.NewInvalidArgumentError("username, email, and password are required")
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
@@ -77,7 +82,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "username or email already exists"}
|
||||
return nil, model.NewConflictError("username or email already exists")
|
||||
}
|
||||
return nil, model.NewInternalError("create user", err)
|
||||
}
|
||||
@@ -90,17 +95,17 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
|
||||
user, err := s.userRepo.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
||||
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||
}
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
||||
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||
}
|
||||
|
||||
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
||||
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, user.ID)
|
||||
@@ -111,32 +116,32 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
|
||||
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
||||
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if claims.Type != auth.TokenRefresh {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
return nil, model.NewInternalError("find session", err)
|
||||
}
|
||||
|
||||
if session.UserID != claims.UserID {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
@@ -189,14 +194,14 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
||||
// LoginWithPasskey authenticates a user using an app passkey token.
|
||||
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
||||
if !strings.HasPrefix(tokenStr, "mygo_") {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey format"}
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey format")
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(tokenStr)
|
||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
return nil, model.NewInternalError("find credential", err)
|
||||
}
|
||||
@@ -206,11 +211,11 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if cred.Type != "app_passkey" {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
|
||||
return nil, model.NewUnauthenticatedError("invalid credential type")
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
@@ -230,18 +235,46 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
|
||||
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return &model.AppError{Status: http.StatusNotFound, Message: "passkey not found", Err: model.ErrNotFound}
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
}
|
||||
return model.NewInternalError("find credential", err)
|
||||
}
|
||||
|
||||
if cred.UserID != userID {
|
||||
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
||||
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
|
||||
}
|
||||
|
||||
return s.credentialRepo.Delete(ctx, credID)
|
||||
}
|
||||
|
||||
// AuthenticateAccessToken validates an access token and returns the active user principal.
|
||||
func (s *AuthService) AuthenticateAccessToken(ctx context.Context, accessTokenStr string) (*Principal, error) {
|
||||
claims, err := auth.ParseToken(accessTokenStr, s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, model.NewUnauthenticatedError("invalid or expired token")
|
||||
}
|
||||
|
||||
if claims.Type != auth.TokenAccess {
|
||||
return nil, model.NewUnauthenticatedError("invalid token type")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("user not found")
|
||||
}
|
||||
return nil, model.NewInternalError("find access token user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, model.NewUnauthenticatedError("user not found")
|
||||
}
|
||||
|
||||
return &Principal{
|
||||
UserID: user.ID,
|
||||
IsAdmin: user.IsAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) {
|
||||
accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -347,8 +346,8 @@ func TestAuthService_RevokePasskeyNotOwner(t *testing.T) {
|
||||
|
||||
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
||||
var ae *model.AppError
|
||||
if !errors.As(err, &ae) || ae.Status != http.StatusForbidden {
|
||||
t.Fatalf("expected AppError 403 Forbidden, got %v", err)
|
||||
if !errors.As(err, &ae) || ae.Kind != model.KindPermissionDenied {
|
||||
t.Fatalf("expected permission denied AppError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,8 +433,8 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||
}
|
||||
if ae.Status != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
||||
if ae.Kind != model.KindUnauthenticated {
|
||||
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||
}
|
||||
if ae.Message != "invalid email or password" {
|
||||
t.Errorf("message = %q, want %q", ae.Message, "invalid email or password")
|
||||
@@ -519,8 +518,8 @@ func TestAuthService_LoginWithPasskeyDisabledUser(t *testing.T) {
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||
}
|
||||
if ae.Status != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
||||
if ae.Kind != model.KindUnauthenticated {
|
||||
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||
}
|
||||
if ae.Message != "invalid passkey" {
|
||||
t.Errorf("message = %q, want %q", ae.Message, "invalid passkey")
|
||||
@@ -554,10 +553,68 @@ func TestAuthService_RefreshDisabledUser(t *testing.T) {
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||
}
|
||||
if ae.Status != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
||||
if ae.Kind != model.KindUnauthenticated {
|
||||
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||
}
|
||||
if ae.Message != "invalid token" {
|
||||
t.Errorf("message = %q, want %q", ae.Message, "invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateAccessToken(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
user.IsAdmin = true
|
||||
if err := userRepo.Update(ctx, user); err != nil {
|
||||
t.Fatalf("promote user: %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
principal, err := svc.AuthenticateAccessToken(ctx, pair.AccessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("AuthenticateAccessToken = %v", err)
|
||||
}
|
||||
if principal.UserID != user.ID {
|
||||
t.Fatalf("UserID = %q, want %q", principal.UserID, user.ID)
|
||||
}
|
||||
if !principal.IsAdmin {
|
||||
t.Fatal("IsAdmin = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_AuthenticateAccessTokenRejectsDeletedUser(t *testing.T) {
|
||||
svc, userRepo := setupAuthServiceWithRepos(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
if err := userRepo.Delete(ctx, user.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.AuthenticateAccessToken(ctx, pair.AccessToken)
|
||||
var ae *model.AppError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||
}
|
||||
if ae.Kind != model.KindUnauthenticated {
|
||||
t.Fatalf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -22,22 +22,22 @@ import (
|
||||
|
||||
// FileInfo is the public representation of a file or directory.
|
||||
type FileInfo struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
ParentID *string `json:"parent_id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
MimeType string `json:"mime_type"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string
|
||||
UserID string
|
||||
ParentID *string
|
||||
Name string
|
||||
Size int64
|
||||
MimeType string
|
||||
IsDir bool
|
||||
Hash string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// FileList is a paginated list of files.
|
||||
type FileList struct {
|
||||
Files []FileInfo `json:"files"`
|
||||
Total int64 `json:"total"`
|
||||
Files []FileInfo
|
||||
Total int64
|
||||
}
|
||||
|
||||
// FileService handles file management business logic.
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -18,16 +17,16 @@ import (
|
||||
"github.com/dhao2001/mygo/internal/storage"
|
||||
)
|
||||
|
||||
// assertAppError checks that err is an *model.AppError with the expected status.
|
||||
func assertAppError(t *testing.T, err error, wantStatus int) bool {
|
||||
// assertAppError checks that err is an *model.AppError with the expected kind.
|
||||
func assertAppError(t *testing.T, err error, wantKind model.ErrorKind) bool {
|
||||
t.Helper()
|
||||
var ae *model.AppError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Errorf("expected *model.AppError, got %T: %v", err, err)
|
||||
return false
|
||||
}
|
||||
if ae.Status != wantStatus {
|
||||
t.Errorf("status = %d, want %d; message = %q", ae.Status, wantStatus, ae.Message)
|
||||
if ae.Kind != wantKind {
|
||||
t.Errorf("kind = %q, want %q; message = %q", ae.Kind, wantKind, ae.Message)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -151,7 +150,7 @@ func TestFileService_UploadRejectsOversizedFileAndCleansStaging(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Upload(ctx, "user1", nil, "too-large.txt", strings.NewReader("123456"))
|
||||
assertAppError(t, err, http.StatusRequestEntityTooLarge)
|
||||
assertAppError(t, err, model.KindPayloadTooLarge)
|
||||
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
@@ -285,7 +284,7 @@ func TestFileService_DownloadForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
_, _, err = svc.Download(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
}
|
||||
|
||||
func TestFileService_DownloadDirectory(t *testing.T) {
|
||||
@@ -326,7 +325,7 @@ func TestFileService_GetNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Get(ctx, "user1", "nonexistent")
|
||||
assertAppError(t, err, http.StatusNotFound)
|
||||
assertAppError(t, err, model.KindNotFound)
|
||||
}
|
||||
|
||||
func TestFileService_GetForbidden(t *testing.T) {
|
||||
@@ -339,7 +338,7 @@ func TestFileService_GetForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = svc.Get(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
}
|
||||
|
||||
func TestFileService_List(t *testing.T) {
|
||||
@@ -437,7 +436,7 @@ func TestFileService_Delete(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = svc.Get(ctx, "user1", info.ID)
|
||||
assertAppError(t, err, http.StatusNotFound)
|
||||
assertAppError(t, err, model.KindNotFound)
|
||||
}
|
||||
|
||||
func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
||||
@@ -469,7 +468,7 @@ func TestFileService_DeleteForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
err = svc.Delete(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
}
|
||||
|
||||
func TestFileService_CreateDir(t *testing.T) {
|
||||
@@ -513,7 +512,7 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
}
|
||||
|
||||
func TestFileService_SoftDelete(t *testing.T) {
|
||||
@@ -530,7 +529,7 @@ func TestFileService_SoftDelete(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err = svc.Get(ctx, "user1", info.ID)
|
||||
assertAppError(t, err, http.StatusNotFound)
|
||||
assertAppError(t, err, model.KindNotFound)
|
||||
}
|
||||
|
||||
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
||||
@@ -590,5 +589,5 @@ func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
||||
}
|
||||
|
||||
err = svc.Delete(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
assertAppError(t, err, model.KindPermissionDenied)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user