mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
- ProvisionOrg/User creates directories and paths correctly - Invalid slugs rejected (path traversal, uppercase, specials) - HKDF DEK is deterministic, unique per org, unique per user, org≠user even with same input (different HKDF info param) - Empty master key returns empty DEK (dev mode, no encryption) - Lookup works after provision, returns false before - DeleteUser removes directory and lookup entry - Integration: OrgDB + PoolManager read/write through dual pools - Multi-org isolation: same table name, different data per org
340 lines
8.3 KiB
Go
340 lines
8.3 KiB
Go
package platform
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
)
|
|
|
|
// mockApp implements the minimal core.App interface for OrgDB tests.
|
|
type mockApp struct {
|
|
core.BaseApp
|
|
dataDir string
|
|
}
|
|
|
|
func (a *mockApp) DataDir() string { return a.dataDir }
|
|
|
|
func testOrgDB(t *testing.T) (*OrgDB, string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
app := &mockApp{dataDir: dir}
|
|
return NewOrgDB(app, "test-master-key-32-bytes-long!!!"), dir
|
|
}
|
|
|
|
// --- OrgDB provisioning ---
|
|
|
|
func TestOrgDB_ProvisionOrg(t *testing.T) {
|
|
db, dir := testOrgDB(t)
|
|
|
|
path, err := db.ProvisionOrg("acme")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
expected := filepath.Join(dir, "orgs", "acme", "org.db")
|
|
if path != expected {
|
|
t.Fatalf("expected %s, got %s", expected, path)
|
|
}
|
|
|
|
// Directory should exist
|
|
if _, err := os.Stat(filepath.Join(dir, "orgs", "acme")); os.IsNotExist(err) {
|
|
t.Fatal("org directory not created")
|
|
}
|
|
|
|
// Users subdirectory should exist
|
|
if _, err := os.Stat(filepath.Join(dir, "orgs", "acme", "users")); os.IsNotExist(err) {
|
|
t.Fatal("users subdirectory not created")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_ProvisionUser(t *testing.T) {
|
|
db, dir := testOrgDB(t)
|
|
|
|
_, _ = db.ProvisionOrg("acme")
|
|
path, err := db.ProvisionUser("acme", "user-001")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
expected := filepath.Join(dir, "orgs", "acme", "users", "user-001", "data.db")
|
|
if path != expected {
|
|
t.Fatalf("expected %s, got %s", expected, path)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Dir(path)); os.IsNotExist(err) {
|
|
t.Fatal("user directory not created")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_ProvisionMultipleOrgs(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
orgs := []string{"acme", "globex", "initech", "umbrella"}
|
|
for _, org := range orgs {
|
|
if _, err := db.ProvisionOrg(org); err != nil {
|
|
t.Fatalf("provision org %s: %v", org, err)
|
|
}
|
|
}
|
|
|
|
// All should be lookupable
|
|
for _, org := range orgs {
|
|
if _, ok := db.GetOrgDBPath(org); !ok {
|
|
t.Fatalf("org %s not found after provisioning", org)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Slug validation ---
|
|
|
|
func TestOrgDB_InvalidSlug(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
cases := []string{"", "..", "../etc", "org/../../etc", "UPPER", "has space", "special!"}
|
|
for _, slug := range cases {
|
|
if _, err := db.ProvisionOrg(slug); err == nil {
|
|
t.Fatalf("expected error for slug %q", slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_ValidSlugs(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
cases := []string{"acme", "org-123", "my_org", "a", "ab", "a-b-c-d"}
|
|
for _, slug := range cases {
|
|
if _, err := db.ProvisionOrg(slug); err != nil {
|
|
t.Fatalf("unexpected error for slug %q: %v", slug, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- DEK derivation ---
|
|
|
|
func TestOrgDB_OrgDEK_Deterministic(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
dek1 := db.OrgDEK("acme")
|
|
dek2 := db.OrgDEK("acme")
|
|
|
|
if dek1 != dek2 {
|
|
t.Fatal("OrgDEK should be deterministic for same input")
|
|
}
|
|
if len(dek1) != 64 { // 32 bytes = 64 hex chars
|
|
t.Fatalf("expected 64 hex chars, got %d", len(dek1))
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_OrgDEK_UniquePerOrg(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
dek1 := db.OrgDEK("acme")
|
|
dek2 := db.OrgDEK("globex")
|
|
|
|
if dek1 == dek2 {
|
|
t.Fatal("different orgs should have different DEKs")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_UserDEK_UniquePerUser(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
dek1 := db.UserDEK("acme", "user-001")
|
|
dek2 := db.UserDEK("acme", "user-002")
|
|
dek3 := db.UserDEK("globex", "user-001")
|
|
|
|
if dek1 == dek2 {
|
|
t.Fatal("different users in same org should have different DEKs")
|
|
}
|
|
if dek1 == dek3 {
|
|
t.Fatal("same user ID in different orgs should have different DEKs")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_DEK_OrgVsUser_Different(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
orgDEK := db.OrgDEK("acme")
|
|
userDEK := db.UserDEK("acme", "acme") // same string as org slug
|
|
|
|
if orgDEK == userDEK {
|
|
t.Fatal("org DEK and user DEK should differ even with same input (different HKDF info)")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_DEK_EmptyMasterKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
app := &mockApp{dataDir: dir}
|
|
db := NewOrgDB(app, "") // no master key
|
|
|
|
if dek := db.OrgDEK("acme"); dek != "" {
|
|
t.Fatalf("expected empty DEK with no master key, got %s", dek)
|
|
}
|
|
if dek := db.UserDEK("acme", "user-001"); dek != "" {
|
|
t.Fatalf("expected empty DEK with no master key, got %s", dek)
|
|
}
|
|
}
|
|
|
|
// --- Lookup ---
|
|
|
|
func TestOrgDB_GetOrgDBPath_NotProvisioned(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
if _, ok := db.GetOrgDBPath("nonexistent"); ok {
|
|
t.Fatal("should not find non-provisioned org")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_GetUserDBPath_NotProvisioned(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
if _, ok := db.GetUserDBPath("acme", "user-001"); ok {
|
|
t.Fatal("should not find non-provisioned user")
|
|
}
|
|
}
|
|
|
|
func TestOrgDB_GetOrgDBPath_AfterProvision(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
expected, _ := db.ProvisionOrg("acme")
|
|
got, ok := db.GetOrgDBPath("acme")
|
|
if !ok {
|
|
t.Fatal("should find provisioned org")
|
|
}
|
|
if got != expected {
|
|
t.Fatalf("expected %s, got %s", expected, got)
|
|
}
|
|
}
|
|
|
|
// --- Delete ---
|
|
|
|
func TestOrgDB_DeleteUser(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
|
|
db.ProvisionOrg("acme")
|
|
path, _ := db.ProvisionUser("acme", "user-001")
|
|
|
|
// Create the actual DB file so the directory has content
|
|
os.WriteFile(path, []byte("test"), 0600)
|
|
|
|
if err := db.DeleteUser("acme", "user-001"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, ok := db.GetUserDBPath("acme", "user-001"); ok {
|
|
t.Fatal("user should be deleted from lookup")
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) {
|
|
t.Fatal("user directory should be removed")
|
|
}
|
|
}
|
|
|
|
// --- Integration: OrgDB + PoolManager ---
|
|
|
|
func TestOrgDB_WithPoolManager(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
pm := NewDBPoolManager(DBPoolConfig{MaxPools: 16, NumShards: 1})
|
|
defer pm.Close()
|
|
|
|
// Provision org
|
|
orgPath, _ := db.ProvisionOrg("acme")
|
|
|
|
// Open a pool for it
|
|
pool, err := pm.Get(orgPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Release()
|
|
|
|
// Create table and insert data
|
|
_, err = pool.Nonconcurrent.NewQuery("CREATE TABLE config (k TEXT PRIMARY KEY, v TEXT)").Execute()
|
|
if err != nil {
|
|
t.Fatalf("create table: %v", err)
|
|
}
|
|
_, err = pool.Nonconcurrent.NewQuery("INSERT INTO config (k, v) VALUES ('name', 'Acme Corp')").Execute()
|
|
if err != nil {
|
|
t.Fatalf("insert: %v", err)
|
|
}
|
|
|
|
// Read back via concurrent pool
|
|
var name string
|
|
if err := pool.Concurrent.NewQuery("SELECT v FROM config WHERE k = 'name'").Row(&name); err != nil {
|
|
t.Fatalf("select: %v", err)
|
|
}
|
|
if name != "Acme Corp" {
|
|
t.Fatalf("expected 'Acme Corp', got %q", name)
|
|
}
|
|
|
|
// Provision user in same org
|
|
userPath, _ := db.ProvisionUser("acme", "user-42")
|
|
userPool, err := pm.Get(userPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer userPool.Release()
|
|
|
|
// User DB is independent from org DB
|
|
_, err = userPool.Nonconcurrent.NewQuery("CREATE TABLE orders (id INTEGER PRIMARY KEY, symbol TEXT)").Execute()
|
|
if err != nil {
|
|
t.Fatalf("create user table: %v", err)
|
|
}
|
|
_, err = userPool.Nonconcurrent.NewQuery("INSERT INTO orders (symbol) VALUES ('AAPL')").Execute()
|
|
if err != nil {
|
|
t.Fatalf("insert user data: %v", err)
|
|
}
|
|
|
|
// Org DB should NOT have the orders table
|
|
var count int
|
|
err = pool.Concurrent.NewQuery("SELECT count(*) FROM sqlite_master WHERE name='orders'").Row(&count)
|
|
if err != nil {
|
|
t.Fatalf("check org db: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatal("org DB should not have user's orders table — data isolation broken")
|
|
}
|
|
|
|
// Stats
|
|
stats := pm.Stats()
|
|
if stats.Opens != 2 {
|
|
t.Fatalf("expected 2 opens (org + user), got %d", stats.Opens)
|
|
}
|
|
}
|
|
|
|
// --- Multi-org isolation ---
|
|
|
|
func TestOrgDB_MultiOrgIsolation(t *testing.T) {
|
|
db, _ := testOrgDB(t)
|
|
pm := NewDBPoolManager(DBPoolConfig{MaxPools: 16, NumShards: 1})
|
|
defer pm.Close()
|
|
|
|
// Two orgs
|
|
path1, _ := db.ProvisionOrg("acme")
|
|
path2, _ := db.ProvisionOrg("globex")
|
|
|
|
p1, _ := pm.Get(path1)
|
|
defer p1.Release()
|
|
p2, _ := pm.Get(path2)
|
|
defer p2.Release()
|
|
|
|
// Each org creates the same table name
|
|
p1.Nonconcurrent.NewQuery("CREATE TABLE accounts (id INTEGER, name TEXT)").Execute()
|
|
p2.Nonconcurrent.NewQuery("CREATE TABLE accounts (id INTEGER, name TEXT)").Execute()
|
|
|
|
p1.Nonconcurrent.NewQuery("INSERT INTO accounts (name) VALUES ('Alice')").Execute()
|
|
p2.Nonconcurrent.NewQuery("INSERT INTO accounts (name) VALUES ('Bob')").Execute()
|
|
|
|
// Data is isolated
|
|
var name1, name2 string
|
|
p1.Concurrent.NewQuery("SELECT name FROM accounts").Row(&name1)
|
|
p2.Concurrent.NewQuery("SELECT name FROM accounts").Row(&name2)
|
|
|
|
if name1 != "Alice" {
|
|
t.Fatalf("acme should have Alice, got %q", name1)
|
|
}
|
|
if name2 != "Bob" {
|
|
t.Fatalf("globex should have Bob, got %q", name2)
|
|
}
|
|
}
|