import assert from 'node:assert/strict' import { readFileSync, readdirSync, existsSync } from 'node:fs' import { resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') const read = path => readFileSync(resolve(root, path), 'utf8') // Deliberately limited to the repository's explicit short-form Path routes. // Unsupported route syntax fails instead of silently skipping coverage. function routes(source) { return [...source.matchAll(/^\s*- id:\s*([^\r\n]+)([\s\S]*?)(?=^\s*- id:|^\S|$(?![\s\S]))/gm)].map(([, id, body]) => { const uri = body.match(/uri:\s*lb:\/\/([\w-]+)/)?.[1] const paths = body.match(/Path=([^\r\n"\]]+)/)?.[1]?.trim().split(',') assert.ok(uri && paths?.length, `Unsupported route: ${id}`) assert.ok(!/\b(?:Method|Host|RewritePath|SetPath|PrefixPath)=/.test(body), `Extend checker for conditional/rewrite route: ${id}`) assert.ok(!/\border\s*:/.test(body), `Extend checker for explicit route order: ${id}`) return { id: id.trim(), uri, paths } }) } function matches(pattern, path) { const parts = pattern.split('/') const actual = path.split('/') for (let i = 0; i < parts.length; i++) { if (parts[i] === '**') return true if (actual[i] === undefined) return false if (parts[i] === '*' || /^\{[^}]+\}$/.test(parts[i])) continue if (parts[i] !== actual[i]) return false } return parts.length === actual.length } function javaFiles(dir) { if (!existsSync(dir)) return [] return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { const path = resolve(dir, entry.name) return entry.isDirectory() ? javaFiles(path) : entry.name.endsWith('.java') ? [path] : [] }) } function controllerPaths(source, file) { const clean = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') if (!/@(?:RestController|Controller)\b/.test(clean)) return [] const header = clean.split(/\bclass\s+/)[0] const mapping = header.match(/@RequestMapping\s*\(([^)]*)\)/) const extract = args => { for (const [, name, value] of clean.matchAll(/static\s+final\s+String\s+(\w+)\s*=\s*"([^"\r\n]*)"\s*;/g)) { args = args.replace(new RegExp(`\\b${name}\\b`, 'g'), `"${value}"`) } while (/"[^"\r\n]*"\s*\+\s*"[^"\r\n]*"/.test(args)) { args = args.replace(/"([^"\r\n]*)"\s*\+\s*"([^"\r\n]*)"/g, '"$1$2"') } const paths = [...args.matchAll(/"(\/[^"\r\n]*)"/g)].map(m => m[1]) assert.ok(paths.length || !args.trim() || /^(?:produces|consumes|method)\s*=/.test(args.trim()), `Non-literal mapping requires checker support: ${file}`) assert.ok(!args.replace(/"[^"\r\n]*"/g, '').includes('+'), `Concatenated mapping requires checker support: ${file}`) return paths.length ? paths : [''] } const bases = mapping ? extract(mapping[1]) : [''] const body = clean.slice(header.length) const methods = [...body.matchAll(/@(?:Get|Post|Put|Delete|Patch|Request)Mapping\b(?:\s*\(([^)]*)\))?/g)] if (!methods.length) return bases return methods.flatMap(([, args = '']) => extract(args).flatMap(path => bases.map(base => `${base}${path}`))) } function verify(path, service, table, file) { if (path.includes('{app}')) { for (const app of ['platform', 'merchant']) verify(path.replace('{app}', app), service, table, file) return } // Internal service-token endpoints must never be forwarded, regardless of owner. const hit = table.find(route => route.paths.some(pattern => matches(pattern, path))) if (path.startsWith('/internal/')) { assert.equal(hit, undefined, `Internal endpoint exposed: ${file} ${path}`) } else if (path.startsWith('/api/') || path.startsWith('/openapi/')) { assert.ok(hit, `Missing Gateway route: ${file} ${path}`) assert.equal(hit.uri, service, `Wrong destination: ${file} ${path} via ${hit.id}`) } } const domestic = routes(read('gateway/src/main/resources/application.yml')) const overseas = routes(read('gateway-overseas/src/main/resources/application-overseas.yml')) const modules = [...read('pom.xml').matchAll(/([^<]+)<\/module>/g)].map(m => m[1]) test('local regression and root CI require route checks before Maven/dispatch', () => { const local = read('scripts/backend-full-regression.ps1') assert.ok(local.indexOf('& node --test') < local.indexOf('& $MavenCommand')) assert.match(local, /gateway-route-contract\.test\.mjs/) const pipeline = read('Jenkinsfile') const gate = pipeline.slice(pipeline.indexOf("stage('Gateway 路由静态门禁')"), pipeline.indexOf("stage('统一 Maven 测试门禁')")) assert.match(gate, /node --test scripts\/gateway-route-contract\.test\.mjs/) assert.doesNotMatch(gate, /SKIP_TESTS|catchError|returnStatus|\|\| true/) }) test('Controller prefixes route to their owning service; internal prefixes stay private', () => { let count = 0 for (const module of modules) { if (module.startsWith('common-') || module.startsWith('gateway') || module === 'platform-bom') continue const app = `${module}/src/main/resources/application.yml` if (!existsSync(resolve(root, app))) continue const service = read(app).match(/application:\s*\r?\n\s+name:\s*([\w-]+)/)?.[1] assert.ok(service, `Missing application name: ${module}`) const tables = ['lastleg-service', 'warehouse-overseas-service'].includes(module) ? [overseas] : ['file', 'print'].includes(module) ? [domestic, overseas] : [domestic] for (const file of javaFiles(resolve(root, module, 'src/main/java'))) { for (const path of controllerPaths(readFileSync(file, 'utf8'), file)) { // PDA release metadata lives in the domestic File control plane. Both // PDA apps use the domestic Gateway for update checks and downloads. const targetTables = path.startsWith('/api/pda-updates/') ? [domestic] : tables for (const table of targetTables) verify(path, service, table, file) count++ } } } assert.ok(count > 100, `Unexpectedly small Controller inventory: ${count}`) }) test('removing container shipping nodes route reproduces the missing registration', () => { const broken = domestic.map(route => ({ ...route, paths: route.paths.filter(p => p !== '/api/platform/v1/container-shipping-nodes/**') })) assert.throws(() => verify('/api/platform/v1/container-shipping-nodes', 'firstleg-service', broken, 'fixture'), /Missing Gateway route/) }) test('wrong target and internal exposure are rejected', () => { assert.throws(() => verify('/api/new', 'firstleg-service', [{ id: 'wrong', uri: 'order-service', paths: ['/api/**'] }], 'fixture'), /Wrong destination/) assert.throws(() => verify('/internal/private', 'firstleg-service', [{ id: 'broad', uri: 'firstleg-service', paths: ['/**'] }], 'fixture'), /Internal endpoint exposed/) }) test('path variables, exact paths and segment boundaries are respected', () => { assert.ok(matches('/api/items/{id}/**', '/api/items/123/action')) assert.ok(matches('/api/items/**', '/api/items')) assert.ok(!matches('/api/items/**', '/api/items-other')) assert.ok(!matches('/api/items', '/api/items/action')) assert.deepEqual(controllerPaths('@RestController @RequestMapping(value = {"/api/a", "/api/b"}) public class Example {}', 'fixture'), ['/api/a', '/api/b']) assert.throws(() => controllerPaths('@RestController @RequestMapping(API_PATH) public class Example {}', 'fixture'), /Non-literal/) assert.deepEqual(controllerPaths('@RestController @RequestMapping("/api") public class Example { @GetMapping("/items") void list() {} }', 'fixture'), ['/api/items']) assert.deepEqual(controllerPaths('@RestController public class Example { static final String PATH = "/api/items"; @GetMapping(PATH) void list() {} }', 'fixture'), ['/api/items']) assert.throws(() => verify('/api/item', 'firstleg-service', [{ id: 'shadow', uri: 'order-service', paths: ['/api/**'] }, { id: 'correct', uri: 'firstleg-service', paths: ['/api/item'] }], 'fixture'), /Wrong destination/) })