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:
+11
-4
@@ -14,7 +14,12 @@ Repository (GORM data access) Storage (file I/O)
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Handler has no business logic — parse request, call service, write response.
|
- Handler has no business logic — parse request, call service, write response.
|
||||||
|
- Handler and middleware never access repositories directly; they depend on services.
|
||||||
- Service has no HTTP awareness — operates on domain models and interfaces.
|
- Service has no HTTP awareness — operates on domain models and interfaces.
|
||||||
|
- `internal/model` and `internal/service` do not import `net/http`, Gin, or `internal/api`.
|
||||||
|
- JSON response DTOs live in HTTP packages. Domain and service result structs are not the public REST schema.
|
||||||
|
- Domain models may use `json:"-"` for defensive redaction of sensitive fields; this is not a REST response contract.
|
||||||
|
- Domain errors carry a protocol-neutral kind, a safe message, and an optional cause. HTTP status mapping happens only at the API boundary.
|
||||||
- Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion.
|
- Repository abstracts the database; Storage abstracts where bytes live, including staged upload promotion.
|
||||||
- `internal/server` is the composition root — wires all dependencies together.
|
- `internal/server` is the composition root — wires all dependencies together.
|
||||||
|
|
||||||
@@ -31,12 +36,12 @@ Rules:
|
|||||||
| **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | ✅ |
|
| **HTTP** | `internal/server` | Gin router init, route registration (public/protected split), graceful shutdown | ✅ |
|
||||||
| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | 🛠 WIP |
|
| | `internal/handler` | HTTP handlers (auth, file, admin, webdav...) | 🛠 WIP |
|
||||||
| | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | 🛠 WIP |
|
| | `internal/middleware` | Gin middleware (logger, jwt, cors, auth) | 🛠 WIP |
|
||||||
| **Business** | `internal/service` | Business logic: `AuthService` (register, login, refresh, logout, passkey CRUD) | ✅ |
|
| **Business** | `internal/service` | Business logic: `AuthService`, `FileService`, `AdminService`; protocol-neutral results and errors | ✅ |
|
||||||
| | `internal/model` | Domain types (User, File, Credential, Session), error codes | ✅ |
|
| | `internal/model` | Domain types (User, File, Credential, Session), protocol-neutral error kinds | ✅ |
|
||||||
| **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | ✅ |
|
| **Data** | `internal/repository` | Repository interfaces + GORM implementations (User, Session, File, Credential) | ✅ |
|
||||||
| | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | 🛠 WIP |
|
| | `internal/storage` | Storage backend interface + local disk impl, with staging and promotion | 🛠 WIP |
|
||||||
| **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | ✅ |
|
| **Util** | `internal/auth` | JWT sign/verify (HS256), token type discrimination (access/refresh), password hashing (bcrypt), app passkey tokens | ✅ |
|
||||||
| | `internal/api` | Unified JSON error response helpers | ✅ |
|
| | `internal/api` | Unified JSON error response helpers and REST error-kind mapping | ✅ |
|
||||||
|
|
||||||
## API Routes (v0)
|
## API Routes (v0)
|
||||||
|
|
||||||
@@ -72,7 +77,9 @@ Applied globally by `gin.Default()`: logger → recovery
|
|||||||
|
|
||||||
Planned globally: cors
|
Planned globally: cors
|
||||||
|
|
||||||
Applied to protected groups: auth (JWT validation, inject user into gin.Context)
|
Applied to protected groups: auth (extract bearer token, delegate access-token authentication to `AuthService`, inject principal into gin.Context)
|
||||||
|
|
||||||
|
Applied to admin groups: admin (check authenticated principal from context)
|
||||||
|
|
||||||
## Server Responsibilities
|
## Server Responsibilities
|
||||||
|
|
||||||
|
|||||||
@@ -87,3 +87,22 @@
|
|||||||
- Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age.
|
- Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age.
|
||||||
- Local storage promotes with `os.Rename` and falls back to copy/delete on cross-device `EXDEV`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row.
|
- Local storage promotes with `os.Rename` and falls back to copy/delete on cross-device `EXDEV`; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row.
|
||||||
- A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently.
|
- A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently.
|
||||||
|
|
||||||
|
## 2026-07-05: Protocol-Neutral Service Boundary
|
||||||
|
|
||||||
|
**Context**: The service and model layers carried HTTP status codes and JSON response tags, and some middleware and handlers accessed repositories directly. That made the business layer harder to reuse for future WebDAV and Nextcloud-compatible APIs.
|
||||||
|
|
||||||
|
**Decisions**:
|
||||||
|
|
||||||
|
| Decision | Guidance |
|
||||||
|
|----------|----------|
|
||||||
|
| Protocol-neutral errors | `model.AppError` uses an error `Kind`, safe message, and optional cause. REST status codes are mapped in `internal/api` only. |
|
||||||
|
| API log references | REST error responses may include `error.log_id` for server-recorded errors: all 5xx responses and 4xx responses with internal causes. |
|
||||||
|
| Service boundaries | Handlers and middleware depend on services, not repositories. `AuthService` authenticates access tokens into a principal; `AdminService` owns admin user operations. |
|
||||||
|
| DTO ownership | Service and model structs are internal/domain data. HTTP handlers assemble response DTOs with JSON tags. |
|
||||||
|
| Architecture enforcement | Package-level tests reject HTTP imports in model/service and repository imports in handlers/middleware. Targeted model tests verify defensive JSON redaction for sensitive fields. |
|
||||||
|
|
||||||
|
**Consequences**:
|
||||||
|
- REST remains a handler/API concern, and future protocols can reuse services without HTTP leakage.
|
||||||
|
- Error responses keep the same top-level shape, with optional `log_id` instead of embedding log references in the message.
|
||||||
|
- Admin and auth middleware behavior is testable through service contracts rather than database access.
|
||||||
|
|||||||
+6
-5
@@ -8,7 +8,7 @@
|
|||||||
| JWT authentication | ✅ | access + refresh tokens, refresh token in DB, app passkey support |
|
| JWT authentication | ✅ | access + refresh tokens, refresh token in DB, app passkey support |
|
||||||
| Web API foundation | ✅ | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` |
|
| Web API foundation | ✅ | WebApp composition, Gin router, graceful shutdown, `GET /api/v1/version` |
|
||||||
| File upload/download/manage APIs | 🛠 WIP | REST API via Gin |
|
| File upload/download/manage APIs | 🛠 WIP | REST API via Gin |
|
||||||
| Admin endpoints | 🛠 WIP | user CRUD for superusers |
|
| Admin endpoints | 🛠 WIP | user service boundary in place for superusers |
|
||||||
| WebDAV | 🛠 WIP | future v0 or v1 |
|
| WebDAV | 🛠 WIP | future v0 or v1 |
|
||||||
|
|
||||||
## Implementation Tasks
|
## Implementation Tasks
|
||||||
@@ -18,16 +18,17 @@ Package-level implementation order (each task includes unit tests):
|
|||||||
1. `internal/config` — Viper loader, config struct ✅
|
1. `internal/config` — Viper loader, config struct ✅
|
||||||
2. `internal/app` — runtime dependency container ✅
|
2. `internal/app` — runtime dependency container ✅
|
||||||
3. `internal/model` — domain types, error codes ✅
|
3. `internal/model` — domain types, error codes ✅
|
||||||
4. `internal/api` — error response helpers ✅
|
4. `internal/api` — protocol-neutral error kind to REST response mapping ✅
|
||||||
5. `internal/auth` — JWT utils ✅
|
5. `internal/auth` — JWT utils ✅
|
||||||
6. `internal/storage` — backend interface + local fs with staged upload promotion
|
6. `internal/storage` — backend interface + local fs with staged upload promotion
|
||||||
7. `internal/repository` — interfaces + GORM/SQLite impl ✅
|
7. `internal/repository` — interfaces + GORM/SQLite impl ✅
|
||||||
8. `internal/service` — auth, file, admin services ✅ (auth done)
|
8. `internal/service` — auth, file, admin services ✅
|
||||||
9. `internal/middleware` — logger, cors, auth ✅ (auth done)
|
9. `internal/middleware` — logger, cors, auth ✅ (auth done; principal boundary enforced)
|
||||||
10. `internal/handler` — auth, account, file, admin handlers 🛠 (auth + account done)
|
10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place)
|
||||||
11. `internal/server` — Gin router, route registration, graceful shutdown ✅
|
11. `internal/server` — Gin router, route registration, graceful shutdown ✅
|
||||||
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
|
12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done)
|
||||||
13. Integration tests
|
13. Integration tests
|
||||||
|
14. Architecture boundary tests ✅
|
||||||
|
|
||||||
## Future
|
## Future
|
||||||
|
|
||||||
|
|||||||
+70
-16
@@ -21,48 +21,102 @@ type ErrorResponse struct {
|
|||||||
// ErrorBody contains human-readable error details.
|
// ErrorBody contains human-readable error details.
|
||||||
type ErrorBody struct {
|
type ErrorBody struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
LogID string `json:"log_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error writes a JSON error response.
|
// Error writes a JSON error response.
|
||||||
func Error(c *gin.Context, status int, message string) {
|
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{
|
c.JSON(status, ErrorResponse{
|
||||||
Error: ErrorBody{
|
Error: ErrorBody{
|
||||||
Message: message,
|
Message: message,
|
||||||
|
LogID: logID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RespondError unpacks an error from the service layer and writes a JSON
|
// RespondError unpacks an error from the service layer and writes a JSON
|
||||||
// error response. It expects *model.AppError; unexpected non-AppError
|
// error response. It maps protocol-neutral domain error kinds to HTTP status
|
||||||
// values are treated as 500 with a safe message. For 500+ errors, a
|
// codes at the API boundary. Unexpected non-AppError values are treated as 500
|
||||||
// random reference hash is included in the response so operators can
|
// with a safe message. Recorded errors return a log_id for correlation.
|
||||||
// correlate it with server logs.
|
|
||||||
func RespondError(c *gin.Context, err error) {
|
func RespondError(c *gin.Context, err error) {
|
||||||
var ae *model.AppError
|
var ae *model.AppError
|
||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
ref := randomHex(8)
|
logID := randomHex(8)
|
||||||
slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler",
|
slog.ErrorContext(c.Request.Context(), "non-AppError escaped to handler",
|
||||||
"ref", ref, "error", err, "type", fmt.Sprintf("%T", err))
|
"log_id", logID, "error", err, "type", fmt.Sprintf("%T", err))
|
||||||
Error(c, http.StatusInternalServerError, "internal server error (ref: "+ref+")")
|
writeError(c, http.StatusInternalServerError, "internal server error", logID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 500+ errors: log full detail with a reference hash, return sanitized message.
|
status := StatusForErrorKind(ae.Kind)
|
||||||
if ae.Status >= 500 {
|
message := ae.Message
|
||||||
ref := randomHex(8)
|
if status >= http.StatusInternalServerError {
|
||||||
|
message = "internal server error"
|
||||||
|
}
|
||||||
|
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
logID := randomHex(8)
|
||||||
slog.ErrorContext(c.Request.Context(), "internal server error",
|
slog.ErrorContext(c.Request.Context(), "internal server error",
|
||||||
"ref", ref, slog.Any("error", ae.Err), "message", ae.Message)
|
"log_id", logID, slog.Any("error", ae.Err), "message", ae.Message)
|
||||||
Error(c, ae.Status, "internal server error (ref: "+ref+")")
|
writeError(c, status, message, logID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4xx errors with internal cause: log at WARN for diagnostics.
|
// 4xx errors with unexpected causes are recorded for diagnostics and return log_id.
|
||||||
if ae.Err != nil {
|
if isLoggableCause(ae.Err) {
|
||||||
|
logID := randomHex(8)
|
||||||
slog.WarnContext(c.Request.Context(), ae.Message,
|
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 {
|
func randomHex(n int) string {
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestError(t *testing.T) {
|
func TestError(t *testing.T) {
|
||||||
@@ -33,4 +36,66 @@ func TestError(t *testing.T) {
|
|||||||
if body.Error.Message != "invalid request" {
|
if body.Error.Message != "invalid request" {
|
||||||
t.Errorf("message = %q, want %q", 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
|
FileRepo repository.FileRepository
|
||||||
CredentialRepo repository.CredentialRepository
|
CredentialRepo repository.CredentialRepository
|
||||||
AuthService *service.AuthService
|
AuthService *service.AuthService
|
||||||
|
AdminService *service.AdminService
|
||||||
FileService *service.FileService
|
FileService *service.FileService
|
||||||
Storage storage.StorageBackend
|
Storage storage.StorageBackend
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default())
|
fileService := service.NewFileService(fileRepo, store, cfg.Storage.MaxUploadSize, slog.Default())
|
||||||
|
adminService := service.NewAdminService(userRepo)
|
||||||
|
|
||||||
return &WebApp{
|
return &WebApp{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
@@ -68,6 +70,7 @@ func Bootstrap(cfg *config.Config) (*WebApp, error) {
|
|||||||
FileRepo: fileRepo,
|
FileRepo: fileRepo,
|
||||||
CredentialRepo: credentialRepo,
|
CredentialRepo: credentialRepo,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
|
AdminService: adminService,
|
||||||
FileService: fileService,
|
FileService: fileService,
|
||||||
Storage: store,
|
Storage: store,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -80,6 +83,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
|
|||||||
fileRepo repository.FileRepository,
|
fileRepo repository.FileRepository,
|
||||||
credentialRepo repository.CredentialRepository,
|
credentialRepo repository.CredentialRepository,
|
||||||
authService *service.AuthService,
|
authService *service.AuthService,
|
||||||
|
adminService *service.AdminService,
|
||||||
fileService *service.FileService,
|
fileService *service.FileService,
|
||||||
store storage.StorageBackend,
|
store storage.StorageBackend,
|
||||||
) *WebApp {
|
) *WebApp {
|
||||||
@@ -92,6 +96,7 @@ func NewWebApp(cfg *config.Config, db *gorm.DB,
|
|||||||
FileRepo: fileRepo,
|
FileRepo: fileRepo,
|
||||||
CredentialRepo: credentialRepo,
|
CredentialRepo: credentialRepo,
|
||||||
AuthService: authService,
|
AuthService: authService,
|
||||||
|
AdminService: adminService,
|
||||||
FileService: fileService,
|
FileService: fileService,
|
||||||
Storage: store,
|
Storage: store,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func TestNewWebApp(t *testing.T) {
|
func TestNewWebApp(t *testing.T) {
|
||||||
cfg := &config.Config{}
|
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 {
|
if webApp.Config != cfg {
|
||||||
t.Fatal("Config was not assigned")
|
t.Fatal("Config was not assigned")
|
||||||
@@ -20,7 +20,7 @@ func TestNewWebApp(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCloseNilDB(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 {
|
if err := webApp.Close(); err != nil {
|
||||||
t.Errorf("Close with nil DB should not error: %v", err)
|
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 (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -26,10 +27,29 @@ type createPasskeyRequest struct {
|
|||||||
Label string `json:"label" binding:"required"`
|
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.
|
// GetAccount handles GET /api/v1/account.
|
||||||
func (h *AccountHandler) GetAccount(c *gin.Context) {
|
func (h *AccountHandler) GetAccount(c *gin.Context) {
|
||||||
userID := middleware.MustGetUserID(c)
|
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.
|
// ListPasskeys handles GET /api/v1/account/passkeys.
|
||||||
@@ -46,7 +66,7 @@ func (h *AccountHandler) ListPasskeys(c *gin.Context) {
|
|||||||
creds = []model.Credential{}
|
creds = []model.Credential{}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, creds)
|
c.JSON(http.StatusOK, toPasskeyResponses(creds))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePasskey handles POST /api/v1/account/passkeys.
|
// CreatePasskey handles POST /api/v1/account/passkeys.
|
||||||
@@ -66,7 +86,11 @@ func (h *AccountHandler) CreatePasskey(c *gin.Context) {
|
|||||||
return
|
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.
|
// RevokePasskey handles DELETE /api/v1/account/passkeys/:id.
|
||||||
@@ -86,3 +110,18 @@ func (h *AccountHandler) RevokePasskey(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusOK)
|
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/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/middleware"
|
"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) {
|
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 := r.Group("/api/v1")
|
||||||
protected.Use(middleware.AuthRequired(secret))
|
protected.Use(middleware.AuthRequired(svc))
|
||||||
{
|
{
|
||||||
account := protected.Group("/account")
|
account := protected.Group("/account")
|
||||||
{
|
{
|
||||||
@@ -65,7 +63,7 @@ func TestAccountEndpoint(t *testing.T) {
|
|||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||||
|
|
||||||
// Get /account
|
// Get /account
|
||||||
@@ -107,7 +105,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
|||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||||
authHeader := "Bearer " + pair.AccessToken
|
authHeader := "Bearer " + pair.AccessToken
|
||||||
|
|
||||||
@@ -134,7 +132,7 @@ func TestPasskeyCRUD(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Revoke passkey
|
// Revoke passkey
|
||||||
var creds []model.Credential
|
var creds []passkeyResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &creds)
|
json.Unmarshal(rec.Body.Bytes(), &creds)
|
||||||
if len(creds) != 1 {
|
if len(creds) != 1 {
|
||||||
t.Fatalf("expected 1 passkey, got %d", len(creds))
|
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/api"
|
||||||
"github.com/dhao2001/mygo/internal/model"
|
"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.
|
// AdminHandler handles admin-only endpoints for user management.
|
||||||
type AdminHandler struct {
|
type AdminHandler struct {
|
||||||
userRepo repository.UserRepository
|
adminService *service.AdminService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAdminHandler creates an AdminHandler.
|
// NewAdminHandler creates an AdminHandler.
|
||||||
func NewAdminHandler(userRepo repository.UserRepository) *AdminHandler {
|
func NewAdminHandler(adminService *service.AdminService) *AdminHandler {
|
||||||
return &AdminHandler{userRepo: userRepo}
|
return &AdminHandler{adminService: adminService}
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminUserResponse exposes all user fields, including status, for admin views.
|
// 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) {
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||||
id := c.Param("id")
|
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)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
|||||||
func (h *AdminHandler) GetUser(c *gin.Context) {
|
func (h *AdminHandler) GetUser(c *gin.Context) {
|
||||||
id := c.Param("id")
|
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 {
|
if err != nil {
|
||||||
api.RespondError(c, err)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
@@ -93,7 +93,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|||||||
limit = 200
|
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 {
|
if err != nil {
|
||||||
api.RespondError(c, err)
|
api.RespondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
authHandler := NewAuthHandler(authService)
|
authHandler := NewAuthHandler(authService)
|
||||||
adminHandler := NewAdminHandler(userRepo)
|
adminService := service.NewAdminService(userRepo)
|
||||||
|
adminHandler := NewAdminHandler(adminService)
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -54,8 +55,8 @@ func setupAdminRouter(t *testing.T) (*gin.Engine, repository.UserRepository) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
admin := r.Group("/api/v1/admin")
|
admin := r.Group("/api/v1/admin")
|
||||||
admin.Use(middleware.AuthRequired(secret))
|
admin.Use(middleware.AuthRequired(authService))
|
||||||
admin.Use(middleware.AdminRequired(userRepo))
|
admin.Use(middleware.AdminRequired())
|
||||||
{
|
{
|
||||||
admin.GET("/users", adminHandler.ListUsers)
|
admin.GET("/users", adminHandler.ListUsers)
|
||||||
admin.GET("/users/:id", adminHandler.GetUser)
|
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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||||
t.Fatalf("unmarshal login response: %v", err)
|
t.Fatalf("unmarshal login response: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/api"
|
"github.com/dhao2001/mygo/internal/api"
|
||||||
|
"github.com/dhao2001/mygo/internal/model"
|
||||||
"github.com/dhao2001/mygo/internal/service"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +37,20 @@ type tokenRequest struct {
|
|||||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
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.
|
// Register handles POST /api/v1/auth/register.
|
||||||
func (h *AuthHandler) Register(c *gin.Context) {
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
var req registerRequest
|
var req registerRequest
|
||||||
@@ -50,7 +66,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, user)
|
c.JSON(http.StatusCreated, toUserResponse(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login handles POST /api/v1/auth/login.
|
// Login handles POST /api/v1/auth/login.
|
||||||
@@ -68,7 +84,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, pair)
|
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh handles POST /api/v1/auth/refresh.
|
// Refresh handles POST /api/v1/auth/refresh.
|
||||||
@@ -86,7 +102,7 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, pair)
|
c.JSON(http.StatusOK, toTokenPairResponse(pair))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logout handles POST /api/v1/auth/logout.
|
// Logout handles POST /api/v1/auth/logout.
|
||||||
@@ -105,3 +121,21 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusOK)
|
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"
|
"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) {
|
func setupTestAuthService(t *testing.T) (*service.AuthService, []byte) {
|
||||||
t.Helper()
|
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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &pair); err != nil {
|
||||||
t.Fatalf("unmarshal response: %v", err)
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
}
|
}
|
||||||
@@ -196,7 +201,7 @@ func TestRefreshHandler(t *testing.T) {
|
|||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var pair service.TokenPair
|
var pair testTokenPairResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &pair)
|
json.Unmarshal(rec.Body.Bytes(), &pair)
|
||||||
|
|
||||||
// Refresh
|
// Refresh
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -52,6 +53,24 @@ type updateFileRequest struct {
|
|||||||
ParentID *string `json:"parent_id"`
|
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.
|
// Upload handles POST /api/v1/files.
|
||||||
// If the content type is multipart/form-data, it uploads a file.
|
// If the content type is multipart/form-data, it uploads a file.
|
||||||
// If the content type is application/json, it creates a directory.
|
// If the content type is application/json, it creates a directory.
|
||||||
@@ -81,7 +100,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, dir)
|
c.JSON(http.StatusCreated, toFileInfoResponse(dir))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +149,7 @@ func (h *FileHandler) Upload(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, info)
|
c.JSON(http.StatusCreated, toFileInfoResponse(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) {
|
func nextUploadFilePart(reader *multipart.Reader) (*multipart.Part, string, error) {
|
||||||
@@ -217,7 +236,7 @@ func (h *FileHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, toFileListResponse(list))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get handles GET /api/v1/files/:id.
|
// Get handles GET /api/v1/files/:id.
|
||||||
@@ -231,7 +250,7 @@ func (h *FileHandler) Get(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, info)
|
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download handles GET /api/v1/files/:id/content.
|
// Download handles GET /api/v1/files/:id/content.
|
||||||
@@ -299,7 +318,7 @@ func (h *FileHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, info)
|
c.JSON(http.StatusOK, toFileInfoResponse(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete handles DELETE /api/v1/files/:id.
|
// Delete handles DELETE /api/v1/files/:id.
|
||||||
@@ -314,3 +333,29 @@ func (h *FileHandler) Delete(c *gin.Context) {
|
|||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -378,7 +378,7 @@ func TestFileHandler_Get(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Get metadata.
|
// Get metadata.
|
||||||
@@ -391,7 +391,7 @@ func TestFileHandler_Get(t *testing.T) {
|
|||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -430,7 +430,7 @@ func TestFileHandler_Download(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Download.
|
// Download.
|
||||||
@@ -466,7 +466,7 @@ func TestFileHandler_DownloadReadErrorAfterResponseStarted(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil {
|
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil {
|
||||||
t.Fatalf("unmarshal upload: %v", err)
|
t.Fatalf("unmarshal upload: %v", err)
|
||||||
}
|
}
|
||||||
@@ -502,7 +502,7 @@ func TestFileHandler_Update(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Rename.
|
// 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())
|
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 {
|
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||||
t.Fatalf("unmarshal: %v", err)
|
t.Fatalf("unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
@@ -542,7 +542,7 @@ func TestFileHandler_UpdateNoFields(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
updateBody, _ := json.Marshal(gin.H{})
|
updateBody, _ := json.Marshal(gin.H{})
|
||||||
@@ -573,7 +573,7 @@ func TestFileHandler_Delete(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Delete.
|
// Delete.
|
||||||
@@ -613,7 +613,7 @@ func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
var uploaded service.FileInfo
|
var uploaded fileInfoResponse
|
||||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||||
|
|
||||||
// Try to access as user2.
|
// Try to access as user2.
|
||||||
|
|||||||
+42
-30
@@ -1,21 +1,28 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/dhao2001/mygo/internal/api"
|
"github.com/dhao2001/mygo/internal/api"
|
||||||
"github.com/dhao2001/mygo/internal/auth"
|
"github.com/dhao2001/mygo/internal/service"
|
||||||
"github.com/dhao2001/mygo/internal/repository"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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.
|
// 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").
|
// On success, it injects the principal and user ID into the context.
|
||||||
func AuthRequired(jwtSecret []byte) gin.HandlerFunc {
|
func AuthRequired(authenticator accessAuthenticator) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
header := c.GetHeader("Authorization")
|
header := c.GetHeader("Authorization")
|
||||||
if header == "" {
|
if header == "" {
|
||||||
@@ -31,20 +38,15 @@ func AuthRequired(jwtSecret []byte) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := auth.ParseToken(parts[1], jwtSecret)
|
principal, err := authenticator.AuthenticateAccessToken(c.Request.Context(), parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
api.Error(c, http.StatusUnauthorized, "invalid or expired token")
|
api.RespondError(c, err)
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type != auth.TokenAccess {
|
c.Set(principalKey, *principal)
|
||||||
api.Error(c, http.StatusUnauthorized, "invalid token type")
|
c.Set(userIDKey, principal.UserID)
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Set(userIDKey, claims.UserID)
|
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,32 +67,42 @@ func GetUserID(c *gin.Context) string {
|
|||||||
func MustGetUserID(c *gin.Context) string {
|
func MustGetUserID(c *gin.Context) string {
|
||||||
userID := GetUserID(c)
|
userID := GetUserID(c)
|
||||||
if userID == "" {
|
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
|
return userID
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRequired returns a Gin middleware that gates access to admin-only
|
// GetPrincipal extracts the authenticated principal injected by AuthRequired.
|
||||||
// endpoints. It must be applied AFTER AuthRequired. It fetches the user from
|
func GetPrincipal(c *gin.Context) (service.Principal, bool) {
|
||||||
// the repository, and returns 403 if the user is not an admin. Soft-deleted
|
v, ok := c.Get(principalKey)
|
||||||
// users are not found by FindByID and will receive a 401 response.
|
if !ok || v == nil {
|
||||||
func AdminRequired(userRepo repository.UserRepository) gin.HandlerFunc {
|
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) {
|
return func(c *gin.Context) {
|
||||||
userID := GetUserID(c)
|
principal, ok := GetPrincipal(c)
|
||||||
if userID == "" {
|
if !ok {
|
||||||
api.Error(c, http.StatusUnauthorized, "missing user context")
|
api.Error(c, http.StatusUnauthorized, "missing user context")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := userRepo.FindByID(c.Request.Context(), userID)
|
if !principal.IsAdmin {
|
||||||
if err != nil {
|
|
||||||
api.Error(c, http.StatusUnauthorized, "user not found")
|
|
||||||
c.Abort()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !user.IsAdmin {
|
|
||||||
api.Error(c, http.StatusForbidden, "admin access required")
|
api.Error(c, http.StatusForbidden, "admin access required")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,29 +7,47 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/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)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(AuthRequired(secret))
|
r.Use(AuthRequired(authenticator))
|
||||||
r.GET("/protected", func(c *gin.Context) {
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthRequiredNoHeader(t *testing.T) {
|
func TestAuthRequiredNoHeader(t *testing.T) {
|
||||||
r := setupTestRouter([]byte("test-secret"))
|
authenticator := &stubAccessAuthenticator{}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -38,10 +56,14 @@ func TestAuthRequiredNoHeader(t *testing.T) {
|
|||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", 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) {
|
func TestAuthRequiredInvalidFormat(t *testing.T) {
|
||||||
r := setupTestRouter([]byte("test-secret"))
|
authenticator := &stubAccessAuthenticator{}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "invalid")
|
req.Header.Set("Authorization", "invalid")
|
||||||
@@ -51,10 +73,14 @@ func TestAuthRequiredInvalidFormat(t *testing.T) {
|
|||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", 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) {
|
func TestAuthRequiredNotBearer(t *testing.T) {
|
||||||
r := setupTestRouter([]byte("test-secret"))
|
authenticator := &stubAccessAuthenticator{}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
||||||
@@ -64,105 +90,51 @@ func TestAuthRequiredNotBearer(t *testing.T) {
|
|||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", 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) {
|
func TestAuthRequiredServiceRejectsToken(t *testing.T) {
|
||||||
secret := []byte("test-secret")
|
authenticator := &stubAccessAuthenticator{err: model.NewUnauthenticatedError("invalid or expired token")}
|
||||||
token, err := auth.GenerateAccessToken("user-1", secret, -1*time.Minute)
|
r := setupTestRouter(authenticator)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := setupTestRouter(secret)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer expired")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
t.Errorf("status = %d, want %d", 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) {
|
func TestAuthRequiredValidToken(t *testing.T) {
|
||||||
secret := []byte("test-secret")
|
authenticator := &stubAccessAuthenticator{
|
||||||
token, err := auth.GenerateAccessToken("user-1", secret, 15*time.Minute)
|
wantToken: "valid",
|
||||||
if err != nil {
|
principal: service.Principal{
|
||||||
t.Fatalf("GenerateAccessToken = %v", err)
|
UserID: "user-1",
|
||||||
|
IsAdmin: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
r := setupTestRouter(authenticator)
|
||||||
|
|
||||||
r := setupTestRouter(secret)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer valid")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
r.ServeHTTP(rec, req)
|
r.ServeHTTP(rec, req)
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
}
|
}
|
||||||
}
|
if !strings.Contains(rec.Body.String(), "user-1") {
|
||||||
|
t.Errorf("response body %q does not contain user id", rec.Body.String())
|
||||||
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(), "true") {
|
||||||
r := setupTestRouter(secret)
|
t.Errorf("response body %q does not contain admin flag", rec.Body.String())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +142,7 @@ func TestMustGetUserIDPanics(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.GET("/naked", func(c *gin.Context) {
|
r.GET("/naked", func(c *gin.Context) {
|
||||||
MustGetUserID(c) // should panic — no AuthRequired middleware applied
|
MustGetUserID(c)
|
||||||
})
|
})
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/naked", nil)
|
req := httptest.NewRequest(http.MethodGet, "/naked", nil)
|
||||||
@@ -184,75 +156,27 @@ func TestMustGetUserIDPanics(t *testing.T) {
|
|||||||
r.ServeHTTP(rec, req)
|
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 {
|
type errorBody struct {
|
||||||
Error struct {
|
Error struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupAdminTestDB(t *testing.T) repository.UserRepository {
|
func injectPrincipalMiddleware(principal *service.Principal) gin.HandlerFunc {
|
||||||
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 {
|
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Set(userIDKey, userID)
|
if principal != nil {
|
||||||
|
c.Set(principalKey, *principal)
|
||||||
|
c.Set(userIDKey, principal.UserID)
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRequired_AdminPasses(t *testing.T) {
|
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)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(injectUserIDMiddleware("admin-1"))
|
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "admin-1", IsAdmin: true}))
|
||||||
r.Use(AdminRequired(repo))
|
r.Use(AdminRequired())
|
||||||
r.GET("/admin", func(c *gin.Context) {
|
r.GET("/admin", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
@@ -267,24 +191,10 @@ func TestAdminRequired_AdminPasses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRequired_NonAdminForbidden(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)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(injectUserIDMiddleware("user-1"))
|
r.Use(injectPrincipalMiddleware(&service.Principal{UserID: "user-1", IsAdmin: false}))
|
||||||
r.Use(AdminRequired(repo))
|
r.Use(AdminRequired())
|
||||||
r.GET("/admin", func(c *gin.Context) {
|
r.GET("/admin", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
@@ -306,49 +216,10 @@ func TestAdminRequired_NonAdminForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRequired_SoftDeletedAdmin(t *testing.T) {
|
func TestAdminRequired_NoPrincipal(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(injectUserIDMiddleware("admin-2"))
|
r.Use(AdminRequired())
|
||||||
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.GET("/admin", func(c *gin.Context) {
|
r.GET("/admin", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import (
|
|||||||
// The primary password is stored on the User model; additional credentials
|
// The primary password is stored on the User model; additional credentials
|
||||||
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
||||||
type Credential struct {
|
type Credential struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||||
Type string `gorm:"index;type:varchar(32);not null" json:"type"`
|
Type string `gorm:"index;type:varchar(32);not null"`
|
||||||
Label string `gorm:"type:varchar(128)" json:"label"`
|
Label string `gorm:"type:varchar(128)"`
|
||||||
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
LastUsedAt *time.Time
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-19
@@ -3,7 +3,6 @@ package model
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -14,48 +13,76 @@ var (
|
|||||||
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
|
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
|
||||||
)
|
)
|
||||||
|
|
||||||
// AppError is a service-layer error that carries an HTTP status, a user-safe
|
// ErrorKind classifies domain errors without tying them to a transport.
|
||||||
// message, and an optional internal cause for logging. Handlers unpack it
|
type ErrorKind string
|
||||||
// transparently via api.RespondError.
|
|
||||||
|
// 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 {
|
type AppError struct {
|
||||||
Status int // HTTP status code
|
Kind ErrorKind
|
||||||
Message string // safe for API response
|
Message string
|
||||||
Err error // internal cause (nil = user-caused, skip logging)
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *AppError) Error() string { return e.Message }
|
func (e *AppError) Error() string { return e.Message }
|
||||||
func (e *AppError) Unwrap() error { return e.Err }
|
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 {
|
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 {
|
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 {
|
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 {
|
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 {
|
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.
|
// msg describes the operation that failed; cause is the underlying error.
|
||||||
func NewInternalError(msg string, cause error) *AppError {
|
func NewInternalError(msg string, cause error) *AppError {
|
||||||
return &AppError{
|
return &AppError{
|
||||||
Status: http.StatusInternalServerError,
|
Kind: KindInternal,
|
||||||
Message: "internal server error",
|
Message: "internal server error",
|
||||||
Err: fmt.Errorf("%s: %w", msg, cause),
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-10
@@ -9,16 +9,16 @@ const StatusUserDeleted = "user_deleted"
|
|||||||
|
|
||||||
// File represents a file or directory entry in the virtual filesystem.
|
// File represents a file or directory entry in the virtual filesystem.
|
||||||
type File struct {
|
type File struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"`
|
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)" json:"parent_id"`
|
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" json:"name"`
|
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3"`
|
||||||
Size int64 `gorm:"default:0" json:"size"`
|
Size int64 `gorm:"default:0"`
|
||||||
MimeType string `gorm:"type:varchar(127)" json:"mime_type"`
|
MimeType string `gorm:"type:varchar(127)"`
|
||||||
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
|
StoragePath string `gorm:"type:varchar(512)" json:"-"`
|
||||||
Hash string `gorm:"type:varchar(64)" json:"-"`
|
Hash string `gorm:"type:varchar(64)" json:"-"`
|
||||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||||
IsDir bool `gorm:"default:false" json:"is_dir"`
|
IsDir bool `gorm:"default:false"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
func TestFileJSONOmitsInternalFields(t *testing.T) {
|
||||||
f := &File{
|
f := &File{
|
||||||
ID: "1",
|
ID: "1",
|
||||||
UserID: "u1",
|
UserID: "u1",
|
||||||
@@ -14,6 +14,8 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
|||||||
Name: "test.txt",
|
Name: "test.txt",
|
||||||
Size: 100,
|
Size: 100,
|
||||||
MimeType: "text/plain",
|
MimeType: "text/plain",
|
||||||
|
StoragePath: "users/u1/files/1",
|
||||||
|
Hash: "abc123",
|
||||||
Status: StatusActive,
|
Status: StatusActive,
|
||||||
IsDir: false,
|
IsDir: false,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
@@ -30,13 +32,11 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
|||||||
t.Fatalf("json.Unmarshal: %v", err)
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := result["status"]; ok {
|
assertJSONKeysAbsent(t, result,
|
||||||
t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
"StoragePath", "storage_path",
|
||||||
}
|
"Hash", "hash",
|
||||||
|
"Status", "status",
|
||||||
if _, ok := result["hash"]; ok {
|
)
|
||||||
t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusConstants(t *testing.T) {
|
func TestStatusConstants(t *testing.T) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import (
|
|||||||
|
|
||||||
// Session stores a refresh token for a user session.
|
// Session stores a refresh token for a user session.
|
||||||
type Session struct {
|
type Session struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||||
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
ExpiresAt time.Time `gorm:"not null"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import (
|
|||||||
|
|
||||||
// User represents a registered account.
|
// User represents a registered account.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||||
Username string `gorm:"uniqueIndex;type:varchar(64);not null" json:"username"`
|
Username string `gorm:"uniqueIndex;type:varchar(64);not null"`
|
||||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
|
Email string `gorm:"uniqueIndex;type:varchar(255);not null"`
|
||||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
IsAdmin bool `gorm:"default:false"`
|
||||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
Status string `gorm:"type:varchar(32);not null;default:active;index"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// User status constants.
|
// User status constants.
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUserJSONOmitsStatusField(t *testing.T) {
|
func TestUserJSONOmitsPasswordHash(t *testing.T) {
|
||||||
u := User{
|
u := User{
|
||||||
ID: "user-1",
|
ID: "user-1",
|
||||||
Username: "alice",
|
Username: "alice",
|
||||||
Email: "alice@example.com",
|
Email: "alice@example.com",
|
||||||
|
PasswordHash: "hash-secret",
|
||||||
Status: StatusActive,
|
Status: StatusActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,57 @@ func TestUserJSONOmitsStatusField(t *testing.T) {
|
|||||||
t.Fatalf("json.Unmarshal: %v", err)
|
t.Fatalf("json.Unmarshal: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, ok := m["status"]; ok {
|
assertJSONKeysAbsent(t, m, "PasswordHash", "password_hash")
|
||||||
t.Error("Status field should not appear in JSON output")
|
}
|
||||||
|
|
||||||
|
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) {
|
func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
||||||
jwtSecret := []byte(webApp.Config.JWT.Secret)
|
|
||||||
accountHandler := handler.NewAccountHandler(webApp.AuthService)
|
accountHandler := handler.NewAccountHandler(webApp.AuthService)
|
||||||
fileHandler := handler.NewFileHandler(webApp.FileService)
|
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")
|
account := rg.Group("/account")
|
||||||
{
|
{
|
||||||
@@ -39,7 +38,7 @@ func setupProtectedRoutes(rg *gin.RouterGroup, webApp *app.WebApp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
admin := rg.Group("/admin")
|
admin := rg.Group("/admin")
|
||||||
admin.Use(middleware.AdminRequired(webApp.UserRepo))
|
admin.Use(middleware.AdminRequired())
|
||||||
{
|
{
|
||||||
admin.GET("/users", adminHandler.ListUsers)
|
admin.GET("/users", adminHandler.ListUsers)
|
||||||
admin.GET("/users/:id", adminHandler.GetUser)
|
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)
|
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)
|
router := NewRouter(webApp)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil)
|
||||||
@@ -84,6 +84,7 @@ func TestAdminRoutes(t *testing.T) {
|
|||||||
refreshTTL := 168 * time.Hour
|
refreshTTL := 168 * time.Hour
|
||||||
|
|
||||||
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
|
authService := service.NewAuthService(userRepo, sessionRepo, credentialRepo, jwtSecret, accessTTL, refreshTTL)
|
||||||
|
adminService := service.NewAdminService(userRepo)
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
JWT: config.JWTConfig{
|
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)
|
router := NewRouter(webApp)
|
||||||
|
|
||||||
// Helper: register a user via POST /api/v1/auth/register and return the user ID.
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,15 +15,21 @@ import (
|
|||||||
|
|
||||||
// TokenPair contains the access and refresh tokens returned after authentication.
|
// TokenPair contains the access and refresh tokens returned after authentication.
|
||||||
type TokenPair struct {
|
type TokenPair struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string
|
||||||
RefreshToken string `json:"refresh_token"`
|
RefreshToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatedPasskey contains the raw token for a newly created app passkey.
|
// CreatedPasskey contains the raw token for a newly created app passkey.
|
||||||
type CreatedPasskey struct {
|
type CreatedPasskey struct {
|
||||||
ID string `json:"id"`
|
ID string
|
||||||
Raw string `json:"raw"`
|
Raw string
|
||||||
Label string `json:"label"`
|
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.
|
// AuthService handles user authentication and session management.
|
||||||
@@ -59,7 +64,7 @@ func NewAuthService(
|
|||||||
// Register creates a new user account.
|
// Register creates a new user account.
|
||||||
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
||||||
if username == "" || email == "" || password == "" {
|
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)
|
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 err := s.userRepo.Create(ctx, user); err != nil {
|
||||||
if errors.Is(err, model.ErrDuplicate) {
|
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)
|
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)
|
user, err := s.userRepo.FindByEmail(ctx, email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
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)
|
return nil, model.NewInternalError("find user", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.Status != model.StatusActive {
|
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 {
|
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)
|
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) {
|
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
||||||
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.Type != auth.TokenRefresh {
|
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)
|
tokenHash := auth.HashToken(refreshTokenStr)
|
||||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
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)
|
return nil, model.NewInternalError("find session", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if session.UserID != claims.UserID {
|
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)
|
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
return nil, model.NewUnauthenticatedError("invalid token")
|
||||||
}
|
}
|
||||||
if user.Status != model.StatusActive {
|
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 {
|
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.
|
// LoginWithPasskey authenticates a user using an app passkey token.
|
||||||
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
||||||
if !strings.HasPrefix(tokenStr, "mygo_") {
|
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)
|
tokenHash := auth.HashToken(tokenStr)
|
||||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
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)
|
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)
|
return nil, model.NewInternalError("find user", err)
|
||||||
}
|
}
|
||||||
if user.Status != model.StatusActive {
|
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" {
|
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 {
|
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)
|
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
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)
|
return model.NewInternalError("find credential", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cred.UserID != userID {
|
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)
|
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) {
|
func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) {
|
||||||
accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL)
|
accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -347,8 +346,8 @@ func TestAuthService_RevokePasskeyNotOwner(t *testing.T) {
|
|||||||
|
|
||||||
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
||||||
var ae *model.AppError
|
var ae *model.AppError
|
||||||
if !errors.As(err, &ae) || ae.Status != http.StatusForbidden {
|
if !errors.As(err, &ae) || ae.Kind != model.KindPermissionDenied {
|
||||||
t.Fatalf("expected AppError 403 Forbidden, got %v", err)
|
t.Fatalf("expected permission denied AppError, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,8 +433,8 @@ func TestAuthService_LoginDisabledUser(t *testing.T) {
|
|||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if ae.Status != http.StatusUnauthorized {
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||||
}
|
}
|
||||||
if ae.Message != "invalid email or password" {
|
if ae.Message != "invalid email or password" {
|
||||||
t.Errorf("message = %q, want %q", 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) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if ae.Status != http.StatusUnauthorized {
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||||
}
|
}
|
||||||
if ae.Message != "invalid passkey" {
|
if ae.Message != "invalid passkey" {
|
||||||
t.Errorf("message = %q, want %q", 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) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected AppError, got %T: %v", err, err)
|
t.Fatalf("expected AppError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if ae.Status != http.StatusUnauthorized {
|
if ae.Kind != model.KindUnauthenticated {
|
||||||
t.Errorf("status = %d, want %d", ae.Status, http.StatusUnauthorized)
|
t.Errorf("kind = %q, want %q", ae.Kind, model.KindUnauthenticated)
|
||||||
}
|
}
|
||||||
if ae.Message != "invalid token" {
|
if ae.Message != "invalid token" {
|
||||||
t.Errorf("message = %q, want %q", 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.
|
// FileInfo is the public representation of a file or directory.
|
||||||
type FileInfo struct {
|
type FileInfo struct {
|
||||||
ID string `json:"id"`
|
ID string
|
||||||
UserID string `json:"user_id"`
|
UserID string
|
||||||
ParentID *string `json:"parent_id"`
|
ParentID *string
|
||||||
Name string `json:"name"`
|
Name string
|
||||||
Size int64 `json:"size"`
|
Size int64
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string
|
||||||
IsDir bool `json:"is_dir"`
|
IsDir bool
|
||||||
Hash string `json:"hash,omitempty"`
|
Hash string
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileList is a paginated list of files.
|
// FileList is a paginated list of files.
|
||||||
type FileList struct {
|
type FileList struct {
|
||||||
Files []FileInfo `json:"files"`
|
Files []FileInfo
|
||||||
Total int64 `json:"total"`
|
Total int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileService handles file management business logic.
|
// FileService handles file management business logic.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,16 +17,16 @@ import (
|
|||||||
"github.com/dhao2001/mygo/internal/storage"
|
"github.com/dhao2001/mygo/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// assertAppError checks that err is an *model.AppError with the expected status.
|
// assertAppError checks that err is an *model.AppError with the expected kind.
|
||||||
func assertAppError(t *testing.T, err error, wantStatus int) bool {
|
func assertAppError(t *testing.T, err error, wantKind model.ErrorKind) bool {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var ae *model.AppError
|
var ae *model.AppError
|
||||||
if !errors.As(err, &ae) {
|
if !errors.As(err, &ae) {
|
||||||
t.Errorf("expected *model.AppError, got %T: %v", err, err)
|
t.Errorf("expected *model.AppError, got %T: %v", err, err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if ae.Status != wantStatus {
|
if ae.Kind != wantKind {
|
||||||
t.Errorf("status = %d, want %d; message = %q", ae.Status, wantStatus, ae.Message)
|
t.Errorf("kind = %q, want %q; message = %q", ae.Kind, wantKind, ae.Message)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -151,7 +150,7 @@ func TestFileService_UploadRejectsOversizedFileAndCleansStaging(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err := svc.Upload(ctx, "user1", nil, "too-large.txt", strings.NewReader("123456"))
|
_, 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()
|
store.mu.RLock()
|
||||||
defer store.mu.RUnlock()
|
defer store.mu.RUnlock()
|
||||||
@@ -285,7 +284,7 @@ func TestFileService_DownloadForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, _, err = svc.Download(ctx, "user2", info.ID)
|
_, _, err = svc.Download(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
assertAppError(t, err, model.KindPermissionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_DownloadDirectory(t *testing.T) {
|
func TestFileService_DownloadDirectory(t *testing.T) {
|
||||||
@@ -326,7 +325,7 @@ func TestFileService_GetNotFound(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
_, err := svc.Get(ctx, "user1", "nonexistent")
|
_, err := svc.Get(ctx, "user1", "nonexistent")
|
||||||
assertAppError(t, err, http.StatusNotFound)
|
assertAppError(t, err, model.KindNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_GetForbidden(t *testing.T) {
|
func TestFileService_GetForbidden(t *testing.T) {
|
||||||
@@ -339,7 +338,7 @@ func TestFileService_GetForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(ctx, "user2", info.ID)
|
_, err = svc.Get(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
assertAppError(t, err, model.KindPermissionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_List(t *testing.T) {
|
func TestFileService_List(t *testing.T) {
|
||||||
@@ -437,7 +436,7 @@ func TestFileService_Delete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(ctx, "user1", info.ID)
|
_, err = svc.Get(ctx, "user1", info.ID)
|
||||||
assertAppError(t, err, http.StatusNotFound)
|
assertAppError(t, err, model.KindNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
func TestFileService_DeleteNonEmptyDirectory(t *testing.T) {
|
||||||
@@ -469,7 +468,7 @@ func TestFileService_DeleteForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = svc.Delete(ctx, "user2", info.ID)
|
err = svc.Delete(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
assertAppError(t, err, model.KindPermissionDenied)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_CreateDir(t *testing.T) {
|
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"))
|
_, 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) {
|
func TestFileService_SoftDelete(t *testing.T) {
|
||||||
@@ -530,7 +529,7 @@ func TestFileService_SoftDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err = svc.Get(ctx, "user1", info.ID)
|
_, err = svc.Get(ctx, "user1", info.ID)
|
||||||
assertAppError(t, err, http.StatusNotFound)
|
assertAppError(t, err, model.KindNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
||||||
@@ -590,5 +589,5 @@ func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = svc.Delete(ctx, "user2", info.ID)
|
err = svc.Delete(ctx, "user2", info.ID)
|
||||||
assertAppError(t, err, http.StatusForbidden)
|
assertAppError(t, err, model.KindPermissionDenied)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user