import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { stringify } from 'yaml'; import { loadProjectConfig } from '../lib/project-config.mjs'; const defaultBase = () => ({ spring: { profiles: { active: 'dev' } } }); const datasource = overrides => ({ url: 'jdbc:mysql://db.example.test:3307/fixture_db?useSSL=false&serverTimezone=Asia%2FShanghai', username: 'fixture-user', password: 'fixture-password', ...overrides, }); const profileDocument = (overrides = {}) => ({ spring: { datasource: { dynamic: { datasource: { master: datasource(overrides) } } } }, server: { port: 48083 }, }); const documents = value => (Array.isArray(value) ? value : [value]).map(document => stringify(document)).join('---\n'); async function fixture(t, { base = defaultBase(), profiles = { dev: profileDocument() } } = {}) { const root = await mkdtemp(join(tmpdir(), 'oss-project-config-test-')); t.after(() => rm(root, { recursive: true, force: true })); const resources = join(root, 'yudao-server/src/main/resources'); await mkdir(resources, { recursive: true }); await writeFile(join(resources, 'application.yaml'), typeof base === 'string' ? base : documents(base)); for (const [name, content] of Object.entries(profiles)) { await writeFile(join(resources, 'application-' + name + '.yaml'), typeof content === 'string' ? content : documents(content)); } return root; } test('default profile, master credentials and actual server port come from the project YAML', async t => { const projectRoot = await fixture(t); const result = await loadProjectConfig({ projectRoot, env: {} }); assert.equal(result.profile, 'dev'); assert.deepEqual(result.mysql, { host: 'db.example.test', port: 3307, database: 'fixture_db', user: 'fixture-user', password: 'fixture-password', }); assert.equal(result.downloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file'); assert.deepEqual(result.sourceFiles, [ resolve(projectRoot, 'yudao-server/src/main/resources/application.yaml'), resolve(projectRoot, 'yudao-server/src/main/resources/application-dev.yaml'), ]); }); test('explicit profile overrides environment, which overrides the base active profile', async t => { const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url: 'jdbc:mysql://dev.example.test/dev_db' }), local: profileDocument({ url: 'jdbc:mysql://local.example.test/local_db' }), prod: profileDocument({ url: 'jdbc:mysql://prod.example.test/prod_db' }), } }); const explicit = await loadProjectConfig({ projectRoot, profile: 'local', env: { SPRING_PROFILES_ACTIVE: 'prod' } }); const environment = await loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_ACTIVE: 'prod' } }); const base = await loadProjectConfig({ projectRoot, env: {} }); assert.equal(explicit.mysql.database, 'local_db'); assert.equal(environment.mysql.database, 'prod_db'); assert.equal(base.mysql.database, 'dev_db'); }); test('single-profile selection resolves a base property or environment placeholder with a default', async t => { const projectRoot = await fixture(t, { base: { selection: { profile: '${APP_PROFILE:dev}' }, spring: { profiles: { active: '${selection.profile}' } } }, profiles: { dev: profileDocument(), local: profileDocument() }, }); assert.equal((await loadProjectConfig({ projectRoot, env: {} })).profile, 'dev'); assert.equal((await loadProjectConfig({ projectRoot, env: { APP_PROFILE: 'local' } })).profile, 'local'); }); test('explicit profile works even when the base active profile is absent or unusable', async t => { const projectRoot = await fixture(t, { base: { spring: { profiles: { active: ['unused', 'group'] } } } }); const result = await loadProjectConfig({ projectRoot, profile: 'dev', env: {} }); assert.equal(result.profile, 'dev'); }); test('multiple, missing and path-traversal profiles fail with actionable selection errors', async t => { for (const active of [['dev', 'prod'], 'dev,prod', 'dev prod', '', '../prod', null, undefined]) { const projectRoot = await fixture(t, { base: { spring: { profiles: { active } } } }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), /单个 profile.*--profile/); } const projectRoot = await fixture(t); await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_ACTIVE: 'dev,prod' } }), /单个 profile/); await assert.rejects(loadProjectConfig({ projectRoot, profile: '../dev', env: {} }), /单个 profile/); }); test('multiple unconditional documents merge recursively across base and selected profile', async t => { const projectRoot = await fixture(t, { base: [ { spring: { profiles: { active: 'dev' }, datasource: { dynamic: { primary: 'main', datasource: { main: { username: 'base-user', password: 'base-password' } } } } } }, { spring: { datasource: { dynamic: { datasource: { main: { url: 'jdbc:mysql://base.example.test/base_db' } } } } }, server: { port: 8081 } }, ], profiles: { dev: [ { spring: { datasource: { dynamic: { datasource: { main: { url: 'jdbc:mysql://selected.example.test:3308/selected_db' } } } } } }, { spring: { datasource: { dynamic: { datasource: { main: { password: 'selected-password' } } } } }, server: { port: 48083 } }, ] }, }); const result = await loadProjectConfig({ projectRoot, env: {} }); assert.deepEqual(result.mysql, { host: 'selected.example.test', port: 3308, database: 'selected_db', user: 'base-user', password: 'selected-password' }); assert.equal(result.downloadBaseUrl, 'http://127.0.0.1:48083/admin-api/infra/file'); }); test('environment directly overrides master URL, username and password', async t => { const projectRoot = await fixture(t); const result = await loadProjectConfig({ projectRoot, env: { SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_URL: 'jdbc:mysql://override.example.test:3310/env_db', SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_USERNAME: 'env-user', SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: ' env:pass#word ', } }); assert.deepEqual(result.mysql, { host: 'override.example.test', port: 3310, database: 'env_db', user: 'env-user', password: ' env:pass#word ' }); }); test('dynamic primary environment override selects the named source instead of master or slave', async t => { const document = profileDocument(); document.spring.datasource.dynamic.datasource.reporting = datasource({ url: 'jdbc:mysql://reporting.example.test/reporting_db' }); document.spring.datasource.dynamic.datasource.slave = datasource({ url: 'jdbc:mysql://slave.example.test/slave_db' }); const projectRoot = await fixture(t, { profiles: { dev: document } }); const result = await loadProjectConfig({ projectRoot, env: { SPRING_DATASOURCE_DYNAMIC_PRIMARY: 'reporting', SPRING_DATASOURCE_DYNAMIC_DATASOURCE_REPORTING_PASSWORD: 'reporting-env-password', } }); assert.equal(result.mysql.database, 'reporting_db'); assert.equal(result.mysql.password, 'reporting-env-password'); }); test('required fields support property references and nested environment defaults', async t => { const document = profileDocument({ url: 'jdbc:mysql://${DB_HOST:${database.host}}:${DB_PORT:${database.port:3306}}/${database.name}', username: '${DB_USER:${database.user}}', password: '${DB_PASSWORD:${database.password}}', }); document.database = { host: 'property.example.test', name: 'property_db', user: 'property-user', password: 'property-password' }; const projectRoot = await fixture(t, { profiles: { dev: document } }); const base = await loadProjectConfig({ projectRoot, env: {} }); assert.deepEqual(base.mysql, { host: 'property.example.test', port: 3306, database: 'property_db', user: 'property-user', password: 'property-password' }); const overridden = await loadProjectConfig({ projectRoot, env: { DB_HOST: 'env.example.test', DB_PORT: '3309', DB_PASSWORD: 'env-password' } }); assert.equal(overridden.mysql.host, 'env.example.test'); assert.equal(overridden.mysql.port, 3309); assert.equal(overridden.mysql.password, 'env-password'); }); test('unrelated unresolved placeholders, service credentials and cycles are never resolved', async t => { const document = profileDocument(); document.unrelated = { secret: '${MISSING_SERVICE_SECRET}', a: '${unrelated.b}', b: '${unrelated.a}', encrypted: 'ENC(unrelated-service-secret)' }; const projectRoot = await fixture(t, { profiles: { dev: document } }); assert.equal((await loadProjectConfig({ projectRoot, env: {} })).mysql.database, 'fixture_db'); }); test('HTTP context path, admin prefix and SSL settings determine the download URL', async t => { const document = profileDocument(); document.server = { port: 8443, servlet: { contextPath: '/backend/' }, ssl: { enabled: true } }; document.yudao = { web: { adminApi: { prefix: '/management/' } } }; const projectRoot = await fixture(t, { profiles: { dev: document } }); const result = await loadProjectConfig({ projectRoot, env: {} }); assert.equal(result.downloadBaseUrl, 'https://127.0.0.1:8443/backend/management/infra/file'); const overridden = await loadProjectConfig({ projectRoot, env: { SERVER_PORT: '9080', SERVER_SERVLET_CONTEXT_PATH: '/overridden', YUDAO_WEB_ADMIN_API_PREFIX: '/admin', SERVER_SSL_ENABLED: 'false', } }); assert.equal(overridden.downloadBaseUrl, 'http://127.0.0.1:9080/overridden/admin/infra/file'); }); test('Spring defaults apply to missing primary, HTTP port and API prefix', async t => { const document = profileDocument(); delete document.server; const projectRoot = await fixture(t, { profiles: { dev: document } }); assert.equal((await loadProjectConfig({ projectRoot, env: {} })).downloadBaseUrl, 'http://127.0.0.1:8080/admin-api/infra/file'); }); test('an explicit root context path does not produce duplicate slashes', async t => { const document = profileDocument(); document.server.servlet = { 'context-path': '/' }; document.yudao = { web: { 'admin-api': { prefix: '/' } } }; const projectRoot = await fixture(t, { profiles: { dev: document } }); assert.equal((await loadProjectConfig({ projectRoot, env: {} })).downloadBaseUrl, 'http://127.0.0.1:48083/infra/file'); }); test('single-host MySQL URL supports omitted ports and IPv6 without changing database names', async t => { for (const [url, expected] of [ ['jdbc:mysql://localhost/test_db', { host: 'localhost', port: 3306, database: 'test_db' }], ['jdbc:mysql://[::1]:3309/test_db', { host: '::1', port: 3309, database: 'test_db' }], ['jdbc:mysql://db.example.test/test%20db', { host: 'db.example.test', port: 3306, database: 'test db' }], ]) { const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url }) } }); const result = await loadProjectConfig({ projectRoot, env: {} }); assert.deepEqual({ host: result.mysql.host, port: result.mysql.port, database: result.mysql.database }, expected); } }); test('ambiguous, unsupported and incomplete JDBC URLs are rejected without echoing their values', async t => { for (const url of [ 'jdbc:mysql:loadbalance://first,second/db', 'jdbc:mysql://first,second/db', 'jdbc:mysql://first:3306,second:3306/db', 'jdbc:postgresql://secret-host/secret-database', 'jdbc:mysql://db.example.test', 'jdbc:mysql://db.example.test/a/b', 'jdbc:mysql://db.example.test:65536/db', 'jdbc:mysql://db.example.test:0/db', 'jdbc:mysql://user:sensitive-url-password@db.example.test/db', 'jdbc:mysql://db.example.test/a%2Fb', ]) { const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ url }) } }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => { assert.match(error.message, /MySQL|jdbc:mysql/); assert.ok(!error.message.includes(url)); assert.ok(!error.message.includes('sensitive-url-password')); return true; }); } }); test('missing or still-encrypted passwords are rejected, with direct environment override available', async t => { for (const password of [null, undefined, '', 'ENC(sensitive-encrypted-password)']) { const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ password }) } }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => { assert.match(error.message, /缺少|缺失|ENC/); assert.ok(!error.message.includes('sensitive-encrypted-password')); return true; }); assert.equal((await loadProjectConfig({ projectRoot, env: { SPRING_DATASOURCE_DYNAMIC_DATASOURCE_MASTER_PASSWORD: 'provided-password' } })).mysql.password, 'provided-password'); } }); test('required unresolved, malformed or cyclic placeholders stop safely without revealing secrets', async t => { for (const password of [ 'sensitive-prefix-${MISSING_PASSWORD}', 'sensitive-prefix-${MISSING_PASSWORD', '${spring.datasource.dynamic.datasource.master.password}', ]) { const projectRoot = await fixture(t, { profiles: { dev: profileDocument({ password }) } }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => { assert.match(error.message, /占位符/); assert.ok(!error.message.includes('sensitive-prefix')); return true; }); } }); test('numeric YAML scalar credentials retain bigint precision', async t => { const document = profileDocument({ password: 900719925474099312345n }); const projectRoot = await fixture(t, { profiles: { dev: document } }); assert.equal((await loadProjectConfig({ projectRoot, env: {} })).mysql.password, '900719925474099312345'); }); test('config imports, included profiles and a selected profile group require explicit configuration', async t => { for (const base of [ { spring: { profiles: { active: 'dev' }, config: { import: 'optional:file:sensitive-location' } } }, { spring: { profiles: { active: 'dev', include: ['common'] } } }, { spring: { profiles: { active: 'dev', group: { dev: ['common', 'database'] } } } }, ]) { const projectRoot = await fixture(t, { base }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => { assert.match(error.message, /import|include|配置组/); assert.ok(!error.message.includes('sensitive-location')); return true; }); } const projectRoot = await fixture(t); await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_CONFIG_IMPORT: 'optional:file:sensitive-location' } }), /import/); await assert.rejects(loadProjectConfig({ projectRoot, env: { SPRING_PROFILES_GROUP_DEV: 'common,database' } }), /配置组/); }); test('an unrelated profile group does not affect an explicitly selected concrete profile', async t => { const projectRoot = await fixture(t, { base: { spring: { profiles: { active: 'dev', group: { production: ['prod', 'shared'] } } } } }); assert.equal((await loadProjectConfig({ projectRoot, env: {} })).profile, 'dev'); }); test('conditional YAML documents and active-profile redefinition are rejected', async t => { for (const content of [ [profileDocument(), { spring: { config: { activate: { 'on-profile': 'dev' } } }, server: { port: 8080 } }], [profileDocument(), { spring: { profiles: 'dev' }, server: { port: 8080 } }], [profileDocument(), { spring: { profiles: { active: 'prod' } } }], ]) { const projectRoot = await fixture(t, { profiles: { dev: content } }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), /条件|歧义/); } }); test('missing files and malformed YAML errors do not expose parser source or secrets', async t => { const projectRoot = await fixture(t, { base: 'spring: [sensitive-password' }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => { assert.match(error.message, /YAML/); assert.ok(!error.message.includes('sensitive-password')); return true; }); const validRoot = await fixture(t); await assert.rejects(loadProjectConfig({ projectRoot: validRoot, profile: 'missing', env: {} }), /--profile/); await assert.rejects(loadProjectConfig({ projectRoot: '', env: {} }), /项目根目录/); }); test('duplicate keys inside one YAML document cannot silently choose credentials', async t => { const projectRoot = await fixture(t, { base: 'spring:\n profiles:\n active: dev\nspring:\n password: sensitive-password\n' }); await assert.rejects(loadProjectConfig({ projectRoot, env: {} }), error => { assert.match(error.message, /YAML/); assert.ok(!error.message.includes('sensitive-password')); return true; }); }); test('invalid HTTP ports and route prefixes fail instead of generating unusable URLs', async t => { const projectRoot = await fixture(t); for (const env of [ { SERVER_PORT: '0' }, { SERVER_PORT: '65536' }, { SERVER_PORT: 'not-a-port' }, { SERVER_SERVLET_CONTEXT_PATH: 'missing-leading-slash' }, { YUDAO_WEB_ADMIN_API_PREFIX: '/admin?query' }, { SERVER_SERVLET_CONTEXT_PATH: '/a/../b' }, { SERVER_SSL_ENABLED: 'unknown' }, ]) await assert.rejects(loadProjectConfig({ projectRoot, env }), /HTTP/); });