/**
 * Security Regression Tests
 *
 * Tests for all 24 security fixes applied to DashCaddy.
 * These tests verify that previously-fixed vulnerabilities remain patched.
 * Grouped by the module/route they protect.
 */

const request = require('supertest');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const crypto = require('crypto');

const tmpDir = path.join(os.tmpdir(), `security-tests-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });

const testServicesFile = path.join(tmpDir, 'services.json');
const testConfigFile = path.join(tmpDir, 'config.json');
const testCaddyfile = path.join(tmpDir, 'Caddyfile');
const testCredentialsFile = path.join(tmpDir, 'credentials.json');
const testTotpConfigFile = path.join(tmpDir, 'totp-config.json');
const testErrorLogFile = path.join(tmpDir, 'error.log');

process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.CADDYFILE_PATH = testCaddyfile;
process.env.CREDENTIALS_FILE = testCredentialsFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';

fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
fs.writeFileSync(testCaddyfile, '# Test Caddyfile\n', 'utf8');

const app = require('../server');

afterAll(() => {
  fs.rmSync(tmpDir, { recursive: true, force: true });
});

// ============================================================
// CREDENTIAL MANAGER — Cache TTL
// ============================================================
describe('Credential Manager Cache TTL', () => {
  const CredentialManager = require('../credential-manager').constructor;

  test('cache entries should have expiration timestamps', () => {
    const cm = new CredentialManager();
    cm.cache.set('test.key', { value: 'secret', exp: Date.now() + 300000 });
    const cached = cm.cache.get('test.key');
    expect(cached).toHaveProperty('exp');
    expect(cached.exp).toBeGreaterThan(Date.now());
  });

  test('expired cache entries should not be returned by retrieve', async () => {
    const cm = new CredentialManager();
    // Set an expired entry
    cm.cache.set('expired.key', { value: 'old-secret', exp: Date.now() - 1000 });
    // retrieve() checks cache TTL — expired entry should be deleted
    // Since there's no file backing, it will return null
    const result = await cm.retrieve('expired.key');
    expect(result).toBeNull();
    expect(cm.cache.has('expired.key')).toBe(false);
  });
});

// ============================================================
// CRYPTO UTILS — Key Rotation
// ============================================================
describe('Crypto Utils — Key Rotation', () => {
  const cryptoUtils = require('../crypto-utils');

  test('rotateKey should be exported and callable', () => {
    // rotateKey writes to disk so just verify the function exists and signature
    expect(typeof cryptoUtils.rotateKey).toBe('function');
  });

  test('decryptWithKey should decrypt with specified key', () => {
    const key = crypto.randomBytes(32);
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
    let encrypted = cipher.update('test-data', 'utf8', 'base64');
    encrypted += cipher.final('base64');
    const authTag = cipher.getAuthTag();
    const encStr = `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;

    const result = cryptoUtils.decryptWithKey(encStr, key);
    expect(result).toBe('test-data');
  });

  test('decryptWithKey should reject invalid format', () => {
    const key = crypto.randomBytes(32);
    expect(() => cryptoUtils.decryptWithKey('invalid-no-colons', key)).toThrow('Invalid encrypted data format');
  });
});

// ============================================================
// TOTP — Disable requires code verification
// ============================================================
describe('TOTP Disable Security', () => {
  test('POST /api/totp/disable should reject missing code when TOTP is active', async () => {
    // This tests that disabling TOTP requires a valid code
    // When TOTP is not set up, the endpoint just disables it
    // But when it IS set up, code is mandatory
    const res = await request(app)
      .post('/api/totp/disable')
      .send({});

    // If TOTP isn't set up in test env, it will succeed (200)
    // The important thing is it doesn't crash
    expect([200, 400, 401]).toContain(res.statusCode);
  });

  test('POST /api/totp/disable should reject non-6-digit code', async () => {
    const res = await request(app)
      .post('/api/totp/disable')
      .send({ code: 'abc' });

    // If TOTP is active, should reject non-numeric codes
    expect([200, 400, 401]).toContain(res.statusCode);
  });
});

// ============================================================
// SITES — Caddy reload error leak prevention
// ============================================================
describe('Sites Route Security', () => {
  test('POST /api/site should reject invalid domain format', async () => {
    const res = await request(app)
      .post('/api/site')
      .send({ domain: '<script>alert(1)</script>', upstream: 'localhost:8080' });

    expect(res.statusCode).toBe(400);
    expect(res.body.error).toContain('DC-301');
  });

  test('POST /api/site should reject invalid upstream format', async () => {
    const res = await request(app)
      .post('/api/site')
      .send({ domain: 'test.sami', upstream: 'not-valid' });

    expect(res.statusCode).toBe(400);
    expect(res.body.error).toContain('upstream');
  });

  test('POST /api/site/external should reject URLs with Caddyfile injection chars', async () => {
    const res = await request(app)
      .post('/api/site/external')
      .send({
        subdomain: 'test',
        externalUrl: 'https://evil.com/path{inject}'
      });

    // Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {})
    expect([400, 500]).toContain(res.statusCode);
    // Must never succeed
    expect(res.statusCode).not.toBe(200);
  });

  test('POST /api/site/external should reject URLs with newlines', async () => {
    const res = await request(app)
      .post('/api/site/external')
      .send({
        subdomain: 'test',
        externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234'
      });

    expect(res.statusCode).toBe(400);
  });

  test('POST /api/site/external should reject missing fields', async () => {
    const res = await request(app)
      .post('/api/site/external')
      .send({});

    expect(res.statusCode).toBe(400);
  });

  test('POST /api/site/external should reject invalid subdomain', async () => {
    const res = await request(app)
      .post('/api/site/external')
      .send({
        subdomain: '../etc/passwd',
        externalUrl: 'https://example.com'
      });

    expect(res.statusCode).toBe(400);
  });
});

// ============================================================
// ERROR LOGS — No stack trace leak
// ============================================================
describe('Error Logs — No Stack Trace Leak', () => {
  beforeAll(async () => {
    // Write a fake error log with stack traces
    const logContent = [
      '[2026-03-07 12:00:00] server: Something failed',
      'Error: Internal failure',
      '    at Object.<anonymous> (/app/server.js:123:45)',
      '    at Module._compile (node:internal/modules/cjs/loader:1234:14)',
      '================================================================================',
      '[2026-03-07 12:01:00] dns: DNS timeout',
      'Error: connect ECONNREFUSED 192.168.1.1:5380',
      '    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)',
      '================================================================================'
    ].join('\n');
    // Write to the server's error log file location
    // The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to
    await fsp.writeFile(testErrorLogFile, logContent);
  });

  test('GET /api/error-logs should not include details/stack traces', async () => {
    const res = await request(app).get('/api/error-logs');

    expect(res.statusCode).toBe(200);
    expect(res.body.success).toBe(true);

    // If there are logs, verify none contain 'details' field
    if (res.body.logs.length > 0) {
      for (const log of res.body.logs) {
        expect(log).not.toHaveProperty('details');
        // Verify it has the safe fields
        if (log.timestamp) {
          expect(log).toHaveProperty('timestamp');
          expect(log).toHaveProperty('context');
          expect(log).toHaveProperty('error');
        }
      }
    }
  });
});

// ============================================================
// CONTAINERS — ID validation
// ============================================================
describe('Container ID Validation', () => {
  test('GET /api/containers/:id/check-update should 404 for nonexistent container', async () => {
    const res = await request(app).get('/api/containers/nonexistent123/check-update');

    // Should return 404 (not found) not 500 (unhandled error)
    expect([404]).toContain(res.statusCode);
  });

  test('POST /api/containers/:id/update should 404 for nonexistent container', async () => {
    const res = await request(app).post('/api/containers/nonexistent123/update');

    expect([404]).toContain(res.statusCode);
  });

  test('GET /api/logs/container/:id should 404 for nonexistent container', async () => {
    const res = await request(app).get('/api/logs/container/nonexistent123');

    expect([404]).toContain(res.statusCode);
  });

  test('GET /api/logs/stream/:id should 404 for nonexistent container', async () => {
    const res = await request(app).get('/api/logs/stream/nonexistent123');

    expect([404]).toContain(res.statusCode);
  });
});

// ============================================================
// LOG FILE — Path traversal prevention
// ============================================================
describe('Log File Path Traversal', () => {
  test('GET /api/logs/file should reject missing path', async () => {
    const res = await request(app).get('/api/logs/file');

    expect(res.statusCode).toBe(400);
  });

  test('GET /api/logs/file should reject traversal paths', async () => {
    const res = await request(app)
      .get('/api/logs/file')
      .query({ path: '/etc/shadow' });

    // Should be 403 (not allowed) or 404 (not found), never 200
    expect([403, 404]).toContain(res.statusCode);
  });

  test('GET /api/logs/file should reject Windows system paths', async () => {
    const res = await request(app)
      .get('/api/logs/file')
      .query({ path: 'C:\\Windows\\System32\\config\\SAM' });

    expect([403, 404]).toContain(res.statusCode);
  });

  test('GET /api/logs/file should reject parent directory traversal', async () => {
    const res = await request(app)
      .get('/api/logs/file')
      .query({ path: '/var/log/../../etc/passwd' });

    expect([403, 404]).toContain(res.statusCode);
  });
});

// ============================================================
// BACKUP — No encryption key in export, TOTP re-auth for restore
// ============================================================
describe('Backup Security', () => {
  test('GET /api/backup/export should not include encryption key', async () => {
    const res = await request(app).get('/api/backup/export');

    if (res.statusCode === 200 && res.body.backup) {
      const backup = res.body.backup;
      // Verify encryptionKey is NOT in the backup files
      expect(backup.files).not.toHaveProperty('encryptionKey');
      // Verify TOTP backup doesn't include manualKey
      if (backup.totp) {
        expect(backup.totp).not.toHaveProperty('manualKey');
      }
    }
  });

  test('POST /api/backup/restore should reject invalid backup format', async () => {
    const res = await request(app)
      .post('/api/backup/restore')
      .send({ backup: { invalid: true } });

    expect(res.statusCode).toBe(400);
  });

  test('POST /api/backup/restore should not restore encryptionKey even if provided', async () => {
    const res = await request(app)
      .post('/api/backup/restore')
      .send({
        backup: {
          version: '1.0',
          files: {
            encryptionKey: {
              type: 'text',
              content: 'malicious-key-data'
            }
          }
        }
      });

    // The encryptionKey should be skipped (not in fileMapping)
    if (res.statusCode === 200) {
      // If it succeeded, verify encryptionKey was skipped
      expect(res.body.results.restored).not.toContain('encryptionKey');
    }
  });
});

// ============================================================
// SESSION COOKIE — Secure flag
// ============================================================
describe('Session Cookie Security', () => {
  test('session cookies should include Secure flag', async () => {
    // TOTP verify would set a session cookie on success
    // We can check the middleware by looking at any response that sets cookies
    const res = await request(app)
      .post('/api/totp/verify')
      .send({ code: '123456' });

    // Even though verify fails, check cookie format if any cookies are set
    const cookies = res.headers['set-cookie'];
    if (cookies) {
      for (const cookie of Array.isArray(cookies) ? cookies : [cookies]) {
        if (cookie.includes('dashcaddy_session')) {
          expect(cookie.toLowerCase()).toContain('secure');
          expect(cookie.toLowerCase()).toContain('httponly');
          expect(cookie.toLowerCase()).toContain('samesite');
        }
      }
    }
  });
});

// ============================================================
// CUSTOM VOLUME — Host path validation
// ============================================================
describe('Custom Volume Path Validation', () => {
  // This tests the processTemplateVariables function indirectly
  // The helpers.js validates custom volume hostPath against allowed roots

  test('should not allow arbitrary host paths in volume overrides', async () => {
    // Deploy endpoint would use processTemplateVariables
    // Sending a custom volume with a dangerous path
    const res = await request(app)
      .post('/api/apps/deploy')
      .send({
        appId: 'plex',
        subdomain: 'test-plex',
        ip: '192.168.1.100',
        port: '32400',
        customVolumes: [{
          containerPath: '/config',
          hostPath: '/etc/shadow'
        }]
      });

    // The deploy will likely fail for other reasons (no Docker, etc.)
    // But if it reaches volume processing, the dangerous path should be rejected
    // The key check: it shouldn't return 200 with /etc/shadow mounted
    if (res.statusCode === 200) {
      // If somehow succeeded, verify the dangerous path wasn't used
      expect(JSON.stringify(res.body)).not.toContain('/etc/shadow');
    }
  });
});

// ============================================================
// LOGO DELETE — Path traversal prevention
// ============================================================
describe('Logo Delete Path Traversal', () => {
  test('DELETE /api/logo should safely handle config with traversal paths', async () => {
    // Write config with a malicious logo path
    const configWithMaliciousLogo = {
      customLogo: '/assets/../../etc/passwd',
      customLogoDark: '/assets/../../../root/.ssh/id_rsa'
    };
    await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8');

    const res = await request(app).delete('/api/logo');

    // Should succeed (reset branding) without deleting files outside assets dir
    expect(res.statusCode).toBe(200);
    expect(res.body.success).toBe(true);

    // Reset config for other tests
    await fsp.writeFile(testConfigFile, '{}', 'utf8');
  });
});

// ============================================================
// DNS — SSRF prevention (server parameter validation)
// ============================================================
describe('DNS Server SSRF Prevention', () => {
  test('DELETE /api/dns/record should not succeed with arbitrary server IPs', async () => {
    const res = await request(app)
      .delete('/api/dns/record')
      .query({
        domain: 'test.sami',
        type: 'A',
        server: '169.254.169.254'  // AWS metadata endpoint
      });

    // Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test)
    expect(res.statusCode).not.toBe(200);
  });

  test('POST /api/dns/record should not succeed with arbitrary server IPs', async () => {
    const res = await request(app)
      .post('/api/dns/record')
      .send({
        domain: 'test.sami',
        ipAddress: '192.168.1.1',
        server: '10.0.0.1'  // Not a configured DNS server
      });

    expect(res.statusCode).not.toBe(200);
  });

  test('GET /api/dns/resolve should not succeed with arbitrary server IPs', async () => {
    const res = await request(app)
      .get('/api/dns/resolve')
      .query({
        domain: 'test.sami',
        server: '127.0.0.1'
      });

    expect(res.statusCode).not.toBe(200);
  });

  test('GET /api/dns/logs should reject arbitrary server IPs', async () => {
    const res = await request(app)
      .get('/api/dns/logs')
      .query({ server: '192.168.1.1' });

    expect([400]).toContain(res.statusCode);
  });

  test('GET /api/dns/check-update should reject arbitrary server IPs', async () => {
    const res = await request(app)
      .get('/api/dns/check-update')
      .query({ server: '8.8.8.8' });

    expect([400]).toContain(res.statusCode);
  });

  test('POST /api/dns/update should reject arbitrary server IPs', async () => {
    const res = await request(app)
      .post('/api/dns/update')
      .query({ server: '1.1.1.1' });

    expect([400]).toContain(res.statusCode);
  });
});

// ============================================================
// _httpFetch — Response size limit
// ============================================================
describe('HTTP Fetch Response Size Limit', () => {
  // This is tested indirectly — the _httpFetch function has a 10MB limit
  // We can verify the constant exists by checking the server module
  test('server should define MAX_RESPONSE_SIZE constant', () => {
    // Read server.js and verify the limit is defined
    const serverSource = fs.readFileSync(
      path.join(__dirname, '..', 'server.js'), 'utf8'
    );
    expect(serverSource).toContain('MAX_RESPONSE_SIZE');
    expect(serverSource).toContain('10 * 1024 * 1024');
  });
});

// ============================================================
// MIDDLEWARE — Session cookie format
// ============================================================
describe('Middleware Security', () => {
  test('middleware should set Secure flag on cookies', () => {
    const middlewareSource = fs.readFileSync(
      path.join(__dirname, '..', 'middleware.js'), 'utf8'
    );
    // Verify the Set-Cookie string includes Secure
    expect(middlewareSource).toContain('; Secure;');
  });
});

// ============================================================
// SAVECONFIG — Atomic operations
// ============================================================
describe('Config Save Atomicity', () => {
  test('saveConfig should use state manager for locking', () => {
    const serverSource = fs.readFileSync(
      path.join(__dirname, '..', 'server.js'), 'utf8'
    );
    // Verify saveConfig uses configStateManager.update (not raw fs.writeFile)
    expect(serverSource).toContain('configStateManager.update');
  });
});

// ============================================================
// SITES — External URL validation
// ============================================================
describe('External URL Security', () => {
  test('sites.js should validate URL components for unsafe chars', () => {
    const sitesSource = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8'
    );
    // Verify the unsafe character regex exists
    expect(sitesSource).toContain('unsafeCaddyChars');
    expect(sitesSource).toMatch(/[{}\\n\\r]/);
  });
});

// ============================================================
// CREDENTIAL MANAGER — Locking
// ============================================================
describe('Credential Manager File Locking', () => {
  test('credential-manager should use proper-lockfile', () => {
    const cmSource = fs.readFileSync(
      path.join(__dirname, '..', 'credential-manager.js'), 'utf8'
    );
    expect(cmSource).toContain('proper-lockfile');
    expect(cmSource).toContain('_lockedUpdate');
  });
});

// ============================================================
// TOTP CONFIG — No plaintext secret in file
// ============================================================
describe('TOTP Config File Security', () => {
  test('loadTotpConfig should delete secret from file data', () => {
    const serverSource = fs.readFileSync(
      path.join(__dirname, '..', 'server.js'), 'utf8'
    );
    // Verify the secret deletion exists in loadTotpConfig
    expect(serverSource).toContain('delete loaded.secret');
  });

  test('totp verify-setup should not write secret to config file', () => {
    const totpSource = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8'
    );
    // Verify totpConfig.secret assignment is NOT present
    expect(totpSource).not.toContain('totpConfig.secret = pendingSecret');
    expect(totpSource).not.toContain('totpConfig.secret =');
  });
});

// ============================================================
// HELPERS — Volume path validation
// ============================================================
describe('Helpers — Volume Security', () => {
  test('helpers.js should validate hostPath against allowed roots', () => {
    const helpersSource = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8'
    );
    expect(helpersSource).toContain('allowedRoots');
    expect(helpersSource).toContain('platformPaths.dockerData');
    expect(helpersSource).toContain('Custom volume host path rejected');
  });
});

// ============================================================
// ERROR LOGS — No details field
// ============================================================
describe('Error Logs — Response Format', () => {
  test('errorlogs.js should not include details field', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8'
    );
    // The parsed log object should only have timestamp, context, error
    // NOT details (which contains stack traces)
    const returnBlock = source.match(/return \{[\s\S]*?\}/);
    if (returnBlock) {
      expect(returnBlock[0]).not.toContain('details');
    }
  });
});

// ============================================================
// ASSETS — path.basename for logo deletion
// ============================================================
describe('Assets — Logo Path Safety', () => {
  test('assets.js should use path.basename for logo filename extraction', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8'
    );
    expect(source).toContain('path.basename(logoPath)');
    // Should NOT use string replace for path extraction
    expect(source).not.toContain("logoPath.replace('/assets/', '')");
  });
});

// ============================================================
// BACKUP — encryptionKey excluded
// ============================================================
describe('Backup — Encryption Key Exclusion', () => {
  test('backup.js should not include encryptionKey in filesToBackup', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8'
    );
    // Should have a comment about deliberate exclusion
    expect(source).toContain('encryptionKey deliberately excluded');
    // Should NOT have encryptionKey as a key in filesToBackup array
    expect(source).not.toMatch(/\{\s*key:\s*'encryptionKey'/);
  });

  test('backup.js restore fileMapping should not include encryptionKey', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8'
    );
    // The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it
    // The preview route's fileMapping is allowed to have it (informational only)
    const restoreSection = source.substring(source.indexOf('encryptionKey excluded'));
    const restoreMapping = restoreSection.match(/const fileMapping = \{[\s\S]*?\};/);
    if (restoreMapping) {
      expect(restoreMapping[0]).not.toContain('encryptionKey:');
    }
  });

  test('backup.js should require TOTP for sensitive restores', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8'
    );
    expect(source).toContain('sensitiveKeys');
    expect(source).toContain('totpCode');
    expect(source).toContain('TOTP code required');
  });
});

// ============================================================
// DNS — validateDnsServer function
// ============================================================
describe('DNS — Server Validation Function', () => {
  test('dns.js should define validateDnsServer', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8'
    );
    expect(source).toContain('function validateDnsServer');
    expect(source).toContain('configuredIps');
    expect(source).toContain('validatorLib.isIP');
  });
});

// ============================================================
// CONTAINERS — getVerifiedContainer usage
// ============================================================
describe('Containers — Verified Container Access', () => {
  test('containers.js update route should use getVerifiedContainer', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8'
    );
    // update and check-update should both use getVerifiedContainer
    const updateSection = source.substring(source.indexOf("'/:id/update'"));
    expect(updateSection).toContain('getVerifiedContainer');

    const checkUpdateSection = source.substring(source.indexOf("'/:id/check-update'"));
    expect(checkUpdateSection).toContain('getVerifiedContainer');
  });
});

// ============================================================
// LOGS — Symlink resolution
// ============================================================
describe('Logs — Symlink Resolution', () => {
  test('logs.js should use realpath for symlink resolution', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8'
    );
    expect(source).toContain('fsp.realpath');
    expect(source).toContain('path.sep');
  });

  test('logs.js container routes should verify container exists', () => {
    const source = fs.readFileSync(
      path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8'
    );
    // Both container/:id and stream/:id should have inspect + NotFoundError
    expect(source).toContain('container.inspect()');
    expect(source).toContain('NotFoundError');
  });
});
