dealspace/lib/dbcore_test.go

628 lines
14 KiB
Go

package lib
import (
"bytes"
"os"
"testing"
"time"
"github.com/google/uuid"
)
// testDB creates an in-memory SQLite database with migrations applied.
func testDB(t *testing.T) (*DB, *Config) {
t.Helper()
// Create temp file for SQLite (in-memory doesn't work well with WAL)
tmpFile, err := os.CreateTemp("", "dealspace-test-*.db")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
tmpFile.Close()
t.Cleanup(func() { os.Remove(tmpFile.Name()) })
db, err := OpenDB(tmpFile.Name(), "../migrations/001_initial.sql")
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { db.Close() })
masterKey := make([]byte, 32)
for i := range masterKey {
masterKey[i] = byte(i)
}
cfg := &Config{
MasterKey: masterKey,
JWTSecret: []byte("test-jwt-secret"),
}
return db, cfg
}
// testUser creates a test user with the given role and returns the user ID.
func testUser(t *testing.T, db *DB, cfg *Config, projectID, role string) string {
t.Helper()
userID := uuid.New().String()
now := time.Now().UnixMilli()
user := &User{
UserID: userID,
Email: userID + "@test.com",
Name: "Test User",
Password: "$2a$10$test", // bcrypt placeholder
OrgID: "test-org",
OrgName: "Test Org",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := UserCreate(db, user); err != nil {
t.Fatalf("UserCreate: %v", err)
}
// Grant access with appropriate ops based on role
ops := "r"
switch role {
case RoleIBAdmin, RoleIBMember:
ops = "rwdm"
case RoleSellerAdmin, RoleSellerMember:
ops = "rwd"
case RoleBuyerAdmin, RoleBuyerMember:
ops = "r"
}
access := &Access{
ID: uuid.New().String(),
ProjectID: projectID,
UserID: userID,
Role: role,
Ops: ops,
CanGrant: role == RoleIBAdmin || role == RoleSellerAdmin,
GrantedBy: "system",
GrantedAt: now,
}
if err := AccessGrant(db, access); err != nil {
t.Fatalf("AccessGrant: %v", err)
}
return userID
}
// testProject creates a test project entry and returns the project ID.
func testProject(t *testing.T, db *DB, cfg *Config, ownerID string) string {
t.Helper()
projectID := uuid.New().String()
now := time.Now().UnixMilli()
// First grant the owner access to create entries
access := &Access{
ID: uuid.New().String(),
ProjectID: projectID,
UserID: ownerID,
Role: RoleIBAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: "system",
GrantedAt: now,
}
if err := AccessGrant(db, access); err != nil {
t.Fatalf("AccessGrant for owner: %v", err)
}
// Create project entry
entry := &Entry{
ProjectID: projectID,
Type: TypeProject,
Depth: 0,
Stage: StagePreDataroom,
SummaryText: "Test Project",
DataText: `{"name": "Test Project"}`,
}
if err := EntryWrite(db, cfg, ownerID, entry); err != nil {
t.Fatalf("EntryWrite project: %v", err)
}
return projectID
}
func TestEntryWriteRead(t *testing.T) {
db, cfg := testDB(t)
// Create owner user first
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
OrgID: "test-org",
OrgName: "Test Org",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := UserCreate(db, owner); err != nil {
t.Fatalf("UserCreate: %v", err)
}
projectID := testProject(t, db, cfg, ownerID)
// Write an entry
entry := &Entry{
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StagePreDataroom,
SummaryText: "Test Summary",
DataText: `{"question": "What is the answer?"}`,
}
if err := EntryWrite(db, cfg, ownerID, entry); err != nil {
t.Fatalf("EntryWrite: %v", err)
}
if entry.EntryID == "" {
t.Error("EntryID should be set after write")
}
if entry.Version != 1 {
t.Errorf("Version should be 1, got %d", entry.Version)
}
// Read it back
filter := EntryFilter{
ProjectID: projectID,
Type: TypeRequest,
}
entries, err := EntryRead(db, cfg, ownerID, projectID, filter)
if err != nil {
t.Fatalf("EntryRead: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
got := entries[0]
if got.EntryID != entry.EntryID {
t.Errorf("EntryID mismatch: got %s, want %s", got.EntryID, entry.EntryID)
}
if got.SummaryText != "Test Summary" {
t.Errorf("SummaryText mismatch: got %q, want %q", got.SummaryText, "Test Summary")
}
if got.DataText != `{"question": "What is the answer?"}` {
t.Errorf("DataText mismatch: got %q", got.DataText)
}
}
func TestEntryReadAccessDenied(t *testing.T) {
db, cfg := testDB(t)
// Create owner and project
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := UserCreate(db, owner); err != nil {
t.Fatalf("UserCreate: %v", err)
}
projectID := testProject(t, db, cfg, ownerID)
// Create another user with NO access to this project
noAccessUser := &User{
UserID: uuid.New().String(),
Email: "noaccess@test.com",
Name: "No Access",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := UserCreate(db, noAccessUser); err != nil {
t.Fatalf("UserCreate: %v", err)
}
// Try to read with no-access user
filter := EntryFilter{ProjectID: projectID}
_, err := EntryRead(db, cfg, noAccessUser.UserID, projectID, filter)
if err != ErrAccessDenied {
t.Errorf("expected ErrAccessDenied, got %v", err)
}
}
func TestSoftDelete(t *testing.T) {
db, cfg := testDB(t)
// Setup
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, owner)
projectID := testProject(t, db, cfg, ownerID)
// Create entry
entry := &Entry{
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StagePreDataroom,
SummaryText: "To be deleted",
}
EntryWrite(db, cfg, ownerID, entry)
entryID := entry.EntryID
// Delete it
if err := EntryDelete(db, ownerID, projectID, entryID); err != nil {
t.Fatalf("EntryDelete: %v", err)
}
// Verify not returned in normal reads
filter := EntryFilter{ProjectID: projectID, Type: TypeRequest}
entries, err := EntryRead(db, cfg, ownerID, projectID, filter)
if err != nil {
t.Fatalf("EntryRead: %v", err)
}
for _, e := range entries {
if e.EntryID == entryID {
t.Error("deleted entry should not be returned in normal reads")
}
}
// Verify deleted_at is set via direct query
var deletedAt *int64
err = db.Conn.QueryRow("SELECT deleted_at FROM entries WHERE entry_id = ?", entryID).Scan(&deletedAt)
if err != nil {
t.Fatalf("query deleted_at: %v", err)
}
if deletedAt == nil {
t.Error("deleted_at should be set")
}
}
func TestOptimisticLocking(t *testing.T) {
db, cfg := testDB(t)
// Setup
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, owner)
projectID := testProject(t, db, cfg, ownerID)
// Create entry v1
entry := &Entry{
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StagePreDataroom,
SummaryText: "Version 1",
}
EntryWrite(db, cfg, ownerID, entry)
if entry.Version != 1 {
t.Fatalf("initial version should be 1, got %d", entry.Version)
}
// Update successfully to v2
entry.SummaryText = "Version 2"
if err := EntryWrite(db, cfg, ownerID, entry); err != nil {
t.Fatalf("EntryWrite v2: %v", err)
}
if entry.Version != 2 {
t.Fatalf("version should be 2, got %d", entry.Version)
}
// Try to update with stale version (v1)
staleEntry := &Entry{
EntryID: entry.EntryID,
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StagePreDataroom,
SummaryText: "Stale update",
Version: 1, // Stale!
}
err := EntryWrite(db, cfg, ownerID, staleEntry)
if err != ErrVersionConflict {
t.Errorf("expected ErrVersionConflict, got %v", err)
}
}
func TestBlindIndexSearch(t *testing.T) {
db, cfg := testDB(t)
// Setup
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, owner)
projectID := testProject(t, db, cfg, ownerID)
// Create entry with search key
hmacKey, _ := DeriveHMACKey(cfg.MasterKey, projectID)
searchTerm := "unique-search-term-123"
blindIdx := BlindIndex(hmacKey, searchTerm)
entry := &Entry{
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StagePreDataroom,
SummaryText: "Searchable Entry",
SearchKey: blindIdx,
}
EntryWrite(db, cfg, ownerID, entry)
// Search by blind index
filter := EntryFilter{
ProjectID: projectID,
SearchKey: blindIdx,
}
entries, err := EntryRead(db, cfg, ownerID, projectID, filter)
if err != nil {
t.Fatalf("EntryRead with SearchKey: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].EntryID != entry.EntryID {
t.Error("wrong entry returned")
}
// Search with different blind index should return nothing
wrongIdx := BlindIndex(hmacKey, "wrong-term")
filter.SearchKey = wrongIdx
entries, err = EntryRead(db, cfg, ownerID, projectID, filter)
if err != nil {
t.Fatalf("EntryRead with wrong SearchKey: %v", err)
}
if len(entries) != 0 {
t.Errorf("expected 0 entries with wrong search key, got %d", len(entries))
}
}
func TestUserByEmail(t *testing.T) {
db, _ := testDB(t)
now := time.Now().UnixMilli()
user := &User{
UserID: uuid.New().String(),
Email: "test@example.com",
Name: "Test User",
Password: "$2a$10$test",
OrgID: "test-org",
OrgName: "Test Org",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
if err := UserCreate(db, user); err != nil {
t.Fatalf("UserCreate: %v", err)
}
// Find by email
found, err := UserByEmail(db, "test@example.com")
if err != nil {
t.Fatalf("UserByEmail: %v", err)
}
if found == nil {
t.Fatal("user not found")
}
if found.UserID != user.UserID {
t.Error("wrong user returned")
}
// Not found
notFound, err := UserByEmail(db, "nonexistent@example.com")
if err != nil {
t.Fatalf("UserByEmail nonexistent: %v", err)
}
if notFound != nil {
t.Error("should return nil for nonexistent email")
}
}
func TestSessionCreateAndRevoke(t *testing.T) {
db, _ := testDB(t)
now := time.Now().UnixMilli()
user := &User{
UserID: uuid.New().String(),
Email: "session@test.com",
Name: "Session User",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, user)
session := &Session{
ID: uuid.New().String(),
UserID: user.UserID,
Fingerprint: "test-fingerprint",
CreatedAt: now,
ExpiresAt: now + 86400000, // +1 day
Revoked: false,
}
if err := SessionCreate(db, session); err != nil {
t.Fatalf("SessionCreate: %v", err)
}
// Retrieve session
found, err := SessionByID(db, session.ID)
if err != nil {
t.Fatalf("SessionByID: %v", err)
}
if found == nil {
t.Fatal("session not found")
}
if found.Revoked {
t.Error("session should not be revoked")
}
// Revoke
if err := SessionRevoke(db, session.ID); err != nil {
t.Fatalf("SessionRevoke: %v", err)
}
// Check revoked
found, _ = SessionByID(db, session.ID)
if !found.Revoked {
t.Error("session should be revoked")
}
}
func TestBuyerCannotSeePreDataroom(t *testing.T) {
db, cfg := testDB(t)
// Setup owner
ownerID := uuid.New().String()
now := time.Now().UnixMilli()
owner := &User{
UserID: ownerID,
Email: "owner@test.com",
Name: "Owner",
Password: "$2a$10$test",
Active: true,
CreatedAt: now,
UpdatedAt: now,
}
UserCreate(db, owner)
projectID := testProject(t, db, cfg, ownerID)
// Create pre_dataroom entry
preEntry := &Entry{
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StagePreDataroom,
SummaryText: "Pre-dataroom entry",
}
EntryWrite(db, cfg, ownerID, preEntry)
// Create dataroom entry
drEntry := &Entry{
ProjectID: projectID,
Type: TypeRequest,
Depth: 1,
Stage: StageDataroom,
SummaryText: "Dataroom entry",
}
EntryWrite(db, cfg, ownerID, drEntry)
// Create buyer user
buyerID := testUser(t, db, cfg, projectID, RoleBuyerMember)
// Buyer reads entries
filter := EntryFilter{ProjectID: projectID, Type: TypeRequest}
entries, err := EntryRead(db, cfg, buyerID, projectID, filter)
if err != nil {
t.Fatalf("EntryRead as buyer: %v", err)
}
// Should only see dataroom entry
if len(entries) != 1 {
t.Errorf("buyer should see 1 entry (dataroom only), got %d", len(entries))
}
for _, e := range entries {
if e.Stage == StagePreDataroom {
t.Error("buyer should not see pre_dataroom entries")
}
}
// Owner should see both
entries, _ = EntryRead(db, cfg, ownerID, projectID, filter)
if len(entries) != 2 {
t.Errorf("owner should see 2 entries, got %d", len(entries))
}
}
func TestLocalStore(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "dealspace-store-test")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
store, err := NewLocalStore(tmpDir)
if err != nil {
t.Fatalf("NewLocalStore: %v", err)
}
// Write
data := []byte("test object data")
id := "abcdef1234567890"
if err := store.Write(id, data); err != nil {
t.Fatalf("Write: %v", err)
}
// Exists
if !store.Exists(id) {
t.Error("object should exist")
}
// Read
read, err := store.Read(id)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !bytes.Equal(read, data) {
t.Error("data mismatch")
}
// Delete
if err := store.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
if store.Exists(id) {
t.Error("object should not exist after delete")
}
// Read nonexistent
_, err = store.Read("nonexistent")
if err != ErrObjectNotFound {
t.Errorf("expected ErrObjectNotFound, got %v", err)
}
}