Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
327 lines
11 KiB
JavaScript
327 lines
11 KiB
JavaScript
// @vitest-environment node
|
|
/**
|
|
* API Keys PostgreSQL Integration Tests
|
|
* TDD: 🔴 RED → 🟢 GREEN → 🔵 REFACTOR
|
|
*
|
|
* Run: DATABASE_TYPE=postgres npm test -- backend/__tests__/api-keys/api-keys-postgres.test.js
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
import crypto from 'crypto';
|
|
import pg from 'pg';
|
|
|
|
const { Pool } = pg;
|
|
|
|
// PostgreSQL connection for tests
|
|
const pool = new Pool({
|
|
host: process.env.DB_HOST || 'localhost',
|
|
port: parseInt(process.env.DB_PORT || '5432'),
|
|
database: process.env.DB_NAME || 'godcrm_prod',
|
|
user: process.env.DB_USER || 'godcrm',
|
|
password: process.env.DB_PASSWORD || 'godcrm_dev_2026'
|
|
});
|
|
|
|
// Helper functions
|
|
const dbQuery = async (sql, params = []) => {
|
|
const result = await pool.query(sql, params);
|
|
return result.rows;
|
|
};
|
|
|
|
const dbGet = async (sql, params = []) => {
|
|
const rows = await dbQuery(sql, params);
|
|
return rows[0];
|
|
};
|
|
|
|
const dbRun = async (sql, params = []) => {
|
|
const result = await pool.query(sql, params);
|
|
return { rowCount: result.rowCount, rows: result.rows };
|
|
};
|
|
|
|
// Hash API key
|
|
const hashApiKey = (key) => {
|
|
return crypto.createHash('sha256').update(key).digest('hex');
|
|
};
|
|
|
|
// Generate random API key
|
|
const generateApiKey = () => {
|
|
return 'sk-' + crypto.randomBytes(16).toString('hex');
|
|
};
|
|
|
|
describe('API Keys PostgreSQL Integration', () => {
|
|
|
|
beforeAll(async () => {
|
|
// Verify connection
|
|
try {
|
|
await pool.query('SELECT 1');
|
|
console.log('✅ PostgreSQL connected');
|
|
} catch (err) {
|
|
console.error('❌ PostgreSQL connection failed:', err.message);
|
|
throw err;
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup test data
|
|
await pool.query(`DELETE FROM api_keys WHERE name LIKE 'TEST_%'`).catch(() => {});
|
|
await pool.end();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Cleanup before each test
|
|
await pool.query(`DELETE FROM api_keys WHERE name LIKE 'TEST_%'`).catch(() => {});
|
|
});
|
|
|
|
describe('System api_keys table', () => {
|
|
|
|
it('should have api_keys table with required columns', async () => {
|
|
const columns = await dbQuery(`
|
|
SELECT column_name, data_type
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'api_keys'
|
|
ORDER BY ordinal_position
|
|
`);
|
|
|
|
const columnNames = columns.map(c => c.column_name);
|
|
|
|
expect(columnNames).toContain('id');
|
|
expect(columnNames).toContain('key_prefix');
|
|
expect(columnNames).toContain('key_hash');
|
|
expect(columnNames).toContain('is_active');
|
|
});
|
|
|
|
it('should insert API key with prefix and hash', async () => {
|
|
const apiKey = generateApiKey();
|
|
const keyPrefix = apiKey.substring(0, 7);
|
|
const keyHash = hashApiKey(apiKey);
|
|
|
|
const result = await dbRun(`
|
|
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, scopes, is_active, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
|
RETURNING id
|
|
`, [1, 'TEST_insert_key', keyPrefix, keyHash, 'all', 1]);
|
|
|
|
expect(result.rowCount).toBe(1);
|
|
expect(result.rows[0].id).toBeDefined();
|
|
});
|
|
|
|
it('should find API key by prefix and hash', async () => {
|
|
const apiKey = generateApiKey();
|
|
const keyPrefix = apiKey.substring(0, 7);
|
|
const keyHash = hashApiKey(apiKey);
|
|
|
|
// Insert
|
|
await dbRun(`
|
|
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, scopes, is_active, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
|
`, [1, 'TEST_find_key', keyPrefix, keyHash, 'all', 1]);
|
|
|
|
// Find by prefix + hash
|
|
const found = await dbGet(`
|
|
SELECT * FROM api_keys WHERE key_prefix = $1 AND key_hash = $2
|
|
`, [keyPrefix, keyHash]);
|
|
|
|
expect(found).toBeDefined();
|
|
expect(found.name).toBe('TEST_find_key');
|
|
expect(found.key_prefix).toBe(keyPrefix);
|
|
expect(found.is_active).toBe(1);
|
|
});
|
|
|
|
it('should NOT find key with wrong hash', async () => {
|
|
const apiKey = generateApiKey();
|
|
const keyPrefix = apiKey.substring(0, 7);
|
|
const keyHash = hashApiKey(apiKey);
|
|
const wrongHash = hashApiKey('wrong-key');
|
|
|
|
// Insert
|
|
await dbRun(`
|
|
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, scopes, is_active, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
|
`, [1, 'TEST_wrong_hash', keyPrefix, keyHash, 'all', 1]);
|
|
|
|
// Try to find with wrong hash
|
|
const notFound = await dbGet(`
|
|
SELECT * FROM api_keys WHERE key_prefix = $1 AND key_hash = $2
|
|
`, [keyPrefix, wrongHash]);
|
|
|
|
expect(notFound).toBeUndefined();
|
|
});
|
|
|
|
it('should NOT authenticate inactive key', async () => {
|
|
const apiKey = generateApiKey();
|
|
const keyPrefix = apiKey.substring(0, 7);
|
|
const keyHash = hashApiKey(apiKey);
|
|
|
|
// Insert inactive key
|
|
await dbRun(`
|
|
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, scopes, is_active, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
|
`, [1, 'TEST_inactive_key', keyPrefix, keyHash, 'all', 0]);
|
|
|
|
// Try to find active key
|
|
const notFound = await dbGet(`
|
|
SELECT * FROM api_keys WHERE key_prefix = $1 AND key_hash = $2 AND is_active = 1
|
|
`, [keyPrefix, keyHash]);
|
|
|
|
expect(notFound).toBeUndefined();
|
|
});
|
|
|
|
it('should update last_used_at and request_count', async () => {
|
|
const apiKey = generateApiKey();
|
|
const keyPrefix = apiKey.substring(0, 7);
|
|
const keyHash = hashApiKey(apiKey);
|
|
|
|
// Insert
|
|
const insertResult = await dbRun(`
|
|
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, scopes, is_active, request_count, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
|
RETURNING id
|
|
`, [1, 'TEST_usage_key', keyPrefix, keyHash, 'all', 1, 0]);
|
|
|
|
const keyId = insertResult.rows[0].id;
|
|
|
|
// Update usage stats
|
|
await dbRun(`
|
|
UPDATE api_keys
|
|
SET last_used_at = NOW(), request_count = request_count + 1
|
|
WHERE id = $1
|
|
`, [keyId]);
|
|
|
|
// Verify
|
|
const updated = await dbGet(`SELECT * FROM api_keys WHERE id = $1`, [keyId]);
|
|
|
|
expect(updated.request_count).toBe(1);
|
|
expect(updated.last_used_at).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('api_keys_list CRM table', () => {
|
|
|
|
it('should find api_keys_list table', async () => {
|
|
const table = await dbGet(`
|
|
SELECT ut.id, ut.name, p.name as project_name, p.space_id
|
|
FROM universal_tables ut
|
|
JOIN projects p ON ut.project_id = p.id
|
|
WHERE ut.name = 'api_keys_list'
|
|
LIMIT 1
|
|
`);
|
|
|
|
// Table should exist in System Data
|
|
if (table) {
|
|
expect(table.name).toBe('api_keys_list');
|
|
console.log(`✅ Found api_keys_list in project: ${table.project_name} (space ${table.space_id})`);
|
|
} else {
|
|
console.log('⚠️ api_keys_list table not found - will be created on first use');
|
|
}
|
|
});
|
|
|
|
it('should be able to store key data in table_rows', async () => {
|
|
// Find api_keys_list table
|
|
const table = await dbGet(`
|
|
SELECT ut.id, ut.base_id FROM universal_tables ut WHERE ut.name = 'api_keys_list' LIMIT 1
|
|
`);
|
|
|
|
if (!table) {
|
|
console.log('⚠️ Skipping - no api_keys_list table');
|
|
return;
|
|
}
|
|
|
|
const apiKey = generateApiKey();
|
|
const keyData = {
|
|
name: 'TEST_crm_key',
|
|
key_prefix: apiKey.substring(0, 7),
|
|
scopes: ['*'],
|
|
is_active: true,
|
|
created_at: new Date().toISOString()
|
|
};
|
|
|
|
// Insert with base_id (required field)
|
|
const baseId = table.base_id || table.id;
|
|
const result = await dbRun(`
|
|
INSERT INTO table_rows (table_id, base_id, data, created_at, updated_at)
|
|
VALUES ($1, $2, $3, NOW(), NOW())
|
|
RETURNING id
|
|
`, [table.id, baseId, JSON.stringify(keyData)]);
|
|
|
|
expect(result.rowCount).toBe(1);
|
|
|
|
// Verify
|
|
const inserted = await dbGet(`
|
|
SELECT id, data FROM table_rows WHERE id = $1
|
|
`, [result.rows[0].id]);
|
|
|
|
expect(inserted).toBeDefined();
|
|
const data = typeof inserted.data === 'string' ? JSON.parse(inserted.data) : inserted.data;
|
|
expect(data.name).toBe('TEST_crm_key');
|
|
|
|
// Cleanup
|
|
await dbRun(`DELETE FROM table_rows WHERE id = $1`, [result.rows[0].id]);
|
|
});
|
|
|
|
it('should list keys from table_rows with correct data', async () => {
|
|
const table = await dbGet(`
|
|
SELECT id FROM universal_tables WHERE name = 'api_keys_list' LIMIT 1
|
|
`);
|
|
|
|
if (!table) {
|
|
console.log('⚠️ Skipping - no api_keys_list table');
|
|
return;
|
|
}
|
|
|
|
// Get existing keys
|
|
const keys = await dbQuery(`
|
|
SELECT id, data FROM table_rows WHERE table_id = $1
|
|
`, [table.id]);
|
|
|
|
console.log(`📊 Found ${keys.length} keys in api_keys_list`);
|
|
|
|
// Each key should have required fields
|
|
for (const key of keys) {
|
|
const data = typeof key.data === 'string' ? JSON.parse(key.data) : key.data;
|
|
expect(data.name || data.key_prefix).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Authentication flow', () => {
|
|
|
|
it('should authenticate with valid API key (full flow)', async () => {
|
|
const apiKey = generateApiKey();
|
|
const keyPrefix = apiKey.substring(0, 7);
|
|
const keyHash = hashApiKey(apiKey);
|
|
|
|
// 1. Create key in database
|
|
await dbRun(`
|
|
INSERT INTO api_keys (user_id, name, key_prefix, key_hash, scopes, is_active, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
|
`, [1, 'TEST_auth_flow', keyPrefix, keyHash, 'all', 1]);
|
|
|
|
// 2. Simulate authentication (what middleware does)
|
|
const inputPrefix = apiKey.substring(0, 7);
|
|
const inputHash = hashApiKey(apiKey);
|
|
|
|
const keyRecord = await dbGet(`
|
|
SELECT ak.*, u.id as uid, u.email, u.name as user_name, u.role
|
|
FROM api_keys ak
|
|
JOIN users u ON ak.user_id = u.id
|
|
WHERE ak.key_prefix = $1 AND ak.key_hash = $2
|
|
`, [inputPrefix, inputHash]);
|
|
|
|
// 3. Verify authentication succeeded
|
|
expect(keyRecord).toBeDefined();
|
|
expect(keyRecord.is_active).toBe(1);
|
|
expect(keyRecord.uid).toBe(1);
|
|
});
|
|
|
|
it('should reject authentication with invalid key', async () => {
|
|
const fakeKey = 'sk-' + crypto.randomBytes(16).toString('hex');
|
|
const fakePrefix = fakeKey.substring(0, 7);
|
|
const fakeHash = hashApiKey(fakeKey);
|
|
|
|
// Try to authenticate non-existent key
|
|
const keyRecord = await dbGet(`
|
|
SELECT * FROM api_keys WHERE key_prefix = $1 AND key_hash = $2
|
|
`, [fakePrefix, fakeHash]);
|
|
|
|
expect(keyRecord).toBeUndefined();
|
|
});
|
|
});
|
|
});
|