Skip to content

Commit 4e6b876

Browse files
committed
fix: address Copilot review comments on PR #5205
- msal-config-schema: reject unparseable version instead of coercing to "null" - HttpMockRouter: reset RegExp lastIndex so global/sticky match patterns are reusable across requests - extract decodeJwtSegment into its own file, fix atob/UTF-8 round-trip for non-ASCII claims - service-discovery docs: dedupe heading, close dangling code fence
1 parent b9bb088 commit 4e6b876

8 files changed

Lines changed: 55 additions & 11 deletions

File tree

packages/framework/src/__tests__/mock/documented-usage.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { afterEach, describe, expect, it, vi } from 'vitest';
22

3+
import { decodeJwtSegment } from '@equinor/fusion-framework-module-msal/mock';
4+
35
import { init } from '../../init.js';
46
import { createMockService, FrameworkMockConfigurator, mockFramework } from '../../mock/index.js';
57

@@ -71,7 +73,7 @@ describe('documented usage', () => {
7173
});
7274

7375
const token = await fusion.modules.auth.acquireAccessToken();
74-
const claims = JSON.parse(atob(token?.split('.')[1] ?? ''));
76+
const claims = JSON.parse(decodeJwtSegment(token?.split('.')[1] ?? ''));
7577

7678
// MsalProvider — not the mock — turns "no scopes requested" into `${clientId}/.default`
7779
expect(claims.scp).toBe('my-app/.default');

packages/modules/http/src/mock/HttpMockRouter.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ interface RegisteredMiddleware {
2222
handler: HttpMockMiddleware;
2323
}
2424

25+
/**
26+
* Tests `url` against a match constraint.
27+
*
28+
* @remarks
29+
* A `g`/`y` `RegExp` is stateful — `.test()` advances its `lastIndex`, so the
30+
* same registration would match on one call and silently miss on the next.
31+
* Resetting `lastIndex` first keeps a registered pattern reusable across requests.
32+
*/
33+
function matchesUrl(match: string | RegExp, url: string): boolean {
34+
// A plain substring constraint has no statefulness to reset.
35+
if (typeof match === 'string') return url.includes(match);
36+
match.lastIndex = 0;
37+
return match.test(url);
38+
}
39+
2540
/**
2641
* Runs a chain of middleware against requests and answers them without
2742
* reaching the network.
@@ -161,13 +176,9 @@ export class HttpMockRouter {
161176
// Skip handlers registered for another HTTP method.
162177
if (registered.method && registered.method !== method) continue;
163178
// Only apply URL matching when the registration specifies a URL constraint.
164-
if (registered.match !== undefined) {
165-
const matched =
166-
typeof registered.match === 'string'
167-
? url.includes(registered.match)
168-
: registered.match.test(url);
179+
if (registered.match !== undefined && !matchesUrl(registered.match, url)) {
169180
// Continue searching when this handler's URL constraint does not match.
170-
if (!matched) continue;
181+
continue;
171182
}
172183
// clone the request so one middleware reading the body (e.g. `.json()`) does not
173184
// exhaust it for the next middleware in the chain

packages/modules/msal/src/mock/create-mock-token.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const base64Url = (value: string): string => {
6666
* ```typescript
6767
* const token = createMockToken({ name: 'Test User', scp: 'Files.Read' });
6868
* const [, payload] = token.split('.');
69-
* JSON.parse(atob(payload)).name; // 'Test User'
69+
* JSON.parse(decodeJwtSegment(payload)).name; // 'Test User'
7070
* ```
7171
*/
7272
export const createMockToken = (claims: MockTokenClaims = {}): string => {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Decodes a base64url segment of a {@link createMockToken} JWT back to its JSON string.
3+
*
4+
* @remarks
5+
* Plain `atob` alone mangles non-ASCII claims: it treats its output as latin1,
6+
* while segments are UTF-8 encoded. This reverses that encoding and also
7+
* restores the standard base64 alphabet/padding `atob` expects.
8+
*
9+
* @param segment - A base64url segment, e.g. from splitting a JWT on `.`.
10+
* @returns The decoded UTF-8 string.
11+
*/
12+
export function decodeJwtSegment(segment: string): string {
13+
const base64 = segment
14+
.replace(/-/g, '+')
15+
.replace(/_/g, '/')
16+
.padEnd(segment.length + ((4 - (segment.length % 4)) % 4), '=');
17+
const binary = atob(base64);
18+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
19+
return new TextDecoder().decode(bytes);
20+
}
21+
22+
export default decodeJwtSegment;

packages/modules/msal/src/mock/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export { createMsalMockClient } from './create-msal-mock-client';
2626
export { MsalMockConfigurator } from './MsalMockConfigurator';
2727
export { enableMsalMock, msalMockModule, type AuthConfigMockFn } from './module';
2828
export { createMockToken, type MockTokenClaims } from './create-mock-token';
29+
export { decodeJwtSegment } from './decode-jwt-segment';

packages/modules/msal/src/msal-config-schema.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,16 @@ export const MsalConfigSchema = z.object({
3030
Object.values(CacheLookupPolicy).includes(val as CacheLookupPolicy),
3131
)
3232
.optional(),
33-
version: z.string().transform((x: string) => String(semver.coerce(x))),
33+
version: z.string().transform((value, ctx) => {
34+
const coerced = semver.coerce(value);
35+
// `semver.coerce` returns `null` for an unparseable version; without this guard it
36+
// would silently become the literal string "null" instead of failing validation.
37+
if (!coerced) {
38+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid MSAL module version' });
39+
return z.NEVER;
40+
}
41+
return coerced.version;
42+
}),
3443
telemetry: TelemetryConfigSchema,
3544
});
3645

packages/modules/service-discovery/docs/api-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ type Service = {
2727
overridden?: boolean; // True when session-overridden
2828
defaultScopes: string[]; // @deprecated — use `scopes`
2929
};
30+
```

packages/modules/service-discovery/docs/session-overrides.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# Session Overrides
22

3-
## Session Overrides
4-
53
> [!TIP]
64
> Session overrides let you redirect services to local or staging URLs during development without touching application config.
75

0 commit comments

Comments
 (0)