Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/api/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,17 @@ class MemoryMemberships implements WorkspaceMembershipRepository {
return this.rows.filter((row) => row.workspaceId === workspaceId && row.deletedAt === null);
}

async listActiveWithNicknameByWorkspace(workspaceId: string) {
return this.rows
.filter((row) => row.workspaceId === workspaceId && row.deletedAt === null)
.map((row) => ({
userId: row.userId,
nickname: `user-${row.userId.slice(0, 8)}`,
role: row.role as MembershipRole,
joinedAt: row.createdAt
}));
}

async updateRole(id: string, role: MembershipRole, at: Date) {
const row = this.rows.find((candidate) => candidate.id === id && candidate.deletedAt === null);
if (!row) {
Expand Down Expand Up @@ -550,6 +561,18 @@ class MemoryInvites implements InviteRepository {
(row) => row.email.toLowerCase() === email.toLowerCase() && row.acceptedAt === null && row.deletedAt === null
);
}

async listPending(workspaceId: string, at: Date) {
return this.rows
.filter(
(row) =>
row.workspaceId === workspaceId &&
row.acceptedAt === null &&
row.deletedAt === null &&
row.expiresAt > at
)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
}

// 用户停用-工作交接的假仓库:只实现 unassignActiveClaimsForUser(停用路由用的唯一方法)。
Expand Down Expand Up @@ -1979,6 +2002,65 @@ test("invite create derives tenant and role from the server-side admin context",
assert.equal(invites.rows[0]?.workspaceId, "22220000-0000-4000-8000-0000000000ad");
});

// R18 批 H1:GET /api/auth/invites?status=pending —— 列未过期邀请(未接受∧未撤销∧未过期),绝不回 token。
test("invite list returns pending invites without tokens, excluding accepted/revoked/expired", async () => {
const admin = user({ id: "10000000-0000-4000-8000-0000000000ae", nickname: "admin", isAdmin: true });
const { deps, invites, runtimeSettings } = inviteCtx(admin);
const workspaceId = runtimeSettings.auth.defaultWorkspaceId;
const { token: adminToken } = await mintSession(deps, admin, { authMethod: "password" });

const app = withErrors(new Hono<AuthEnv>());
app.route("/auth", createAuthRoutes(deps));
const cookie = await signedCookie(adminToken, runtimeSettings);

// 一条活跃邀请(POST 创建,workspaceId = actor 默认工作区)。
const createRes = await app.request("/auth/invites", {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ email: "pending@example.com" })
});
assert.equal(createRes.status, 201);
// 一条已过期、一条已接受、一条已撤销——都不该出现在清单里。
await invites.create({ email: "expired@example.com", tokenHash: "hash-expired", workspaceId, expiresAt: new Date(now.getTime() - 1000) });
const accepted = await invites.create({ email: "accepted@example.com", tokenHash: "hash-accepted", workspaceId, expiresAt: new Date(now.getTime() + 1_000_000) });
await invites.accept(accepted.id, admin.id, now);
const revoked = await invites.create({ email: "revoked@example.com", tokenHash: "hash-revoked", workspaceId, expiresAt: new Date(now.getTime() + 1_000_000) });
await invites.revoke(revoked.id, now);

const listRes = await app.request("/auth/invites?status=pending", { headers: { Cookie: cookie } });
assert.equal(listRes.status, 200);
const body = (await listRes.json()) as { invites: Array<Record<string, unknown>> };
assert.equal(body.invites.length, 1, "only the single active invite is listed");
const only = body.invites[0]!;
assert.equal(only["email"], "pending@example.com");
assert.ok(typeof only["invite_id"] === "string" && (only["invite_id"] as string).length > 0);
assert.ok(typeof only["expires_at"] === "string");
assert.ok(typeof only["created_at"] === "string");
assert.equal("token" in only, false, "list never leaks the invite token");
});

test("invite list requires admin (403) and rejects non-pending status (400)", async () => {
const admin = user({ id: "10000000-0000-4000-8000-0000000000af", nickname: "admin", isAdmin: true });
const member = user({ id: "10000000-0000-4000-8000-0000000000b0", nickname: "member" });
const { deps, users, runtimeSettings } = inviteCtx(admin);
(users as unknown as { rows: UserAuthRow[] }).rows.push(member);
const { token: adminToken } = await mintSession(deps, admin, { authMethod: "password" });
const { token: memberToken } = await mintSession(deps, member, { authMethod: "password" });

const app = withErrors(new Hono<AuthEnv>());
app.route("/auth", createAuthRoutes(deps));

const byMember = await app.request("/auth/invites?status=pending", {
headers: { Cookie: await signedCookie(memberToken, runtimeSettings) }
});
assert.equal(byMember.status, 403);

const badStatus = await app.request("/auth/invites?status=accepted", {
headers: { Cookie: await signedCookie(adminToken, runtimeSettings) }
});
assert.equal(badStatus.status, 400);
});

// ——— 团队就绪 gap[41]:安全/身份事件审计 ———

// auditLogs 写必抛——验「尽力而为」:审计失败绝不破坏认证主流程的状态码/响应体。
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/conversations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2167,13 +2167,41 @@ test("listParticipants returns scope=participants with real rows for a collab (a

assert.deepEqual(result, {
scope: "participants",
is_dm: false,
participants: [
{ user_id: userId, nickname: "阿曼", role: "owner" },
{ user_id: participantUserId, nickname: "小赵", role: "member" }
]
});
});

test("listParticipants marks a DM (dm_key set) with is_dm=true", async () => {
const service = createConversationService(
repository({
async findVisibleAccessRecord() {
return accessRecord({ conversation: conversationRow({ dmKey: "dm:a:b" }), participantRole: "owner" });
},
async listParticipantsWithNickname() {
return [
{ userId, nickname: "阿曼", role: "owner" },
{ userId: participantUserId, nickname: "小赵", role: "member" }
];
}
}),
{
driveFiles: driveFiles(async () => {
throw new Error("Drive must not be called");
}),
now: () => now
}
);

const result = await service.listParticipants({ actor: actor(), conversationId });

assert.equal(result.scope, "participants");
assert.equal(result.is_dm, true);
});

test("listParticipants 404s an invisible (or non-participant) conversation before any query", async () => {
let listCalls = 0;
const service = createConversationService(
Expand Down Expand Up @@ -2248,6 +2276,7 @@ test("addParticipant adds to a non-dm collab, broadcasts participants.updated, a
assert.deepEqual(writeInput, { workspaceId, conversationId, addedUserId: participantUserId, at: now });
assert.equal(result.added, true);
assert.equal(result.participants.scope, "participants");
assert.equal(result.participants.is_dm, false);
assert.equal(result.participants.participants.length, 2);
assert.equal(capture.published[0]?.type, "conversation.participants.updated");
const event = capture.published[0]?.data as { data: { conversation_id: string; change: string; user_id: string } };
Expand Down
89 changes: 89 additions & 0 deletions apps/api/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,29 @@ const inviteCreateResponseSchema = {
},
additionalProperties: false
} as const;
// R18 批 H1(成员管理面板 · 未过期邀请清单):GET /api/auth/invites?status=pending 的响应。绝不带
// token——服务端只存 sha256,明文取不回;只回 invite_id/email/过期时间/创建时间。
const pendingInvitesListResponseSchema = {
type: "object",
required: ["invites"],
properties: {
invites: {
type: "array",
items: {
type: "object",
required: ["invite_id", "email", "expires_at", "created_at"],
properties: {
invite_id: uuidStringSchema,
email: { type: "string", format: "email", maxLength: 320 },
expires_at: dateTimeStringSchema,
created_at: dateTimeStringSchema
},
additionalProperties: false
}
}
},
additionalProperties: false
} as const;
const inviteAcceptRequestBodySchema = {
type: "object",
required: ["token", "nickname", "password"],
Expand Down Expand Up @@ -775,6 +798,15 @@ const authInviteAcceptResponses = {
"422": authValidationResponse
}
} as const;
const authInviteListResponses = {
responses: {
"200": rawJsonResponse(pendingInvitesListResponseSchema, "Pending (unexpired, unaccepted) invites for the workspace").responses["200"],
"400": authBadRequestResponse,
"401": authNotIdentifiedResponse,
"403": authForbiddenResponse,
"404": authNotFoundResponse
}
} as const;
const authDeactivateResponses = {
responses: {
"200": rawJsonResponse(authOkResponseSchema, "Deactivated user").responses["200"],
Expand Down Expand Up @@ -5820,6 +5852,42 @@ const removeWorkspaceMemberResponses = {
"500": conversationInternalResponse
}
} as const;
// R18 批 H1(成员清单):GET /api/workspace/members —— 管理员读 roster(昵称/角色/加入时间/是否本人)。
const listWorkspaceMembersResponses = {
responses: {
"200": jsonDataResponse(
{
type: "object",
required: ["members"],
properties: {
members: {
type: "array",
items: {
type: "object",
required: ["user_id", "nickname", "role", "joined_at", "is_self"],
properties: {
user_id: uuidStringSchema,
nickname: { type: "string", minLength: 1, maxLength: 96 },
role: workspaceMemberRoleSchema,
joined_at: dateTimeStringSchema,
is_self: { type: "boolean" }
},
additionalProperties: false
}
}
},
additionalProperties: false
},
"Workspace member roster"
).responses["200"],
"401": conversationAuthRequiredResponse,
"403": jsonErrorStatusResponse("403", "Only workspace admins/owners may list members", [
"member_manage_forbidden",
"human_required"
]).responses["403"],
"500": conversationInternalResponse
}
} as const;
const updateWorkspaceMemberRoleResponses = {
responses: {
"200": jsonDataResponse(
Expand Down Expand Up @@ -7186,6 +7254,20 @@ export function getOpenApiDocument() {
}
},
"/api/auth/invites": {
get: {
tags: ["auth"],
summary: "Admin: list pending (unexpired, unaccepted) invites for the workspace (never returns tokens)",
parameters: [
{
name: "status",
in: "query",
required: false,
schema: { type: "string", enum: ["pending"], default: "pending" },
description: "Only 'pending' is supported"
}
],
...authInviteListResponses
},
post: {
tags: ["auth"],
summary: "Admin: create an out-of-band invite, returns a one-time token",
Expand Down Expand Up @@ -8207,6 +8289,13 @@ export function getOpenApiDocument() {
...removeConversationParticipantResponses
}
},
"/api/workspace/members": {
get: {
tags: ["conversations"],
summary: "List the workspace member roster (admin/owner only): nickname, role, joined-at, is-self",
...listWorkspaceMembersResponses
}
},
"/api/workspace/members/{userId}": {
delete: {
tags: ["conversations"],
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,40 @@ export function createAuthRoutes(
);
});

// R18 批 H1(成员管理面板 · 未过期邀请清单):管理员列出本工作区仍可用的邀请(未接受 ∧ 未撤销 ∧
// 未过期,最新在前)。门控与错误码同 POST /invites(鉴权先行 401 → 非管理员 403 → 非密码模式 404 →
// 运行时不支持 501)。绝不回 token——服务端只存 sha256(token),明文取不回(POST 创建时一次性回过)。
routes.get("/invites", async (c) => {
const deps = resolveAuthDependencies(source);
const actingUser = await resolveCurrentUser(c, deps); // 鉴权先行 → 未鉴权 401 fail-closed
if (!actingUser.isAdmin) {
throw new HTTPException(403, { message: "需要管理员权限" });
}
if (!passwordModeEnabled(deps)) {
throw new HTTPException(404, { message: "邀请功能未启用" });
}
if (!deps.invites) {
throw new HTTPException(501, { message: "当前运行时不支持邀请" });
}
// ?status=pending 是目前唯一支持的取值(默认 pending)——收窄查询面,其它取值直接 400,
// 避免客户端误以为 status=accepted 等也能查(本端点只服务成员管理面板的未过期邀请追踪)。
const status = c.req.query("status") ?? "pending";
if (status !== "pending") {
throw new HTTPException(400, { message: "只支持 status=pending" });
}
const at = (deps.now ?? (() => new Date()))();
const actor = await resolveHumanActor(deps, actingUser);
const rows = await deps.invites.listPending(actor.workspaceId, at);
return c.json({
invites: rows.map((row) => ({
invite_id: row.id,
email: row.email,
expires_at: row.expiresAt.toISOString(),
created_at: row.createdAt.toISOString()
}))
});
});

// 公开入口:收件人凭 out-of-band token 接受邀请 → 建账号 + 凭据 + 默认成员 + 会话。
routes.post("/invites/accept", async (c) => {
const deps = resolveAuthDependencies(source);
Expand Down
62 changes: 62 additions & 0 deletions apps/api/src/routes/workspace-members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ function memberService(overrides: Partial<WorkspaceMemberService> = {}): Workspa
throw new Error(`${name} not expected`);
};
return {
listMembers: reject("listMembers"),
removeMember: reject("removeMember"),
updateMemberRole: reject("updateMemberRole"),
...overrides
Expand Down Expand Up @@ -135,6 +136,67 @@ function routeApp(runtimeSettings: Settings, members: WorkspaceMemberService) {
return app;
}

test("GET members requires authentication before reaching the service", async () => {
const runtimeSettings = settings();
const app = routeApp(
runtimeSettings,
memberService({
async listMembers() {
throw new Error("anonymous request must not reach the service");
}
})
);

const response = await app.request("/api/workspace/members");

assert.equal(response.status, 401);
});

test("GET members returns the roster from the service", async () => {
const runtimeSettings = settings();
let seen: unknown;
const roster = {
members: [
{ user_id: adminUserId, nickname: "r17-admin", role: "owner" as const, joined_at: now.toISOString(), is_self: true },
{ user_id: targetUserId, nickname: "小赵", role: "member" as const, joined_at: now.toISOString(), is_self: false }
]
};
const app = routeApp(
runtimeSettings,
memberService({
async listMembers(input) {
seen = input;
return roster;
}
})
);
const headers = { Cookie: await cookie(runtimeSettings) };

const response = await app.request("/api/workspace/members", { headers });

assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { ok: true, data: roster });
assert.ok((seen as { actor: unknown }).actor, "the actor is forwarded to the service");
});

test("GET members preserves the service's typed 403 for non-managers", async () => {
const runtimeSettings = settings();
const app = routeApp(
runtimeSettings,
memberService({
async listMembers() {
throw new WorkspaceMemberServiceError(403, "member_manage_forbidden", "只有工作区管理员可以管理成员。");
}
})
);
const headers = { Cookie: await cookie(runtimeSettings) };

const response = await app.request("/api/workspace/members", { headers });

assert.equal(response.status, 403);
assert.equal(((await response.json()) as { error: { code: string } }).error.code, "member_manage_forbidden");
});

test("DELETE member requires authentication before reaching the service", async () => {
const runtimeSettings = settings();
const app = routeApp(
Expand Down
Loading
Loading