init: initial commit
Checks / verify (push) Canceled after 41s

This commit is contained in:
2026-10-06 11:24:02 +08:00
parent 456e87d4f2
commit ba6dfffab7
63 changed files with 7223 additions and 0 deletions
+319
View File
@@ -0,0 +1,319 @@
openapi: 3.0.3
info:
title: Veyra API
version: 0.2.0
description: 工程基础、开发身份和账户初始化;密码登录仅限 local/test。
servers:
- url: http://127.0.0.1:8080
paths:
/healthz:
get:
operationId: getLiveness
responses:
'200':
description: API 进程存活,与依赖是否就绪无关
content:
application/json:
schema:
type: object
required: [status]
properties:
status: {type: string, enum: [alive]}
/readyz:
get:
operationId: getReadiness
responses:
'200': {$ref: '#/components/responses/Health'}
'503': {$ref: '#/components/responses/Health'}
/api/v1/system/health:
get:
operationId: getSystemHealth
responses:
'200': {$ref: '#/components/responses/Health'}
'503': {$ref: '#/components/responses/Health'}
/api/admin/v1/auth/login:
post:
operationId: adminLogin
description: 开发环境登录,要求同源 Origin;会话只在 HttpOnly Cookie 中返回。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/LoginRequest'}
responses:
'200':
description: 已创建管理端会话
headers:
Set-Cookie: {schema: {type: string}, description: veyra_admin_session; HttpOnly; SameSite=Strict; Path=/api/admin}
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data: {$ref: '#/components/schemas/AdminLogin'}
'401': {$ref: '#/components/responses/Error'}
'403': {$ref: '#/components/responses/Error'}
'422': {$ref: '#/components/responses/Error'}
'429': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/v1/auth/login:
post:
operationId: developmentAppLogin
description: local/test 开发登录,返回 App 专用会话。正式短信登录与刷新流程待接入。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/LoginRequest'}
responses:
'200':
description: 已创建 App 会话
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data: {$ref: '#/components/schemas/AppLogin'}
'401': {$ref: '#/components/responses/Error'}
'422': {$ref: '#/components/responses/Error'}
'429': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/admin/v1/me:
get:
operationId: getAdminIdentity
security: [{adminCookie: []}]
responses:
'200': {$ref: '#/components/responses/Identity'}
'401': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/v1/me:
get:
operationId: getAppIdentity
security: [{appBearer: []}]
responses:
'200': {$ref: '#/components/responses/Identity'}
'401': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/admin/v1/auth/logout:
post:
operationId: adminLogout
description: 同源 Origin 请求,撤销会话并清除管理端 Cookie。
security: [{adminCookie: []}]
responses:
'200': {$ref: '#/components/responses/Logout'}
'401': {$ref: '#/components/responses/Error'}
'403': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/v1/auth/logout:
post:
operationId: appLogout
security: [{appBearer: []}]
responses:
'200': {$ref: '#/components/responses/Logout'}
'401': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/admin/v1/users:
get:
operationId: listAppUsers
description: 最近创建的 100 位 App 用户,不包含密码、登录名或后台权限。
security: [{adminCookie: []}]
responses:
'200':
description: App 用户列表
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data:
type: array
items: {$ref: '#/components/schemas/User'}
'401': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/v1/accounts:
get:
operationId: listOwnAccounts
security: [{appBearer: []}]
responses:
'200':
description: 当前用户账户列表
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data:
type: array
items: {$ref: '#/components/schemas/AccountSummary'}
'401': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
post:
operationId: createOwnAccount
description: simulation 要求 initial_cash,manual_real 必须省略;每种模式仅一个账户。
security: [{appBearer: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateAccount'}
responses:
'201': {$ref: '#/components/responses/AccountSummary'}
'401': {$ref: '#/components/responses/Error'}
'409': {$ref: '#/components/responses/Error'}
'422': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
/api/v1/accounts/{id}/summary:
get:
operationId: getOwnAccountSummary
security: [{appBearer: []}]
parameters:
- name: id
in: path
required: true
schema: {type: string, format: uuid}
responses:
'200': {$ref: '#/components/responses/AccountSummary'}
'401': {$ref: '#/components/responses/Error'}
'404': {$ref: '#/components/responses/Error'}
'503': {$ref: '#/components/responses/Error'}
components:
securitySchemes:
adminCookie: {type: apiKey, in: cookie, name: veyra_admin_session}
appBearer: {type: http, scheme: bearer}
responses:
Error:
description: 标准业务错误
content:
application/json:
schema:
type: object
required: [error, meta]
properties:
error:
type: object
required: [code, message, retryable]
properties:
code: {type: string}
message: {type: string}
retryable: {type: boolean}
meta: {$ref: '#/components/schemas/Meta'}
Identity:
description: 当前身份
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data: {$ref: '#/components/schemas/User'}
Logout:
description: 会话已撤销
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data:
type: object
required: [revoked]
properties:
revoked: {type: boolean}
AccountSummary:
description: 账户与账本估算现金
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- properties:
data: {$ref: '#/components/schemas/AccountSummary'}
Health:
description: PostgreSQL 和 Redis 都正常时为 200,任一不可用或未配置时为 503
content:
application/json:
schema:
$ref: '#/components/schemas/Health'
schemas:
Meta:
type: object
required: [request_id]
properties:
request_id: {type: string, format: uuid}
server_time: {type: string, format: date-time}
Envelope:
type: object
required: [data, meta]
properties:
data: {}
meta: {$ref: '#/components/schemas/Meta'}
User:
type: object
required: [id, display_name, status, created_at, updated_at]
properties:
id: {type: string, format: uuid}
display_name: {type: string}
status: {type: string, enum: [ACTIVE, DISABLED]}
created_at: {type: string, format: date-time}
updated_at: {type: string, format: date-time}
LoginRequest:
type: object
additionalProperties: false
required: [login_name, password]
properties:
login_name: {type: string, minLength: 3, maxLength: 64}
password: {type: string, minLength: 1, maxLength: 72, writeOnly: true}
AdminLogin:
type: object
required: [user, expires_at]
properties:
user: {$ref: '#/components/schemas/User'}
expires_at: {type: string, format: date-time}
AppLogin:
allOf:
- {$ref: '#/components/schemas/AdminLogin'}
- type: object
required: [access_token]
properties:
access_token: {type: string}
CreateAccount:
type: object
additionalProperties: false
required: [mode, name]
properties:
mode: {type: string, enum: [simulation, manual_real]}
name: {type: string, minLength: 1, maxLength: 80}
initial_cash: {type: string, description: CNY 十进制字符串,最多两位小数,范围 0 至 1000000000.00}
AccountSummary:
type: object
required: [id, user_id, mode, name, currency, ledger_status, created_at, cash_balance]
properties:
id: {type: string, format: uuid}
user_id: {type: string, format: uuid}
mode: {type: string, enum: [simulation, manual_real]}
name: {type: string}
currency: {type: string, enum: [CNY]}
ledger_status: {type: string, enum: [COMPLETE, INCOMPLETE]}
created_at: {type: string, format: date-time}
cash_balance: {type: string, nullable: true, description: CNY 十进制字符串,现金历史不完整时为 null}
Health:
type: object
required: [status, checked_at, components]
properties:
status: {type: string, enum: [ready, not_ready]}
checked_at: {type: string, format: date-time}
components:
type: object
required: [postgresql, redis]
properties:
postgresql: {$ref: '#/components/schemas/Component'}
redis: {$ref: '#/components/schemas/Component'}
Component:
type: object
required: [status]
properties:
status: {type: string, enum: [ready, unavailable, not_configured]}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"context"
"errors"
"flag"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
identityrepo "veyra/services/api/internal/identity/repository"
identityservice "veyra/services/api/internal/identity/service"
"veyra/services/api/internal/operations/service"
"veyra/services/api/internal/platform/config"
"veyra/services/api/internal/platform/ratelimit"
portfoliorepo "veyra/services/api/internal/portfolio/repository"
portfolioservice "veyra/services/api/internal/portfolio/service"
transport "veyra/services/api/internal/transport/http"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("API stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
envFile := flag.String("env-file", "", "optional environment file")
flag.Parse()
if *envFile != "" {
if err := config.LoadEnvFile(*envFile); err != nil {
return err
}
}
c, err := config.Load()
if err != nil {
return err
}
var postgres service.Probe
var authRepo identityservice.Repository
var accountRepo portfolioservice.Repository
if c.PGUser != "" {
if err := c.ValidateDatabase(); err != nil {
return err
}
pool, err := pgxpool.New(context.Background(), c.DatabaseURL(c.PGDatabase))
if err != nil {
return errors.New("invalid PostgreSQL configuration")
}
defer pool.Close()
postgres = pool.Ping
authRepo = identityrepo.NewPostgres(pool)
accountRepo = portfoliorepo.NewPostgres(pool)
}
redisClient := redis.NewClient(&redis.Options{Addr: c.RedisAddr, Username: c.RedisUsername, Password: c.RedisPassword, DB: c.RedisDB, DialTimeout: time.Second, ReadTimeout: time.Second, WriteTimeout: time.Second, MaxRetries: -1, ContextTimeoutEnabled: true})
defer redisClient.Close()
health := service.NewHealthService(postgres, func(ctx context.Context) error { return redisClient.Ping(ctx).Err() })
auth, err := identityservice.NewAuthService(authRepo)
if err != nil {
return err
}
identityOptions := transport.IdentityOptions{Auth: auth, Accounts: portfolioservice.NewAccountService(accountRepo), Limiter: ratelimit.NewRedis(redisClient), DevelopmentLogin: c.Environment == "local" || c.Environment == "test", SecureCookie: c.Environment != "local" && c.Environment != "test"}
server := &http.Server{Addr: c.HTTPAddr, Handler: transport.NewRouter(health, logger, identityOptions), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
result := make(chan error, 1)
go func() { result <- server.ListenAndServe() }()
logger.Info("API starting", "address", c.HTTPAddr, "environment", c.Environment)
select {
case err := <-result:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(shutdown)
}
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"time"
"veyra/services/api/internal/platform/config"
"veyra/services/api/internal/platform/database"
)
func main() {
if err := run(); err != nil {
slog.Error("database command failed", "error", err)
os.Exit(1)
}
}
func run() error {
envFile := flag.String("env-file", "", "optional environment file")
action := flag.String("action", "migrate", "create or migrate")
flag.Parse()
if *envFile != "" {
if err := config.LoadEnvFile(*envFile); err != nil {
return err
}
}
c, err := config.Load()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
switch *action {
case "create":
created, err := database.Create(ctx, c)
if err != nil {
return err
}
slog.Info("database ready", "database", c.PGDatabase, "created", created)
case "migrate":
pool, err := database.Open(ctx, c)
if err != nil {
return err
}
defer pool.Close()
if err := database.Migrate(ctx, pool); err != nil {
return err
}
slog.Info("migrations applied", "database", c.PGDatabase)
default:
return fmt.Errorf("action must be create or migrate")
}
return nil
}
+73
View File
@@ -0,0 +1,73 @@
// Command user provisions development identities without exposing a registration API.
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"time"
"veyra/services/api/internal/identity/repository"
"veyra/services/api/internal/identity/service"
"veyra/services/api/internal/platform/config"
"veyra/services/api/internal/platform/database"
)
func main() {
if err := run(); err != nil {
slog.Error("provision identity failed", "error", err)
os.Exit(1)
}
}
func run() error {
envFile := flag.String("env-file", "", "environment file")
role := flag.String("role", "ADMIN", "ADMIN or USER")
flag.Parse()
if *envFile != "" {
if err := config.LoadEnvFile(*envFile); err != nil {
return err
}
}
c, err := config.Load()
if err != nil {
return err
}
if c.Environment != "local" && c.Environment != "test" {
return fmt.Errorf("development provisioning is disabled outside local/test environments")
}
var login, password, name string
switch *role {
case "ADMIN":
login = os.Getenv("ADMIN_LOGIN")
password = os.Getenv("ADMIN_PASSWORD")
name = "管理员"
case "USER":
login = os.Getenv("DEV_USER_LOGIN")
password = os.Getenv("DEV_USER_PASSWORD")
name = "开发测试用户"
default:
return fmt.Errorf("role must be ADMIN or USER")
}
if login == "" || password == "" {
return fmt.Errorf("configure the selected identity login and password in the local environment file")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
pool, err := database.Open(ctx, c)
if err != nil {
return err
}
defer pool.Close()
auth, err := service.NewAuthService(repository.NewPostgres(pool))
if err != nil {
return err
}
user, err := auth.ProvisionUser(ctx, login, password, name, *role)
if err != nil {
return err
}
slog.Info("identity provisioned", "user_id", user.ID, "role", *role)
return nil
}
+49
View File
@@ -0,0 +1,49 @@
module veyra/services/api
go 1.26.2
require (
github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.48.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+116
View File
@@ -0,0 +1,116 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -0,0 +1,27 @@
package domain
import (
"time"
)
type Credential struct {
ID string `db:"id"`
UserID string `db:"user_id"`
LoginName string `db:"login_name"`
PasswordHash string `db:"password_hash" json:"-"`
CreatedAt time.Time `db:"created_at"`
}
func (Credential) TableName() string { return "veyra_credential" }
type Session struct {
ID string `db:"id"`
UserID string `db:"user_id"`
TokenHash string `db:"token_hash" json:"-"`
Audience string `db:"audience"`
ExpiresAt time.Time `db:"expires_at"`
RevokedAt *time.Time `db:"revoked_at"`
CreatedAt time.Time `db:"created_at"`
}
func (Session) TableName() string { return "veyra_session" }
@@ -0,0 +1,15 @@
package domain
import "time"
// User maps explicitly to veyra_user. IDs are canonical UUID strings.
type User struct {
ID string `db:"id" json:"id"`
DisplayName string `db:"display_name" json:"display_name"`
Status string `db:"status" json:"status"`
Role string `db:"role" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (User) TableName() string { return "veyra_user" }
@@ -0,0 +1,94 @@
package repository
import (
"context"
"errors"
"time"
"veyra/services/api/internal/platform/apperror"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/identity/domain"
)
type Postgres struct{ pool *pgxpool.Pool }
func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} }
func (r *Postgres) CreateUser(ctx context.Context, user domain.User, credential domain.Credential) (domain.User, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.User{}, apperror.ErrUnavailable
}
defer tx.Rollback(context.Background())
err = tx.QueryRow(ctx, `INSERT INTO veyra_user(id,display_name,status,role) VALUES($1,$2,$3,$4) RETURNING created_at,updated_at`, user.ID, user.DisplayName, user.Status, user.Role).Scan(&user.CreatedAt, &user.UpdatedAt)
if err != nil {
return domain.User{}, mapError(err)
}
_, err = tx.Exec(ctx, `INSERT INTO veyra_credential(id,user_id,login_name,password_hash) VALUES($1,$2,$3,$4)`, credential.ID, credential.UserID, credential.LoginName, credential.PasswordHash)
if err != nil {
return domain.User{}, mapError(err)
}
if err = tx.Commit(ctx); err != nil {
return domain.User{}, apperror.ErrUnavailable
}
return user, nil
}
func (r *Postgres) FindCredential(ctx context.Context, login string) (domain.User, domain.Credential, error) {
var u domain.User
var c domain.Credential
err := r.pool.QueryRow(ctx, `SELECT u.id,u.display_name,u.status,u.role,u.created_at,u.updated_at,c.id,c.user_id,c.login_name,c.password_hash,c.created_at FROM veyra_user u JOIN veyra_credential c ON c.user_id=u.id WHERE c.login_name=$1`, login).Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt, &c.ID, &c.UserID, &c.LoginName, &c.PasswordHash, &c.CreatedAt)
return u, c, mapError(err)
}
func (r *Postgres) CreateSession(ctx context.Context, s domain.Session) error {
_, err := r.pool.Exec(ctx, `INSERT INTO veyra_session(id,user_id,token_hash,audience,expires_at) VALUES($1,$2,$3,$4,$5)`, s.ID, s.UserID, s.TokenHash, s.Audience, s.ExpiresAt)
return mapError(err)
}
func (r *Postgres) FindSessionUser(ctx context.Context, hash, audience string, now time.Time) (domain.User, error) {
var u domain.User
err := r.pool.QueryRow(ctx, `SELECT u.id,u.display_name,u.status,u.role,u.created_at,u.updated_at FROM veyra_session s JOIN veyra_user u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.audience=$2 AND s.expires_at>$3 AND s.revoked_at IS NULL AND u.status='ACTIVE' AND (($2='admin' AND u.role='ADMIN') OR ($2='app' AND u.role='USER'))`, hash, audience, now).Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, apperror.ErrUnauthenticated
}
return u, mapError(err)
}
func (r *Postgres) RevokeSession(ctx context.Context, hash, audience string, now time.Time) error {
_, err := r.pool.Exec(ctx, `UPDATE veyra_session SET revoked_at=$3 WHERE token_hash=$1 AND audience=$2 AND revoked_at IS NULL`, hash, audience, now)
return mapError(err)
}
func (r *Postgres) ListUsers(ctx context.Context, limit int) ([]domain.User, error) {
rows, err := r.pool.Query(ctx, `SELECT id,display_name,status,role,created_at,updated_at FROM veyra_user WHERE role='USER' ORDER BY created_at DESC,id DESC LIMIT $1`, limit)
if err != nil {
return nil, apperror.ErrUnavailable
}
defer rows.Close()
users := make([]domain.User, 0)
for rows.Next() {
var u domain.User
if err := rows.Scan(&u.ID, &u.DisplayName, &u.Status, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, apperror.ErrUnavailable
}
users = append(users, u)
}
return users, mapError(rows.Err())
}
func mapError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, pgx.ErrNoRows) {
return apperror.ErrNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return apperror.ErrConflict
}
return apperror.ErrUnavailable
}
@@ -0,0 +1,144 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"veyra/services/api/internal/platform/apperror"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"veyra/services/api/internal/identity/domain"
)
type Repository interface {
CreateUser(context.Context, domain.User, domain.Credential) (domain.User, error)
FindCredential(context.Context, string) (domain.User, domain.Credential, error)
CreateSession(context.Context, domain.Session) error
FindSessionUser(context.Context, string, string, time.Time) (domain.User, error)
RevokeSession(context.Context, string, string, time.Time) error
ListUsers(context.Context, int) ([]domain.User, error)
}
type AuthService struct {
repo Repository
now func() time.Time
dummyHash []byte
}
func NewAuthService(repo Repository) (*AuthService, error) {
hash, err := bcrypt.GenerateFromPassword([]byte("invalid-user-timing-placeholder"), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("initialize password verifier: %w", err)
}
return &AuthService{repo: repo, now: time.Now, dummyHash: hash}, nil
}
var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`)
// ProvisionUser is called by a local CLI. Public registration cannot choose ADMIN.
func (s *AuthService) ProvisionUser(ctx context.Context, login, password, name, role string) (domain.User, error) {
login = strings.ToLower(strings.TrimSpace(login))
name = strings.TrimSpace(name)
if !loginPattern.MatchString(login) || len(password) < 12 || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") {
return domain.User{}, apperror.ErrInvalidInput
}
if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return domain.User{}, fmt.Errorf("hash password: %w", err)
}
user := domain.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role}
credential := domain.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)}
return s.repo.CreateUser(ctx, user, credential)
}
type LoginResult struct {
User domain.User
Token string
ExpiresAt time.Time
}
func (s *AuthService) Login(ctx context.Context, login, password, audience string) (LoginResult, error) {
if audience != "admin" && audience != "app" {
return LoginResult{}, apperror.ErrInvalidInput
}
if s.repo == nil {
return LoginResult{}, apperror.ErrUnavailable
}
login = strings.ToLower(strings.TrimSpace(login))
if !loginPattern.MatchString(login) || len(password) > 72 || password == "" {
return LoginResult{}, apperror.ErrUnauthenticated
}
user, credential, err := s.repo.FindCredential(ctx, login)
if err != nil {
if errors.Is(err, apperror.ErrNotFound) {
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
return LoginResult{}, apperror.ErrUnauthenticated
}
return LoginResult{}, err
}
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil || user.Status != "ACTIVE" || (audience == "admin" && user.Role != "ADMIN") || (audience == "app" && user.Role != "USER") {
return LoginResult{}, apperror.ErrUnauthenticated
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return LoginResult{}, fmt.Errorf("generate session token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(raw)
expires := s.now().UTC().Add(8 * time.Hour)
session := domain.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires}
if err := s.repo.CreateSession(ctx, session); err != nil {
return LoginResult{}, err
}
return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil
}
func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (domain.User, error) {
if s.repo == nil {
return domain.User{}, apperror.ErrUnavailable
}
if len(token) != 43 {
return domain.User{}, apperror.ErrUnauthenticated
}
if audience != "admin" && audience != "app" {
return domain.User{}, apperror.ErrUnauthenticated
}
user, err := s.repo.FindSessionUser(ctx, TokenHash(token), audience, s.now().UTC())
if err != nil {
return domain.User{}, err
}
if user.Status != "ACTIVE" || audience == "admin" && user.Role != "ADMIN" || audience == "app" && user.Role != "USER" {
return domain.User{}, apperror.ErrUnauthenticated
}
return user, nil
}
func (s *AuthService) Logout(ctx context.Context, token, audience string) error {
if s.repo == nil {
return apperror.ErrUnavailable
}
return s.repo.RevokeSession(ctx, TokenHash(token), audience, s.now().UTC())
}
func (s *AuthService) ListUsers(ctx context.Context) ([]domain.User, error) {
if s.repo == nil {
return nil, apperror.ErrUnavailable
}
return s.repo.ListUsers(ctx, 100)
}
func TokenHash(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
@@ -0,0 +1,92 @@
package service
import (
"context"
"errors"
"testing"
"time"
"veyra/services/api/internal/identity/domain"
"veyra/services/api/internal/platform/apperror"
)
type testRepository struct {
user domain.User
credential domain.Credential
session domain.Session
createdSessions int
}
func (r *testRepository) CreateUser(_ context.Context, u domain.User, c domain.Credential) (domain.User, error) {
r.user = u
r.credential = c
return u, nil
}
func (r *testRepository) FindCredential(_ context.Context, login string) (domain.User, domain.Credential, error) {
if login != r.credential.LoginName {
return domain.User{}, domain.Credential{}, apperror.ErrNotFound
}
return r.user, r.credential, nil
}
func (r *testRepository) CreateSession(_ context.Context, s domain.Session) error {
r.session = s
r.createdSessions++
return nil
}
func (r *testRepository) FindSessionUser(_ context.Context, _, _ string, _ time.Time) (domain.User, error) {
return r.user, nil
}
func (r *testRepository) RevokeSession(_ context.Context, _, _ string, _ time.Time) error { return nil }
func (r *testRepository) ListUsers(context.Context, int) ([]domain.User, error) {
return []domain.User{r.user}, nil
}
func TestPasswordAndSessionSecretsAreHashed(t *testing.T) {
ctx := context.Background()
repo := &testRepository{}
auth, err := NewAuthService(repo)
if err != nil {
t.Fatal(err)
}
password := "a-development-password"
if _, err := auth.ProvisionUser(ctx, " ADMIN ", password, "Admin", "ADMIN"); err != nil {
t.Fatal(err)
}
if repo.credential.PasswordHash == password || repo.credential.LoginName != "admin" {
t.Fatal("credential handling failed")
}
result, err := auth.Login(ctx, "admin", password, "admin")
if err != nil {
t.Fatal(err)
}
if len(result.Token) != 43 || repo.session.TokenHash == result.Token || repo.session.TokenHash != TokenHash(result.Token) {
t.Fatal("raw session secret persisted")
}
if _, err := auth.Login(ctx, "admin", "incorrect-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("wrong password accepted")
}
if _, err := auth.Login(ctx, "admin", password, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("admin credential used for app login")
}
if _, err := auth.Login(ctx, "missing", password, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("missing identity was disclosed")
}
if repo.createdSessions != 1 {
t.Fatal("failed login created a session")
}
repo.user.Status = "DISABLED"
if _, err := auth.Authenticate(ctx, result.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("disabled user remained authenticated")
}
}
func TestUserCannotAuthenticateAsAdmin(t *testing.T) {
repo := &testRepository{user: domain.User{Status: "ACTIVE", Role: "USER"}}
auth, err := NewAuthService(repo)
if err != nil {
t.Fatal(err)
}
if _, err := auth.Authenticate(context.Background(), "1234567890123456789012345678901234567890123", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("user role passed admin authorization")
}
}
@@ -0,0 +1,183 @@
package integration
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
identityrepo "veyra/services/api/internal/identity/repository"
identity "veyra/services/api/internal/identity/service"
operations "veyra/services/api/internal/operations/service"
"veyra/services/api/internal/platform/apperror"
"veyra/services/api/internal/platform/database"
portfoliorepo "veyra/services/api/internal/portfolio/repository"
portfolio "veyra/services/api/internal/portfolio/service"
transport "veyra/services/api/internal/transport/http"
)
func TestIdentityAccountLifecycle(t *testing.T) {
dsn := os.Getenv("VEYRA_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set VEYRA_TEST_DATABASE_URL for real database integration")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer func() {
if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
if err := database.Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
auth, err := identity.NewAuthService(identityrepo.NewPostgres(pool))
if err != nil {
t.Fatal(err)
}
u1, err := auth.ProvisionUser(ctx, "alice", "a-test-only-password", "Alice", "USER")
if err != nil {
t.Fatal(err)
}
u2, err := auth.ProvisionUser(ctx, "bob", "another-test-password", "Bob", "USER")
if err != nil {
t.Fatal(err)
}
if _, err := auth.ProvisionUser(ctx, "alice", "a-test-only-password", "Duplicate", "USER"); !errors.Is(err, apperror.ErrConflict) {
t.Fatalf("duplicate identity: %v", err)
}
var count int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_user").Scan(&count); err != nil || count != 2 {
t.Fatal("credential conflict left an orphan user")
}
login, err := auth.Login(ctx, "alice", "a-test-only-password", "app")
if err != nil {
t.Fatal(err)
}
if _, err := auth.Authenticate(ctx, login.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("app session escaped audience")
}
if _, err := auth.Authenticate(ctx, login.Token, "app"); err != nil {
t.Fatal(err)
}
if err := auth.Logout(ctx, login.Token, "app"); err != nil {
t.Fatal(err)
}
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("revoked session remained valid")
}
accounts := portfolio.NewAccountService(portfoliorepo.NewPostgres(pool))
cash := "100000.01"
a, err := accounts.Create(ctx, u1.ID, "simulation", "Simulation", &cash)
if err != nil || a.CashBalance == nil || *a.CashBalance != cash {
t.Fatalf("simulation balance: %v", err)
}
if _, err := accounts.Get(ctx, u2.ID, a.ID); !errors.Is(err, apperror.ErrNotFound) {
t.Fatal("cross-user account disclosed")
}
if _, err := accounts.Create(ctx, u1.ID, "simulation", "Duplicate", &cash); !errors.Is(err, apperror.ErrConflict) {
t.Fatal("duplicate simulation account accepted")
}
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_cash_entry").Scan(&count); err != nil || count != 1 {
t.Fatal("duplicate creation left a cash entry")
}
real, err := accounts.Create(ctx, u1.ID, "manual_real", "Manual", nil)
if err != nil || real.CashBalance != nil || real.LedgerStatus != "INCOMPLETE" {
t.Fatalf("manual account fabricated cash: %v", err)
}
if _, err := accounts.Create(ctx, u2.ID, "manual_real", "Manual", &cash); !errors.Is(err, apperror.ErrInvalidInput) {
t.Fatal("manual account accepted simulated funding")
}
list, err := accounts.List(ctx, u2.ID)
if err != nil || len(list) != 0 {
t.Fatal("account list escaped owner scope")
}
login, err = auth.Login(ctx, "alice", "a-test-only-password", "app")
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "UPDATE veyra_session SET created_at=now()-interval '1 day',expires_at=now()-interval '1 hour' WHERE token_hash=$1", identity.TokenHash(login.Token)); err != nil {
t.Fatal(err)
}
if _, err := auth.Authenticate(ctx, login.Token, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
t.Fatal("expired session remained valid")
}
if _, err := auth.ProvisionUser(ctx, "admin", "an-admin-test-password", "Admin", "ADMIN"); err != nil {
t.Fatal(err)
}
probe := func(context.Context) error { return nil }
router := transport.NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), transport.IdentityOptions{Auth: auth, Accounts: accounts, Limiter: allowLogin{}, DevelopmentLogin: true})
call := func(method, path, body, origin string, cookie *http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "http://example.com"+path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if origin != "" {
req.Header.Set("Origin", origin)
}
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
response := call(http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"an-admin-test-password"}`, "http://example.com", nil)
if response.Code != http.StatusOK {
t.Fatalf("admin login failed: %d", response.Code)
}
cookies := response.Result().Cookies()
if len(cookies) != 1 || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteStrictMode || cookies[0].Path != "/api/admin" {
t.Fatal("admin cookie constraints missing")
}
cookie := cookies[0]
if strings.Contains(response.Body.String(), cookie.Value) {
t.Fatal("admin token leaked into response body")
}
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
t.Fatal("cookie authentication failed")
}
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://attacker.example", cookie); response.Code != http.StatusForbidden {
t.Fatal("cross-origin logout allowed")
}
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusOK {
t.Fatal("CSRF attempt revoked a session")
}
if response := call(http.MethodPost, "/api/admin/v1/auth/logout", "", "http://example.com", cookie); response.Code != http.StatusOK {
t.Fatal("logout failed")
}
if response := call(http.MethodGet, "/api/admin/v1/me", "", "", cookie); response.Code != http.StatusUnauthorized {
t.Fatal("revoked admin cookie remained valid")
}
}
type allowLogin struct{}
func (allowLogin) Login(context.Context, string, string) (bool, error) { return true, nil }
@@ -0,0 +1,49 @@
package service
import (
"context"
"sync"
"time"
)
type Probe func(context.Context) error
type Component struct {
Status string `json:"status"`
}
type Health struct {
Status string `json:"status"`
CheckedAt time.Time `json:"checked_at"`
Components map[string]Component `json:"components"`
}
type HealthService struct{ postgres, redis Probe }
func NewHealthService(postgres, redis Probe) *HealthService {
return &HealthService{postgres: postgres, redis: redis}
}
func (s *HealthService) Check(ctx context.Context) Health {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
result := Health{Status: "ready", CheckedAt: time.Now().UTC(), Components: map[string]Component{}}
var mu sync.Mutex
var wg sync.WaitGroup
for name, probe := range map[string]Probe{"postgresql": s.postgres, "redis": s.redis} {
wg.Go(func() {
status := "unavailable"
if probe == nil {
status = "not_configured"
} else if probe(ctx) == nil {
status = "ready"
}
mu.Lock()
defer mu.Unlock()
result.Components[name] = Component{Status: status}
if status != "ready" {
result.Status = "not_ready"
}
})
}
wg.Wait()
return result
}
+1
View File
@@ -0,0 +1 @@
Build apps/admin to populate this directory before building the release binary.
@@ -0,0 +1,55 @@
// Package adminui serves the compiled management console from the API binary.
package adminui
import (
"embed"
"io/fs"
"net/http"
"path"
"strings"
)
//go:embed all:dist
var assets embed.FS
func Handler() http.Handler {
root, _ := fs.Sub(assets, "dist")
files := http.FileServer(http.FS(root))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if strings.HasPrefix(name, ".") || strings.Contains(name, "/.") {
http.NotFound(w, r)
return
}
if _, err := fs.Stat(root, "index.html"); err != nil {
http.Error(w, "Management console not built. Run make admin-build, then rebuild the API.", http.StatusServiceUnavailable)
return
}
if name != "" && name != "." {
if info, err := fs.Stat(root, name); err == nil && !info.IsDir() {
files.ServeHTTP(w, r)
return
}
}
if path.Ext(name) != "" || strings.HasPrefix(name, "assets/") {
http.NotFound(w, r)
return
}
index, err := fs.ReadFile(root, "index.html")
if err != nil {
http.Error(w, "Management console unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(index)
}
})
}
@@ -0,0 +1,13 @@
// Package apperror defines safe application errors shared across module boundaries.
package apperror
import "errors"
var (
ErrInvalidInput = errors.New("invalid input")
ErrUnauthenticated = errors.New("authentication required")
ErrForbidden = errors.New("permission denied")
ErrConflict = errors.New("already exists")
ErrNotFound = errors.New("not found")
ErrUnavailable = errors.New("service unavailable")
)
@@ -0,0 +1,105 @@
// Package config loads and validates application configuration without logging secrets.
package config
import (
"bufio"
"fmt"
"net"
"net/url"
"os"
"strconv"
"strings"
)
type Config struct {
Environment, HTTPAddr string
PGHost, PGPort, PGDatabase, PGUser, PGPassword, PGSSLMode, PGAdminDatabase string
RedisAddr, RedisUsername, RedisPassword string
RedisDB int
}
// LoadEnvFile reads literal KEY=VALUE entries. Existing environment values win.
// It never evaluates shell expressions or prints values.
func LoadEnvFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open configuration file: %w", err)
}
defer f.Close()
s := bufio.NewScanner(f)
for line := 1; s.Scan(); line++ {
v := strings.TrimSpace(s.Text())
if v == "" || strings.HasPrefix(v, "#") {
continue
}
key, value, ok := strings.Cut(v, "=")
key = strings.TrimSpace(key)
if !ok || key == "" {
return fmt.Errorf("invalid configuration at line %d", line)
}
for i, r := range key {
if !(r == '_' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || i > 0 && r >= '0' && r <= '9') {
return fmt.Errorf("invalid key at line %d", line)
}
}
value = strings.TrimSpace(value)
if len(value) >= 2 && (value[0] == '\'' && value[len(value)-1] == '\'' || value[0] == '"' && value[len(value)-1] == '"') {
value = value[1 : len(value)-1]
}
if _, exists := os.LookupEnv(key); !exists {
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("set configuration at line %d: %w", line, err)
}
}
}
return s.Err()
}
func Load() (Config, error) {
c := Config{Environment: env("APP_ENV", "local"), HTTPAddr: env("HTTP_ADDR", "127.0.0.1:8080"), PGHost: env("PGHOST", "192.168.100.127"), PGPort: env("PGPORT", "5432"), PGDatabase: env("PGDATABASE", "veyra"), PGUser: os.Getenv("PGUSER"), PGPassword: os.Getenv("PGPASSWORD"), PGSSLMode: env("PGSSLMODE", "disable"), PGAdminDatabase: env("PGADMIN_DATABASE", "postgres"), RedisAddr: env("REDIS_ADDR", "192.168.100.127:6379"), RedisUsername: os.Getenv("REDIS_USERNAME"), RedisPassword: os.Getenv("REDIS_PASSWORD")}
var err error
c.RedisDB, err = strconv.Atoi(env("REDIS_DB", "0"))
if err != nil || c.RedisDB < 0 {
return Config{}, fmt.Errorf("REDIS_DB must be a nonnegative integer")
}
port, err := strconv.Atoi(c.PGPort)
if err != nil || port < 1 || port > 65535 {
return Config{}, fmt.Errorf("PGPORT must be a valid port")
}
if _, _, err := net.SplitHostPort(c.HTTPAddr); err != nil {
return Config{}, fmt.Errorf("invalid HTTP_ADDR")
}
if _, _, err := net.SplitHostPort(c.RedisAddr); err != nil {
return Config{}, fmt.Errorf("invalid REDIS_ADDR")
}
switch c.PGSSLMode {
case "disable", "allow", "prefer", "require", "verify-ca", "verify-full":
default:
return Config{}, fmt.Errorf("invalid PGSSLMODE")
}
return c, nil
}
func (c Config) ValidateDatabase() error {
if c.PGUser == "" {
return fmt.Errorf("PGUSER is required; configure the local .env file")
}
if c.PGHost == "" || c.PGDatabase == "" || c.PGAdminDatabase == "" {
return fmt.Errorf("database host and names are required")
}
return nil
}
func (c Config) DatabaseURL(database string) string {
u := url.URL{Scheme: "postgres", Host: net.JoinHostPort(c.PGHost, c.PGPort), User: url.UserPassword(c.PGUser, c.PGPassword), Path: "/" + database}
q := url.Values{"sslmode": {c.PGSSLMode}, "connect_timeout": {"5"}}
u.RawQuery = q.Encode()
return u.String()
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
@@ -0,0 +1,25 @@
package config
import (
"net/url"
"testing"
)
func TestDatabaseURLPreservesCredentials(t *testing.T) {
c := Config{PGHost: "192.168.100.127", PGPort: "5432", PGUser: "user@name", PGPassword: "p@ss:/?#&", PGSSLMode: "require"}
u, err := url.Parse(c.DatabaseURL("veyra"))
if err != nil {
t.Fatal(err)
}
password, _ := u.User.Password()
if password != c.PGPassword || u.User.Username() != c.PGUser || u.Path != "/veyra" || u.Query().Get("sslmode") != "require" {
t.Fatal("connection URL changed configuration values")
}
}
func TestRejectInvalidRedisDB(t *testing.T) {
t.Setenv("REDIS_DB", "-1")
if _, err := Load(); err == nil {
t.Fatal("negative database must be rejected")
}
}
@@ -0,0 +1,111 @@
package database
import (
"context"
"crypto/sha256"
"embed"
"fmt"
"sort"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/platform/config"
)
//go:embed migrations/*.sql
var migrations embed.FS
func Open(ctx context.Context, c config.Config) (*pgxpool.Pool, error) {
if err := c.ValidateDatabase(); err != nil {
return nil, err
}
p, err := pgxpool.New(ctx, c.DatabaseURL(c.PGDatabase))
if err != nil {
return nil, fmt.Errorf("configure PostgreSQL pool")
}
if err := p.Ping(ctx); err != nil {
p.Close()
return nil, fmt.Errorf("PostgreSQL connection unavailable")
}
return p, nil
}
// Create creates only the configured database and never drops or resets data.
func Create(ctx context.Context, c config.Config) (bool, error) {
if err := c.ValidateDatabase(); err != nil {
return false, err
}
conn, err := pgx.Connect(ctx, c.DatabaseURL(c.PGAdminDatabase))
if err != nil {
return false, fmt.Errorf("administrative PostgreSQL connection unavailable")
}
defer conn.Close(context.Background())
if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock(hashtext($1)::bigint)", "veyra:create:"+c.PGDatabase); err != nil {
return false, fmt.Errorf("lock database creation: %w", err)
}
var exists bool
if err := conn.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", c.PGDatabase).Scan(&exists); err != nil {
return false, fmt.Errorf("inspect database: %w", err)
}
if exists {
return false, nil
}
if _, err := conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{c.PGDatabase}.Sanitize()); err != nil {
return false, fmt.Errorf("create database failed; verify role CREATEDB permission")
}
return true, nil
}
// Migrate serializes concurrent runners and atomically records each migration.
// Even the migration ledger follows the veyra_ prefix and string UUID ID rules.
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return err
}
defer conn.Release()
tx, err := conn.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(context.Background())
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock(727198321)"); err != nil {
return err
}
if _, err := tx.Exec(ctx, `CREATE TABLE IF NOT EXISTS veyra_schema_migration (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
version varchar(120) NOT NULL UNIQUE, checksum varchar(64) NOT NULL, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
entries, err := migrations.ReadDir("migrations")
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
sql, err := migrations.ReadFile("migrations/" + entry.Name())
if err != nil {
return err
}
checksum := fmt.Sprintf("%x", sha256.Sum256(sql))
var stored string
err = tx.QueryRow(ctx, "SELECT checksum FROM veyra_schema_migration WHERE version=$1", entry.Name()).Scan(&stored)
if err == nil {
if stored != checksum {
return fmt.Errorf("migration %s checksum mismatch", entry.Name())
}
continue
}
if err != pgx.ErrNoRows {
return err
}
if _, err := tx.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("apply %s: %w", entry.Name(), err)
}
if _, err := tx.Exec(ctx, "INSERT INTO veyra_schema_migration(id,version,checksum) VALUES($1,$2,$3)", uuid.NewString(), entry.Name(), checksum); err != nil {
return err
}
}
return tx.Commit(ctx)
}
@@ -0,0 +1,67 @@
package database
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Integration testing uses a disposable schema on an explicitly provided test database.
func TestMigrationIntegration(t *testing.T) {
dsn := os.Getenv("VEYRA_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set VEYRA_TEST_DATABASE_URL to run PostgreSQL integration tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
schema := "veyra_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatal(err)
}
defer func() {
if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil {
t.Error(err)
}
}()
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
for i := 0; i < 2; i++ {
if err := Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
}
var count int
if err := pool.QueryRow(ctx, "SELECT count(*) FROM veyra_schema_migration").Scan(&count); err != nil || count != 2 {
t.Fatalf("migration ledger count=%d err=%v", count, err)
}
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES($1,$2)", uuid.NewString(), "Test"); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "INSERT INTO veyra_user(id,display_name) VALUES('invalid','Test')"); err == nil {
t.Fatal("invalid UUID was accepted")
}
if _, err := pool.Exec(ctx, "UPDATE veyra_schema_migration SET checksum='changed'"); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, pool); err == nil {
t.Fatal("modified migration checksum was accepted")
}
}
@@ -0,0 +1,18 @@
CREATE TABLE veyra_user (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
display_name varchar(80) NOT NULL CHECK (length(trim(display_name)) > 0),
status varchar(16) NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'DISABLED')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE veyra_account (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
mode varchar(16) NOT NULL CHECK (mode IN ('simulation', 'manual_real')),
name varchar(80) NOT NULL CHECK (length(trim(name)) > 0),
currency varchar(3) NOT NULL DEFAULT 'CNY' CHECK (currency = 'CNY'),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, mode)
);
CREATE INDEX veyra_account_user_id_idx ON veyra_account(user_id);
@@ -0,0 +1,32 @@
ALTER TABLE veyra_user ADD COLUMN role varchar(16) NOT NULL DEFAULT 'USER' CHECK (role IN ('USER', 'ADMIN'));
CREATE TABLE veyra_credential (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
user_id varchar(36) NOT NULL UNIQUE REFERENCES veyra_user(id),
login_name varchar(64) NOT NULL UNIQUE CHECK (login_name ~ '^[a-z0-9_]{3,64}$'),
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE veyra_session (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
user_id varchar(36) NOT NULL REFERENCES veyra_user(id),
token_hash varchar(64) NOT NULL UNIQUE,
audience varchar(8) NOT NULL CHECK (audience IN ('app', 'admin')),
expires_at timestamptz NOT NULL,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (expires_at > created_at)
);
CREATE INDEX veyra_session_user_id_idx ON veyra_session(user_id);
ALTER TABLE veyra_account ADD COLUMN ledger_status varchar(16) NOT NULL DEFAULT 'INCOMPLETE' CHECK (ledger_status IN ('COMPLETE', 'INCOMPLETE'));
CREATE TABLE veyra_cash_entry (
id varchar(36) PRIMARY KEY CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
account_id varchar(36) NOT NULL REFERENCES veyra_account(id),
entry_type varchar(16) NOT NULL CHECK (entry_type IN ('INITIAL')),
amount_minor bigint NOT NULL CHECK (amount_minor >= 0 AND amount_minor <= 100000000000),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (account_id, entry_type)
);
@@ -0,0 +1,12 @@
package database
import "time"
type SchemaMigration struct {
ID string `db:"id"`
Version string `db:"version"`
Checksum string `db:"checksum"`
AppliedAt time.Time `db:"applied_at"`
}
func (SchemaMigration) TableName() string { return "veyra_schema_migration" }
@@ -0,0 +1,34 @@
package ratelimit
import (
"context"
"crypto/sha256"
"fmt"
"github.com/redis/go-redis/v9"
)
type Redis struct{ client *redis.Client }
func NewRedis(client *redis.Client) *Redis { return &Redis{client: client} }
var loginCounter = redis.NewScript(`local n = redis.call('INCR', KEYS[1]); if n == 1 then redis.call('EXPIRE', KEYS[1], 900) end; return n`)
// Login permits at most 20 attempts per IP and 10 per login in a 15-minute window.
// Redis errors fail closed. Neither credential nor IP is stored in plaintext keys.
func (r *Redis) Login(ctx context.Context, ip, login string) (bool, error) {
for _, item := range []struct {
kind, value string
limit int64
}{{"ip", ip, 20}, {"name", login, 10}} {
key := fmt.Sprintf("veyra:login:%s:%x", item.kind, sha256.Sum256([]byte(item.value)))
n, err := loginCounter.Run(ctx, r.client, []string{key}).Int64()
if err != nil {
return false, err
}
if n > item.limit {
return false, nil
}
}
return true, nil
}
@@ -0,0 +1,15 @@
package domain
import "time"
type Account struct {
ID string `db:"id" json:"id"`
UserID string `db:"user_id" json:"user_id"`
Mode string `db:"mode" json:"mode"`
Name string `db:"name" json:"name"`
Currency string `db:"currency" json:"currency"`
LedgerStatus string `db:"ledger_status" json:"ledger_status"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
func (Account) TableName() string { return "veyra_account" }
@@ -0,0 +1,19 @@
package domain
import "time"
// CashEntry amounts are integer CNY cents; no floating-point accounting.
type CashEntry struct {
ID string `db:"id"`
AccountID string `db:"account_id"`
EntryType string `db:"entry_type"`
AmountMinor int64 `db:"amount_minor"`
CreatedAt time.Time `db:"created_at"`
}
func (CashEntry) TableName() string { return "veyra_cash_entry" }
type AccountSummary struct {
Account
CashBalance *string `json:"cash_balance"`
}
@@ -0,0 +1,35 @@
package domain
import (
"fmt"
"regexp"
"strconv"
"strings"
"veyra/services/api/internal/platform/apperror"
)
var moneyPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,9})(\.[0-9]{1,2})?$`)
// ParseMoney converts decimal text into exact cents, rejecting rounding and exponents.
func ParseMoney(value string) (int64, error) {
if !moneyPattern.MatchString(value) {
return 0, apperror.ErrInvalidInput
}
whole, fraction, _ := strings.Cut(value, ".")
units, err := strconv.ParseInt(whole, 10, 64)
if err != nil {
return 0, apperror.ErrInvalidInput
}
fraction += strings.Repeat("0", 2-len(fraction))
cents, err := strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, apperror.ErrInvalidInput
}
amount := units*100 + cents
if amount > 100000000000 {
return 0, apperror.ErrInvalidInput
}
return amount, nil
}
func FormatMoney(minor int64) string { return fmt.Sprintf("%d.%02d", minor/100, minor%100) }
@@ -0,0 +1,21 @@
package domain
import "testing"
func TestExactMoney(t *testing.T) {
for _, tc := range []struct {
input string
minor int64
formatted string
}{{"0", 0, "0.00"}, {"0.01", 1, "0.01"}, {"100000.10", 10000010, "100000.10"}, {"3.1", 310, "3.10"}, {"1000000000.00", 100000000000, "1000000000.00"}} {
amount, err := ParseMoney(tc.input)
if err != nil || amount != tc.minor || FormatMoney(amount) != tc.formatted {
t.Fatalf("%s: amount=%d err=%v", tc.input, amount, err)
}
}
for _, input := range []string{"-1", "1.001", "1e3", "NaN", " 1", "01", "1.", "1000000000.01", "999999999999999999999", ""} {
if _, err := ParseMoney(input); err == nil {
t.Fatalf("accepted invalid amount %q", input)
}
}
}
@@ -0,0 +1,99 @@
package repository
import (
"context"
"errors"
"veyra/services/api/internal/platform/apperror"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"veyra/services/api/internal/portfolio/domain"
)
type Postgres struct{ pool *pgxpool.Pool }
func NewPostgres(pool *pgxpool.Pool) *Postgres { return &Postgres{pool: pool} }
func (r *Postgres) Create(ctx context.Context, a domain.Account, entry *domain.CashEntry) (domain.AccountSummary, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return domain.AccountSummary{}, apperror.ErrUnavailable
}
defer tx.Rollback(context.Background())
// Lock the owner so account creation cannot race with disabling the user.
var status string
if err := tx.QueryRow(ctx, `SELECT status FROM veyra_user WHERE id=$1 AND role='USER' FOR UPDATE`, a.UserID).Scan(&status); err != nil {
return domain.AccountSummary{}, mapError(err)
}
if status != "ACTIVE" {
return domain.AccountSummary{}, apperror.ErrForbidden
}
err = tx.QueryRow(ctx, `INSERT INTO veyra_account(id,user_id,mode,name,currency,ledger_status) VALUES($1,$2,$3,$4,$5,$6) RETURNING created_at`, a.ID, a.UserID, a.Mode, a.Name, a.Currency, a.LedgerStatus).Scan(&a.CreatedAt)
if err != nil {
return domain.AccountSummary{}, mapError(err)
}
var balance *string
if entry != nil {
if _, err := tx.Exec(ctx, `INSERT INTO veyra_cash_entry(id,account_id,entry_type,amount_minor) VALUES($1,$2,$3,$4)`, entry.ID, entry.AccountID, entry.EntryType, entry.AmountMinor); err != nil {
return domain.AccountSummary{}, mapError(err)
}
value := domain.FormatMoney(entry.AmountMinor)
balance = &value
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountSummary{}, apperror.ErrUnavailable
}
return domain.AccountSummary{Account: a, CashBalance: balance}, nil
}
const summarySelect = `SELECT a.id,a.user_id,a.mode,a.name,a.currency,a.ledger_status,a.created_at,CASE WHEN a.ledger_status='COMPLETE' THEN (SELECT COALESCE(sum(e.amount_minor),0)::bigint FROM veyra_cash_entry e WHERE e.account_id=a.id) ELSE NULL END FROM veyra_account a`
func scanSummary(row pgx.Row) (domain.AccountSummary, error) {
var s domain.AccountSummary
var minor *int64
err := row.Scan(&s.ID, &s.UserID, &s.Mode, &s.Name, &s.Currency, &s.LedgerStatus, &s.CreatedAt, &minor)
if err != nil {
return s, mapError(err)
}
if minor != nil {
value := domain.FormatMoney(*minor)
s.CashBalance = &value
}
return s, nil
}
func (r *Postgres) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) {
return scanSummary(r.pool.QueryRow(ctx, summarySelect+` WHERE a.user_id=$1 AND a.id=$2`, userID, accountID))
}
func (r *Postgres) List(ctx context.Context, userID string) ([]domain.AccountSummary, error) {
rows, err := r.pool.Query(ctx, summarySelect+` WHERE a.user_id=$1 ORDER BY a.created_at,a.id`, userID)
if err != nil {
return nil, apperror.ErrUnavailable
}
defer rows.Close()
list := make([]domain.AccountSummary, 0)
for rows.Next() {
s, err := scanSummary(rows)
if err != nil {
return nil, err
}
list = append(list, s)
}
return list, mapError(rows.Err())
}
func mapError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, pgx.ErrNoRows) {
return apperror.ErrNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return apperror.ErrConflict
}
return apperror.ErrUnavailable
}
@@ -0,0 +1,69 @@
package service
import (
"context"
"strings"
"unicode/utf8"
"veyra/services/api/internal/platform/apperror"
"github.com/google/uuid"
"veyra/services/api/internal/portfolio/domain"
)
type Repository interface {
Create(context.Context, domain.Account, *domain.CashEntry) (domain.AccountSummary, error)
List(context.Context, string) ([]domain.AccountSummary, error)
Get(context.Context, string, string) (domain.AccountSummary, error)
}
type AccountService struct{ repo Repository }
func NewAccountService(repo Repository) *AccountService { return &AccountService{repo: repo} }
func (s *AccountService) Create(ctx context.Context, userID, mode, name string, initialCash *string) (domain.AccountSummary, error) {
if s.repo == nil {
return domain.AccountSummary{}, apperror.ErrUnavailable
}
name = strings.TrimSpace(name)
if name == "" || utf8.RuneCountInString(name) > 80 {
return domain.AccountSummary{}, apperror.ErrInvalidInput
}
account := domain.Account{ID: uuid.NewString(), UserID: userID, Mode: mode, Name: name, Currency: "CNY", LedgerStatus: "INCOMPLETE"}
var entry *domain.CashEntry
switch mode {
case "simulation":
if initialCash == nil {
return domain.AccountSummary{}, apperror.ErrInvalidInput
}
amount, err := domain.ParseMoney(*initialCash)
if err != nil {
return domain.AccountSummary{}, err
}
account.LedgerStatus = "COMPLETE"
entry = &domain.CashEntry{ID: uuid.NewString(), AccountID: account.ID, EntryType: "INITIAL", AmountMinor: amount}
case "manual_real":
if initialCash != nil {
return domain.AccountSummary{}, apperror.ErrInvalidInput
}
default:
return domain.AccountSummary{}, apperror.ErrInvalidInput
}
return s.repo.Create(ctx, account, entry)
}
func (s *AccountService) List(ctx context.Context, userID string) ([]domain.AccountSummary, error) {
if s.repo == nil {
return nil, apperror.ErrUnavailable
}
return s.repo.List(ctx, userID)
}
func (s *AccountService) Get(ctx context.Context, userID, accountID string) (domain.AccountSummary, error) {
if s.repo == nil {
return domain.AccountSummary{}, apperror.ErrUnavailable
}
if _, err := uuid.Parse(accountID); err != nil {
return domain.AccountSummary{}, apperror.ErrNotFound
}
return s.repo.Get(ctx, userID, accountID)
}
@@ -0,0 +1,252 @@
package http
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strings"
"time"
"veyra/services/api/internal/platform/apperror"
"github.com/gin-gonic/gin"
"veyra/services/api/internal/identity/domain"
identity "veyra/services/api/internal/identity/service"
portfolio "veyra/services/api/internal/portfolio/service"
)
type LoginLimiter interface {
Login(context.Context, string, string) (bool, error)
}
type IdentityOptions struct {
Auth *identity.AuthService
Accounts *portfolio.AccountService
Limiter LoginLimiter
DevelopmentLogin bool
SecureCookie bool
}
const sessionCookie = "veyra_admin_session"
func bindIdentity(r *gin.Engine, options IdentityOptions) {
r.SetTrustedProxies(nil)
adminAuth := func(c *gin.Context) { authenticate(c, options, "admin") }
appAuth := func(c *gin.Context) { authenticate(c, options, "app") }
login := func(audience string) gin.HandlerFunc {
return func(c *gin.Context) {
if !options.DevelopmentLogin {
fail(c, apperror.ErrUnavailable)
return
}
if audience == "admin" && !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
var input struct {
LoginName string `json:"login_name"`
Password string `json:"password"`
}
if !decode(c, &input) {
return
}
if len(input.LoginName) > 64 || len(input.Password) > 72 {
fail(c, apperror.ErrUnauthenticated)
return
}
if options.Auth == nil || options.Limiter == nil {
fail(c, apperror.ErrUnavailable)
return
}
allowed, err := options.Limiter.Login(c.Request.Context(), c.ClientIP(), strings.ToLower(strings.TrimSpace(input.LoginName)))
if err != nil {
fail(c, apperror.ErrUnavailable)
return
}
if !allowed {
c.Header("Retry-After", "900")
failure(c, http.StatusTooManyRequests, "RATE_LIMITED", "登录尝试过于频繁,请稍后再试。", true)
return
}
result, err := options.Auth.Login(c.Request.Context(), input.LoginName, input.Password, audience)
if err != nil {
fail(c, err)
return
}
if audience == "admin" {
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: result.Token, Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: 8 * 60 * 60, Expires: result.ExpiresAt})
success(c, http.StatusOK, gin.H{"user": result.User, "expires_at": result.ExpiresAt})
} else {
success(c, http.StatusOK, gin.H{"user": result.User, "access_token": result.Token, "expires_at": result.ExpiresAt})
}
}
}
r.POST("/api/admin/v1/auth/login", login("admin"))
r.POST("/api/v1/auth/login", login("app"))
logout := func(audience string) gin.HandlerFunc {
return func(c *gin.Context) {
if audience == "admin" && !sameOrigin(c.Request) {
fail(c, apperror.ErrForbidden)
return
}
token := requestToken(c.Request, audience)
if err := options.Auth.Logout(c.Request.Context(), token, audience); err != nil {
fail(c, err)
return
}
if audience == "admin" {
http.SetCookie(c.Writer, &http.Cookie{Name: sessionCookie, Value: "", Path: "/api/admin", HttpOnly: true, Secure: options.SecureCookie, SameSite: http.SameSiteStrictMode, MaxAge: -1})
}
success(c, http.StatusOK, gin.H{"revoked": true})
}
}
r.POST("/api/admin/v1/auth/logout", adminAuth, logout("admin"))
r.POST("/api/v1/auth/logout", appAuth, logout("app"))
r.GET("/api/admin/v1/me", adminAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) })
r.GET("/api/v1/me", appAuth, func(c *gin.Context) { success(c, http.StatusOK, currentUser(c)) })
r.GET("/api/admin/v1/users", adminAuth, func(c *gin.Context) {
users, err := options.Auth.ListUsers(c.Request.Context())
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, users)
})
r.GET("/api/v1/accounts", appAuth, func(c *gin.Context) {
if options.Accounts == nil {
fail(c, apperror.ErrUnavailable)
return
}
list, err := options.Accounts.List(c.Request.Context(), currentUser(c).ID)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, list)
})
r.POST("/api/v1/accounts", appAuth, func(c *gin.Context) {
var input struct {
Mode string `json:"mode"`
Name string `json:"name"`
InitialCash *string `json:"initial_cash"`
}
if !decode(c, &input) {
return
}
if options.Accounts == nil {
fail(c, apperror.ErrUnavailable)
return
}
account, err := options.Accounts.Create(c.Request.Context(), currentUser(c).ID, input.Mode, input.Name, input.InitialCash)
if err != nil {
fail(c, err)
return
}
success(c, http.StatusCreated, account)
})
r.GET("/api/v1/accounts/:id/summary", appAuth, func(c *gin.Context) {
if options.Accounts == nil {
fail(c, apperror.ErrUnavailable)
return
}
account, err := options.Accounts.Get(c.Request.Context(), currentUser(c).ID, c.Param("id"))
if err != nil {
fail(c, err)
return
}
success(c, http.StatusOK, account)
})
}
func authenticate(c *gin.Context, options IdentityOptions, audience string) {
if options.Auth == nil {
fail(c, apperror.ErrUnavailable)
return
}
token := requestToken(c.Request, audience)
if token == "" {
fail(c, apperror.ErrUnauthenticated)
return
}
user, err := options.Auth.Authenticate(c.Request.Context(), token, audience)
if err != nil {
fail(c, err)
return
}
c.Set("user", user)
c.Next()
}
func requestToken(r *http.Request, audience string) string {
if audience == "admin" {
cookie, err := r.Cookie(sessionCookie)
if err == nil {
return cookie.Value
}
return ""
}
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
return ""
}
return token
}
func currentUser(c *gin.Context) domain.User { return c.MustGet("user").(domain.User) }
// The embedded console and Vite development proxy both call APIs on their own origin.
func sameOrigin(r *http.Request) bool {
origin, err := url.Parse(r.Header.Get("Origin"))
if err != nil || origin.Host == "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return false
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return origin.Scheme == scheme && origin.Host == r.Host
}
func decode(c *gin.Context, value any) bool {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
if !strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "application/json") {
failure(c, http.StatusUnsupportedMediaType, "INVALID_CONTENT_TYPE", "请使用 application/json。", false)
return false
}
decoder := json.NewDecoder(c.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
fail(c, apperror.ErrInvalidInput)
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
fail(c, apperror.ErrInvalidInput)
return false
}
return true
}
func success(c *gin.Context, status int, data any) {
c.JSON(status, gin.H{"data": data, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID"), "server_time": time.Now().UTC()}})
}
func failure(c *gin.Context, status int, code, message string, retryable bool) {
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": message, "retryable": retryable}, "meta": gin.H{"request_id": c.Writer.Header().Get("X-Request-ID")}})
}
func fail(c *gin.Context, err error) {
switch {
case errors.Is(err, apperror.ErrInvalidInput):
failure(c, http.StatusUnprocessableEntity, "INVALID_INPUT", "请检查输入内容;资金最多两位小数,密码为 12–72 字节。", false)
case errors.Is(err, apperror.ErrUnauthenticated):
failure(c, http.StatusUnauthorized, "UNAUTHENTICATED", "登录信息无效或会话已过期。", false)
case errors.Is(err, apperror.ErrForbidden):
failure(c, http.StatusForbidden, "FORBIDDEN", "无权执行此操作。", false)
case errors.Is(err, apperror.ErrConflict):
failure(c, http.StatusConflict, "CONFLICT", "该登录名或账户类型已存在。", false)
case errors.Is(err, apperror.ErrNotFound):
failure(c, http.StatusNotFound, "NOT_FOUND", "记录不存在。", false)
default:
failure(c, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "服务暂不可用;请检查依赖连接和数据库迁移。", true)
}
}
@@ -0,0 +1,53 @@
package http
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
identity "veyra/services/api/internal/identity/service"
operations "veyra/services/api/internal/operations/service"
)
func TestAuthenticationAndCSRFBoundaries(t *testing.T) {
auth, err := identity.NewAuthService(nil)
if err != nil {
t.Fatal(err)
}
probe := func(context.Context) error { return nil }
router := NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{Auth: auth, DevelopmentLogin: true})
for _, tc := range []struct {
method, path, body, origin string
status int
}{
{http.MethodGet, "/api/admin/v1/users", "", "", 401},
{http.MethodGet, "/api/v1/accounts", "", "", 401},
{http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password"}`, "http://evil.example", 403},
{http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password"}`, "", 403},
{http.MethodPost, "/api/admin/v1/auth/login", `{"login_name":"admin","password":"test-password","role":"ADMIN"}`, "http://example.com", 422},
{http.MethodPost, "/api/v1/auth/login", `{} {}`, "", 422},
} {
req := httptest.NewRequest(tc.method, "http://example.com"+tc.path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", tc.origin)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("%s %s got %d want %d", tc.method, tc.path, rec.Code, tc.status)
}
}
}
func TestDevelopmentLoginDisabledByDefault(t *testing.T) {
probe := func(context.Context) error { return nil }
router := NewRouter(operations.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{}`)))
if rec.Code != http.StatusServiceUnavailable {
t.Fatal("development login open outside explicitly enabled environment")
}
}
@@ -0,0 +1,59 @@
// Package http adapts application services to the HTTP protocol.
package http
import (
"context"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"veyra/services/api/internal/operations/service"
"veyra/services/api/internal/platform/adminui"
)
func NewRouter(health *service.HealthService, logger *slog.Logger, identity IdentityOptions) http.Handler {
gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
c.Request = c.Request.WithContext(ctx)
requestID := uuid.NewString()
c.Header("X-Request-ID", requestID)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Cache-Control", "no-store")
start := time.Now()
defer func() {
if recover() != nil {
logger.Error("request panic", "request_id", requestID)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "INTERNAL_ERROR", "message": "Internal server error", "request_id": requestID}})
}
logger.Info("http request", "method", c.Request.Method, "route", c.FullPath(), "status", c.Writer.Status(), "duration_ms", time.Since(start).Milliseconds(), "request_id", requestID)
}()
c.Next()
})
r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "alive"}) })
check := func(c *gin.Context) {
h := health.Check(c.Request.Context())
status := http.StatusOK
if h.Status != "ready" {
status = http.StatusServiceUnavailable
}
c.JSON(status, h)
}
r.GET("/readyz", check)
r.GET("/api/v1/system/health", check)
bindIdentity(r, identity)
console := adminui.Handler()
r.NoRoute(func(c *gin.Context) {
if !strings.HasPrefix(c.Request.URL.Path, "/api/") && c.Request.URL.Path != "/api" {
console.ServeHTTP(c.Writer, c.Request)
return
}
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "NOT_FOUND", "message": "Endpoint not found"}})
})
return r
}
@@ -0,0 +1,68 @@
package http
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"veyra/services/api/internal/operations/service"
)
func TestLivenessAndReadinessAreIndependent(t *testing.T) {
secretError := "password=secret-internal-detail"
health := service.NewHealthService(nil, func(context.Context) error { return errors.New(secretError) })
router := NewRouter(health, slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
for _, tc := range []struct {
path string
status int
}{{"/healthz", http.StatusOK}, {"/readyz", http.StatusServiceUnavailable}, {"/api/v1/system/health", http.StatusServiceUnavailable}} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
if rec.Code != tc.status {
t.Fatalf("%s: got %d", tc.path, rec.Code)
}
if strings.Contains(rec.Body.String(), secretError) {
t.Fatal("health endpoint leaked internal error")
}
if rec.Header().Get("X-Request-ID") == "" {
t.Fatal("missing trace ID")
}
}
}
func TestReadyWhenBothDependenciesRespond(t *testing.T) {
probe := func(context.Context) error { return nil }
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d", rec.Code)
}
}
func TestEmbeddedConsoleAndAPIBoundary(t *testing.T) {
probe := func(context.Context) error { return nil }
router := NewRouter(service.NewHealthService(probe, probe), slog.New(slog.NewTextHandler(io.Discard, nil)), IdentityOptions{})
for _, path := range []string{"/", "/research/candidates"} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code == http.StatusServiceUnavailable {
t.Skip("build management console before testing embedded assets")
}
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("embedded SPA %s: status=%d", path, rec.Code)
}
}
for _, path := range []string{"/api/v1/missing", "/assets/missing.js", "/.keep"} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("missing route %s must not serve the SPA: %d", path, rec.Code)
}
}
}