chrome-extension-test-framework
Version:
Universal testing framework for Chrome extensions - Fast static analysis without browser dependencies
565 lines (471 loc) • 26 kB
JavaScript
/**
* ManifestTestSuite - manifest.jsonの検証テストスイート
*/
const TestSuite = require('../lib/TestSuite');
const TestCase = require('../lib/TestCase');
const Validator = require('../lib/Validator');
const fs = require('fs');
const path = require('path');
const PermissionsAnalyzer = require('../lib/PermissionsAnalyzer');
const PermissionDetector = require('../lib/PermissionDetector');
class ManifestTestSuite extends TestSuite {
constructor(config) {
super({
name: 'Manifest Validation',
description: 'Chrome拡張機能のmanifest.jsonを検証',
config: config // configを親クラスに渡す
});
this.config = config;
this.setupTests();
}
setupTests() {
// manifest.jsonの存在確認
this.test('manifest.json exists', async (config) => {
const manifestPath = path.join(config.extensionPath, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
throw new Error('manifest.json not found');
}
});
// JSONの妥当性確認
this.test('Valid JSON format', async (config) => {
const manifestPath = path.join(config.extensionPath, 'manifest.json');
const content = fs.readFileSync(manifestPath, 'utf8');
try {
JSON.parse(content);
} catch (error) {
throw new Error(`Invalid JSON: ${error.message}`);
}
});
// Manifest V3確認
this.test('Manifest version is 3', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.manifest_version !== 3) {
throw new Error(`Expected manifest_version 3, got ${manifest.manifest_version}`);
}
});
// 必須フィールド確認
this.test('Required fields present', async (config) => {
const manifest = await this.loadManifest(config);
const required = ['manifest_version', 'name', 'version'];
const missing = required.filter(field => !(field in manifest));
if (missing.length > 0) {
throw new Error(`Missing required fields: ${missing.join(', ')}`);
}
});
// バージョン形式確認
this.test('Version format valid', async (config) => {
const manifest = await this.loadManifest(config);
const versionRegex = /^\d+(\.\d+){0,3}$/;
if (!versionRegex.test(manifest.version)) {
throw new Error(`Invalid version format: ${manifest.version}`);
}
});
// 名前の長さ制限
this.test('Name length within limits', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.name && manifest.name.length > 45) {
throw new Error(`Name too long: ${manifest.name.length} characters (max 45)`);
}
});
// 説明の長さ制限
this.test('Description length within limits', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.description && manifest.description.length > 132) {
throw new Error(`Description too long: ${manifest.description.length} characters (max 132)`);
}
});
// アイコンの検証
this.test('Icons configuration', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.icons) {
const recommendedSizes = ['16', '48', '128'];
const missingSizes = recommendedSizes.filter(size => !(size in manifest.icons));
if (missingSizes.length > 0) {
console.warn(` ⚠️ Missing recommended icon sizes: ${missingSizes.join(', ')}`);
}
// 各アイコンファイルの存在確認
for (const [size, iconPath] of Object.entries(manifest.icons)) {
const fullPath = path.join(config.extensionPath, iconPath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Icon file not found: ${iconPath}`);
}
}
}
});
// Service Worker(Manifest V3)
this.test('Service worker configuration', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.background) {
if (!manifest.background.service_worker) {
throw new Error('Manifest V3 requires service_worker in background');
}
// Service Workerファイルの存在確認
const swPath = path.join(config.extensionPath, manifest.background.service_worker);
if (!fs.existsSync(swPath)) {
throw new Error(`Service worker file not found: ${manifest.background.service_worker}`);
}
}
});
// Content Scripts検証
this.test('Content scripts validation', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.content_scripts) {
manifest.content_scripts.forEach((script, index) => {
// matchesパターンの確認
if (!script.matches || script.matches.length === 0) {
throw new Error(`Content script ${index} missing matches pattern`);
}
// スクリプトファイルの存在確認
if (script.js) {
script.js.forEach(jsPath => {
const fullPath = path.join(config.extensionPath, jsPath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Content script file not found: ${jsPath}`);
}
});
}
});
}
});
// パーミッション検証
this.test('Permissions validation', async (config) => {
const manifest = await this.loadManifest(config);
// 詳細な権限分析
const permAnalyzer = new PermissionsAnalyzer();
const analysis = permAnalyzer.analyze(manifest);
// センシティブな権限がある場合は詳細を表示
if (analysis.sensitivePermissions.length > 0) {
console.warn(' ⚠️ Sensitive permissions detected:');
analysis.sensitivePermissions.forEach(perm => {
console.warn(` • ${perm.name}: ${perm.description}`);
});
}
// 幅広いホスト権限の警告
const broadHostPerms = analysis.hostPermissions.filter(h => h.risk === 'high');
if (broadHostPerms.length > 0) {
console.warn(' 🌐 Broad host permissions detected:');
broadHostPerms.forEach(host => {
console.warn(` • ${host.pattern}: ${host.description}`);
});
}
// 推奨事項を表示
if (analysis.recommendations.length > 0 && config.verbose) {
console.log(' 💡 Permission recommendations:');
analysis.recommendations.forEach(rec => {
console.log(` • ${rec.message}`);
});
}
// 非推奨のパーミッション
const deprecatedPermissions = ['background', 'unlimitedStorage'];
const foundDeprecated = (manifest.permissions || []).filter(p => deprecatedPermissions.includes(p));
if (foundDeprecated.length > 0) {
console.warn(` ⚠️ Deprecated permissions: ${foundDeprecated.join(', ')}`);
}
});
// Web Accessible Resources検証
this.test('Web accessible resources', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.web_accessible_resources) {
manifest.web_accessible_resources.forEach((resource, index) => {
if (!resource.resources || resource.resources.length === 0) {
throw new Error(`Web accessible resource ${index} missing resources`);
}
if (!resource.matches || resource.matches.length === 0) {
console.warn(` ⚠️ Web accessible resource ${index} has no matches restriction`);
}
});
}
});
// デフォルトロケール検証
this.test('Default locale validation', async (config) => {
const manifest = await this.loadManifest(config);
if (manifest.default_locale) {
const localesDir = path.join(config.extensionPath, '_locales', manifest.default_locale);
if (!fs.existsSync(localesDir)) {
throw new Error(`Default locale directory not found: _locales/${manifest.default_locale}`);
}
const messagesFile = path.join(localesDir, 'messages.json');
if (!fs.existsSync(messagesFile)) {
throw new Error(`Messages file not found for default locale: ${manifest.default_locale}`);
}
}
});
// Chrome Action API検証(Manifest V3)
this.test('Chrome action API validation', async (config) => {
const manifest = await this.loadManifest(config);
// 廃止されたAPIの使用チェック
if (manifest.browser_action) {
throw new Error('browser_action is deprecated in Manifest V3. Use action instead.');
}
if (manifest.page_action) {
throw new Error('page_action is deprecated in Manifest V3. Use action instead.');
}
// action APIの検証
if (manifest.action) {
// default_popupの検証
if (manifest.action.default_popup) {
const popupPath = path.join(config.extensionPath, manifest.action.default_popup);
if (!fs.existsSync(popupPath)) {
throw new Error(`Action popup file not found: ${manifest.action.default_popup}`);
}
// HTMLファイルかチェック
if (!manifest.action.default_popup.endsWith('.html')) {
console.warn(` ⚠️ Action popup should be an HTML file`);
}
}
// default_iconの検証
if (manifest.action.default_icon) {
if (typeof manifest.action.default_icon === 'string') {
// 文字列の場合
const iconPath = path.join(config.extensionPath, manifest.action.default_icon);
if (!fs.existsSync(iconPath)) {
throw new Error(`Action icon file not found: ${manifest.action.default_icon}`);
}
} else if (typeof manifest.action.default_icon === 'object') {
// オブジェクトの場合(推奨)
const recommendedSizes = ['16', '24', '32'];
for (const [size, iconPath] of Object.entries(manifest.action.default_icon)) {
const fullPath = path.join(config.extensionPath, iconPath);
if (!fs.existsSync(fullPath)) {
throw new Error(`Action icon file not found: ${iconPath}`);
}
}
// 推奨サイズの確認
const missingSizes = recommendedSizes.filter(size => !(size in manifest.action.default_icon));
if (missingSizes.length > 0) {
console.warn(` ⚠️ Missing recommended action icon sizes: ${missingSizes.join(', ')}`);
}
}
}
// default_titleの検証
if (manifest.action.default_title && manifest.action.default_title.length > 40) {
console.warn(` ⚠️ Action title might be too long: ${manifest.action.default_title.length} characters`);
}
}
});
// Declarative Net Request API検証
this.test('Declarative net request validation', async (config) => {
const manifest = await this.loadManifest(config);
// webRequestの使用チェック(制限されている)
if (manifest.permissions?.includes('webRequest')) {
console.warn(` ⚠️ webRequest permission is limited in Manifest V3. Consider using declarativeNetRequest`);
}
// declarativeNetRequestの検証
if (manifest.declarative_net_request) {
const dnr = manifest.declarative_net_request;
// ルールリソースの検証
if (dnr.rule_resources) {
dnr.rule_resources.forEach((resource, index) => {
if (!resource.id) {
throw new Error(`Rule resource ${index} missing id`);
}
if (!resource.path) {
throw new Error(`Rule resource ${resource.id} missing path`);
}
// ルールファイルの存在確認
const rulePath = path.join(config.extensionPath, resource.path);
if (!fs.existsSync(rulePath)) {
throw new Error(`Rule file not found: ${resource.path}`);
}
// ルールファイルの検証
try {
const ruleContent = fs.readFileSync(rulePath, 'utf8');
const rules = JSON.parse(ruleContent);
if (!Array.isArray(rules)) {
throw new Error(`Rule file ${resource.path} must contain an array of rules`);
}
// ルール数の制限チェック
if (resource.enabled && rules.length > 5000) {
console.warn(` ⚠️ Rule resource ${resource.id} has ${rules.length} rules (max 5000 for enabled rulesets)`);
}
} catch (error) {
if (error.name === 'SyntaxError') {
throw new Error(`Invalid JSON in rule file ${resource.path}: ${error.message}`);
}
throw error;
}
});
}
}
});
// Chrome Scripting API検証
this.test('Chrome scripting API validation', async (config) => {
const manifest = await this.loadManifest(config);
// scripting権限の確認
const hasScriptingPermission = manifest.permissions?.includes('scripting');
const hasActiveTabPermission = manifest.permissions?.includes('activeTab');
// JavaScriptファイルでのAPI使用をチェック
const jsFiles = this.findJavaScriptFiles(config.extensionPath);
let usesOldAPI = false;
let usesNewAPI = false;
let oldAPIUsage = [];
let newAPIUsage = [];
for (const file of jsFiles) {
const content = fs.readFileSync(file, 'utf8');
const relativePath = path.relative(config.extensionPath, file);
// 旧API(tabs.executeScript)の使用検出
if (/chrome\.tabs\.executeScript/g.test(content)) {
usesOldAPI = true;
oldAPIUsage.push(relativePath);
}
// 旧API(tabs.insertCSS)の使用検出
if (/chrome\.tabs\.insertCSS/g.test(content)) {
usesOldAPI = true;
oldAPIUsage.push(relativePath);
}
// 新API(scripting.executeScript)の使用検出
if (/chrome\.scripting\.executeScript/g.test(content)) {
usesNewAPI = true;
newAPIUsage.push(relativePath);
// scripting権限がない場合
if (!hasScriptingPermission) {
throw new Error('chrome.scripting API is used but "scripting" permission is not declared');
}
}
// 新API(scripting.insertCSS)の使用検出
if (/chrome\.scripting\.insertCSS/g.test(content)) {
usesNewAPI = true;
newAPIUsage.push(relativePath);
if (!hasScriptingPermission) {
throw new Error('chrome.scripting API is used but "scripting" permission is not declared');
}
}
// 新API(scripting.removeCSS)の使用検出
if (/chrome\.scripting\.removeCSS/g.test(content)) {
usesNewAPI = true;
newAPIUsage.push(relativePath);
if (!hasScriptingPermission) {
throw new Error('chrome.scripting API is used but "scripting" permission is not declared');
}
}
}
// 旧APIの使用に対する警告
if (usesOldAPI) {
console.warn(` ⚠️ Deprecated API usage detected:`);
[...new Set(oldAPIUsage)].forEach(file => {
console.warn(` - ${file}: tabs.executeScript/insertCSS is deprecated`);
});
console.warn(` 💡 Migrate to chrome.scripting API for Manifest V3 compatibility`);
}
// host_permissionsの確認(scripting APIに必要)
if (usesNewAPI && !manifest.host_permissions && !hasActiveTabPermission) {
console.warn(` ⚠️ chrome.scripting API requires host_permissions or activeTab permission`);
}
// optional_host_permissionsの確認
if (manifest.optional_host_permissions && usesNewAPI) {
console.log(` ℹ️ Optional host permissions detected. Ensure proper permission handling in code`);
}
});
// Minimum Chrome Version検証
this.test('Minimum Chrome version', async (config) => {
const manifest = await this.loadManifest(config);
// Manifest V3の最小バージョン
const MANIFEST_V3_MIN_VERSION = 88;
if (manifest.minimum_chrome_version) {
const version = parseInt(manifest.minimum_chrome_version.split('.')[0]);
if (version < MANIFEST_V3_MIN_VERSION) {
throw new Error(`Minimum Chrome version ${version} is too low for Manifest V3. Requires Chrome ${MANIFEST_V3_MIN_VERSION} or higher`);
}
// 特定のAPIに必要なバージョン
const apiRequirements = {
'chrome.action': 88,
'chrome.scripting': 88,
'chrome.storage.session': 102,
'chrome.declarativeNetRequest': 84
};
// 使用されているAPIをチェック
const warnings = [];
if (manifest.action && version < apiRequirements['chrome.action']) {
warnings.push(`chrome.action requires Chrome ${apiRequirements['chrome.action']}+`);
}
if (manifest.permissions?.includes('scripting') && version < apiRequirements['chrome.scripting']) {
warnings.push(`chrome.scripting requires Chrome ${apiRequirements['chrome.scripting']}+`);
}
if (manifest.permissions?.includes('storage') && version < apiRequirements['chrome.storage.session']) {
console.warn(` ℹ️ chrome.storage.session requires Chrome ${apiRequirements['chrome.storage.session']}+`);
}
if (warnings.length > 0) {
warnings.forEach(warning => console.warn(` ⚠️ ${warning}`));
}
} else {
console.warn(` ⚠️ No minimum_chrome_version specified. Consider setting it to 88 or higher for Manifest V3`);
}
});
// ファントム権限の検出(実際に使用されていない権限)
this.test('Phantom permissions detection', async (config) => {
const manifest = await this.loadManifest(config);
const detector = new PermissionDetector();
// すべてのJavaScriptファイルを取得
const jsFiles = await this.getAllFiles('', [], { skipExclude: false });
const files = [];
for (const filePath of jsFiles.filter(f => f.endsWith('.js'))) {
const fullPath = path.join(config.extensionPath, filePath);
const content = fs.readFileSync(fullPath, 'utf8');
files.push({ path: filePath, content });
}
// 使用されている権限を検出
const detectedPermissions = detector.detectUsedPermissions(files);
// マニフェストと比較
const comparison = detector.compareWithManifest(manifest, detectedPermissions);
// 未使用の権限を報告
if (comparison.unusedPermissions.length > 0) {
console.warn(' ⚠️ Unused permissions detected (phantom permissions):');
comparison.unusedPermissions.forEach(perm => {
console.warn(` • ${perm} - declared but not used in code`);
});
console.warn(' 💡 Consider removing unused permissions to follow the principle of least privilege');
}
// 不足している権限を報告
if (comparison.missingPermissions.length > 0) {
throw new Error(`Missing required permissions: ${comparison.missingPermissions.join(', ')}`);
}
// 広範なホスト権限の警告
if (comparison.unusedHosts.length > 0) {
const broadPatterns = comparison.unusedHosts.filter(h =>
h === '<all_urls>' || h === '*://*/*' || h === 'http://*/*' || h === 'https://*/*'
);
if (broadPatterns.length > 0) {
console.warn(' ⚠️ Overly broad host permissions detected:');
broadPatterns.forEach(pattern => {
console.warn(` • ${pattern} - consider using activeTab or specific domains`);
});
}
}
// API使用状況のサマリー(verboseモード)
if (config.verbose && Object.keys(detectedPermissions.apiUsage).length > 0) {
console.log(' 📊 Chrome API usage detected:');
Object.entries(detectedPermissions.apiUsage)
.sort(([,a], [,b]) => b - a)
.slice(0, 10)
.forEach(([api, count]) => {
console.log(` • ${api}: ${count} calls`);
});
}
});
}
/**
* JavaScriptファイルを検索
*/
findJavaScriptFiles(extensionPath) {
const files = [];
function traverse(dir) {
try {
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory() && !item.startsWith('.') && item !== 'node_modules') {
traverse(itemPath);
} else if (stat.isFile() && item.endsWith('.js')) {
files.push(itemPath);
}
}
} catch (error) {
// ディレクトリが読めない場合はスキップ
}
}
traverse(extensionPath);
return files;
}
}
module.exports = ManifestTestSuite;