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:
@@ -8,11 +8,11 @@ import (
|
||||
// The primary password is stored on the User model; additional credentials
|
||||
// (app passkeys, WebAuthn, OAuth) are stored here with a type discriminator.
|
||||
type Credential struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
||||
Type string `gorm:"index;type:varchar(32);not null" json:"type"`
|
||||
Label string `gorm:"type:varchar(128)" json:"label"`
|
||||
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||
Type string `gorm:"index;type:varchar(32);not null"`
|
||||
Label string `gorm:"type:varchar(128)"`
|
||||
SecretHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
+46
-19
@@ -3,7 +3,6 @@ package model
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -14,48 +13,76 @@ var (
|
||||
ErrUploadTooLarge = errors.New("upload exceeds maximum size")
|
||||
)
|
||||
|
||||
// AppError is a service-layer error that carries an HTTP status, a user-safe
|
||||
// message, and an optional internal cause for logging. Handlers unpack it
|
||||
// transparently via api.RespondError.
|
||||
// ErrorKind classifies domain errors without tying them to a transport.
|
||||
type ErrorKind string
|
||||
|
||||
// Protocol-neutral error kinds.
|
||||
const (
|
||||
KindInvalidArgument ErrorKind = "invalid_argument"
|
||||
KindUnauthenticated ErrorKind = "unauthenticated"
|
||||
KindPermissionDenied ErrorKind = "permission_denied"
|
||||
KindNotFound ErrorKind = "not_found"
|
||||
KindConflict ErrorKind = "conflict"
|
||||
KindPayloadTooLarge ErrorKind = "payload_too_large"
|
||||
KindInternal ErrorKind = "internal"
|
||||
)
|
||||
|
||||
// AppError is a service-layer error that carries a protocol-neutral kind, a
|
||||
// user-safe message, and an optional internal cause for logging.
|
||||
type AppError struct {
|
||||
Status int // HTTP status code
|
||||
Message string // safe for API response
|
||||
Err error // internal cause (nil = user-caused, skip logging)
|
||||
Kind ErrorKind
|
||||
Message string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string { return e.Message }
|
||||
func (e *AppError) Unwrap() error { return e.Err }
|
||||
|
||||
// NewBadRequestError creates a 400 AppError.
|
||||
// NewInvalidArgumentError creates an invalid-argument AppError.
|
||||
func NewInvalidArgumentError(message string) *AppError {
|
||||
return &AppError{Kind: KindInvalidArgument, Message: message}
|
||||
}
|
||||
|
||||
// NewBadRequestError creates an invalid-argument AppError.
|
||||
func NewBadRequestError(message string) *AppError {
|
||||
return &AppError{Status: http.StatusBadRequest, Message: message}
|
||||
return NewInvalidArgumentError(message)
|
||||
}
|
||||
|
||||
// NewConflictError creates a 409 AppError.
|
||||
// NewUnauthenticatedError creates an unauthenticated AppError.
|
||||
func NewUnauthenticatedError(message string) *AppError {
|
||||
return &AppError{Kind: KindUnauthenticated, Message: message}
|
||||
}
|
||||
|
||||
// NewConflictError creates a conflict AppError.
|
||||
func NewConflictError(message string) *AppError {
|
||||
return &AppError{Status: http.StatusConflict, Message: message}
|
||||
return &AppError{Kind: KindConflict, Message: message}
|
||||
}
|
||||
|
||||
// NewNotFoundError creates a 404 AppError.
|
||||
// NewNotFoundError creates a not-found AppError.
|
||||
func NewNotFoundError(message string, cause error) *AppError {
|
||||
return &AppError{Status: http.StatusNotFound, Message: message, Err: cause}
|
||||
return &AppError{Kind: KindNotFound, Message: message, Err: cause}
|
||||
}
|
||||
|
||||
// NewForbiddenError creates a 403 AppError.
|
||||
// NewPermissionDeniedError creates a permission-denied AppError.
|
||||
func NewPermissionDeniedError(message string, cause error) *AppError {
|
||||
return &AppError{Kind: KindPermissionDenied, Message: message, Err: cause}
|
||||
}
|
||||
|
||||
// NewForbiddenError creates a permission-denied AppError.
|
||||
func NewForbiddenError(message string, cause error) *AppError {
|
||||
return &AppError{Status: http.StatusForbidden, Message: message, Err: cause}
|
||||
return NewPermissionDeniedError(message, cause)
|
||||
}
|
||||
|
||||
// NewPayloadTooLargeError creates a 413 AppError.
|
||||
// NewPayloadTooLargeError creates a payload-too-large AppError.
|
||||
func NewPayloadTooLargeError(message string, cause error) *AppError {
|
||||
return &AppError{Status: http.StatusRequestEntityTooLarge, Message: message, Err: cause}
|
||||
return &AppError{Kind: KindPayloadTooLarge, Message: message, Err: cause}
|
||||
}
|
||||
|
||||
// NewInternalError creates a 500 AppError with a wrapped internal cause.
|
||||
// NewInternalError creates an internal AppError with a wrapped internal cause.
|
||||
// msg describes the operation that failed; cause is the underlying error.
|
||||
func NewInternalError(msg string, cause error) *AppError {
|
||||
return &AppError{
|
||||
Status: http.StatusInternalServerError,
|
||||
Kind: KindInternal,
|
||||
Message: "internal server error",
|
||||
Err: fmt.Errorf("%s: %w", msg, cause),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppErrorConstructors(t *testing.T) {
|
||||
cause := errors.New("database offline")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err *AppError
|
||||
kind ErrorKind
|
||||
message string
|
||||
cause error
|
||||
}{
|
||||
{
|
||||
name: "invalid argument",
|
||||
err: NewInvalidArgumentError("bad input"),
|
||||
kind: KindInvalidArgument,
|
||||
message: "bad input",
|
||||
},
|
||||
{
|
||||
name: "unauthenticated",
|
||||
err: NewUnauthenticatedError("invalid token"),
|
||||
kind: KindUnauthenticated,
|
||||
message: "invalid token",
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
err: NewPermissionDeniedError("access denied", ErrForbidden),
|
||||
kind: KindPermissionDenied,
|
||||
message: "access denied",
|
||||
cause: ErrForbidden,
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
err: NewNotFoundError("missing", ErrNotFound),
|
||||
kind: KindNotFound,
|
||||
message: "missing",
|
||||
cause: ErrNotFound,
|
||||
},
|
||||
{
|
||||
name: "conflict",
|
||||
err: NewConflictError("duplicate"),
|
||||
kind: KindConflict,
|
||||
message: "duplicate",
|
||||
},
|
||||
{
|
||||
name: "payload too large",
|
||||
err: NewPayloadTooLargeError("too large", ErrUploadTooLarge),
|
||||
kind: KindPayloadTooLarge,
|
||||
message: "too large",
|
||||
cause: ErrUploadTooLarge,
|
||||
},
|
||||
{
|
||||
name: "internal",
|
||||
err: NewInternalError("load record", cause),
|
||||
kind: KindInternal,
|
||||
message: "internal server error",
|
||||
cause: cause,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.err.Kind != tt.kind {
|
||||
t.Fatalf("Kind = %q, want %q", tt.err.Kind, tt.kind)
|
||||
}
|
||||
if tt.err.Message != tt.message {
|
||||
t.Fatalf("Message = %q, want %q", tt.err.Message, tt.message)
|
||||
}
|
||||
if tt.cause != nil && !errors.Is(tt.err, tt.cause) {
|
||||
t.Fatalf("errors.Is(%v, %v) = false", tt.err, tt.cause)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -9,16 +9,16 @@ const StatusUserDeleted = "user_deleted"
|
||||
|
||||
// File represents a file or directory entry in the virtual filesystem.
|
||||
type File struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null" json:"user_id"`
|
||||
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)" json:"parent_id"`
|
||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3" json:"name"`
|
||||
Size int64 `gorm:"default:0" json:"size"`
|
||||
MimeType string `gorm:"type:varchar(127)" json:"mime_type"`
|
||||
StoragePath string `gorm:"type:varchar(512)" json:"storage_path"`
|
||||
Hash string `gorm:"type:varchar(64)" json:"-"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||
IsDir bool `gorm:"default:false" json:"is_dir"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
UserID string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:1;type:varchar(36);not null"`
|
||||
ParentID *string `gorm:"index;uniqueIndex:idx_user_parent_name,priority:2;type:varchar(36)"`
|
||||
Name string `gorm:"type:varchar(255);not null;uniqueIndex:idx_user_parent_name,priority:3"`
|
||||
Size int64 `gorm:"default:0"`
|
||||
MimeType string `gorm:"type:varchar(127)"`
|
||||
StoragePath string `gorm:"type:varchar(512)" json:"-"`
|
||||
Hash string `gorm:"type:varchar(64)" json:"-"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||
IsDir bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
+18
-18
@@ -6,18 +6,20 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
||||
func TestFileJSONOmitsInternalFields(t *testing.T) {
|
||||
f := &File{
|
||||
ID: "1",
|
||||
UserID: "u1",
|
||||
ParentID: nil,
|
||||
Name: "test.txt",
|
||||
Size: 100,
|
||||
MimeType: "text/plain",
|
||||
Status: StatusActive,
|
||||
IsDir: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
ID: "1",
|
||||
UserID: "u1",
|
||||
ParentID: nil,
|
||||
Name: "test.txt",
|
||||
Size: 100,
|
||||
MimeType: "text/plain",
|
||||
StoragePath: "users/u1/files/1",
|
||||
Hash: "abc123",
|
||||
Status: StatusActive,
|
||||
IsDir: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(f)
|
||||
@@ -30,13 +32,11 @@ func TestFile_StatusExcludedFromJSON(t *testing.T) {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := result["status"]; ok {
|
||||
t.Errorf("Status field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
||||
}
|
||||
|
||||
if _, ok := result["hash"]; ok {
|
||||
t.Errorf("Hash field should be excluded from JSON (json:\"-\"), but it appeared in output")
|
||||
}
|
||||
assertJSONKeysAbsent(t, result,
|
||||
"StoragePath", "storage_path",
|
||||
"Hash", "hash",
|
||||
"Status", "status",
|
||||
)
|
||||
}
|
||||
|
||||
func TestStatusConstants(t *testing.T) {
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
|
||||
// Session stores a refresh token for a user session.
|
||||
type Session struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null" json:"user_id"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
UserID string `gorm:"index;type:varchar(36);not null"`
|
||||
TokenHash string `gorm:"uniqueIndex;type:varchar(255);not null" json:"-"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import (
|
||||
|
||||
// User represents a registered account.
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
||||
Username string `gorm:"uniqueIndex;type:varchar(64);not null" json:"username"`
|
||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null" json:"email"`
|
||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `gorm:"primaryKey;type:varchar(36)"`
|
||||
Username string `gorm:"uniqueIndex;type:varchar(64);not null"`
|
||||
Email string `gorm:"uniqueIndex;type:varchar(255);not null"`
|
||||
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"`
|
||||
IsAdmin bool `gorm:"default:false"`
|
||||
Status string `gorm:"type:varchar(32);not null;default:active;index"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// User status constants.
|
||||
|
||||
@@ -5,12 +5,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserJSONOmitsStatusField(t *testing.T) {
|
||||
func TestUserJSONOmitsPasswordHash(t *testing.T) {
|
||||
u := User{
|
||||
ID: "user-1",
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
Status: StatusActive,
|
||||
ID: "user-1",
|
||||
Username: "alice",
|
||||
Email: "alice@example.com",
|
||||
PasswordHash: "hash-secret",
|
||||
Status: StatusActive,
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(u)
|
||||
@@ -23,7 +24,57 @@ func TestUserJSONOmitsStatusField(t *testing.T) {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := m["status"]; ok {
|
||||
t.Error("Status field should not appear in JSON output")
|
||||
assertJSONKeysAbsent(t, m, "PasswordHash", "password_hash")
|
||||
}
|
||||
|
||||
func TestSessionJSONOmitsTokenHash(t *testing.T) {
|
||||
s := Session{
|
||||
ID: "session-1",
|
||||
UserID: "user-1",
|
||||
TokenHash: "token-hash-secret",
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
assertJSONKeysAbsent(t, m, "TokenHash", "token_hash")
|
||||
}
|
||||
|
||||
func TestCredentialJSONOmitsSecretHash(t *testing.T) {
|
||||
c := Credential{
|
||||
ID: "credential-1",
|
||||
UserID: "user-1",
|
||||
Type: "app_passkey",
|
||||
Label: "Phone",
|
||||
SecretHash: "credential-secret",
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("json.Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
assertJSONKeysAbsent(t, m, "SecretHash", "secret_hash")
|
||||
}
|
||||
|
||||
func assertJSONKeysAbsent(t *testing.T, m map[string]interface{}, keys ...string) {
|
||||
t.Helper()
|
||||
|
||||
for _, key := range keys {
|
||||
if _, ok := m[key]; ok {
|
||||
t.Errorf("%s field should not appear in JSON output", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user