feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)

Implements server/internal/codes/ with all required functionality:

## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors

## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)

## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
  - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
  - SELECT … FOR UPDATE → single winner under N-concurrent redemptions
  - Same-plan: extends existing subscription (max(expires_at,now)+days)
  - Cross-plan: creates new subscription (max(now,latest)+days)
  - Idempotent: same user re-submits → 200 without re-applying
  - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
  - Automatic retry on hash collision (birthday probability ≈10⁻⁸)

## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200

## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}

## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists

## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity

## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies

## Tests
- generator_test.go (unit, no deps):
  - Format, uniqueness (10k codes, zero collisions), normalization,
    check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
  - Signature rejection, missing signature, stale/future timestamp,
    missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
  - N=20 concurrent redeemers → exactly 1 winner
  - Idempotent redeem returns success for same user
  - Other-user redemption → CODE_REDEEMED + failure counted
  - 5 failures → ACCOUNT_LOCKED on 6th attempt
  - Same-plan extension: expires_at precision assertion
  - Cross-plan creation: new subscription row
  - Audit log written on every successful redemption
  - CSV export: no plaintext in DB, hash present
  - Webhook nonce replay: idempotent 200
  - Webhook same-hash: idempotent 200, single DB row

Run unit tests: make test
Run integration tests: make test-integration (requires Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-13 02:16:16 +08:00
parent a642bf16a2
commit afcd7b325c
17 changed files with 2750 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
package codes_test
import (
"strings"
"testing"
"pangolin/server/internal/codes"
)
// TestCrockfordAlphabetCoverage verifies the 32-symbol encoding alphabet.
func TestCrockfordAlphabetCoverage(t *testing.T) {
forbidden := []rune{'I', 'L', 'O', 'U'}
alpha := "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
if len(alpha) != 32 {
t.Fatalf("alphabet length = %d, want 32", len(alpha))
}
for _, ch := range forbidden {
if strings.ContainsRune(alpha, ch) {
t.Errorf("forbidden char %c found in alphabet", ch)
}
}
}
// TestGenerateCodeFormat verifies that GenerateCode returns a 16-char code
// made entirely of the Crockford alphabet (first 15 chars) + valid check char.
func TestGenerateCodeFormat(t *testing.T) {
for i := 0; i < 1000; i++ {
code, err := codes.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))
}
// Canonicalize must accept a freshly generated code.
canonical, err := codes.Canonicalize(code)
if err != nil {
t.Errorf("Canonicalize(%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 := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
h := codes.Hash(code)
if _, dup := seen[h]; dup {
t.Fatalf("hash collision at iteration %d: code=%s hash=%s", i, code, h)
}
seen[h] = struct{}{}
}
}
// TestCanonicalizeNormalization verifies the I/L→1 and O→0 substitutions.
func TestCanonicalizeNormalization(t *testing.T) {
// Generate a valid code to use as a base.
base, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
// Test lower-case input equals upper-case canonical.
lower := strings.ToLower(base)
canonical, err := codes.Canonicalize(lower)
if err != nil {
t.Errorf("Canonicalize(lower) error: %v", err)
}
if canonical != base {
t.Errorf("Canonicalize(lower) = %q, want %q", canonical, base)
}
// Test substitution: replace a '1' in the code with 'I' and 'L', verify they
// normalise to the same canonical form.
idx := strings.IndexByte(base, '1')
if idx >= 0 && idx < 15 {
withI := base[:idx] + "I" + base[idx+1:]
withL := base[:idx] + "L" + base[idx+1:]
withLower := base[:idx] + "l" + base[idx+1:]
for _, variant := range []string{withI, withL, withLower} {
c, err := codes.Canonicalize(variant)
if err != nil {
t.Errorf("Canonicalize(%q) error: %v", variant, err)
continue
}
if c != base {
t.Errorf("Canonicalize(%q) = %q, want %q", variant, c, base)
}
}
}
// Replace a '0' with 'O' and verify normalisation.
idx = strings.IndexByte(base, '0')
if idx >= 0 && idx < 15 {
withO := base[:idx] + "O" + base[idx+1:]
c, err := codes.Canonicalize(withO)
if err != nil {
t.Errorf("Canonicalize(%q) error: %v", withO, err)
} else if c != base {
t.Errorf("Canonicalize(%q) = %q, want %q", withO, c, base)
}
}
}
// TestCheckCharDetectsSingleErrors verifies that mutating any single data
// character in a valid code causes Canonicalize to return an error.
func TestCheckCharDetectsSingleErrors(t *testing.T) {
const alpha = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
code, err := codes.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:]
_, err := codes.Canonicalize(mutated)
if err == nil {
t.Errorf("mutating pos %d (%c→%c) not detected: code=%q mutated=%q",
pos, original, replacement, code, mutated)
}
}
}
}
// TestHashConsistency verifies that Hash is deterministic and that different
// codes produce different hashes.
func TestHashConsistency(t *testing.T) {
code1, _ := codes.GenerateCode()
code2, _ := codes.GenerateCode()
for code1 == code2 {
code2, _ = codes.GenerateCode()
}
h1a := codes.Hash(code1)
h1b := codes.Hash(code1)
h2 := codes.Hash(code2)
if h1a != h1b {
t.Error("Hash is not deterministic")
}
if h1a == h2 {
t.Error("Different codes produced the same hash")
}
if len(h1a) != 64 {
t.Errorf("Hash length = %d, want 64 (hex SHA-256)", len(h1a))
}
}
// TestCanonicalizeRejectsInvalidLength tests length validation.
func TestCanonicalizeRejectsInvalidLength(t *testing.T) {
cases := []string{"", "ABCDE", "ABCDEFGH12345678X"}
for _, c := range cases {
if _, err := codes.Canonicalize(c); err == nil {
t.Errorf("Canonicalize(%q) should fail but did not", c)
}
}
}
// TestCanonicalizeRejectsInvalidChars tests that characters outside the
// Crockford alphabet are rejected for data positions.
func TestCanonicalizeRejectsInvalidChars(t *testing.T) {
base, _ := codes.GenerateCode()
// Replace position 0 with an invalid character.
invalid := "!" + base[1:]
if _, err := codes.Canonicalize(invalid); err == nil {
t.Errorf("Canonicalize(%q) should fail for invalid char", invalid)
}
}
// TestHyphenStripping verifies that hyphens inserted for readability are stripped.
func TestHyphenStripping(t *testing.T) {
code, err := codes.GenerateCode()
if err != nil {
t.Fatalf("GenerateCode: %v", err)
}
// Insert hyphens: XXXX-XXXX-XXXX-XXXX
hyphenated := code[:4] + "-" + code[4:8] + "-" + code[8:12] + "-" + code[12:]
canonical, err := codes.Canonicalize(hyphenated)
if err != nil {
t.Errorf("Canonicalize(hyphenated) error: %v", err)
}
if canonical != code {
t.Errorf("Canonicalize(hyphenated) = %q, want %q", canonical, code)
}
}