refactor: reuse the SSO app registration for M365 calendar sync

One Entra enterprise application now covers both interactive login and
the calendar sync's client-credentials Graph access, instead of
requiring a second app registration. GraphClientService reads
OIDC_ISSUER_URL/OIDC_CLIENT_ID/OIDC_CLIENT_SECRET (same as SSO) and
derives the tenant ID from the issuer URL's path, rather than three
dedicated M365_CALENDAR_* env vars. The app registration just needs the
"Calendars.ReadWrite" Application permission added alongside its
existing delegated login scopes.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 14:35:18 +02:00
1 parent e7b157ec35
commit e133a677ab
6 files changed
+70 -44

No files matched your search

@@ -17,30 +17,36 @@ describe('GraphClientService', () => {
service = new GraphClientService();
fetchMock = jest.fn();
global.fetch = fetchMock;
process.env.M365_CALENDAR_TENANT_ID = 'tenant-1';
process.env.M365_CALENDAR_CLIENT_ID = 'client-1';
process.env.M365_CALENDAR_CLIENT_SECRET = 'secret-1';
process.env.OIDC_ISSUER_URL =
'https://login.microsoftonline.com/tenant-1/v2.0';
process.env.OIDC_CLIENT_ID = 'client-1';
process.env.OIDC_CLIENT_SECRET = 'secret-1';
});
afterEach(() => {
delete process.env.M365_CALENDAR_TENANT_ID;
delete process.env.M365_CALENDAR_CLIENT_ID;
delete process.env.M365_CALENDAR_CLIENT_SECRET;
delete process.env.OIDC_ISSUER_URL;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
});
describe('isConfigured', () => {
it('returns true when all three env vars are set', () => {
it('returns true when all three SSO env vars are set', () => {
expect(service.isConfigured()).toBe(true);
});
it('returns false when a required env var is missing', () => {
delete process.env.M365_CALENDAR_CLIENT_SECRET;
delete process.env.OIDC_CLIENT_SECRET;
expect(service.isConfigured()).toBe(false);
});
it('returns false when the issuer URL has no tenant segment', () => {
process.env.OIDC_ISSUER_URL = 'https://login.microsoftonline.com/';
expect(service.isConfigured()).toBe(false);
});
});
describe('createEvent', () => {
it('fetches a token, then posts an all-day event and returns the new id', async () => {
it('fetches a token from the tenant derived from OIDC_ISSUER_URL, then posts an all-day event', async () => {
fetchMock
.mockResolvedValueOnce(
jsonResponse(200, { access_token: 'tok-1', expires_in: 3600 }),
@@ -54,10 +60,15 @@ describe('GraphClientService', () => {
expect(result).toBe('graph-event-1');
expect(fetchMock).toHaveBeenCalledTimes(2);
const [tokenUrl] = fetchMock.mock.calls[0] as [string];
const [tokenUrl, tokenInit] = fetchMock.mock.calls[0] as [
string,
{ body: URLSearchParams },
];
expect(tokenUrl).toBe(
'https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token',
);
expect(tokenInit.body.get('client_id')).toBe('client-1');
expect(tokenInit.body.get('client_secret')).toBe('secret-1');
const [eventUrl, eventInit] = fetchMock.mock.calls[1] as [
string,
{ headers: Record<string, string>; body: string },