Files
heicode-mananger/heicode/model/token_keyhash_test.go
T
chenchenandClaude Opus 4.7 d8f61957f0 feat(manager): version 1.4.2 — H2 sk- hash + M9 audit drawer + M3 docs + M7 vault
Bundled release bumping Manager to 1.4.2 with four product-doc gap
closures lined up in a single deploy.

VERSION:
  - 1.2.0 → 1.4.2 (catches up after Sprints 1-5 shipped under 1.2.0)

H2 — sk- hash phase A (server-side, zero client impact):
  - tokens table: new key_hash varchar(64) index column
  - Token.Insert() dual-writes Key + KeyHash on every new token
  - BackfillTokenKeyHash() runs at startup, batches 500 rows at a
    time, idempotent. Fills legacy rows that pre-date the column
    without blocking app boot
  - 5 unit tests pin: sha256 correctness, dual-write on Insert,
    empty Key → empty hash, backfill behaviour, idempotency
  - Phase B (switch lookup index off plaintext + drop Key column)
    can ship later once telemetry shows key_hash IS NULL count is 0

M9 — task detail drawer with audit timeline:
  - Deployments page click → Sheet drawer with RunDetailPanel +
    new RunAuditTimeline component
  - Timeline pulls from existing /api/agnet/deployments/:id/events
    which Sprint 1 already wired to the persistent
    agnet_audit_events table — no new backend
  - Vertical timeline w/ coloured dots (primary / amber / rose by
    classifyEventLevel), occurred_at + correlation_id per row,
    max-height + overflow for long traces
  - 15s polling; empty/loading/error states all rendered

M3 — project_doc as a first-class binding step:
  - Resource binding wizard split "SK or project docs" into two
    distinct steps: "Connect project docs" + "Connect SK skill packs"
  - Each step's Connect button pre-selects the matching type in
    the advanced sheet so users don't accidentally tag a doc repo
    as Git or SK
  - Summary dialog still receives the combined skOrDocSources view
    to keep the recommendation-card contract unchanged

M7 — secret vault status (admin panel):
  - controller/secret_store.go: new GetSecretStoreStatus handler
    + fetchHealth() method. Hits OpenBao /sys/health (token-less
    upstream endpoint), maps to a sanitized response — NEVER
    returns secret names or values per product docs §13.9
  - Graceful degradation: env vars unset → "not configured" pill;
    network error → "unreachable"; sealed → amber warning; healthy
    → green
  - Mounted at GET /api/secret-store/status behind middleware.AdminAuth
  - New SecretStoreSection in system-settings/maintenance,
    registered before Performance. Read-only card with refresh
    button, 7 status fields, message line, "how to enable" hint

Verification:
  - go vet ./... clean
  - go test ./controller/... ./middleware/... ./model/... all green
  - tsc --noEmit clean
  - Backend M7 endpoint deliberately tolerant — production may not
    have OPENBAO_ADDR set yet, UI shows "not configured" instead of
    500ing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:19:42 +08:00

172 lines
4.8 KiB
Go

package model
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// H2 phase A — tests pin the dual-write behaviour:
// 1. Insert() populates KeyHash from Key automatically.
// 2. computeKeyHash matches stdlib sha256 hex.
// 3. BackfillTokenKeyHash fills legacy rows in batches and is
// idempotent (running it twice doesn't change anything).
// 4. Empty Key produces empty hash (no spurious SHA of "").
//
// All tests share the package-level DB set up in task_cas_test.go's
// TestMain. We DELETE FROM tokens at the top of each case so the
// shared SQLite stays isolated.
func resetTokensTable(t *testing.T) {
t.Helper()
if err := DB.AutoMigrate(&Token{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := DB.Exec("DELETE FROM tokens").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
}
func TestComputeKeyHash_MatchesStdlibSHA256(t *testing.T) {
cases := []struct{ in, want string }{
{"", ""},
{"a", func() string {
s := sha256.Sum256([]byte("a"))
return hex.EncodeToString(s[:])
}()},
{"sk-deadbeef1234567890abcdef", func() string {
s := sha256.Sum256([]byte("sk-deadbeef1234567890abcdef"))
return hex.EncodeToString(s[:])
}()},
}
for _, c := range cases {
got := computeKeyHash(c.in)
if got != c.want {
t.Errorf("computeKeyHash(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTokenInsert_PopulatesKeyHash(t *testing.T) {
resetTokensTable(t)
tok := &Token{
UserId: 7,
Name: "h2-test",
Key: "sk-h2-insert-deadbeef0001",
}
if err := tok.Insert(); err != nil {
t.Fatalf("insert: %v", err)
}
if tok.KeyHash == "" {
t.Fatal("KeyHash should be set after Insert()")
}
want := computeKeyHash(tok.Key)
if tok.KeyHash != want {
t.Errorf("KeyHash = %q, want %q", tok.KeyHash, want)
}
// Confirm DB row carries the hash.
var stored Token
if err := DB.First(&stored, tok.Id).Error; err != nil {
t.Fatalf("lookup: %v", err)
}
if stored.KeyHash != want {
t.Errorf("DB row KeyHash = %q, want %q", stored.KeyHash, want)
}
}
func TestTokenInsert_EmptyKey_YieldsEmptyHash(t *testing.T) {
// Defensive: rows that legitimately have no key (e.g. test fixtures)
// should not get a SHA-256 of empty string in the hash column.
resetTokensTable(t)
tok := &Token{
UserId: 8,
Name: "h2-no-key",
Key: "",
}
// SQLite uniqueIndex on Key blocks two empty-key rows; that's
// orthogonal to this test which only asserts hash behaviour
// against the row we did insert.
if err := tok.Insert(); err != nil {
t.Fatalf("insert: %v", err)
}
if tok.KeyHash != "" {
t.Errorf("empty Key should yield empty KeyHash, got %q", tok.KeyHash)
}
}
func TestBackfillTokenKeyHash_FillsLegacyRows(t *testing.T) {
resetTokensTable(t)
// Seed: 3 rows directly via DB.Create (bypasses Insert hook) to
// simulate legacy rows from before the column existed.
for i, k := range []string{"sk-leg-001", "sk-leg-002", "sk-leg-003"} {
row := Token{UserId: i + 1, Name: "legacy", Key: k, KeyHash: ""}
if err := DB.Create(&row).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
// One row with empty Key — backfill must leave it alone.
if err := DB.Create(&Token{UserId: 99, Name: "no-key", Key: "", KeyHash: ""}).Error; err != nil {
t.Fatalf("seed empty: %v", err)
}
if err := BackfillTokenKeyHash(); err != nil {
t.Fatalf("backfill: %v", err)
}
// All three keyed rows now have a hash.
var keyed []Token
if err := DB.Where("key <> ''").Find(&keyed).Error; err != nil {
t.Fatalf("query: %v", err)
}
for _, r := range keyed {
want := computeKeyHash(r.Key)
if r.KeyHash != want {
t.Errorf("row id=%d key=%q has KeyHash=%q, want %q",
r.Id, r.Key, r.KeyHash, want)
}
}
// The empty-key row stays empty.
var emptyKeyRow Token
if err := DB.Where("key = ''").First(&emptyKeyRow).Error; err != nil {
t.Fatalf("empty row: %v", err)
}
if emptyKeyRow.KeyHash != "" {
t.Errorf("empty-key row should keep empty hash, got %q", emptyKeyRow.KeyHash)
}
}
func TestBackfillTokenKeyHash_Idempotent(t *testing.T) {
// Production safety: backfill runs on every container start.
// Running it on an already-backfilled table must be a no-op,
// not a double-write that thrashes Redis cache invalidation.
resetTokensTable(t)
tok := &Token{UserId: 1, Name: "ok", Key: "sk-stable-001"}
if err := tok.Insert(); err != nil {
t.Fatalf("insert: %v", err)
}
firstHash := tok.KeyHash
// First backfill — should already see hash filled by Insert, do nothing.
if err := BackfillTokenKeyHash(); err != nil {
t.Fatalf("backfill 1: %v", err)
}
// Second backfill — same.
if err := BackfillTokenKeyHash(); err != nil {
t.Fatalf("backfill 2: %v", err)
}
var after Token
if err := DB.First(&after, tok.Id).Error; err != nil {
t.Fatalf("lookup: %v", err)
}
if after.KeyHash != firstHash {
t.Errorf("hash drifted across backfills: was %q, now %q",
firstHash, after.KeyHash)
}
}