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>
172 lines
4.8 KiB
Go
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)
|
|
}
|
|
}
|