feat(tsk_GXDoc3Cs07Rn): apierr + idgen + CONVENTIONS.md
apierr: - Add New() constructor, StatusFor() HTTP-status mapping - Add ErrUnauthorized, ErrForbidden, ErrNotFound, ErrConflict predefined errors - Add chi-compatible Middleware for panic(*Error) → JSON recovery - Add apierr_test.go (8 tests; covers New, StatusFor, WriteJSON, Middleware) idgen: - Implement idgen.go: New()/NewString() (UUID v7 via google/uuid v1.6.0) - Implement GenerateCode/CanonicalizeCode/HashCode (Crockford Base32 moved from codes) - Add idgen_test.go (12 tests; UUID v7 ordering/uniqueness + Crockford format/normalization/check) codes: - Refactor generator.go to delegate GenerateCode/Canonicalize/Hash to idgen - All existing codes generator tests continue to pass unchanged server: - Add CONVENTIONS.md covering package structure, error handling, ID generation, database conventions, handler templates, auth context, testing, and logging rules - Move google/uuid from indirect to direct dependency in go.mod Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
# Pangolin 后端编码规范 (CONVENTIONS)
|
||||
|
||||
> 本文件是给 Claude Code 和所有后端开发者的**强制性**编码规约。
|
||||
> 与 `design/server/ARCHITECTURE.md` 配合使用:架构文档说「做什么」,本文说「怎么做」。
|
||||
|
||||
---
|
||||
|
||||
## 0. 铁律(违反即阻断 PR)
|
||||
|
||||
1. **API 错误文案双语**:所有面向客户端的错误必须使用 `apierr.Error`(包含 `message_zh` + `message_en`)。
|
||||
2. **脱敏**:任何错误文案、日志、注释中禁止出现「VPN」「翻墙」「科学上网」。
|
||||
3. **明文激活码禁止入库**:只能存储 `idgen.HashCode(canonical)`;明文只能存在于生成响应和 CSV 导出的内存流中。
|
||||
4. **无日志口径**:不记录用户的目的地址、DNS 查询、流量内容;只记录 `usage_daily` 的字节数和分钟数。
|
||||
|
||||
---
|
||||
|
||||
## 1. 包结构
|
||||
|
||||
```
|
||||
server/
|
||||
├── cmd/ 应用入口(main 包),每个二进制一个子目录
|
||||
│ ├── api/ 控制面 HTTP 服务
|
||||
│ └── agent/ 节点 agent(gRPC 服务)
|
||||
├── internal/ 私有实现,不对外暴露
|
||||
│ ├── apierr/ API 错误类型与辅助函数(零依赖)
|
||||
│ ├── idgen/ ID 生成(UUID v7 + Crockford Base32)
|
||||
│ ├── auth/ 认证(邮箱验证码 / argon2id / JWT RS256)
|
||||
│ ├── codes/ 激活码生命周期(生成 / webhook / 兑换 / 审计)
|
||||
│ ├── devices/ 设备管理
|
||||
│ ├── nodes/ 节点目录 + connect/disconnect
|
||||
│ ├── usage/ 用量聚合 + 广告解锁
|
||||
│ ├── admin/ 管理端(内部,独立端口)
|
||||
│ ├── config/ 配置加载
|
||||
│ ├── db/ 数据库连接 + migration
|
||||
│ ├── redisutil/ Redis 连接
|
||||
│ ├── store/ 跨模块的共享 DB 查询
|
||||
│ └── mtls/ 节点 mTLS 证书管理
|
||||
├── api/ OpenAPI 契约(openapi.yaml)
|
||||
├── migrations/ SQL migration 文件(golang-migrate 格式)
|
||||
└── CONVENTIONS.md 本文件
|
||||
```
|
||||
|
||||
**包命名**:小写单词,无缩写(`redisutil` 可接受);禁止包名与 Go 标准库重名。
|
||||
|
||||
**导入顺序**(`goimports` / `golangci-lint` 强制):
|
||||
1. 标准库
|
||||
2. 第三方包(`github.com/xxx/yyy`,非本项目)
|
||||
3. 本项目内部包(`github.com/wangjia/pangolin/server/internal/…`)
|
||||
|
||||
各组之间留一个空行。
|
||||
|
||||
---
|
||||
|
||||
## 2. 错误处理(`internal/apierr`)
|
||||
|
||||
### 2.1 规则
|
||||
|
||||
- **所有 HTTP handler 的错误响应**必须经过 `apierr.WriteJSON`。
|
||||
- **绝不**直接 `http.Error(w, "...", status)` 或 `json.Encode(map[string]string{...})`。
|
||||
- 若使用 `apierr.Middleware`,handler 可以 `panic(apierr.ErrXxx)` 代替 `WriteJSON + return`;但不可混用两种风格。
|
||||
|
||||
### 2.2 使用模式
|
||||
|
||||
```go
|
||||
// ❌ 错误示例
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
|
||||
// ✅ 正确:使用预定义错误
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
|
||||
// ✅ 正确:使用 New 创建临时错误
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, apierr.New(
|
||||
"DEVICE_LIMIT_EXCEEDED",
|
||||
"设备数量已达套餐上限",
|
||||
"Device limit reached for your plan",
|
||||
))
|
||||
return
|
||||
```
|
||||
|
||||
### 2.3 HTTP 状态码映射
|
||||
|
||||
| 场景 | 错误 Code | HTTP 状态 |
|
||||
|------|-----------|-----------|
|
||||
| 请求体格式错误 / 缺少字段 | `BAD_REQUEST` | 400 |
|
||||
| JWT 缺失或过期 | `UNAUTHORIZED` | 401 |
|
||||
| 操作不允许(权限不足) | `FORBIDDEN` | 403 |
|
||||
| 资源不存在 | `NOT_FOUND` | 404 |
|
||||
| 状态冲突(幂等操作重复但语义不同) | `CONFLICT` | 409 |
|
||||
| 限流 / 账户锁定 | `RATE_LIMITED` / `ACCOUNT_LOCKED` | 429 |
|
||||
| 未预期的内部错误 | `INTERNAL_ERROR` | 500 |
|
||||
|
||||
`apierr.StatusFor(e)` 实现了上述映射;显式指定 status 时应与映射一致。
|
||||
|
||||
### 2.4 错误日志
|
||||
|
||||
- 业务错误(4xx):**不记录日志**(用户侧错误,无需报警)。
|
||||
- 内部错误(5xx):使用 `slog.Error("...", "error", err)` 记录,不在响应体中暴露原始 error。
|
||||
|
||||
---
|
||||
|
||||
## 3. ID 生成(`internal/idgen`)
|
||||
|
||||
### 3.1 实体 ID(UUID v7)
|
||||
|
||||
所有数据库实体(users、devices、subscriptions、nodes……)使用 UUID v7:
|
||||
|
||||
```go
|
||||
import "github.com/wangjia/pangolin/server/internal/idgen"
|
||||
|
||||
// 新建记录时
|
||||
id := idgen.NewString() // "018efa4b-xxxx-7xxx-xxxx-xxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
UUID v7 优点:时间有序(B-tree 索引友好)+ 全局唯一 + 不暴露自增 ID。
|
||||
|
||||
**禁止**使用数据库自增整型 `AUTO_INCREMENT` 作为主键(整型 ID 易被枚举,且不适合分布式)。
|
||||
|
||||
### 3.2 激活码(Crockford Base32)
|
||||
|
||||
激活码通过 `idgen.GenerateCode()` 生成,格式为 16 字符 Crockford Base32(含 mod-37 校验位)。
|
||||
**只存储 hash,不存明文**:
|
||||
|
||||
```go
|
||||
code, err := idgen.GenerateCode() // "0123456789ABCDE3" (明文,一次性)
|
||||
hash := idgen.HashCode(code) // sha256 hex,存入 DB
|
||||
canonical, err := idgen.CanonicalizeCode(userInput) // 输入规范化 + 校验
|
||||
```
|
||||
|
||||
### 3.3 Redis Key 命名
|
||||
|
||||
```
|
||||
<module>:<entity>:<id> → e.g. redeem:fail:018efa4b-... (失败计数)
|
||||
<module>:<noun> → e.g. node:version (全局版本号)
|
||||
webhook:nonce:<nonce> → webhook 去重
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据库操作
|
||||
|
||||
### 4.1 原则
|
||||
|
||||
- 每个模块拥有自己的 `store.go`(只操作该模块的表)。
|
||||
- 跨模块读取(只读)允许在自己的 `store.go` 中实现 JOIN;跨模块写入必须通过各模块的 Service。
|
||||
- 事务(`sql.Tx`)在 Service 层开启和提交,Store 方法接受 `*sql.Tx` 参数。
|
||||
|
||||
### 4.2 命名
|
||||
|
||||
| 类型 | 命名 |
|
||||
|------|------|
|
||||
| 查询单行 | `Find<Entity>By<Key>` |
|
||||
| 查询多行 | `List<Entities>By<Filter>` |
|
||||
| 插入 | `Insert<Entity>` |
|
||||
| 更新 | `Update<Entity>` |
|
||||
| 删除 | `Delete<Entity>` |
|
||||
| 带锁查询 | `Find<Entity>By<Key>ForUpdate` |
|
||||
|
||||
### 4.3 Migration 文件
|
||||
|
||||
文件名格式:`NNN_<snake_case_description>.up.sql` / `.down.sql`(golang-migrate 规范)。
|
||||
每个 migration 必须可回滚(down.sql 不得为空)。
|
||||
|
||||
---
|
||||
|
||||
## 5. HTTP Handler 结构
|
||||
|
||||
每个模块的 handler 遵循以下模板:
|
||||
|
||||
```go
|
||||
// Handler 结构体持有 Service 引用(不直接持有 Store)。
|
||||
type RedeemHandler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewRedeemHandler(svc *Service) *RedeemHandler { ... }
|
||||
|
||||
// ServeHTTP 方法:
|
||||
// 1. 路由方法校验
|
||||
// 2. 从 Context 取认证信息
|
||||
// 3. 解析请求体
|
||||
// 4. 调用 Service
|
||||
// 5. 写响应(成功 2xx JSON / 失败 apierr.WriteJSON)
|
||||
func (h *RedeemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ... }
|
||||
```
|
||||
|
||||
**禁止** Handler 直接操作数据库(不注入 Store / DB)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 认证上下文
|
||||
|
||||
JWT 中间件(`internal/auth`)将 claims 注入 `context.Context`。
|
||||
各 handler 通过统一的 context key 取值:
|
||||
|
||||
```go
|
||||
// 在 handler 中取认证用户 ID:
|
||||
userID, ok := r.Context().Value(codes.CtxKeyUserID).(int64)
|
||||
if !ok || userID == 0 {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试规范
|
||||
|
||||
### 7.1 分层
|
||||
|
||||
| 类型 | 文件命名 | 依赖 |
|
||||
|------|---------|------|
|
||||
| 单元测试 | `*_test.go`(`package xxx_test`)| 纯内存,无外部服务 |
|
||||
| 集成测试 | `*_integration_test.go` | testcontainers(MySQL + Redis)|
|
||||
|
||||
### 7.2 要求
|
||||
|
||||
- **每个导出函数/方法至少有一个单元测试**(happy path + 至少一个 error path)。
|
||||
- 覆盖率目标:核心包(`apierr`、`idgen`、`codes`)≥ 90%。
|
||||
- 集成测试使用 testcontainers,**不依赖外部服务**,可在 CI 无配置跑通。
|
||||
- 禁止在测试中硬编码随机种子;使用 `crypto/rand` 的函数天然随机,不需要固定 seed。
|
||||
|
||||
### 7.3 测试辅助函数命名
|
||||
|
||||
```go
|
||||
// 必须:t.Helper()
|
||||
// 命名:must<Action>(always fatal on error)
|
||||
func mustGenerateCode(t *testing.T) string {
|
||||
t.Helper()
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
return code
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 日志规范
|
||||
|
||||
使用 `log/slog`(Go 1.21+):
|
||||
|
||||
```go
|
||||
slog.Info("code redeemed", "user_id", userID, "duration_days", result.DurationDays)
|
||||
slog.Error("internal error", "error", err, "endpoint", r.URL.Path)
|
||||
```
|
||||
|
||||
**绝对禁止**在日志中输出:
|
||||
- 明文激活码
|
||||
- 用户密码 / 密钥
|
||||
- 目的地址 / DNS 查询内容
|
||||
- 完整 JWT token
|
||||
|
||||
---
|
||||
|
||||
## 9. CI 检查
|
||||
|
||||
`Makefile` 的 `make lint` 和 `make test` 必须全部通过才能合并。
|
||||
|
||||
```
|
||||
make lint → golangci-lint (errcheck, staticcheck, gosec, goimports)
|
||||
make test → go test ./...(包含单元测试;集成测试需 -tags integration)
|
||||
```
|
||||
|
||||
OpenAPI 同步:`make openapi-check` 验证 `api/openapi.yaml` 与 handler 签名一致(oapi-codegen)。
|
||||
+1
-1
@@ -109,7 +109,7 @@ require (
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// Package apierr defines the bilingual error response type used across all API modules.
|
||||
// Error messages follow the desensitisation rules: no "VPN" / "翻墙" / "科学上网" wording.
|
||||
// Package apierr defines the canonical error response shape used across all
|
||||
// v1 API handlers: {code, message_zh, message_en}. It provides constructor
|
||||
// helpers for common HTTP error categories (400/401/403/404/409/429/500)
|
||||
// and a middleware that serialises *Error values to JSON automatically.
|
||||
//
|
||||
// Error messages follow the desensitisation rules: no "VPN" / "翻墙" /
|
||||
// "科学上网" wording is permitted in any user-facing message.
|
||||
package apierr
|
||||
|
||||
import (
|
||||
@@ -8,15 +13,66 @@ import (
|
||||
)
|
||||
|
||||
// Error is the canonical API error body: {code, message_zh, message_en}.
|
||||
// All v1 handlers must return errors in this shape — never raw strings.
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
MessageZH string `json:"message_zh"`
|
||||
MessageEn string `json:"message_en"`
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *Error) Error() string { return e.Code + ": " + e.MessageEn }
|
||||
|
||||
// Standard code-module errors.
|
||||
// New creates a new *Error with an application error code and bilingual messages.
|
||||
// Use this when none of the predefined errors fit the situation.
|
||||
func New(code, messageZH, messageEn string) *Error {
|
||||
return &Error{Code: code, MessageZH: messageZH, MessageEn: messageEn}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Predefined errors — common HTTP error categories
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// General HTTP-category errors (400 / 401 / 403 / 404 / 409 / 429 / 500).
|
||||
var (
|
||||
ErrBadRequest = &Error{
|
||||
Code: "BAD_REQUEST",
|
||||
MessageZH: "请求参数有误",
|
||||
MessageEn: "Invalid request parameters",
|
||||
}
|
||||
ErrUnauthorized = &Error{
|
||||
Code: "UNAUTHORIZED",
|
||||
MessageZH: "请先登录",
|
||||
MessageEn: "Authentication required",
|
||||
}
|
||||
ErrForbidden = &Error{
|
||||
Code: "FORBIDDEN",
|
||||
MessageZH: "权限不足",
|
||||
MessageEn: "Permission denied",
|
||||
}
|
||||
ErrNotFound = &Error{
|
||||
Code: "NOT_FOUND",
|
||||
MessageZH: "资源不存在",
|
||||
MessageEn: "Resource not found",
|
||||
}
|
||||
ErrConflict = &Error{
|
||||
Code: "CONFLICT",
|
||||
MessageZH: "资源状态冲突",
|
||||
MessageEn: "Resource state conflict",
|
||||
}
|
||||
ErrRateLimited = &Error{
|
||||
Code: "RATE_LIMITED",
|
||||
MessageZH: "操作过于频繁,请稍后再试",
|
||||
MessageEn: "Too many attempts, please try again later",
|
||||
}
|
||||
ErrInternal = &Error{
|
||||
Code: "INTERNAL_ERROR",
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
)
|
||||
|
||||
// Activation-code errors.
|
||||
var (
|
||||
ErrInvalidCode = &Error{
|
||||
Code: "INVALID_CODE",
|
||||
@@ -38,28 +94,15 @@ var (
|
||||
MessageZH: "该激活码已失效",
|
||||
MessageEn: "This code is no longer valid",
|
||||
}
|
||||
ErrRateLimited = &Error{
|
||||
Code: "RATE_LIMITED",
|
||||
MessageZH: "操作过于频繁,请稍后再试",
|
||||
MessageEn: "Too many attempts, please try again later",
|
||||
}
|
||||
ErrLocked = &Error{
|
||||
Code: "ACCOUNT_LOCKED",
|
||||
MessageZH: "账户已临时锁定,请1小时后重试",
|
||||
MessageEn: "Account temporarily locked, please retry in 1 hour",
|
||||
}
|
||||
ErrInternal = &Error{
|
||||
Code: "INTERNAL_ERROR",
|
||||
MessageZH: "服务器内部错误,请稍后重试",
|
||||
MessageEn: "Internal server error, please try again later",
|
||||
}
|
||||
ErrBadRequest = &Error{
|
||||
Code: "BAD_REQUEST",
|
||||
MessageZH: "请求参数有误",
|
||||
MessageEn: "Invalid request parameters",
|
||||
}
|
||||
)
|
||||
|
||||
// Webhook-specific errors.
|
||||
// Webhook-specific errors.
|
||||
var (
|
||||
ErrWebhookSignature = &Error{
|
||||
Code: "WEBHOOK_INVALID_SIGNATURE",
|
||||
MessageZH: "签名校验失败",
|
||||
@@ -77,9 +120,71 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// WriteJSON writes the given status code and error body as JSON.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// StatusFor returns a suitable HTTP status code for the given *Error, inferred
|
||||
// from the error Code string. It covers the standard mapping used across all
|
||||
// v1 handlers; callers may override with explicit WriteJSON calls when needed.
|
||||
func StatusFor(e *Error) int {
|
||||
switch e.Code {
|
||||
case "UNAUTHORIZED":
|
||||
return http.StatusUnauthorized
|
||||
case "FORBIDDEN":
|
||||
return http.StatusForbidden
|
||||
case "NOT_FOUND":
|
||||
return http.StatusNotFound
|
||||
case "CONFLICT":
|
||||
return http.StatusConflict
|
||||
case "RATE_LIMITED", "ACCOUNT_LOCKED":
|
||||
return http.StatusTooManyRequests
|
||||
case "INTERNAL_ERROR":
|
||||
return http.StatusInternalServerError
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
// WriteJSON writes the given HTTP status code and error body as JSON.
|
||||
func WriteJSON(w http.ResponseWriter, status int, e *Error) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(e)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Chi-compatible middleware
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Middleware is a chi-compatible middleware that recovers from panics of type
|
||||
// *Error and writes the appropriate JSON response via StatusFor + WriteJSON.
|
||||
// Any panic with a non-*Error value is re-raised so other recovery middleware
|
||||
// (e.g. chi's built-in Recoverer) can handle it.
|
||||
//
|
||||
// Usage in handlers — instead of:
|
||||
//
|
||||
// apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||
// return
|
||||
//
|
||||
// A handler may simply:
|
||||
//
|
||||
// panic(apierr.ErrBadRequest)
|
||||
//
|
||||
// This keeps handler code linear and avoids partial-write bugs when the caller
|
||||
// forgets to return after WriteJSON.
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rv := recover(); rv != nil {
|
||||
if e, ok := rv.(*Error); ok {
|
||||
WriteJSON(w, StatusFor(e), e)
|
||||
return
|
||||
}
|
||||
// Unknown panic type — re-raise for upstream recovery middleware.
|
||||
panic(rv)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package apierr_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
)
|
||||
|
||||
// TestErrorInterface verifies that *Error implements the error interface.
|
||||
func TestErrorInterface(t *testing.T) {
|
||||
var err error = apierr.ErrBadRequest
|
||||
if err == nil {
|
||||
t.Fatal("ErrBadRequest should not be nil")
|
||||
}
|
||||
if err.Error() == "" {
|
||||
t.Error("Error() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNew verifies that New constructs an *Error with the given fields.
|
||||
func TestNew(t *testing.T) {
|
||||
e := apierr.New("TEST_CODE", "测试消息", "test message")
|
||||
if e.Code != "TEST_CODE" {
|
||||
t.Errorf("Code = %q, want %q", e.Code, "TEST_CODE")
|
||||
}
|
||||
if e.MessageZH != "测试消息" {
|
||||
t.Errorf("MessageZH = %q, want %q", e.MessageZH, "测试消息")
|
||||
}
|
||||
if e.MessageEn != "test message" {
|
||||
t.Errorf("MessageEn = %q, want %q", e.MessageEn, "test message")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatusFor verifies HTTP status code mapping.
|
||||
func TestStatusFor(t *testing.T) {
|
||||
cases := []struct {
|
||||
code string
|
||||
wantStatus int
|
||||
}{
|
||||
{"UNAUTHORIZED", http.StatusUnauthorized},
|
||||
{"FORBIDDEN", http.StatusForbidden},
|
||||
{"NOT_FOUND", http.StatusNotFound},
|
||||
{"CONFLICT", http.StatusConflict},
|
||||
{"RATE_LIMITED", http.StatusTooManyRequests},
|
||||
{"ACCOUNT_LOCKED", http.StatusTooManyRequests},
|
||||
{"INTERNAL_ERROR", http.StatusInternalServerError},
|
||||
{"BAD_REQUEST", http.StatusBadRequest},
|
||||
{"INVALID_CODE", http.StatusBadRequest},
|
||||
{"CODE_NOT_FOUND", http.StatusBadRequest},
|
||||
{"UNKNOWN_CODE", http.StatusBadRequest},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
e := &apierr.Error{Code: tc.code}
|
||||
got := apierr.StatusFor(e)
|
||||
if got != tc.wantStatus {
|
||||
t.Errorf("StatusFor({Code:%q}) = %d, want %d", tc.code, got, tc.wantStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteJSON verifies that WriteJSON sets the correct Content-Type,
|
||||
// status code, and JSON body.
|
||||
func TestWriteJSON(t *testing.T) {
|
||||
e := apierr.New("TEST", "中文", "english")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
apierr.WriteJSON(w, http.StatusBadRequest, e)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
|
||||
}
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct != "application/json; charset=utf-8" {
|
||||
t.Errorf("Content-Type = %q, want %q", ct, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
var got apierr.Error
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Code != e.Code {
|
||||
t.Errorf("body.code = %q, want %q", got.Code, e.Code)
|
||||
}
|
||||
if got.MessageZH != e.MessageZH {
|
||||
t.Errorf("body.message_zh = %q, want %q", got.MessageZH, e.MessageZH)
|
||||
}
|
||||
if got.MessageEn != e.MessageEn {
|
||||
t.Errorf("body.message_en = %q, want %q", got.MessageEn, e.MessageEn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareCatchesAPIError verifies that the middleware intercepts
|
||||
// a panic(*Error) and writes the correct JSON response.
|
||||
func TestMiddlewareCatchesAPIError(t *testing.T) {
|
||||
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic(apierr.ErrUnauthorized)
|
||||
})
|
||||
|
||||
handler := apierr.Middleware(panicHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
resp := w.Result()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
var got apierr.Error
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Code != apierr.ErrUnauthorized.Code {
|
||||
t.Errorf("body.code = %q, want %q", got.Code, apierr.ErrUnauthorized.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMiddlewareReRaisesNonAPIError verifies that the middleware re-raises
|
||||
// panics that are not of type *Error.
|
||||
func TestMiddlewareReRaisesNonAPIError(t *testing.T) {
|
||||
panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("unexpected string panic")
|
||||
})
|
||||
|
||||
handler := apierr.Middleware(panicHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
defer func() {
|
||||
if rv := recover(); rv == nil {
|
||||
t.Error("expected panic to be re-raised, but it was not")
|
||||
}
|
||||
}()
|
||||
handler.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
// TestMiddlewarePassesthrough verifies that the middleware is a no-op when
|
||||
// the handler does not panic.
|
||||
func TestMiddlewarePassesthrough(t *testing.T) {
|
||||
okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
|
||||
handler := apierr.Middleware(okHandler)
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPredefinedErrors checks that all predefined errors have non-empty fields
|
||||
// and comply with the desensitisation rule (no forbidden words in messages).
|
||||
func TestPredefinedErrors(t *testing.T) {
|
||||
forbidden := []string{"VPN", "翻墙", "科学上网"}
|
||||
errors := []*apierr.Error{
|
||||
apierr.ErrBadRequest,
|
||||
apierr.ErrUnauthorized,
|
||||
apierr.ErrForbidden,
|
||||
apierr.ErrNotFound,
|
||||
apierr.ErrConflict,
|
||||
apierr.ErrRateLimited,
|
||||
apierr.ErrInternal,
|
||||
apierr.ErrInvalidCode,
|
||||
apierr.ErrCodeNotFound,
|
||||
apierr.ErrCodeRedeemed,
|
||||
apierr.ErrCodeVoid,
|
||||
apierr.ErrLocked,
|
||||
apierr.ErrWebhookSignature,
|
||||
apierr.ErrWebhookTimestamp,
|
||||
apierr.ErrWebhookReplay,
|
||||
}
|
||||
|
||||
for _, e := range errors {
|
||||
if e.Code == "" {
|
||||
t.Errorf("error %+v has empty Code", e)
|
||||
}
|
||||
if e.MessageZH == "" {
|
||||
t.Errorf("error %q has empty MessageZH", e.Code)
|
||||
}
|
||||
if e.MessageEn == "" {
|
||||
t.Errorf("error %q has empty MessageEn", e.Code)
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if contains(e.MessageZH, f) || contains(e.MessageEn, f) {
|
||||
t.Errorf("error %q contains forbidden word %q", e.Code, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
||||
}
|
||||
|
||||
func containsStr(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,5 +1 @@
|
||||
// Package apierr defines the canonical error response shape used across all
|
||||
// v1 API handlers: {code, message_zh, message_en}. It provides constructor
|
||||
// helpers for common HTTP error categories (400/401/403/404/409/429/500)
|
||||
// and a middleware that serialises *APIError values to JSON automatically.
|
||||
package apierr
|
||||
|
||||
@@ -8,169 +8,32 @@
|
||||
package codes
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/idgen"
|
||||
)
|
||||
|
||||
// Crockford Base32 encoding alphabet (32 symbols, excludes I L O U).
|
||||
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// crockfordCheck is the extended 37-symbol check-character alphabet.
|
||||
// Symbols 0–31 are identical to crockfordAlphabet; 32–36 are *, ~, $, =, U.
|
||||
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
|
||||
|
||||
// crockfordDecode maps every printable ASCII character to its Crockford
|
||||
// numeric value (0–31), or to -1 if the character is not valid.
|
||||
var crockfordDecode [128]int8
|
||||
|
||||
func init() {
|
||||
for i := range crockfordDecode {
|
||||
crockfordDecode[i] = -1
|
||||
}
|
||||
for i, ch := range crockfordAlphabet {
|
||||
crockfordDecode[ch] = int8(i)
|
||||
// Lower-case equivalents.
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
crockfordDecode[ch-'A'+'a'] = int8(i)
|
||||
}
|
||||
}
|
||||
// Normalisation rules per Crockford spec:
|
||||
// I, i, l, L → 1
|
||||
// O, o → 0
|
||||
crockfordDecode['I'] = crockfordDecode['1']
|
||||
crockfordDecode['i'] = crockfordDecode['1']
|
||||
crockfordDecode['l'] = crockfordDecode['1']
|
||||
crockfordDecode['L'] = crockfordDecode['1']
|
||||
crockfordDecode['O'] = crockfordDecode['0']
|
||||
crockfordDecode['o'] = crockfordDecode['0']
|
||||
// GenerateCode generates a single random activation code in canonical Crockford
|
||||
// Base32 form (15 random data chars + 1 check char = 16 chars total).
|
||||
// It delegates to idgen.GenerateCode which uses crypto/rand.
|
||||
func GenerateCode() (string, error) {
|
||||
return idgen.GenerateCode()
|
||||
}
|
||||
|
||||
// Canonicalize converts an activation-code string into canonical form:
|
||||
// uppercase, with I/L→1 and O→0 substitutions applied.
|
||||
// Hyphens and spaces are stripped before validation.
|
||||
// Returns an error if the string contains characters outside the normalised
|
||||
// Crockford alphabet or if the length is not exactly 16.
|
||||
// Crockford alphabet, if the length is not exactly 16, or if the check character
|
||||
// is incorrect.
|
||||
func Canonicalize(code string) (string, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
// Strip any hyphens/spaces inserted for readability (e.g., XXXX-XXXX-XXXX-XXXX).
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
if len(code) != 16 {
|
||||
return "", fmt.Errorf("codes: code must be exactly 16 characters, got %d", len(code))
|
||||
}
|
||||
|
||||
var buf [16]byte
|
||||
for i := 0; i < 16; i++ {
|
||||
ch := code[i]
|
||||
if ch >= 128 {
|
||||
return "", fmt.Errorf("codes: non-ASCII character at position %d", i)
|
||||
}
|
||||
v := crockfordDecode[ch]
|
||||
if v < 0 {
|
||||
// For the check symbol position (last char) we allow the full 37-symbol set.
|
||||
// Check separately below after we have the string.
|
||||
if i < 15 {
|
||||
return "", fmt.Errorf("codes: invalid character %q at position %d", ch, i)
|
||||
}
|
||||
// Position 15 is the check character; validate separately.
|
||||
buf[i] = byte(strings.ToUpper(string(ch))[0])
|
||||
continue
|
||||
}
|
||||
buf[i] = crockfordAlphabet[v]
|
||||
}
|
||||
|
||||
canonical := string(buf[:])
|
||||
|
||||
// Validate the check character.
|
||||
if err := validateCheckChar(canonical); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
return idgen.CanonicalizeCode(code)
|
||||
}
|
||||
|
||||
// Hash returns the hex-encoded SHA-256 of the canonical plaintext code.
|
||||
// This is the value stored in the database; the plaintext is never stored.
|
||||
func Hash(canonical string) string {
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// computeCheckValue uses Horner's method to compute the Crockford check value
|
||||
// (mod 37) of the 15 data characters in s[0:15].
|
||||
// s must already be in canonical (uppercase) form.
|
||||
func computeCheckValue(s string) int {
|
||||
result := 0
|
||||
for i := 0; i < 15; i++ {
|
||||
ch := s[i]
|
||||
if ch >= 128 {
|
||||
return -1
|
||||
}
|
||||
v := int(crockfordDecode[ch])
|
||||
if v < 0 {
|
||||
return -1
|
||||
}
|
||||
result = (result*32 + v) % 37
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateCheckChar verifies that the 16th character of canonical is the
|
||||
// correct Crockford mod-37 check symbol.
|
||||
func validateCheckChar(canonical string) error {
|
||||
if len(canonical) != 16 {
|
||||
return errors.New("codes: invalid length for check validation")
|
||||
}
|
||||
expected := computeCheckValue(canonical)
|
||||
if expected < 0 {
|
||||
return errors.New("codes: invalid data characters")
|
||||
}
|
||||
want := rune(crockfordCheck[expected])
|
||||
got := rune(canonical[15])
|
||||
// The check character might be in the extended set (*~$=U) so compare directly.
|
||||
if got != want {
|
||||
return fmt.Errorf("codes: check character mismatch: want %c, got %c", want, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateCode generates a single random activation code in canonical Crockford
|
||||
// Base32 form (15 random data chars + 1 check char = 16 chars total).
|
||||
// Uses crypto/rand for cryptographically-secure randomness.
|
||||
func GenerateCode() (string, error) {
|
||||
// We need 15 random values each in [0, 32).
|
||||
// To avoid modular bias, use rejection sampling with bytes from crypto/rand.
|
||||
// Each byte gives a value in [0, 256); we accept values in [0, 224) to ensure
|
||||
// uniform distribution over [0, 32) (224 = 7*32).
|
||||
const dataLen = 15
|
||||
var buf [dataLen]byte
|
||||
i := 0
|
||||
for i < dataLen {
|
||||
var tmp [dataLen * 2]byte // over-read to reduce syscall count
|
||||
if _, err := rand.Read(tmp[:]); err != nil {
|
||||
return "", fmt.Errorf("codes: crypto/rand: %w", err)
|
||||
}
|
||||
for _, b := range tmp {
|
||||
if b < 224 { // 224 = 7*32; accept to avoid bias
|
||||
buf[i] = crockfordAlphabet[b%32]
|
||||
i++
|
||||
if i == dataLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data := string(buf[:])
|
||||
checkVal := computeCheckValue(data + "0") // dummy 16th char, only first 15 used
|
||||
if checkVal < 0 {
|
||||
// Should never happen since we only use valid chars.
|
||||
return "", errors.New("codes: internal check computation error")
|
||||
}
|
||||
return data + string(crockfordCheck[checkVal]), nil
|
||||
return idgen.HashCode(canonical)
|
||||
}
|
||||
|
||||
// ErrDuplicate is returned by the batch generator when a generated code
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
// Package idgen generates application-level identifiers.
|
||||
// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs).
|
||||
// For activation codes it produces 16-character Crockford Base32 strings
|
||||
// with a check digit, suitable for display and human entry.
|
||||
package idgen
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Package idgen generates application-level identifiers.
|
||||
// For most entities it wraps github.com/google/uuid (v7 time-ordered UUIDs).
|
||||
// For activation codes it produces 16-character Crockford Base32 strings
|
||||
// with a check digit, suitable for display and human entry.
|
||||
package idgen
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UUID v7 — time-ordered identifiers for database entities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// New returns a new UUID v7 (time-ordered, random node bits).
|
||||
// UUID v7 is the recommended identifier format for database entities; its
|
||||
// time-ordered structure improves B-tree index locality compared with UUID v4.
|
||||
// Panics only if the system's crypto/rand source is unavailable (a fatal
|
||||
// misconfiguration; panic is appropriate).
|
||||
func New() uuid.UUID {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
panic("idgen: crypto/rand unavailable: " + err.Error())
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// NewString returns a new UUID v7 as a canonical lower-case string
|
||||
// (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
|
||||
func NewString() string {
|
||||
return New().String()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Crockford Base32 — human-friendly activation codes
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// crockfordAlphabet is the 32-symbol encoding alphabet (excludes I, L, O, U
|
||||
// to prevent visual confusion with 1, 1, 0, and V respectively).
|
||||
const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
// crockfordCheck is the extended 37-symbol check-character alphabet used for
|
||||
// the Crockford mod-37 check symbol. Symbols 0–31 match crockfordAlphabet;
|
||||
// symbols 32–36 are *, ~, $, =, U.
|
||||
const crockfordCheck = "0123456789ABCDEFGHJKMNPQRSTVWXYZ*~$=U"
|
||||
|
||||
// crockfordDecode maps every printable ASCII character to its Crockford
|
||||
// numeric value (0–31), or to -1 if the character is not valid.
|
||||
// Normalisation rules per the Crockford spec are applied at init time:
|
||||
//
|
||||
// I, i, l, L → 1
|
||||
// O, o → 0
|
||||
var crockfordDecode [128]int8
|
||||
|
||||
func init() {
|
||||
for i := range crockfordDecode {
|
||||
crockfordDecode[i] = -1
|
||||
}
|
||||
for i, ch := range crockfordAlphabet {
|
||||
crockfordDecode[ch] = int8(i)
|
||||
// Accept lower-case equivalents.
|
||||
if ch >= 'A' && ch <= 'Z' {
|
||||
crockfordDecode[ch-'A'+'a'] = int8(i)
|
||||
}
|
||||
}
|
||||
// Normalisation: I/i/l/L → 1; O/o → 0.
|
||||
crockfordDecode['I'] = crockfordDecode['1']
|
||||
crockfordDecode['i'] = crockfordDecode['1']
|
||||
crockfordDecode['l'] = crockfordDecode['1']
|
||||
crockfordDecode['L'] = crockfordDecode['1']
|
||||
crockfordDecode['O'] = crockfordDecode['0']
|
||||
crockfordDecode['o'] = crockfordDecode['0']
|
||||
}
|
||||
|
||||
// computeCheckValue computes the Crockford mod-37 check value (Horner's method)
|
||||
// of the first 15 characters of s (which must already be in canonical form).
|
||||
// Returns -1 if any character is invalid.
|
||||
func computeCheckValue(s string) int {
|
||||
result := 0
|
||||
for i := 0; i < 15; i++ {
|
||||
ch := s[i]
|
||||
if ch >= 128 {
|
||||
return -1
|
||||
}
|
||||
v := int(crockfordDecode[ch])
|
||||
if v < 0 {
|
||||
return -1
|
||||
}
|
||||
result = (result*32 + v) % 37
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateCheckChar verifies that canonical[15] is the correct Crockford
|
||||
// mod-37 check symbol for canonical[0:15].
|
||||
func validateCheckChar(canonical string) error {
|
||||
if len(canonical) != 16 {
|
||||
return errors.New("idgen: invalid length for check validation")
|
||||
}
|
||||
expected := computeCheckValue(canonical)
|
||||
if expected < 0 {
|
||||
return errors.New("idgen: invalid data characters in code")
|
||||
}
|
||||
want := rune(crockfordCheck[expected])
|
||||
got := rune(canonical[15])
|
||||
if got != want {
|
||||
return fmt.Errorf("idgen: check character mismatch: want %c, got %c", want, got)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CanonicalizeCode converts an activation-code string into its canonical form:
|
||||
// uppercase, with I/L→1 and O→0 substitutions applied, with the check
|
||||
// character validated. Hyphens and spaces are stripped before validation
|
||||
// (supports the common XXXX-XXXX-XXXX-XXXX display format).
|
||||
//
|
||||
// Returns an error if the string contains characters outside the normalised
|
||||
// Crockford alphabet, if the length (after stripping) is not exactly 16, or
|
||||
// if the check character is incorrect.
|
||||
func CanonicalizeCode(code string) (string, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
code = strings.ReplaceAll(code, "-", "")
|
||||
code = strings.ReplaceAll(code, " ", "")
|
||||
|
||||
if len(code) != 16 {
|
||||
return "", fmt.Errorf("idgen: code must be exactly 16 characters, got %d", len(code))
|
||||
}
|
||||
|
||||
var buf [16]byte
|
||||
for i := 0; i < 16; i++ {
|
||||
ch := code[i]
|
||||
if ch >= 128 {
|
||||
return "", fmt.Errorf("idgen: non-ASCII character at position %d", i)
|
||||
}
|
||||
v := crockfordDecode[ch]
|
||||
if v < 0 {
|
||||
if i < 15 {
|
||||
return "", fmt.Errorf("idgen: invalid character %q at position %d", ch, i)
|
||||
}
|
||||
// Position 15 is the check character; validate separately below.
|
||||
buf[i] = []byte(strings.ToUpper(string(ch)))[0]
|
||||
continue
|
||||
}
|
||||
buf[i] = crockfordAlphabet[v]
|
||||
}
|
||||
|
||||
canonical := string(buf[:])
|
||||
if err := validateCheckChar(canonical); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// HashCode returns the hex-encoded SHA-256 digest of the canonical plaintext
|
||||
// activation code. This digest is the value stored in the database; the
|
||||
// plaintext code itself must never be persisted or logged.
|
||||
func HashCode(canonical string) string {
|
||||
sum := sha256.Sum256([]byte(canonical))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// GenerateCode generates a single random activation code in canonical
|
||||
// Crockford Base32 form: 15 random data characters followed by 1 mod-37
|
||||
// check character (16 characters total). Uses crypto/rand for
|
||||
// cryptographically-secure randomness.
|
||||
//
|
||||
// To avoid modular bias, the function uses rejection sampling: random bytes
|
||||
// in [0, 224) are accepted (224 = 7×32 ensures a uniform distribution over
|
||||
// the 32-symbol alphabet), bytes ≥ 224 are discarded.
|
||||
func GenerateCode() (string, error) {
|
||||
const dataLen = 15
|
||||
var buf [dataLen]byte
|
||||
i := 0
|
||||
for i < dataLen {
|
||||
// Over-read to reduce crypto/rand syscall count.
|
||||
var tmp [dataLen * 2]byte
|
||||
if _, err := rand.Read(tmp[:]); err != nil {
|
||||
return "", fmt.Errorf("idgen: crypto/rand: %w", err)
|
||||
}
|
||||
for _, b := range tmp {
|
||||
if b < 224 { // accept range: 224 = 7×32
|
||||
buf[i] = crockfordAlphabet[b%32]
|
||||
i++
|
||||
if i == dataLen {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data := string(buf[:])
|
||||
// Compute check value over the 15 data chars (the dummy 16th char is ignored).
|
||||
checkVal := computeCheckValue(data + "0")
|
||||
if checkVal < 0 {
|
||||
// Cannot happen: all buf[i] values come from crockfordAlphabet.
|
||||
return "", errors.New("idgen: internal check computation error")
|
||||
}
|
||||
return data + string(crockfordCheck[checkVal]), nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package idgen_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wangjia/pangolin/server/internal/idgen"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UUID v7 tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestNewReturnsVersion7 verifies that New() returns a UUID with version 7.
|
||||
func TestNewReturnsVersion7(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
id := idgen.New()
|
||||
if id.Version() != uuid.Version(7) {
|
||||
t.Fatalf("New(): version = %d, want 7", id.Version())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewStringFormat verifies that NewString() returns a properly formatted UUID string.
|
||||
func TestNewStringFormat(t *testing.T) {
|
||||
s := idgen.NewString()
|
||||
// Standard UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars)
|
||||
if len(s) != 36 {
|
||||
t.Fatalf("NewString() length = %d, want 36", len(s))
|
||||
}
|
||||
parts := strings.Split(s, "-")
|
||||
if len(parts) != 5 {
|
||||
t.Fatalf("NewString() has %d hyphen-separated parts, want 5", len(parts))
|
||||
}
|
||||
expected := []int{8, 4, 4, 4, 12}
|
||||
for i, p := range parts {
|
||||
if len(p) != expected[i] {
|
||||
t.Errorf("part %d: length = %d, want %d", i, len(p), expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewUniqueness verifies that 10 000 generated UUIDs are all distinct.
|
||||
func TestNewUniqueness(t *testing.T) {
|
||||
const n = 10_000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
s := idgen.NewString()
|
||||
if _, dup := seen[s]; dup {
|
||||
t.Fatalf("duplicate UUID at iteration %d: %s", i, s)
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewTimeOrdered verifies that sequentially generated UUIDs are
|
||||
// monotonically non-decreasing in their string representation (UUID v7 is
|
||||
// time-ordered so lexicographic sort ≈ generation order).
|
||||
func TestNewTimeOrdered(t *testing.T) {
|
||||
prev := idgen.NewString()
|
||||
for i := 0; i < 1000; i++ {
|
||||
next := idgen.NewString()
|
||||
if next < prev {
|
||||
t.Fatalf("UUID ordering violation: %s > %s", prev, next)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Crockford Base32 activation-code tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code
|
||||
// composed entirely of the Crockford alphabet (15 data chars + 1 check char).
|
||||
func TestGenerateCodeFormat(t *testing.T) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode error: %v", err)
|
||||
}
|
||||
if len(code) != 16 {
|
||||
t.Errorf("code %q: length = %d, want 16", code, len(code))
|
||||
}
|
||||
// A freshly generated code must canonicalize to itself.
|
||||
canonical, err := idgen.CanonicalizeCode(code)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(%q): %v", code, err)
|
||||
}
|
||||
if canonical != code {
|
||||
t.Errorf("canonical form mismatch: got %q, want %q", canonical, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateCodeUniqueness checks that 10 000 generated codes have no
|
||||
// hash collisions (birthday probability ≈ 10^−8 for 75-bit codes).
|
||||
func TestGenerateCodeUniqueness(t *testing.T) {
|
||||
const n = 10_000
|
||||
seen := make(map[string]struct{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
h := idgen.HashCode(code)
|
||||
if _, dup := seen[h]; dup {
|
||||
t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h)
|
||||
}
|
||||
seen[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeCodeNormalization verifies I/L→1 and O→0 substitutions.
|
||||
func TestCanonicalizeCodeNormalization(t *testing.T) {
|
||||
base, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
|
||||
// Lower-case input must produce the upper-case canonical form.
|
||||
lower := strings.ToLower(base)
|
||||
canonical, err := idgen.CanonicalizeCode(lower)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(lower) error: %v", err)
|
||||
}
|
||||
if canonical != base {
|
||||
t.Errorf("CanonicalizeCode(lower) = %q, want %q", canonical, base)
|
||||
}
|
||||
|
||||
// I, L → 1.
|
||||
idx := strings.IndexByte(base, '1')
|
||||
if idx >= 0 && idx < 15 {
|
||||
for _, sub := range []string{"I", "L", "i", "l"} {
|
||||
variant := base[:idx] + sub + base[idx+1:]
|
||||
c, err := idgen.CanonicalizeCode(variant)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
|
||||
continue
|
||||
}
|
||||
if c != base {
|
||||
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// O → 0.
|
||||
idx = strings.IndexByte(base, '0')
|
||||
if idx >= 0 && idx < 15 {
|
||||
variant := base[:idx] + "O" + base[idx+1:]
|
||||
c, err := idgen.CanonicalizeCode(variant)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(%q) error: %v", variant, err)
|
||||
} else if c != base {
|
||||
t.Errorf("CanonicalizeCode(%q) = %q, want %q", variant, c, base)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCharDetectsSingleErrors verifies that mutating any single data
|
||||
// character in a valid code causes CanonicalizeCode to return an error.
|
||||
func TestCheckCharDetectsSingleErrors(t *testing.T) {
|
||||
const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
|
||||
for pos := 0; pos < 15; pos++ {
|
||||
original := rune(code[pos])
|
||||
for _, replacement := range alpha {
|
||||
if replacement == original {
|
||||
continue
|
||||
}
|
||||
mutated := code[:pos] + string(replacement) + code[pos+1:]
|
||||
if _, err := idgen.CanonicalizeCode(mutated); err == nil {
|
||||
t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q",
|
||||
pos, original, replacement, code, mutated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashCodeConsistency verifies that HashCode is deterministic and that
|
||||
// different codes produce different digests.
|
||||
func TestHashCodeConsistency(t *testing.T) {
|
||||
code1, _ := idgen.GenerateCode()
|
||||
code2, _ := idgen.GenerateCode()
|
||||
for code1 == code2 {
|
||||
code2, _ = idgen.GenerateCode()
|
||||
}
|
||||
|
||||
h1a := idgen.HashCode(code1)
|
||||
h1b := idgen.HashCode(code1)
|
||||
h2 := idgen.HashCode(code2)
|
||||
|
||||
if h1a != h1b {
|
||||
t.Error("HashCode is not deterministic")
|
||||
}
|
||||
if h1a == h2 {
|
||||
t.Error("different codes produced the same digest")
|
||||
}
|
||||
if len(h1a) != 64 {
|
||||
t.Errorf("HashCode length = %d, want 64 (hex SHA-256)", len(h1a))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeCodeRejectsInvalidLength tests length validation.
|
||||
func TestCanonicalizeCodeRejectsInvalidLength(t *testing.T) {
|
||||
cases := []string{"", "ABCDE", "ABCDEFGH12345678X"}
|
||||
for _, c := range cases {
|
||||
if _, err := idgen.CanonicalizeCode(c); err == nil {
|
||||
t.Errorf("CanonicalizeCode(%q) should fail for length %d", c, len(c))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeCodeRejectsInvalidChars tests that non-Crockford characters
|
||||
// at data positions (0–14) are rejected.
|
||||
func TestCanonicalizeCodeRejectsInvalidChars(t *testing.T) {
|
||||
base, _ := idgen.GenerateCode()
|
||||
invalid := "!" + base[1:]
|
||||
if _, err := idgen.CanonicalizeCode(invalid); err == nil {
|
||||
t.Errorf("CanonicalizeCode(%q) should fail for invalid character", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyphenStripping verifies that hyphens inserted for display readability
|
||||
// (e.g. XXXX-XXXX-XXXX-XXXX) are stripped before validation.
|
||||
func TestHyphenStripping(t *testing.T) {
|
||||
code, err := idgen.GenerateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCode: %v", err)
|
||||
}
|
||||
hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
|
||||
canonical, err := idgen.CanonicalizeCode(hyphenated)
|
||||
if err != nil {
|
||||
t.Errorf("CanonicalizeCode(hyphenated) error: %v", err)
|
||||
}
|
||||
if canonical != code {
|
||||
t.Errorf("CanonicalizeCode(hyphenated) = %q, want %q", canonical, code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user