@remit/backend 0.0.123 → 0.0.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AccountResponse,
|
|
3
|
+
AccountService as AccountServiceName,
|
|
3
4
|
FolderAppointment,
|
|
4
5
|
} from "@remit/api-openapi-types";
|
|
5
6
|
import type { AccountItem } from "@remit/data-ports";
|
|
6
7
|
import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
|
|
7
|
-
import { AccountAuthType } from "@remit/domain-enums";
|
|
8
|
+
import { AccountAuthType, AccountService } from "@remit/domain-enums";
|
|
8
9
|
import type { AccountOverrides } from "./account-overrides.js";
|
|
9
10
|
import type { AccountSignature } from "./account-signature.js";
|
|
10
11
|
|
|
@@ -84,6 +85,41 @@ export const assertPasswordProvided = (
|
|
|
84
85
|
}
|
|
85
86
|
};
|
|
86
87
|
|
|
88
|
+
const MICROSOFT_GRANT_SERVICES: readonly AccountServiceName[] = [
|
|
89
|
+
AccountService.Mail,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export const resolveSyncedServices = (
|
|
93
|
+
authType: string,
|
|
94
|
+
requested: readonly AccountServiceName[],
|
|
95
|
+
): AccountServiceName[] => {
|
|
96
|
+
const services = Object.values(AccountService).filter((service) =>
|
|
97
|
+
requested.includes(service),
|
|
98
|
+
);
|
|
99
|
+
if (services.length === 0) {
|
|
100
|
+
throw new BadRequestError(
|
|
101
|
+
"syncedServices must name at least one service. To stop syncing an account entirely, delete it with DELETE /accounts/{accountId}.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (authType !== AccountAuthType.OauthMicrosoft) {
|
|
105
|
+
if (services.some((service) => service !== AccountService.Mail)) {
|
|
106
|
+
throw new BadRequestError(
|
|
107
|
+
`A password account syncs mail only, so syncedServices must be ["${AccountService.Mail}"].`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return services;
|
|
111
|
+
}
|
|
112
|
+
const uncovered = services.filter(
|
|
113
|
+
(service) => !MICROSOFT_GRANT_SERVICES.includes(service),
|
|
114
|
+
);
|
|
115
|
+
if (uncovered.length > 0) {
|
|
116
|
+
throw new BadRequestError(
|
|
117
|
+
`This account's Microsoft consent does not cover ${uncovered.join(", ")}. Grant it through POST /accounts/oauth/microsoft/start.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return services;
|
|
121
|
+
};
|
|
122
|
+
|
|
87
123
|
// SECURITY: passwordHash, oauthRefreshTokenHash, and smtpPasswordHash are
|
|
88
124
|
// intentionally omitted — never expose token material in API responses.
|
|
89
125
|
// Display name, mute flag, and signatures live in per-account AccountSetting
|
|
@@ -103,6 +139,7 @@ export const toAccountResponse = (
|
|
|
103
139
|
username: account.username,
|
|
104
140
|
email: account.email,
|
|
105
141
|
authType: account.authType ?? AccountAuthType.Password,
|
|
142
|
+
syncedServices: account.syncedServices,
|
|
106
143
|
imapHost: account.imapHost,
|
|
107
144
|
imapPort: account.imapPort,
|
|
108
145
|
imapTls: account.imapTls,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountResponse } from "@remit/api-openapi-types";
|
|
4
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
5
|
+
import {
|
|
6
|
+
AccountAuthType,
|
|
7
|
+
AccountService,
|
|
8
|
+
ConnectionState,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import {
|
|
11
|
+
AccountConfigRepo,
|
|
12
|
+
AccountRepo,
|
|
13
|
+
AccountSettingRepo,
|
|
14
|
+
ConfigImportRepo,
|
|
15
|
+
MailboxRepo,
|
|
16
|
+
} from "@remit/drizzle-service";
|
|
17
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
18
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
19
|
+
import type { Context } from "openapi-backend";
|
|
20
|
+
import { deriveAccountConfigId } from "../auth.js";
|
|
21
|
+
import { handleError } from "../error.js";
|
|
22
|
+
import { formatResponse } from "../response.js";
|
|
23
|
+
import {
|
|
24
|
+
_resetForTest,
|
|
25
|
+
type RemitClient,
|
|
26
|
+
setClient,
|
|
27
|
+
} from "../service/data-client.js";
|
|
28
|
+
import { AccountDetailOperations, AccountOperations } from "./account.js";
|
|
29
|
+
import { ConfigOperations } from "./config.js";
|
|
30
|
+
|
|
31
|
+
const SUB = "cognito-sub-1180";
|
|
32
|
+
const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
|
|
33
|
+
|
|
34
|
+
type Handler = (
|
|
35
|
+
context: Context,
|
|
36
|
+
event: APIGatewayProxyEvent,
|
|
37
|
+
) => Promise<unknown>;
|
|
38
|
+
|
|
39
|
+
const getConfig = ConfigOperations.ConfigOperations_getConfig as Handler;
|
|
40
|
+
const createAccount =
|
|
41
|
+
AccountOperations.AccountOperations_createAccount as Handler;
|
|
42
|
+
const updateAccount =
|
|
43
|
+
AccountDetailOperations.AccountDetailOperations_updateAccount as Handler;
|
|
44
|
+
|
|
45
|
+
const event = (body?: unknown): APIGatewayProxyEvent =>
|
|
46
|
+
({
|
|
47
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
48
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
49
|
+
}) as unknown as APIGatewayProxyEvent;
|
|
50
|
+
|
|
51
|
+
const context = (params: Record<string, string> = {}): Context =>
|
|
52
|
+
({ request: { params } }) as unknown as Context;
|
|
53
|
+
|
|
54
|
+
const respond = async (
|
|
55
|
+
run: () => Promise<unknown>,
|
|
56
|
+
): Promise<APIGatewayProxyResult> =>
|
|
57
|
+
run().then(
|
|
58
|
+
(body) => formatResponse(body as Record<string, unknown>),
|
|
59
|
+
(error: unknown) => handleError(error),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const bodyOf = <T>(response: APIGatewayProxyResult): T =>
|
|
63
|
+
JSON.parse(response.body) as T;
|
|
64
|
+
|
|
65
|
+
let accounts: AccountRepo;
|
|
66
|
+
let close: () => void;
|
|
67
|
+
|
|
68
|
+
const seedAccount = (authType: AccountItem["authType"]) =>
|
|
69
|
+
accounts.create({
|
|
70
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
71
|
+
username: `${authType}@example.com`,
|
|
72
|
+
email: `${authType}@example.com`,
|
|
73
|
+
authType,
|
|
74
|
+
imapHost: "imap.example.com",
|
|
75
|
+
imapPort: 993,
|
|
76
|
+
imapTls: true,
|
|
77
|
+
imapStartTls: false,
|
|
78
|
+
isActive: true,
|
|
79
|
+
connectionState: ConnectionState.NotAuthenticated,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const readServices = async (accountId: string): Promise<string[]> => {
|
|
83
|
+
const config = bodyOf<{ accounts: AccountResponse[] }>(
|
|
84
|
+
await respond(() => getConfig(context(), event())),
|
|
85
|
+
);
|
|
86
|
+
const account = config.accounts.find((item) => item.accountId === accountId);
|
|
87
|
+
assert.ok(account, `GET /config lists ${accountId}`);
|
|
88
|
+
return account.syncedServices;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const patchServices = (accountId: string, syncedServices: string[]) =>
|
|
92
|
+
respond(() =>
|
|
93
|
+
updateAccount(context({ accountId }), event({ syncedServices })),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
const store = createShippedSqliteDb();
|
|
98
|
+
close = store.close;
|
|
99
|
+
accounts = new AccountRepo(store.db);
|
|
100
|
+
const accountConfig = new AccountConfigRepo(store.db);
|
|
101
|
+
await accountConfig.create({
|
|
102
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
103
|
+
userId: SUB,
|
|
104
|
+
});
|
|
105
|
+
setClient({
|
|
106
|
+
accountConfig,
|
|
107
|
+
account: accounts,
|
|
108
|
+
accountSetting: new AccountSettingRepo(store.db),
|
|
109
|
+
mailbox: new MailboxRepo(store.db),
|
|
110
|
+
configImport: new ConfigImportRepo(store.db),
|
|
111
|
+
} as unknown as RemitClient);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
_resetForTest();
|
|
116
|
+
close();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("the services an account syncs", () => {
|
|
120
|
+
it("reads mail for an account that never chose", async () => {
|
|
121
|
+
const account = await seedAccount(AccountAuthType.Password);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
124
|
+
AccountService.Mail,
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("writes a selection the account can serve and reads it back", async () => {
|
|
129
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
130
|
+
|
|
131
|
+
const response = await patchServices(account.accountId, [
|
|
132
|
+
AccountService.Mail,
|
|
133
|
+
AccountService.Mail,
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
assert.equal(response.statusCode, 200);
|
|
137
|
+
assert.deepEqual(bodyOf<AccountResponse>(response).syncedServices, [
|
|
138
|
+
AccountService.Mail,
|
|
139
|
+
]);
|
|
140
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
141
|
+
AccountService.Mail,
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refuses an empty selection and points at deleting the account", async () => {
|
|
146
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
147
|
+
|
|
148
|
+
const response = await patchServices(account.accountId, []);
|
|
149
|
+
|
|
150
|
+
assert.equal(response.statusCode, 400);
|
|
151
|
+
assert.match(
|
|
152
|
+
bodyOf<{ message: string }>(response).message,
|
|
153
|
+
/DELETE \/accounts\/\{accountId\}/,
|
|
154
|
+
);
|
|
155
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
156
|
+
AccountService.Mail,
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("refuses calendar on a password account", async () => {
|
|
161
|
+
const account = await seedAccount(AccountAuthType.Password);
|
|
162
|
+
|
|
163
|
+
const response = await patchServices(account.accountId, [
|
|
164
|
+
AccountService.Mail,
|
|
165
|
+
AccountService.Calendar,
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
assert.equal(response.statusCode, 400);
|
|
169
|
+
assert.match(
|
|
170
|
+
bodyOf<{ message: string }>(response).message,
|
|
171
|
+
/password account syncs mail only/,
|
|
172
|
+
);
|
|
173
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
174
|
+
AccountService.Mail,
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("refuses calendar the Microsoft consent never covered and names the consent route", async () => {
|
|
179
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
180
|
+
|
|
181
|
+
const response = await patchServices(account.accountId, [
|
|
182
|
+
AccountService.Calendar,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
assert.equal(response.statusCode, 400);
|
|
186
|
+
assert.match(
|
|
187
|
+
bodyOf<{ message: string }>(response).message,
|
|
188
|
+
/POST \/accounts\/oauth\/microsoft\/start/,
|
|
189
|
+
);
|
|
190
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
191
|
+
AccountService.Mail,
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses to create a password account that syncs calendar", async () => {
|
|
196
|
+
const response = await respond(() =>
|
|
197
|
+
createAccount(
|
|
198
|
+
context(),
|
|
199
|
+
event({
|
|
200
|
+
email: "person@example.com",
|
|
201
|
+
password: "hunter2",
|
|
202
|
+
imapHost: "imap.example.com",
|
|
203
|
+
imapPort: 993,
|
|
204
|
+
imapTls: true,
|
|
205
|
+
imapStartTls: false,
|
|
206
|
+
syncedServices: [AccountService.Calendar],
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.equal(response.statusCode, 400);
|
|
212
|
+
assert.deepEqual(
|
|
213
|
+
await accounts.listAllByAccountConfig(ACCOUNT_CONFIG_ID),
|
|
214
|
+
[],
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/handlers/account.ts
CHANGED
|
@@ -8,7 +8,11 @@ import type {
|
|
|
8
8
|
UpdateAccountInput,
|
|
9
9
|
} from "@remit/api-openapi-types";
|
|
10
10
|
import type { AccountItem, IAccountRepository } from "@remit/data-ports";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
AccountAuthType,
|
|
13
|
+
AccountService,
|
|
14
|
+
ConnectionState,
|
|
15
|
+
} from "@remit/domain-enums";
|
|
12
16
|
import { logger } from "@remit/logger-lambda";
|
|
13
17
|
import {
|
|
14
18
|
createMailOAuthService,
|
|
@@ -42,6 +46,7 @@ import {
|
|
|
42
46
|
assertNoDuplicateMailbox,
|
|
43
47
|
assertNotOAuthCreate,
|
|
44
48
|
assertPasswordProvided,
|
|
49
|
+
resolveSyncedServices,
|
|
45
50
|
toAccountResponse,
|
|
46
51
|
} from "./account-guards.js";
|
|
47
52
|
import {
|
|
@@ -203,6 +208,11 @@ export const AccountOperations: Record<
|
|
|
203
208
|
// Password accounts require a non-empty password
|
|
204
209
|
assertPasswordProvided(input.authType, input.password);
|
|
205
210
|
|
|
211
|
+
const syncedServices = resolveSyncedServices(
|
|
212
|
+
AccountAuthType.Password,
|
|
213
|
+
input.syncedServices ?? [AccountService.Mail],
|
|
214
|
+
);
|
|
215
|
+
|
|
206
216
|
const { account, accountConfig, accountSetting, mailbox, secrets } =
|
|
207
217
|
await getClient();
|
|
208
218
|
|
|
@@ -235,6 +245,7 @@ export const AccountOperations: Record<
|
|
|
235
245
|
email: input.email,
|
|
236
246
|
username: input.username ?? input.email,
|
|
237
247
|
authType: AccountAuthType.Password,
|
|
248
|
+
syncedServices,
|
|
238
249
|
passwordHash,
|
|
239
250
|
imapHost: input.imapHost,
|
|
240
251
|
imapPort: input.imapPort,
|
|
@@ -405,6 +416,13 @@ export const AccountDetailOperations: Record<
|
|
|
405
416
|
);
|
|
406
417
|
}
|
|
407
418
|
|
|
419
|
+
if (input.syncedServices !== undefined) {
|
|
420
|
+
updates.syncedServices = resolveSyncedServices(
|
|
421
|
+
existing.authType,
|
|
422
|
+
input.syncedServices,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
408
426
|
// Copy non-password fields
|
|
409
427
|
const fields = [
|
|
410
428
|
"imapHost",
|