mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-04-19 13:33:34 -04:00
307 lines
9.6 KiB
TypeScript
307 lines
9.6 KiB
TypeScript
/**
|
|
* Integration tests: Backup secret stripping
|
|
*
|
|
* When a user downloads a backup, the included profilarr.db should NOT
|
|
* contain any secrets. This prevents credential leakage if a backup
|
|
* file is shared, stored insecurely, or downloaded by a compromised session.
|
|
*
|
|
* The test seeds known secrets into the live database, creates a backup via
|
|
* POST /api/v1/backups, polls the job until complete, downloads and extracts
|
|
* the tar.gz, and inspects the database copy inside.
|
|
*
|
|
* Secrets that must be stripped:
|
|
* - arr_instances.api_key (Radarr/Sonarr API keys)
|
|
* - database_instances.personal_access_token (GitHub PATs)
|
|
* - auth_settings.api_key (bcrypt-hashed Profilarr API key)
|
|
* - ai_settings.api_key (OpenAI/Anthropic keys)
|
|
* - tmdb_settings.api_key (TMDB key)
|
|
* - notification_services.config (JSON with webhook URLs)
|
|
* - users (password hashes)
|
|
* - sessions (active session tokens)
|
|
* - login_attempts (IP addresses)
|
|
*
|
|
* Tests:
|
|
* 1. Backup DB does not contain arr API keys
|
|
* 2. Backup DB does not contain database PATs
|
|
* 3. Backup DB does not contain Profilarr API key
|
|
* 4. Backup DB does not contain AI API key
|
|
* 5. Backup DB does not contain TMDB API key
|
|
* 6. Backup DB does not contain notification webhook URLs
|
|
* 7. Backup DB does not contain user password hashes
|
|
* 8. Backup DB does not contain sessions
|
|
* 9. Backup DB does not contain login attempts
|
|
*/
|
|
|
|
import { assertEquals } from '@std/assert';
|
|
import { TestClient } from '$test-harness/client.ts';
|
|
import { startServer, stopServer, getDbPath } from '$test-harness/server.ts';
|
|
import { createUserDirect, login } from '$test-harness/setup.ts';
|
|
import { setup, teardown, test, run } from '$test-harness/runner.ts';
|
|
import { Database } from '@db/sqlite';
|
|
import { hash } from '@felix/bcrypt';
|
|
|
|
const PORT = 7017;
|
|
const ORIGIN = `http://localhost:${PORT}`;
|
|
|
|
// Known secrets seeded into the live DB
|
|
const ARR_API_KEY = 'sonarr-backup-test-key-abc123';
|
|
const DB_PAT = 'ghp-backup-test-pat-xyz789';
|
|
const TMDB_API_KEY = 'tmdb-backup-test-key-def456';
|
|
const AI_API_KEY = 'sk-backup-test-ai-key-ghi012';
|
|
const PROFILARR_API_KEY = 'profilarr-backup-test-key-jkl345';
|
|
const WEBHOOK_URL = 'https://discord.com/api/webhooks/backup-test-id/backup-test-token';
|
|
|
|
let backupDbPath: string;
|
|
let extractDir: string;
|
|
|
|
async function seedSecrets(dbPath: string) {
|
|
const db = new Database(dbPath);
|
|
try {
|
|
// Arr instance
|
|
db.exec(
|
|
`INSERT INTO arr_instances (name, type, url, api_key, enabled)
|
|
VALUES ('Backup Test Sonarr', 'sonarr', 'http://localhost:8989', ?, 1)`,
|
|
[ARR_API_KEY]
|
|
);
|
|
|
|
// Database instance
|
|
const uuid = crypto.randomUUID();
|
|
db.exec(
|
|
`INSERT INTO database_instances (uuid, name, repository_url, personal_access_token, local_path, enabled)
|
|
VALUES (?, 'Backup Test DB', 'https://github.com/test/repo', ?, ?, 1)`,
|
|
[uuid, DB_PAT, `./data/databases/${uuid}`]
|
|
);
|
|
|
|
// TMDB API key
|
|
db.exec('UPDATE tmdb_settings SET api_key = ? WHERE id = 1', [TMDB_API_KEY]);
|
|
|
|
// AI API key
|
|
const aiRow = db.prepare('SELECT COUNT(*) as count FROM ai_settings').get() as {
|
|
count: number;
|
|
};
|
|
if (aiRow.count > 0) {
|
|
db.exec('UPDATE ai_settings SET api_key = ? WHERE id = 1', [AI_API_KEY]);
|
|
}
|
|
|
|
// Profilarr API key (bcrypt-hashed)
|
|
const hashedApiKey = await hash(PROFILARR_API_KEY);
|
|
db.exec('UPDATE auth_settings SET api_key = ? WHERE id = 1', [hashedApiKey]);
|
|
|
|
// Notification service with webhook URL
|
|
const serviceId = crypto.randomUUID();
|
|
db.exec(
|
|
`INSERT INTO notification_services (id, name, service_type, enabled, config, enabled_types)
|
|
VALUES (?, 'Backup Test Discord', 'discord', 1, ?, '[]')`,
|
|
[serviceId, JSON.stringify({ webhook_url: WEBHOOK_URL, username: 'Profilarr' })]
|
|
);
|
|
|
|
// Login attempt (contains IP addresses)
|
|
db.exec(
|
|
`INSERT INTO login_attempts (ip, endpoint, category) VALUES ('192.168.1.100', '/auth/login', 'unknown')`
|
|
);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger backup via the v1 API, poll the job until complete, download and
|
|
* extract the archive. Returns the path to the extracted profilarr.db.
|
|
*/
|
|
async function downloadAndExtractBackup(client: TestClient): Promise<string> {
|
|
// Trigger backup creation via API
|
|
const createRes = await client.post('/api/v1/backups', {});
|
|
assertEquals(createRes.status, 202, 'Backup creation should return 202');
|
|
const { jobId } = await createRes.json();
|
|
|
|
// Poll job status until complete
|
|
for (let i = 0; i < 30; i++) {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
const jobRes = await client.get(`/api/v1/jobs/${jobId}`);
|
|
const job = await jobRes.json();
|
|
if (job.status === 'success') break;
|
|
if (job.status === 'failure') throw new Error(`Backup job failed: ${job.result?.error}`);
|
|
}
|
|
|
|
// Get the backup filename from the list
|
|
const listRes = await client.get('/api/v1/backups');
|
|
assertEquals(listRes.status, 200, 'Backup list should return 200');
|
|
const backups = await listRes.json();
|
|
if (backups.length === 0) {
|
|
throw new Error('No backup files found after job completed');
|
|
}
|
|
const backupFilename = backups[0].filename;
|
|
|
|
// Download the backup
|
|
const res = await client.get(`/api/v1/backups/${backupFilename}`);
|
|
assertEquals(res.status, 200, 'Backup download should return 200');
|
|
|
|
// Write to a temp file
|
|
const tmpDir = await Deno.makeTempDir({ prefix: 'profilarr-backup-test-' });
|
|
const tarPath = `${tmpDir}/backup.tar.gz`;
|
|
const data = new Uint8Array(await res.arrayBuffer());
|
|
await Deno.writeFile(tarPath, data);
|
|
|
|
// Extract
|
|
extractDir = `${tmpDir}/extracted`;
|
|
await Deno.mkdir(extractDir, { recursive: true });
|
|
const extract = new Deno.Command('tar', {
|
|
args: ['-xzf', tarPath, '-C', extractDir],
|
|
stdout: 'piped',
|
|
stderr: 'piped'
|
|
});
|
|
const { code } = await extract.output();
|
|
if (code !== 0) {
|
|
throw new Error('Failed to extract backup tar.gz');
|
|
}
|
|
|
|
// Find profilarr.db in extracted contents
|
|
const dbPath = `${extractDir}/data/profilarr.db`;
|
|
await Deno.stat(dbPath);
|
|
return dbPath;
|
|
}
|
|
|
|
setup(async () => {
|
|
await startServer(PORT, { AUTH: 'on', ORIGIN }, 'preview');
|
|
await createUserDirect(getDbPath(PORT), 'admin', 'password123');
|
|
await seedSecrets(getDbPath(PORT));
|
|
|
|
// Login and download the backup once — all tests inspect the same extracted DB
|
|
const client = new TestClient(ORIGIN);
|
|
await login(client, 'admin', 'password123', ORIGIN);
|
|
backupDbPath = await downloadAndExtractBackup(client);
|
|
});
|
|
|
|
teardown(async () => {
|
|
await stopServer(PORT);
|
|
if (extractDir) {
|
|
try {
|
|
// extractDir is inside a temp dir — remove the parent
|
|
const tmpDir = extractDir.replace('/extracted', '');
|
|
await Deno.remove(tmpDir, { recursive: true });
|
|
} catch {
|
|
// Cleanup is best-effort
|
|
}
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain arr API keys', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const rows = db.prepare('SELECT api_key FROM arr_instances').all() as { api_key: string }[];
|
|
for (const row of rows) {
|
|
assertEquals(row.api_key === ARR_API_KEY, false, 'Backup DB contains plaintext arr API key');
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain database PATs', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const rows = db.prepare('SELECT personal_access_token FROM database_instances').all() as {
|
|
personal_access_token: string | null;
|
|
}[];
|
|
for (const row of rows) {
|
|
assertEquals(
|
|
row.personal_access_token === DB_PAT,
|
|
false,
|
|
'Backup DB contains plaintext database PAT'
|
|
);
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain Profilarr API key', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const row = db.prepare('SELECT api_key FROM auth_settings WHERE id = 1').get() as {
|
|
api_key: string | null;
|
|
};
|
|
assertEquals(row.api_key, null, 'Backup DB contains Profilarr API key hash');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain AI API key', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const row = db.prepare('SELECT api_key FROM ai_settings WHERE id = 1').get() as
|
|
| {
|
|
api_key: string | null;
|
|
}
|
|
| undefined;
|
|
if (row) {
|
|
assertEquals(row.api_key === AI_API_KEY, false, 'Backup DB contains plaintext AI API key');
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain TMDB API key', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const row = db.prepare('SELECT api_key FROM tmdb_settings WHERE id = 1').get() as {
|
|
api_key: string;
|
|
};
|
|
assertEquals(row.api_key === TMDB_API_KEY, false, 'Backup DB contains plaintext TMDB API key');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain notification webhook URLs', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const rows = db.prepare('SELECT config FROM notification_services').all() as {
|
|
config: string;
|
|
}[];
|
|
for (const row of rows) {
|
|
assertEquals(
|
|
row.config.includes(WEBHOOK_URL),
|
|
false,
|
|
'Backup DB contains webhook URL in notification config'
|
|
);
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain user password hashes', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM users').all();
|
|
assertEquals(rows.length, 0, 'Backup DB still contains user records');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain sessions', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM sessions').all();
|
|
assertEquals(rows.length, 0, 'Backup DB still contains session records');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
test('backup DB does not contain login attempts', () => {
|
|
const db = new Database(backupDbPath);
|
|
try {
|
|
const rows = db.prepare('SELECT * FROM login_attempts').all();
|
|
assertEquals(rows.length, 0, 'Backup DB still contains login attempt records');
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
await run();
|