2026-07-17 13:50:30 +08:00
|
|
|
const assert = require('node:assert/strict')
|
|
|
|
|
const fs = require('node:fs')
|
|
|
|
|
const os = require('node:os')
|
|
|
|
|
const path = require('node:path')
|
|
|
|
|
const test = require('node:test')
|
2026-07-28 20:29:06 +08:00
|
|
|
const crypto = require('node:crypto')
|
2026-07-17 13:50:30 +08:00
|
|
|
|
|
|
|
|
const { withXuqmConfig } = require('../metro')
|
2026-07-28 20:29:06 +08:00
|
|
|
const { canonicalJson } = require('../security/canonical-json.js')
|
|
|
|
|
const trustedConfigKeys = require('../security/trusted-signing-keys.json')
|
2026-07-17 13:50:30 +08:00
|
|
|
|
2026-07-28 20:29:06 +08:00
|
|
|
const TEST_KEY_ID = 'xuqm-config-test-no-secret'
|
|
|
|
|
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
|
|
|
|
|
const publicKeyDer = publicKey.export({ format: 'der', type: 'spki' })
|
|
|
|
|
trustedConfigKeys[TEST_KEY_ID] = publicKeyDer.subarray(-32).toString('base64')
|
|
|
|
|
|
|
|
|
|
function createConfigVector() {
|
|
|
|
|
const config = {
|
|
|
|
|
appKey: 'test-app',
|
|
|
|
|
appName: '测试应用',
|
|
|
|
|
configId: '123e4567-e89b-42d3-a456-426614174000',
|
|
|
|
|
issuedAt: '2026-07-26T00:00:00Z',
|
|
|
|
|
revision: 1,
|
|
|
|
|
schemaVersion: 2,
|
|
|
|
|
serverUrl: 'https://dev.xuqinmin.com',
|
|
|
|
|
}
|
|
|
|
|
const plaintext = Buffer.from(canonicalJson(config), 'utf8')
|
|
|
|
|
const salt = crypto.randomBytes(16)
|
|
|
|
|
const iv = crypto.randomBytes(12)
|
|
|
|
|
const key = crypto.pbkdf2Sync('xuqm-config-file-v2.2026.internal', salt, 120_000, 32, 'sha256')
|
|
|
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
|
|
|
|
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()])
|
|
|
|
|
const unsigned = [
|
|
|
|
|
'XUQM-CONFIG-V2',
|
|
|
|
|
TEST_KEY_ID,
|
|
|
|
|
salt.toString('base64url'),
|
|
|
|
|
iv.toString('base64url'),
|
|
|
|
|
encrypted.toString('base64url'),
|
|
|
|
|
].join('.')
|
|
|
|
|
return `${unsigned}.${crypto.sign(null, Buffer.from(unsigned, 'ascii'), privateKey).toString('base64url')}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CONFIG_VECTOR = createConfigVector()
|
|
|
|
|
|
|
|
|
|
test.after(() => {
|
|
|
|
|
delete trustedConfigKeys[TEST_KEY_ID]
|
|
|
|
|
})
|
2026-07-20 19:31:43 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
test('withXuqmConfig schedules automatic initialization before the app entry', () => {
|
|
|
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
|
|
|
|
const configDir = path.join(projectRoot, 'src/assets/config')
|
|
|
|
|
const existingPrelude = path.join(projectRoot, 'existing-prelude.js')
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(configDir, { recursive: true })
|
2026-07-20 19:31:43 +08:00
|
|
|
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
2026-07-17 13:50:30 +08:00
|
|
|
|
|
|
|
|
const config = withXuqmConfig({
|
|
|
|
|
projectRoot,
|
|
|
|
|
resolver: {},
|
|
|
|
|
serializer: {
|
|
|
|
|
getModulesRunBeforeMainModule: () => [existingPrelude],
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const preludeModules = config.serializer.getModulesRunBeforeMainModule('index.js')
|
|
|
|
|
assert.equal(preludeModules[0], existingPrelude)
|
2026-07-17 14:14:19 +08:00
|
|
|
assert.match(preludeModules[1], /packages[\\/]common[\\/]src[\\/]internal\.ts$/)
|
2026-07-17 13:50:30 +08:00
|
|
|
|
|
|
|
|
const virtualModule = config.resolver.resolveRequest(
|
|
|
|
|
{ resolveRequest: () => assert.fail('fallback resolver should not run') },
|
2026-07-26 23:47:30 +08:00
|
|
|
'@xuqm/rn-common/auto-init-config',
|
2026-07-17 13:50:30 +08:00
|
|
|
'android',
|
|
|
|
|
)
|
|
|
|
|
assert.equal(virtualModule.type, 'sourceFile')
|
2026-07-20 19:31:43 +08:00
|
|
|
const generatedSource = fs.readFileSync(virtualModule.filePath, 'utf8')
|
|
|
|
|
assert.match(generatedSource, /RESOLVED_CONFIG/)
|
|
|
|
|
assert.match(generatedSource, /"appKey":"test-app"/)
|
2026-07-26 23:47:30 +08:00
|
|
|
assert.equal(generatedSource.includes('XUQM-CONFIG-V2'), false)
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('withXuqmConfig rejects legacy V1 files with a migration instruction', () => {
|
|
|
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
|
|
|
|
const configDir = path.join(projectRoot, 'src/assets/config')
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(configDir, { recursive: true })
|
|
|
|
|
fs.writeFileSync(
|
|
|
|
|
path.join(configDir, 'config.xuqmconfig'),
|
|
|
|
|
'XUQM-CONFIG-V1.invalid.invalid.invalid',
|
|
|
|
|
'utf8',
|
|
|
|
|
)
|
|
|
|
|
assert.throws(
|
|
|
|
|
() => withXuqmConfig({ projectRoot }),
|
|
|
|
|
/仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件/,
|
|
|
|
|
)
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('withXuqmConfig rejects a modified V2 signature before decrypting content', () => {
|
|
|
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
|
|
|
|
const configDir = path.join(projectRoot, 'src/assets/config')
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(configDir, { recursive: true })
|
|
|
|
|
const parts = CONFIG_VECTOR.split('.')
|
|
|
|
|
parts[5] = `${parts[5][0] === 'A' ? 'B' : 'A'}${parts[5].slice(1)}`
|
|
|
|
|
const tampered = parts.join('.')
|
|
|
|
|
fs.writeFileSync(path.join(configDir, 'config.xuqmconfig'), tampered, 'utf8')
|
|
|
|
|
assert.throws(() => withXuqmConfig({ projectRoot }), /配置文件签名验证失败/)
|
|
|
|
|
assert.equal(
|
|
|
|
|
fs.existsSync(path.join(projectRoot, 'android/app/src/main/assets/config/config.xuqmconfig')),
|
|
|
|
|
false,
|
|
|
|
|
)
|
2026-07-17 13:50:30 +08:00
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('withXuqmConfig leaves common-only hosts unchanged without a config file', () => {
|
|
|
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
|
|
|
|
const original = {
|
|
|
|
|
projectRoot,
|
|
|
|
|
serializer: {
|
|
|
|
|
getModulesRunBeforeMainModule: () => ['existing-prelude.js'],
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
assert.equal(withXuqmConfig(original), original)
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|
2026-07-26 23:47:30 +08:00
|
|
|
|
|
|
|
|
test('withXuqmConfig rejects ambiguous config directories instead of choosing a file', () => {
|
|
|
|
|
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
|
|
|
|
const configDir = path.join(projectRoot, 'src/assets/config')
|
|
|
|
|
try {
|
|
|
|
|
fs.mkdirSync(configDir, { recursive: true })
|
|
|
|
|
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
|
|
|
|
fs.writeFileSync(path.join(configDir, 'tenant.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
|
|
|
|
assert.throws(
|
|
|
|
|
() => withXuqmConfig({ projectRoot }),
|
|
|
|
|
/只能存在一个 \.xuqmconfig 文件,当前发现 2 个/,
|
|
|
|
|
)
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(projectRoot, { recursive: true, force: true })
|
|
|
|
|
}
|
|
|
|
|
})
|