@jskit-ai/workspaces-core 0.1.81 → 0.1.82
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.descriptor.mjs +38 -5
- package/package.json +9 -9
- package/src/server/common/repositories/workspaceInvitesRepository.js +18 -0
- package/src/server/workspaceMembers/defaultWorkspaceInviteEmail.js +42 -0
- package/src/server/workspaceMembers/registerWorkspaceMembers.js +30 -1
- package/src/server/workspaceMembers/workspaceInviteUrls.js +65 -0
- package/src/server/workspaceMembers/workspaceMembersService.js +82 -2
- package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +36 -0
- package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +11 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +22 -0
- package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +92 -0
- package/src/shared/jsonApiTransports.js +7 -0
- package/src/shared/resources/workspaceMembersResource.js +24 -0
- package/src/shared/resources/workspacePendingInvitationsResource.js +44 -0
- package/templates/packages/main/src/server/email/workspaceInviteEmail.js +23 -0
- package/test/packageDescriptor.test.js +75 -0
- package/test/registerWorkspaceMembers.test.js +140 -0
- package/test/resourcesCanonical.test.js +4 -1
- package/test/usersRouteResources.test.js +4 -1
- package/test/workspaceInvitesRepository.test.js +35 -0
- package/test/workspaceMembersResource.test.js +8 -0
- package/test/workspaceMembersService.test.js +123 -0
- package/test/workspacePendingInvitationsResource.test.js +18 -0
- package/test/workspacePendingInvitationsService.test.js +126 -1
- package/test/workspacesRouteRequestInputValidator.test.js +37 -5
|
@@ -87,6 +87,30 @@ const workspaceInviteCreateOutputSchema = createSchema({
|
|
|
87
87
|
required: true,
|
|
88
88
|
minLength: 1
|
|
89
89
|
},
|
|
90
|
+
inviteUrl: {
|
|
91
|
+
type: "string",
|
|
92
|
+
required: true,
|
|
93
|
+
minLength: 1
|
|
94
|
+
},
|
|
95
|
+
inviteDelivery: {
|
|
96
|
+
type: "object",
|
|
97
|
+
required: true,
|
|
98
|
+
schema: createSchema({
|
|
99
|
+
status: {
|
|
100
|
+
type: "string",
|
|
101
|
+
required: true,
|
|
102
|
+
enum: ["sent", "failed", "mailer_unconfigured"]
|
|
103
|
+
},
|
|
104
|
+
message: {
|
|
105
|
+
type: "string",
|
|
106
|
+
required: true
|
|
107
|
+
},
|
|
108
|
+
providerMessageId: {
|
|
109
|
+
type: "string",
|
|
110
|
+
required: true
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
},
|
|
90
114
|
createdInviteId: {
|
|
91
115
|
type: "string",
|
|
92
116
|
required: true,
|
|
@@ -3,6 +3,45 @@ import { createSchema } from "json-rest-schema";
|
|
|
3
3
|
import { defineResource } from "@jskit-ai/resource-core/shared/resource";
|
|
4
4
|
import { createOperationMessages } from "../operationMessages.js";
|
|
5
5
|
|
|
6
|
+
const invitationTokenQuerySchema = createSchema({
|
|
7
|
+
token: {
|
|
8
|
+
type: "string",
|
|
9
|
+
required: true,
|
|
10
|
+
minLength: 1,
|
|
11
|
+
messages: {
|
|
12
|
+
required: "Invite token is required.",
|
|
13
|
+
minLength: "Invite token is required.",
|
|
14
|
+
default: "Invite token is invalid."
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const invitationWorkspaceSummarySchema = createSchema({
|
|
20
|
+
id: { type: "string", required: true },
|
|
21
|
+
slug: { type: "string", required: true },
|
|
22
|
+
name: { type: "string", required: true },
|
|
23
|
+
avatarUrl: { type: "string", required: true }
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const invitationResolveOutputSchema = createSchema({
|
|
27
|
+
id: { type: "string", required: true },
|
|
28
|
+
token: { type: "string", required: true, minLength: 1 },
|
|
29
|
+
status: {
|
|
30
|
+
type: "string",
|
|
31
|
+
required: true,
|
|
32
|
+
enum: ["pending", "expired", "accepted", "revoked", "not_found"]
|
|
33
|
+
},
|
|
34
|
+
email: { type: "string", required: true },
|
|
35
|
+
maskedEmail: { type: "string", required: true },
|
|
36
|
+
roleSid: { type: "string", required: true },
|
|
37
|
+
expiresAt: { type: "string", required: false, nullable: true, minLength: 1 },
|
|
38
|
+
workspace: {
|
|
39
|
+
type: "object",
|
|
40
|
+
required: true,
|
|
41
|
+
schema: invitationWorkspaceSummarySchema
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
6
45
|
const pendingInvitationsListOutputSchema = createSchema({
|
|
7
46
|
pendingInvites: {
|
|
8
47
|
type: "array",
|
|
@@ -25,6 +64,11 @@ const workspacePendingInvitationsResource = defineResource({
|
|
|
25
64
|
namespace: "workspacePendingInvitations",
|
|
26
65
|
messages: createOperationMessages(),
|
|
27
66
|
operations: {
|
|
67
|
+
resolve: {
|
|
68
|
+
method: "GET",
|
|
69
|
+
query: invitationTokenQuerySchema,
|
|
70
|
+
output: invitationResolveOutputSchema
|
|
71
|
+
},
|
|
28
72
|
list: {
|
|
29
73
|
method: "GET",
|
|
30
74
|
output: pendingInvitationsListOutputSchema
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function renderWorkspaceInviteEmail({
|
|
2
|
+
inviteUrl = "",
|
|
3
|
+
workspace = {},
|
|
4
|
+
inviter = null,
|
|
5
|
+
roleSid = "member",
|
|
6
|
+
expiresAt = ""
|
|
7
|
+
} = {}) {
|
|
8
|
+
const workspaceName = String(workspace?.name || workspace?.slug || "the workspace").trim() || "the workspace";
|
|
9
|
+
const inviterName = String(inviter?.displayName || inviter?.email || "").trim();
|
|
10
|
+
const roleName = String(roleSid || "member").trim() || "member";
|
|
11
|
+
const intro = inviterName
|
|
12
|
+
? `${inviterName} invited you to join ${workspaceName} as ${roleName}.`
|
|
13
|
+
: `You have been invited to join ${workspaceName} as ${roleName}.`;
|
|
14
|
+
const expiryText = expiresAt ? `This invitation expires at ${expiresAt}.` : "";
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
subject: `You're invited to ${workspaceName}`,
|
|
18
|
+
text: [intro, expiryText, "Open this link to accept the invitation:", inviteUrl].filter(Boolean).join("\n\n"),
|
|
19
|
+
html: null
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export { renderWorkspaceInviteEmail };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import descriptor from "../package.descriptor.mjs";
|
|
7
|
+
|
|
8
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PACKAGE_DIR = path.resolve(TEST_DIRECTORY, "..");
|
|
10
|
+
|
|
11
|
+
function findRoute(method, routePath) {
|
|
12
|
+
const routes = descriptor?.metadata?.server?.routes;
|
|
13
|
+
return Array.isArray(routes)
|
|
14
|
+
? routes.find((entry) => entry?.method === method && entry?.path === routePath) || null
|
|
15
|
+
: null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findFileMutation(id) {
|
|
19
|
+
const fileMutations = descriptor?.mutations?.files;
|
|
20
|
+
return Array.isArray(fileMutations)
|
|
21
|
+
? fileMutations.find((entry) => String(entry?.id || "").trim() === id) || null
|
|
22
|
+
: null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function findSourceMutation(id) {
|
|
26
|
+
const sourceMutations = descriptor?.mutations?.source;
|
|
27
|
+
return Array.isArray(sourceMutations)
|
|
28
|
+
? sourceMutations.find((entry) => String(entry?.id || "").trim() === id) || null
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test("workspaces-core descriptor advertises public invite resolution route metadata", () => {
|
|
33
|
+
assert.deepEqual(findRoute("GET", "/api/workspace/invitations/resolve"), {
|
|
34
|
+
method: "GET",
|
|
35
|
+
path: "/api/workspace/invitations/resolve",
|
|
36
|
+
summary: "Resolve safe public workspace invitation metadata."
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("workspaces-core installs an app-owned editable workspace invite email template", async () => {
|
|
41
|
+
const source = await readFile(
|
|
42
|
+
path.join(PACKAGE_DIR, "templates", "packages", "main", "src", "server", "email", "workspaceInviteEmail.js"),
|
|
43
|
+
"utf8"
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
assert.match(source, /function renderWorkspaceInviteEmail/);
|
|
47
|
+
assert.match(source, /export \{ renderWorkspaceInviteEmail \}/);
|
|
48
|
+
assert.match(source, /inviteUrl/);
|
|
49
|
+
assert.deepEqual(findFileMutation("workspaces-core-main-workspace-invite-email-template"), {
|
|
50
|
+
from: "templates/packages/main/src/server/email/workspaceInviteEmail.js",
|
|
51
|
+
to: "packages/main/src/server/email/workspaceInviteEmail.js",
|
|
52
|
+
preserveOnRemove: true,
|
|
53
|
+
reason: "Install app-owned editable workspace invite email template.",
|
|
54
|
+
category: "workspaces-core",
|
|
55
|
+
id: "workspaces-core-main-workspace-invite-email-template"
|
|
56
|
+
});
|
|
57
|
+
assert.deepEqual(findSourceMutation("workspaces-core-server-config-workspace-invite-email-import"), {
|
|
58
|
+
op: "ensure-import",
|
|
59
|
+
file: "config/server.js",
|
|
60
|
+
namedImports: ["renderWorkspaceInviteEmail"],
|
|
61
|
+
from: "../packages/main/src/server/email/workspaceInviteEmail.js",
|
|
62
|
+
reason: "Load app-owned workspace invite email renderer from packages/main.",
|
|
63
|
+
category: "workspaces-core",
|
|
64
|
+
id: "workspaces-core-server-config-workspace-invite-email-import"
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(findSourceMutation("workspaces-core-server-config-workspace-invite-email-template"), {
|
|
67
|
+
op: "ensure-assignment",
|
|
68
|
+
file: "config/server.js",
|
|
69
|
+
target: "config.workspaceInviteEmailTemplate",
|
|
70
|
+
value: "renderWorkspaceInviteEmail",
|
|
71
|
+
reason: "Bind app-owned workspace invite email renderer into server config.",
|
|
72
|
+
category: "workspaces-core",
|
|
73
|
+
id: "workspaces-core-server-config-workspace-invite-email-template"
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { registerWorkspaceMembers } from "../src/server/workspaceMembers/registerWorkspaceMembers.js";
|
|
4
|
+
|
|
5
|
+
function createAppDouble() {
|
|
6
|
+
const serviceCalls = [];
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
serviceCalls,
|
|
10
|
+
app: {
|
|
11
|
+
singleton() {
|
|
12
|
+
return this;
|
|
13
|
+
},
|
|
14
|
+
service(token, factory, metadata) {
|
|
15
|
+
serviceCalls.push({
|
|
16
|
+
token,
|
|
17
|
+
factory,
|
|
18
|
+
metadata
|
|
19
|
+
});
|
|
20
|
+
return this;
|
|
21
|
+
},
|
|
22
|
+
actions() {
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createScopeDouble(bindings = {}) {
|
|
30
|
+
return {
|
|
31
|
+
has(token) {
|
|
32
|
+
return Object.hasOwn(bindings, token);
|
|
33
|
+
},
|
|
34
|
+
make(token) {
|
|
35
|
+
if (!Object.hasOwn(bindings, token)) {
|
|
36
|
+
throw new Error(`Missing test binding: ${token}`);
|
|
37
|
+
}
|
|
38
|
+
const binding = bindings[token];
|
|
39
|
+
return typeof binding === "function" && token !== "appConfig" ? binding(this) : binding;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("registerWorkspaceMembers uses app-owned workspace invite email template from appConfig", async () => {
|
|
45
|
+
const templateCalls = [];
|
|
46
|
+
const mailerCalls = [];
|
|
47
|
+
const fixture = createAppDouble();
|
|
48
|
+
registerWorkspaceMembers(fixture.app);
|
|
49
|
+
const serviceCall = fixture.serviceCalls.find((entry) => entry.token === "workspaces.members.service");
|
|
50
|
+
|
|
51
|
+
const service = serviceCall.factory(createScopeDouble({
|
|
52
|
+
appConfig: {
|
|
53
|
+
workspaceMembers: {
|
|
54
|
+
defaults: {
|
|
55
|
+
inviteExpiresInMs: 60_000
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
roleCatalog: {
|
|
59
|
+
workspace: {
|
|
60
|
+
defaultInviteRole: "member"
|
|
61
|
+
},
|
|
62
|
+
roles: {
|
|
63
|
+
owner: {
|
|
64
|
+
assignable: false,
|
|
65
|
+
permissions: ["*"]
|
|
66
|
+
},
|
|
67
|
+
member: {
|
|
68
|
+
assignable: true,
|
|
69
|
+
permissions: ["workspace.members.view"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
workspaceInviteEmailTemplate(payload) {
|
|
74
|
+
templateCalls.push(payload);
|
|
75
|
+
return {
|
|
76
|
+
subject: "Custom invite",
|
|
77
|
+
text: payload.inviteUrl,
|
|
78
|
+
html: null
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"jskit.env": {
|
|
83
|
+
APP_PUBLIC_URL: "https://app.example.test"
|
|
84
|
+
},
|
|
85
|
+
"workspaces.invitations.enabled": true,
|
|
86
|
+
"workspaces.invite.mailer": {
|
|
87
|
+
async sendWorkspaceInvite(payload) {
|
|
88
|
+
mailerCalls.push(payload);
|
|
89
|
+
return {
|
|
90
|
+
status: "sent",
|
|
91
|
+
message: "sent",
|
|
92
|
+
providerMessageId: "msg-custom"
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
"internal.repository.workspace-memberships": {
|
|
97
|
+
async listActiveByWorkspaceId() {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"internal.repository.workspace-invites": {
|
|
102
|
+
async listPendingByWorkspaceIdWithWorkspace() {
|
|
103
|
+
return [];
|
|
104
|
+
},
|
|
105
|
+
async expirePendingByWorkspaceIdAndEmail() {},
|
|
106
|
+
async insert(payload) {
|
|
107
|
+
return {
|
|
108
|
+
id: "91",
|
|
109
|
+
expiresAt: payload.expiresAt
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
async findPendingByIdForWorkspace() {
|
|
113
|
+
return null;
|
|
114
|
+
},
|
|
115
|
+
async revokeById() {}
|
|
116
|
+
}
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const result = await service.createInvite(
|
|
120
|
+
{
|
|
121
|
+
id: "7",
|
|
122
|
+
slug: "acme",
|
|
123
|
+
name: "Acme",
|
|
124
|
+
ownerUserId: "9",
|
|
125
|
+
avatarUrl: ""
|
|
126
|
+
},
|
|
127
|
+
{ id: "9", email: "owner@example.com" },
|
|
128
|
+
{
|
|
129
|
+
email: "invitee@example.com",
|
|
130
|
+
roleSid: "member"
|
|
131
|
+
}
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
assert.equal(result.inviteDelivery.providerMessageId, "msg-custom");
|
|
135
|
+
assert.equal(templateCalls.length, 1);
|
|
136
|
+
assert.equal(templateCalls[0].workspace.name, "Acme");
|
|
137
|
+
assert.equal(mailerCalls.length, 1);
|
|
138
|
+
assert.equal(mailerCalls[0].message.subject, "Custom invite");
|
|
139
|
+
assert.match(mailerCalls[0].inviteUrl, /^https:\/\/app\.example\.test\/invite\//);
|
|
140
|
+
});
|
|
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
|
|
7
7
|
import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
|
|
8
|
+
import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
|
|
8
9
|
import { workspaceInvitesResource } from "../src/shared/resources/workspaceInvitesResource.js";
|
|
9
10
|
import { workspaceMembershipsResource } from "../src/shared/resources/workspaceMembershipsResource.js";
|
|
10
11
|
import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
|
|
@@ -78,7 +79,9 @@ test("workspaces-core specialized resource operations expose messages and valida
|
|
|
78
79
|
{ label: "workspaceMembers.invitesList", operation: workspaceMembersResource.operations.invitesList },
|
|
79
80
|
{ label: "workspaceMembers.createInvite", operation: workspaceMembersResource.operations.createInvite },
|
|
80
81
|
{ label: "workspaceMembers.revokeInvite", operation: workspaceMembersResource.operations.revokeInvite },
|
|
81
|
-
{ label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite }
|
|
82
|
+
{ label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite },
|
|
83
|
+
{ label: "workspacePendingInvitations.resolve", operation: workspacePendingInvitationsResource.operations.resolve },
|
|
84
|
+
{ label: "workspacePendingInvitations.list", operation: workspacePendingInvitationsResource.operations.list }
|
|
82
85
|
];
|
|
83
86
|
|
|
84
87
|
for (const { label, operation } of operationSpecs) {
|
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { deriveResourceRequiredMetadata } from "@jskit-ai/kernel/_testable";
|
|
7
7
|
import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
|
|
8
8
|
import { workspaceMembersResource } from "../src/shared/resources/workspaceMembersResource.js";
|
|
9
|
+
import { workspacePendingInvitationsResource } from "../src/shared/resources/workspacePendingInvitationsResource.js";
|
|
9
10
|
import { workspaceResource } from "../src/shared/resources/workspaceResource.js";
|
|
10
11
|
import { workspaceSettingsResource } from "../src/shared/resources/workspaceSettingsResource.js";
|
|
11
12
|
|
|
@@ -67,7 +68,9 @@ test("workspace settings and invite operations expose canonical validators", ()
|
|
|
67
68
|
{ label: "workspaceMembers.invitesList", operation: workspaceMembersResource.operations.invitesList },
|
|
68
69
|
{ label: "workspaceMembers.createInvite", operation: workspaceMembersResource.operations.createInvite },
|
|
69
70
|
{ label: "workspaceMembers.revokeInvite", operation: workspaceMembersResource.operations.revokeInvite },
|
|
70
|
-
{ label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite }
|
|
71
|
+
{ label: "workspaceMembers.redeemInvite", operation: workspaceMembersResource.operations.redeemInvite },
|
|
72
|
+
{ label: "workspacePendingInvitations.resolve", operation: workspacePendingInvitationsResource.operations.resolve },
|
|
73
|
+
{ label: "workspacePendingInvitations.list", operation: workspacePendingInvitationsResource.operations.list }
|
|
71
74
|
];
|
|
72
75
|
|
|
73
76
|
for (const { label, operation } of operationSpecs) {
|
|
@@ -173,6 +173,41 @@ test("workspaceInvitesRepository.findPendingByTokenHash reads from the canonical
|
|
|
173
173
|
assert.equal(invite?.workspaceSlug, undefined);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
|
+
test("workspaceInvitesRepository.findByTokenHashWithWorkspace preserves workspace summary fields for public invite resolution", async () => {
|
|
177
|
+
const { api } = createWorkspaceInvitesApiStub({
|
|
178
|
+
rows: [
|
|
179
|
+
{
|
|
180
|
+
id: "45",
|
|
181
|
+
workspace: {
|
|
182
|
+
id: "9",
|
|
183
|
+
slug: "tonymobily3",
|
|
184
|
+
name: "TonyMobily3",
|
|
185
|
+
avatarUrl: "https://example.com/avatar.png"
|
|
186
|
+
},
|
|
187
|
+
email: "invitee@example.com",
|
|
188
|
+
roleSid: "member",
|
|
189
|
+
status: "accepted",
|
|
190
|
+
tokenHash: "hash-token",
|
|
191
|
+
invitedByUser: { id: "1" },
|
|
192
|
+
expiresAt: "2030-01-01 00:00:00.000",
|
|
193
|
+
acceptedAt: "2026-03-10 00:00:00.000",
|
|
194
|
+
revokedAt: null,
|
|
195
|
+
createdAt: "2026-03-09 00:26:35.710",
|
|
196
|
+
updatedAt: "2026-03-10 00:00:00.000"
|
|
197
|
+
}
|
|
198
|
+
]
|
|
199
|
+
});
|
|
200
|
+
const repository = createRepository({ api, knex: createKnexStub() });
|
|
201
|
+
|
|
202
|
+
const invite = await repository.findByTokenHashWithWorkspace("hash-token");
|
|
203
|
+
|
|
204
|
+
assert.equal(invite?.id, "45");
|
|
205
|
+
assert.equal(invite?.status, "accepted");
|
|
206
|
+
assert.equal(invite?.workspaceSlug, "tonymobily3");
|
|
207
|
+
assert.equal(invite?.workspaceName, "TonyMobily3");
|
|
208
|
+
assert.equal(invite?.workspaceAvatarUrl, "https://example.com/avatar.png");
|
|
209
|
+
});
|
|
210
|
+
|
|
176
211
|
test("workspaceInvitesRepository.markAcceptedById uses the internal invite resource for normalized patch writes", async () => {
|
|
177
212
|
const { api, state } = createWorkspaceInvitesApiStub({
|
|
178
213
|
rowById: new Map([
|
|
@@ -29,6 +29,14 @@ test("workspace members invite mutation outputs expose their tracking ids explic
|
|
|
29
29
|
|
|
30
30
|
assert.equal(createOutputSchema.properties.createdInviteId.type, "string");
|
|
31
31
|
assert.equal(createOutputSchema.properties.inviteTokenPreview.type, "string");
|
|
32
|
+
assert.equal(createOutputSchema.properties.inviteUrl.type, "string");
|
|
33
|
+
const inviteDeliverySchemaName = createOutputSchema.properties.inviteDelivery.allOf[0].$ref.replace(/^#\/definitions\//, "");
|
|
34
|
+
const inviteDeliverySchema = createOutputSchema.definitions[inviteDeliverySchemaName];
|
|
35
|
+
assert.deepEqual(inviteDeliverySchema.required, ["status", "message", "providerMessageId"]);
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
inviteDeliverySchema.properties.status.enum,
|
|
38
|
+
["sent", "failed", "mailer_unconfigured"]
|
|
39
|
+
);
|
|
32
40
|
assert.equal(revokeOutputSchema.properties.revokedInviteId.type, "string");
|
|
33
41
|
assert.equal(revokeOutputSchema.properties.roleCatalog["x-json-rest-schema"]?.castType, "object");
|
|
34
42
|
assert.equal(Array.isArray(revokeOutputSchema.properties.roleCatalog.allOf), true);
|
|
@@ -147,6 +147,101 @@ test("workspaceMembersService.createInvite uses configured inviteExpiresInMs", a
|
|
|
147
147
|
assert.ok(expiresAt >= before + 30 * 60 * 1000);
|
|
148
148
|
assert.ok(expiresAt <= after + 30 * 60 * 1000);
|
|
149
149
|
assert.equal(response.createdInviteId, "31");
|
|
150
|
+
assert.equal(response.inviteUrl, `/invite/${encodeURIComponent(response.inviteTokenPreview)}`);
|
|
151
|
+
assert.deepEqual(response.inviteDelivery, {
|
|
152
|
+
status: "mailer_unconfigured",
|
|
153
|
+
message: "No workspace invite mailer is configured.",
|
|
154
|
+
providerMessageId: ""
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("workspaceMembersService.createInvite sends generated invite URLs through the configured mailer", async () => {
|
|
159
|
+
const templateCalls = [];
|
|
160
|
+
const mailerCalls = [];
|
|
161
|
+
const service = createService({
|
|
162
|
+
workspaceMembershipsRepository: {
|
|
163
|
+
async listActiveByWorkspaceId() {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
workspaceInvitesRepository: {
|
|
168
|
+
async expirePendingByWorkspaceIdAndEmail(workspaceId, email) {
|
|
169
|
+
assert.equal(workspaceId, "7");
|
|
170
|
+
assert.equal(email, "invitee@example.com");
|
|
171
|
+
},
|
|
172
|
+
async insert(payload) {
|
|
173
|
+
assert.equal(payload.workspaceId, "7");
|
|
174
|
+
assert.equal(payload.email, "invitee@example.com");
|
|
175
|
+
assert.equal(payload.roleSid, "admin");
|
|
176
|
+
return {
|
|
177
|
+
id: "88",
|
|
178
|
+
expiresAt: payload.expiresAt
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
async listPendingByWorkspaceIdWithWorkspace() {
|
|
182
|
+
return [];
|
|
183
|
+
},
|
|
184
|
+
async findPendingByIdForWorkspace() {
|
|
185
|
+
return null;
|
|
186
|
+
},
|
|
187
|
+
async revokeById() {}
|
|
188
|
+
},
|
|
189
|
+
inviteExpiresInMs: 30 * 60 * 1000,
|
|
190
|
+
roleCatalog: createRoleCatalog(),
|
|
191
|
+
inviteUrlBuilder({ token, workspace }) {
|
|
192
|
+
return `https://app.example.test/invite/${encodeURIComponent(token)}?workspace=${workspace.slug}`;
|
|
193
|
+
},
|
|
194
|
+
workspaceInviteEmailTemplate(payload) {
|
|
195
|
+
templateCalls.push(payload);
|
|
196
|
+
return {
|
|
197
|
+
subject: `Join ${payload.workspace.name}`,
|
|
198
|
+
text: payload.inviteUrl,
|
|
199
|
+
html: null
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
workspaceInviteMailer: {
|
|
203
|
+
async sendWorkspaceInvite(payload, options) {
|
|
204
|
+
mailerCalls.push({ payload, options });
|
|
205
|
+
return {
|
|
206
|
+
status: "sent",
|
|
207
|
+
message: "queued",
|
|
208
|
+
providerMessageId: "msg-1"
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const response = await service.createInvite(
|
|
215
|
+
{
|
|
216
|
+
id: "7",
|
|
217
|
+
slug: "acme",
|
|
218
|
+
name: "Acme",
|
|
219
|
+
ownerUserId: "9",
|
|
220
|
+
avatarUrl: ""
|
|
221
|
+
},
|
|
222
|
+
{ id: "11", email: "owner@example.com" },
|
|
223
|
+
{
|
|
224
|
+
email: "Invitee@Example.com",
|
|
225
|
+
roleSid: "admin"
|
|
226
|
+
},
|
|
227
|
+
authorizedOptions(["workspace.members.invite"])
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
assert.equal(response.createdInviteId, "88");
|
|
231
|
+
assert.match(response.inviteUrl, /^https:\/\/app\.example\.test\/invite\//);
|
|
232
|
+
assert.deepEqual(response.inviteDelivery, {
|
|
233
|
+
status: "sent",
|
|
234
|
+
message: "queued",
|
|
235
|
+
providerMessageId: "msg-1"
|
|
236
|
+
});
|
|
237
|
+
assert.equal(templateCalls.length, 1);
|
|
238
|
+
assert.equal(templateCalls[0].email, "invitee@example.com");
|
|
239
|
+
assert.equal(templateCalls[0].workspace.name, "Acme");
|
|
240
|
+
assert.equal(templateCalls[0].inviteUrl, response.inviteUrl);
|
|
241
|
+
assert.equal(mailerCalls.length, 1);
|
|
242
|
+
assert.equal(mailerCalls[0].payload.email, "invitee@example.com");
|
|
243
|
+
assert.equal(mailerCalls[0].payload.inviteUrl, response.inviteUrl);
|
|
244
|
+
assert.equal(mailerCalls[0].payload.message.subject, "Join Acme");
|
|
150
245
|
});
|
|
151
246
|
|
|
152
247
|
test("workspaceMembersService.revokeInvite returns the revoked invite id", async () => {
|
|
@@ -237,6 +332,34 @@ test("workspaceMembersService rejects invite operations when invitations are dis
|
|
|
237
332
|
),
|
|
238
333
|
/Workspace invitations are disabled/
|
|
239
334
|
);
|
|
335
|
+
await assert.rejects(
|
|
336
|
+
() =>
|
|
337
|
+
service.createInvite(
|
|
338
|
+
{
|
|
339
|
+
id: "7",
|
|
340
|
+
ownerUserId: "9"
|
|
341
|
+
},
|
|
342
|
+
{ id: "11" },
|
|
343
|
+
{
|
|
344
|
+
email: "alice@example.com",
|
|
345
|
+
roleSid: "member"
|
|
346
|
+
},
|
|
347
|
+
authorizedOptions(["workspace.members.invite"])
|
|
348
|
+
),
|
|
349
|
+
/Workspace invitations are disabled/
|
|
350
|
+
);
|
|
351
|
+
await assert.rejects(
|
|
352
|
+
() =>
|
|
353
|
+
service.revokeInvite(
|
|
354
|
+
{
|
|
355
|
+
id: "7",
|
|
356
|
+
ownerUserId: "9"
|
|
357
|
+
},
|
|
358
|
+
"47",
|
|
359
|
+
authorizedOptions(["workspace.invites.revoke"])
|
|
360
|
+
),
|
|
361
|
+
/Workspace invitations are disabled/
|
|
362
|
+
);
|
|
240
363
|
});
|
|
241
364
|
|
|
242
365
|
test("workspaceMembersService.listMembers uses the resolved workspace directly", async () => {
|
|
@@ -31,3 +31,21 @@ test("workspacePendingInvitationsResource output schema accepts already-shaped i
|
|
|
31
31
|
assert.equal(Array.isArray(outputSchema.properties.pendingInvites.items.allOf), true);
|
|
32
32
|
assert.equal(result.pendingInvites[0].roleSid, "member");
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
test("workspacePendingInvitationsResource exposes public resolve query and terminal statuses", () => {
|
|
36
|
+
const querySchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.resolve.query, {
|
|
37
|
+
context: "workspacePendingInvitations.resolve.query",
|
|
38
|
+
defaultMode: "replace"
|
|
39
|
+
});
|
|
40
|
+
const outputSchema = resolveStructuredSchemaTransportSchema(workspacePendingInvitationsResource.operations.resolve.output, {
|
|
41
|
+
context: "workspacePendingInvitations.resolve.output",
|
|
42
|
+
defaultMode: "replace"
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.equal(querySchema.properties.token.type, "string");
|
|
46
|
+
assert.deepEqual(
|
|
47
|
+
outputSchema.properties.status.enum,
|
|
48
|
+
["pending", "expired", "accepted", "revoked", "not_found"]
|
|
49
|
+
);
|
|
50
|
+
assert.equal(outputSchema.properties.workspace["x-json-rest-schema"]?.castType, "object");
|
|
51
|
+
});
|