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:
2026-07-05 23:30:17 +08:00
parent 28e17a5b08
commit 63ede5c237
34 changed files with 1028 additions and 438 deletions
+70 -16
View File
@@ -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 {
+65
View File
@@ -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
}
+5
View File
@@ -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,
}
+2 -2
View File
@@ -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)
}
+68
View File
@@ -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
}
+42 -3
View File
@@ -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
}
+4 -6
View File
@@ -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))
+7 -7
View File
@@ -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
+5 -4
View File
@@ -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)
}
+37 -3
View File
@@ -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,
}
}
+7 -2
View File
@@ -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
+50 -5
View File
@@ -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,
}
}
+12 -12
View File
@@ -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
View File
@@ -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
+71 -200
View File
@@ -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"})
})
+7 -7
View File
@@ -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
View File
@@ -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),
}
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+4 -4
View File
@@ -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
}
+8 -8
View File
@@ -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.
+58 -7
View File
@@ -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)
}
}
}
+3 -4
View File
@@ -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)
+3 -2
View File
@@ -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.
+51
View File
@@ -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
}
+69
View File
@@ -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
View File
@@ -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 {
+66 -9
View File
@@ -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
View File
@@ -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.
+13 -14
View File
@@ -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)
}