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) } }