628 lines
14 KiB
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)
|
|
}
|
|
}
|