fix(server): test & schema

This commit is contained in:
DarkSky
2026-05-04 03:56:14 +08:00
parent 74d5ebad13
commit 1ad088398f
4 changed files with 119 additions and 72 deletions

View File

@@ -469,6 +469,13 @@
"type": "string", "type": "string",
"description": "The account id for the cloudflare r2 storage provider." "description": "The account id for the cloudflare r2 storage provider."
}, },
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
},
"usePresignedURL": { "usePresignedURL": {
"type": "object", "type": "object",
"description": "The presigned url config for the cloudflare r2 storage provider.", "description": "The presigned url config for the cloudflare r2 storage provider.",
@@ -486,13 +493,6 @@
"description": "The presigned key for the cloudflare r2 storage provider." "description": "The presigned key for the cloudflare r2 storage provider."
} }
} }
},
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
} }
} }
} }
@@ -667,6 +667,13 @@
"type": "string", "type": "string",
"description": "The account id for the cloudflare r2 storage provider." "description": "The account id for the cloudflare r2 storage provider."
}, },
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
},
"usePresignedURL": { "usePresignedURL": {
"type": "object", "type": "object",
"description": "The presigned url config for the cloudflare r2 storage provider.", "description": "The presigned url config for the cloudflare r2 storage provider.",
@@ -684,13 +691,6 @@
"description": "The presigned key for the cloudflare r2 storage provider." "description": "The presigned key for the cloudflare r2 storage provider."
} }
} }
},
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
} }
} }
} }
@@ -861,11 +861,14 @@
"properties": { "properties": {
"google": { "google": {
"type": "object", "type": "object",
"description": "Google Calendar integration config\n@default {\"enabled\":false,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push", "description": "Google Calendar integration config\n@default {\"enabled\":false,\"allowNewAccounts\":true,\"clientId\":\"\",\"clientSecret\":\"\",\"externalWebhookUrl\":\"\",\"webhookVerificationToken\":\"\",\"requestTimeoutMs\":10000}\n@link https://developers.google.com/calendar/api/guides/push",
"properties": { "properties": {
"enabled": { "enabled": {
"type": "boolean" "type": "boolean"
}, },
"allowNewAccounts": {
"type": "boolean"
},
"clientId": { "clientId": {
"type": "string" "type": "string"
}, },
@@ -884,6 +887,7 @@
}, },
"default": { "default": {
"enabled": false, "enabled": false,
"allowNewAccounts": true,
"clientId": "", "clientId": "",
"clientSecret": "", "clientSecret": "",
"externalWebhookUrl": "", "externalWebhookUrl": "",
@@ -1296,6 +1300,13 @@
"type": "string", "type": "string",
"description": "The account id for the cloudflare r2 storage provider." "description": "The account id for the cloudflare r2 storage provider."
}, },
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
},
"usePresignedURL": { "usePresignedURL": {
"type": "object", "type": "object",
"description": "The presigned url config for the cloudflare r2 storage provider.", "description": "The presigned url config for the cloudflare r2 storage provider.",
@@ -1313,13 +1324,6 @@
"description": "The presigned key for the cloudflare r2 storage provider." "description": "The presigned key for the cloudflare r2 storage provider."
} }
} }
},
"jurisdiction": {
"type": "string",
"enum": [
"eu"
],
"description": "Optional jurisdiction for the cloudflare r2 endpoint. Set to \"eu\" for EU buckets."
} }
} }
} }

View File

@@ -209,47 +209,77 @@ test('listAccounts includes calendars count', async t => {
t.is(counts.get(accountB.id), 1); t.is(counts.get(accountB.id), 1);
}); });
test('assertCanLinkProvider blocks new google calendar accounts when disabled', async t => { test.serial(
config.calendar.google.allowNewAccounts = false; 'assertCanLinkProvider blocks new google calendar accounts when disabled',
const user = await module.create(Mockers.User); async t => {
config.calendar.google.allowNewAccounts = false;
const user = await module.create(Mockers.User);
const error = await t.throwsAsync( const error = await t.throwsAsync(
calendarService.assertCanLinkProvider(user.id, CalendarProviderName.Google) calendarService.assertCanLinkProvider(
); user.id,
t.true(error instanceof GraphqlBadRequest); CalendarProviderName.Google
t.is( )
(error as GraphqlBadRequest).data?.code, );
'calendar_provider_link_disabled' t.true(error instanceof GraphqlBadRequest);
); t.is(
}); (error as GraphqlBadRequest).data?.code,
'calendar_provider_link_disabled'
);
}
);
test('assertCanLinkProvider allows users with an existing google calendar account', async t => { test.serial(
config.calendar.google.allowNewAccounts = false; 'assertCanLinkProvider allows users with an existing google calendar account',
const user = await module.create(Mockers.User); async t => {
await createAccount(user.id); config.calendar.google.allowNewAccounts = false;
const user = await module.create(Mockers.User);
await createAccount(user.id);
await t.notThrowsAsync( await t.notThrowsAsync(
calendarService.assertCanLinkProvider(user.id, CalendarProviderName.Google) calendarService.assertCanLinkProvider(
); user.id,
}); CalendarProviderName.Google
)
);
}
);
test('handleOAuthCallback does not persist new google account when linking is disabled', async t => { test.serial(
config.calendar.google.allowNewAccounts = false; 'handleOAuthCallback does not persist new google account when linking is disabled',
const provider = new MockCalendarProvider(); async t => {
providerFactory.register(provider); config.calendar.google.allowNewAccounts = false;
const user = await module.create(Mockers.User); const provider = new MockCalendarProvider();
mock.method(providerFactory, 'get', () => provider);
const user = await module.create(Mockers.User);
const error = await t.throwsAsync( const error = await t.throwsAsync(
calendarService.handleOAuthCallback({ calendarService.handleOAuthCallback({
provider: CalendarProviderName.Google, provider: CalendarProviderName.Google,
code: 'code', code: 'code',
redirectUri: 'https://example.com/callback', redirectUri: 'https://example.com/callback',
userId: user.id, userId: user.id,
}) })
); );
t.true(error instanceof GraphqlBadRequest); t.true(error instanceof GraphqlBadRequest);
t.is((await models.calendarAccount.listByUser(user.id)).length, 0); t.is((await models.calendarAccount.listByUser(user.id)).length, 0);
}); }
);
test.serial(
'canLinkProvider returns false for new google calendar accounts when disabled',
async t => {
config.calendar.google.allowNewAccounts = false;
const user = await module.create(Mockers.User);
t.false(
await calendarService.canLinkProvider(
user.id,
CalendarProviderName.Google
)
);
}
);
test('syncSubscription resets invalid sync token and maps events', async t => { test('syncSubscription resets invalid sync token and maps events', async t => {
const user = await module.create(Mockers.User); const user = await module.create(Mockers.User);

View File

@@ -33,17 +33,20 @@ import {
export class CalendarServerConfigResolver { export class CalendarServerConfigResolver {
constructor( constructor(
private readonly providerFactory: CalendarProviderFactory, private readonly providerFactory: CalendarProviderFactory,
private readonly config: Config private readonly config: Config,
private readonly calendar: CalendarService
) {} ) {}
@ResolveField(() => [CalendarProviderName]) @ResolveField(() => [CalendarProviderName])
calendarProviders() { async calendarProviders(@CurrentUser() user?: CurrentUser) {
return this.providerFactory.providers.filter(provider => { const providers = [];
return ( for (const provider of this.providerFactory.providers) {
provider !== CalendarProviderName.Google || if (!(await this.calendar.canLinkProvider(user?.id, provider))) {
this.config.calendar.google.allowNewAccounts !== false continue;
); }
}); providers.push(provider);
}
return providers;
} }
@ResolveField(() => [CalendarCalDAVProviderPresetObjectType]) @ResolveField(() => [CalendarCalDAVProviderPresetObjectType])

View File

@@ -571,18 +571,28 @@ export class CalendarService {
} }
async assertCanLinkProvider(userId: string, provider: CalendarProviderName) { async assertCanLinkProvider(userId: string, provider: CalendarProviderName) {
if (this.canCreateNewAccounts(provider)) { if (await this.canLinkProvider(userId, provider)) {
return;
}
const accounts = await this.models.calendarAccount.listByUser(userId);
if (accounts.some(account => account.provider === provider)) {
return; return;
} }
throw this.providerLinkDisabledError(provider); throw this.providerLinkDisabledError(provider);
} }
async canLinkProvider(
userId: string | null | undefined,
provider: CalendarProviderName
) {
if (this.canCreateNewAccounts(provider)) {
return true;
}
if (!userId) {
return false;
}
const accounts = await this.models.calendarAccount.listByUser(userId);
return accounts.some(account => account.provider === provider);
}
getAuthUrl( getAuthUrl(
provider: CalendarProviderName, provider: CalendarProviderName,
state: string, state: string,