Skip to content

Commit 78aa621

Browse files
committed
Harden template import preflights
Match existing template sites when solution updates add framework suffixes to PAC site names. Add a tenant-alignment preflight so PAC CLI, Azure CLI, and the Dataverse token must point to the same tenant before template import. Copilot-Session: a9952624-0a25-4d6e-ae67-537c962a8a60
1 parent 2b9aa40 commit 78aa621

7 files changed

Lines changed: 307 additions & 20 deletions

File tree

plugins/power-pages/scripts/diff-pages-list.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ const { diffPagesListVerbose } = require('./lib/pages-list-diff');
1212
// than a thrown exception so the skill can surface the problem and stop before
1313
// activation.
1414
function parseArgs(argv) {
15-
const args = {};
15+
const args = { expectedSiteNames: [] };
1616
for (let i = 0; i < argv.length; i++) {
1717
const arg = argv[i];
1818
if (arg === '--before') args.before = argv[++i];
1919
else if (arg === '--after') args.after = argv[++i];
20-
else if (arg === '--expectedSiteName') args.expectedSiteName = argv[++i];
20+
else if (arg === '--expectedSiteName') {
21+
const value = argv[++i];
22+
args.expectedSiteName = value;
23+
args.expectedSiteNames.push(value);
24+
}
2125
}
26+
if (!args.expectedSiteNames.length) delete args.expectedSiteNames;
2227
return args;
2328
}
2429

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use strict';
2+
3+
const { execFileSync } = require('child_process');
4+
const { getAuthToken } = require('./validation-helpers');
5+
6+
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7+
8+
function normalizeGuid(value) {
9+
const text = String(value || '').trim();
10+
return GUID_RE.test(text) ? text.toLowerCase() : null;
11+
}
12+
13+
function parsePacTenantId(output) {
14+
// PAC auth banners are label/value text and vary slightly by version, e.g.:
15+
// Tenant ID: 72f988bf-86f1-41af-91ab-2d7cd011db47
16+
// Tenant: 72f988bf-86f1-41af-91ab-2d7cd011db47
17+
// Values can have extra spaces, so parse only known tenant labels and require
18+
// a GUID-shaped value before trusting it.
19+
const match = String(output || '').match(/^\s*(?:Tenant ID|Tenant)\s*:\s*([0-9a-f-]{36})\s*$/im);
20+
return match ? normalizeGuid(match[1]) : null;
21+
}
22+
23+
function decodeJwtPayload(token) {
24+
const parts = String(token || '').split('.');
25+
if (parts.length < 2) return null;
26+
try {
27+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
28+
} catch {
29+
return null;
30+
}
31+
}
32+
33+
function tenantIdFromToken(token) {
34+
const payload = decodeJwtPayload(token);
35+
return normalizeGuid(payload && payload.tid);
36+
}
37+
38+
function getPacTenantId(execFile = execFileSync) {
39+
try {
40+
return parsePacTenantId(execFile('pac', ['auth', 'who'], { encoding: 'utf8', timeout: 15000 }));
41+
} catch {
42+
return null;
43+
}
44+
}
45+
46+
function getAzAccountTenantId(execFile = execFileSync) {
47+
try {
48+
return normalizeGuid(execFile('az', ['account', 'show', '--query', 'tenantId', '-o', 'tsv'], { encoding: 'utf8', timeout: 15000 }));
49+
} catch {
50+
return null;
51+
}
52+
}
53+
54+
function validateCliTenantAlignment({ envUrl, token, pacTenantId, azTenantId, tokenTenantId } = {}, deps = {}) {
55+
const execFile = deps.execFile || execFileSync;
56+
const getToken = deps.getAuthToken || getAuthToken;
57+
const pacTenant = normalizeGuid(pacTenantId) || getPacTenantId(execFile);
58+
const azTenant = normalizeGuid(azTenantId) || getAzAccountTenantId(execFile);
59+
const bearerToken = token || (envUrl ? getToken(envUrl) : null);
60+
const tokenTenant = normalizeGuid(tokenTenantId) || tenantIdFromToken(bearerToken);
61+
62+
const missing = [];
63+
if (!pacTenant) missing.push('pacTenantId');
64+
if (!azTenant) missing.push('azTenantId');
65+
if (!tokenTenant) missing.push('tokenTenantId');
66+
if (missing.length) {
67+
return {
68+
ok: false,
69+
error: `Could not determine ${missing.join(', ')}. Run pac auth who and az login, then retry.`,
70+
pacTenantId: pacTenant,
71+
azTenantId: azTenant,
72+
tokenTenantId: tokenTenant,
73+
};
74+
}
75+
76+
const mismatches = [];
77+
if (pacTenant !== azTenant) mismatches.push('pac-vs-az');
78+
if (pacTenant !== tokenTenant) mismatches.push('pac-vs-token');
79+
if (azTenant !== tokenTenant) mismatches.push('az-vs-token');
80+
81+
return {
82+
ok: mismatches.length === 0,
83+
pacTenantId: pacTenant,
84+
azTenantId: azTenant,
85+
tokenTenantId: tokenTenant,
86+
mismatches,
87+
error: mismatches.length
88+
? 'PAC CLI and Azure CLI are authenticated to different tenants. Switch PAC auth or Azure CLI tenant before importing.'
89+
: null,
90+
};
91+
}
92+
93+
module.exports = {
94+
decodeJwtPayload,
95+
parsePacTenantId,
96+
tenantIdFromToken,
97+
validateCliTenantAlignment,
98+
};

plugins/power-pages/scripts/lib/pages-list-diff.js

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,32 @@ function normalizeComparableName(value) {
1717
return normalizeName(String(value || '')).toLowerCase();
1818
}
1919

20+
function comparableNameAliases(value) {
21+
const normalized = normalizeComparableName(value);
22+
if (!normalized) return [];
23+
const aliases = new Set([normalized]);
24+
// Template family names can omit the SPA framework while the imported site row
25+
// includes it, e.g. `311 Portal` in solution metadata but `311 Portal React`
26+
// in `pac pages list -v`. Strip only known trailing framework tokens so the
27+
// fallback does not turn into arbitrary fuzzy matching across unrelated sites.
28+
const withoutFramework = normalized.replace(/\s+(react|vue|angular|astro)\s*$/i, '').trim();
29+
if (withoutFramework) aliases.add(withoutFramework);
30+
return [...aliases];
31+
}
32+
33+
function siteNameMatchesExpected(siteName, expectedSiteName) {
34+
const siteAliases = comparableNameAliases(siteName);
35+
const expectedAliases = new Set(comparableNameAliases(expectedSiteName));
36+
return siteAliases.some((alias) => expectedAliases.has(alias));
37+
}
38+
39+
function expectedSiteNamesFromOptions(options = {}) {
40+
const names = [];
41+
if (options.expectedSiteName) names.push(options.expectedSiteName);
42+
if (Array.isArray(options.expectedSiteNames)) names.push(...options.expectedSiteNames);
43+
return names.filter((name) => normalizeComparableName(name));
44+
}
45+
2046
function parsePagesListVerbose(output) {
2147
// `pac pages list -v` is a human table whose exact columns vary by CLI
2248
// version/cloud. The stable token we need is the Website Record ID GUID.
@@ -63,9 +89,9 @@ function diffPagesListVerbose(beforeOutput, afterOutput, options = {}) {
6389
};
6490
}
6591
if (added.length === 0) {
66-
const expectedSiteName = normalizeComparableName(options.expectedSiteName);
67-
if (expectedSiteName) {
68-
const existing = after.filter((row) => normalizeComparableName(row.siteName) === expectedSiteName);
92+
const expectedSiteNames = expectedSiteNamesFromOptions(options);
93+
if (expectedSiteNames.length) {
94+
const existing = after.filter((row) => expectedSiteNames.some((expectedSiteName) => siteNameMatchesExpected(row.siteName, expectedSiteName)));
6995
if (existing.length === 1) {
7096
return {
7197
status: 'existing',
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
const {
7+
parsePacTenantId,
8+
tenantIdFromToken,
9+
validateCliTenantAlignment,
10+
} = require('../lib/cli-tenant-alignment');
11+
const { parseArgs, run } = require('../validate-cli-tenant-alignment');
12+
13+
const TENANT_A = '72f988bf-86f1-41af-91ab-2d7cd011db47';
14+
const TENANT_B = '11111111-2222-3333-4444-555555555555';
15+
16+
function fakeJwt(tenantId) {
17+
return [
18+
Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url'),
19+
Buffer.from(JSON.stringify({ tid: tenantId })).toString('base64url'),
20+
'',
21+
].join('.');
22+
}
23+
24+
function fakeExecFile({ pacTenant = TENANT_A, azTenant = TENANT_A } = {}) {
25+
return (command, args) => {
26+
if (command === 'pac' && args.join(' ') === 'auth who') {
27+
return `Connected as user@contoso.com\nTenant ID: ${pacTenant}\n`;
28+
}
29+
if (command === 'az' && args.join(' ') === 'account show --query tenantId -o tsv') {
30+
return `${azTenant}\n`;
31+
}
32+
throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
33+
};
34+
}
35+
36+
test('parsePacTenantId extracts Tenant ID from PAC auth output', () => {
37+
assert.equal(parsePacTenantId(`User: u\nTenant ID: ${TENANT_A}\n`), TENANT_A);
38+
assert.equal(parsePacTenantId(`Tenant: ${TENANT_A}`), TENANT_A);
39+
assert.equal(parsePacTenantId('Tenant ID: not-a-guid'), null);
40+
});
41+
42+
test('tenantIdFromToken extracts tid from an Azure CLI access token', () => {
43+
assert.equal(tenantIdFromToken(fakeJwt(TENANT_A)), TENANT_A);
44+
assert.equal(tenantIdFromToken('not.jwt'), null);
45+
});
46+
47+
test('validateCliTenantAlignment succeeds when PAC, Azure account, and token tenants match', () => {
48+
assert.deepEqual(validateCliTenantAlignment({
49+
envUrl: 'https://org.crm.dynamics.com',
50+
token: fakeJwt(TENANT_A),
51+
}, {
52+
execFile: fakeExecFile(),
53+
}), {
54+
ok: true,
55+
pacTenantId: TENANT_A,
56+
azTenantId: TENANT_A,
57+
tokenTenantId: TENANT_A,
58+
mismatches: [],
59+
error: null,
60+
});
61+
});
62+
63+
test('validateCliTenantAlignment blocks when PAC and Azure tenants differ', () => {
64+
const result = validateCliTenantAlignment({
65+
envUrl: 'https://org.crm.dynamics.com',
66+
token: fakeJwt(TENANT_A),
67+
}, {
68+
execFile: fakeExecFile({ pacTenant: TENANT_B, azTenant: TENANT_A }),
69+
});
70+
71+
assert.equal(result.ok, false);
72+
assert.deepEqual(result.mismatches, ['pac-vs-az', 'pac-vs-token']);
73+
assert.match(result.error, /different tenants/i);
74+
});
75+
76+
test('run requires either an environment URL or token and passes CLI args through', () => {
77+
assert.deepEqual(parseArgs(['--envUrl', 'https://org.crm.dynamics.com', '--token', 't']), {
78+
envUrl: 'https://org.crm.dynamics.com',
79+
token: 't',
80+
});
81+
assert.equal(run([]).ok, false);
82+
assert.equal(run(['--token', fakeJwt(TENANT_A)], { execFile: fakeExecFile() }).ok, true);
83+
});

plugins/power-pages/scripts/tests/pages-list-diff.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,28 @@ test('diffPagesListVerbose can match an existing template site when import creat
8181
});
8282
});
8383

84+
test('diffPagesListVerbose can match an existing template site by normalized aliases when import updates the solution', () => {
85+
const before = `311 Portal React ${ID1} Inactive`;
86+
const after = before;
87+
88+
assert.deepEqual(diffPagesListVerbose(before, after, { expectedSiteName: '311 Portal' }), {
89+
status: 'existing',
90+
siteName: '311 Portal React',
91+
websiteRecordId: ID1,
92+
state: 'Inactive',
93+
inactive: true,
94+
added: [],
95+
warning: 'No new site appeared in pac pages list; matched an existing site by expected template site name.',
96+
});
97+
});
98+
99+
test('diffPagesListVerbose can match an existing template site using alternate expected names', () => {
100+
const before = `311 Portal React ${ID1} Inactive`;
101+
const after = before;
102+
103+
assert.equal(diffPagesListVerbose(before, after, { expectedSiteNames: ['Supplier Portal', '311 Portal'] }).status, 'existing');
104+
});
105+
84106
test('diffPagesListVerbose reports ambiguous existing template site matches', () => {
85107
const before = `Template Site ${ID1} Inactive\nTemplate Site ${ID2} Inactive`;
86108
const after = before;
@@ -106,5 +128,20 @@ test('parseArgs reads before and after snapshot file paths', () => {
106128
before: '/tmp/before.txt',
107129
after: '/tmp/after.txt',
108130
expectedSiteName: 'Template Site',
131+
expectedSiteNames: ['Template Site'],
132+
});
133+
});
134+
135+
test('parseArgs keeps repeated expected site names as fallback aliases', () => {
136+
assert.deepEqual(parseArgs([
137+
'--before', '/tmp/before.txt',
138+
'--after', '/tmp/after.txt',
139+
'--expectedSiteName', '311 Portal',
140+
'--expectedSiteName', '311 Portal React',
141+
]), {
142+
before: '/tmp/before.txt',
143+
after: '/tmp/after.txt',
144+
expectedSiteName: '311 Portal React',
145+
expectedSiteNames: ['311 Portal', '311 Portal React'],
109146
});
110147
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env node
2+
'use strict';
3+
4+
const { validateCliTenantAlignment } = require('./lib/cli-tenant-alignment');
5+
const { formatJsonResult } = require('./lib/template-cli-args');
6+
7+
function parseArgs(argv) {
8+
const args = {};
9+
for (let i = 0; i < argv.length; i++) {
10+
const arg = argv[i];
11+
if (arg === '--envUrl') args.envUrl = argv[++i];
12+
else if (arg === '--token') args.token = argv[++i];
13+
}
14+
return args;
15+
}
16+
17+
function run(argv = process.argv.slice(2), deps = {}) {
18+
const args = parseArgs(argv);
19+
if (!args.envUrl && !args.token) {
20+
return { ok: false, error: 'Usage: validate-cli-tenant-alignment.js --envUrl <url> [--token <bearer-token>]' };
21+
}
22+
return validateCliTenantAlignment(args, deps);
23+
}
24+
25+
if (require.main === module) {
26+
process.stdout.write(formatJsonResult(run()));
27+
}
28+
29+
module.exports = { parseArgs, run };

0 commit comments

Comments
 (0)