diff --git a/packages/core/src/bridge/apis/group-admin.ts b/packages/core/src/bridge/apis/group-admin.ts index a45f7dc8..b29b38f6 100644 --- a/packages/core/src/bridge/apis/group-admin.ts +++ b/packages/core/src/bridge/apis/group-admin.ts @@ -8,6 +8,13 @@ import { MuteMember } from '@snowluma/protocol/oidb-services/group-admin/mute-me import { SetAddOption } from '@snowluma/protocol/oidb-services/group-admin/set-add-option'; import { SetAddRequest } from '@snowluma/protocol/oidb-services/group-admin/set-add-request'; import { SetAdmin } from '@snowluma/protocol/oidb-services/group-admin/set-admin'; +import { GetAdminPermissions } from '@snowluma/protocol/oidb-services/group-admin/get-admin-permissions'; +import { + SetAdminPermissions, + type GroupAdminPermissionPatch, +} from '@snowluma/protocol/oidb-services/group-admin/set-admin-permissions'; +import { SetAdminReceiveVerify } from '@snowluma/protocol/oidb-services/group-admin/set-admin-receive-verify'; +import { createLogger } from '@snowluma/common/logger'; import { SetGroupName } from '@snowluma/protocol/oidb-services/group-admin/set-group-name'; import { SetGroupRemark } from '@snowluma/protocol/oidb-services/group-admin/set-group-remark'; import { SetMemberCard } from '@snowluma/protocol/oidb-services/group-admin/set-member-card'; @@ -39,6 +46,8 @@ import { ModifyGroupExtInfo } from '@snowluma/protocol/oidb-services/group-admin import { OidbError } from '@snowluma/protocol/oidb-service'; import type { BridgeContext } from '../bridge-context'; +const log = createLogger('Bridge.GroupAdmin'); + /** QQ only accepts this write from the group owner (#411). */ export const MEMBER_PERMISSIONS_OWNER_ONLY = 'only the group owner can change group member permissions'; @@ -343,6 +352,57 @@ export class GroupAdminApi { return SetAdmin.invoke(this.ctx, { groupId, userId, enable }); } + async getAdminPermissions(groupId: number, userId: number) { + const logger = log.child({ uin: Number(this.ctx.identity.uin) }); + logger.debug('Reading administrator permissions group=%s user=%s', groupId, userId); + const snapshot = await GetAdminPermissions.invoke(this.ctx, { groupId, userId }); + logger.debug( + 'Administrator permissions received group=%s user=%s recall=%s kick=%s limit=%s', + groupId, + userId, + snapshot.recallMemberMessage, + snapshot.kickMember, + snapshot.kickMemberPerDay, + ); + return { + group_id: groupId, + user_id: userId, + recall_member_message: snapshot.recallMemberMessage, + kick_member: snapshot.kickMember, + kick_member_per_day: snapshot.kickMemberPerDay, + }; + } + + async setAdminPermissions( + groupId: number, + userId: number, + patch: GroupAdminPermissionPatch, + ): Promise { + const logger = log.child({ uin: Number(this.ctx.identity.uin) }); + logger.debug( + 'Setting administrator permissions group=%s user=%s recall=%s kick=%s limit=%s', + groupId, + userId, + patch.recallMemberMessage, + patch.kickMember, + patch.kickMemberPerDay, + ); + await SetAdminPermissions.invoke(this.ctx, { groupId, userId, ...patch }); + logger.debug('Administrator permissions acknowledged group=%s user=%s', groupId, userId); + } + + async setAdminReceiveVerify(groupId: number, userId: number, enable: boolean): Promise { + const logger = log.child({ uin: Number(this.ctx.identity.uin) }); + logger.debug( + 'Setting administrator receive-verify group=%s user=%s enable=%s', + groupId, + userId, + enable, + ); + await SetAdminReceiveVerify.invoke(this.ctx, { groupId, userId, enable }); + logger.debug('Administrator receive-verify confirmed group=%s user=%s', groupId, userId); + } + setCard(groupId: number, userId: number, card: string): Promise { return SetMemberCard.invoke(this.ctx, { groupId, userId, card }); } diff --git a/packages/core/tests/actions/group-admin.test.ts b/packages/core/tests/actions/group-admin.test.ts index 4ff7a70f..4fd12799 100644 --- a/packages/core/tests/actions/group-admin.test.ts +++ b/packages/core/tests/actions/group-admin.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect, vi } from 'vitest'; import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; import type { OidbBase, OidbSvcTrpcTcp0x88D_0Response } from '@snowluma/proto-defs/oidb'; import type { OidbGetGroupExtResp } from '@snowluma/proto-defs/oidb-actions/group-ext'; +import type { + GetGroupAdminPermissionsResponse, + SetGroupAdminPermissionsRequest, + SetGroupAdminReceiveVerifyRequest, + SetGroupAdminReceiveVerifyResponse, +} from '@snowluma/proto-defs/oidb-actions/group-admin-permissions'; +import type { BridgeContext } from '../../src/bridge/bridge-context'; import type { Oidb0x8a0Req, Oidb0x8a7Resp, @@ -113,6 +120,82 @@ function packRobotExt(robotMemberSwitch?: number, robotMemberExamine?: number) { } describe('apis/group-admin', () => { + it('maps the administrator snapshot without inventing missing permissions', async () => { + const bridge = mockBridge(); + bridge.sendRawPacket.mockResolvedValueOnce( + packResponse( + protobuf_encode>({ + body: { + groupCode: 12345n, + members: [ + { + memberUin: 67890n, + ext: { permissions: { items: [{ item: 1, value: 2 }] } }, + }, + ], + }, + }), + ), + ); + await expect( + new GroupAdminApi(bridge as unknown as BridgeContext).getAdminPermissions(12345, 67890), + ).resolves.toEqual({ + group_id: 12345, + user_id: 67890, + recall_member_message: null, + kick_member: false, + kick_member_per_day: null, + }); + expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1); + expect(bridge.resolveUserUid).not.toHaveBeenCalled(); + }); + + it('writes administrator capabilities and receive-verification through separate services', async () => { + const bridge = mockBridge(); + const api = new GroupAdminApi(bridge as unknown as BridgeContext); + await api.setAdminPermissions(12345, 67890, { recallMemberMessage: false }); + const [permissionCmd, permissionBytes] = bridge.sendRawPacket.mock.calls[0]!; + expect(permissionCmd).toBe('OidbSvcTrpcTcp.0x9c1c_2'); + expect( + protobuf_decode>(permissionBytes).body, + ).toEqual({ + groupCode: 12345n, + memberUin: 67890n, + permissions: [{ item: 2, value: 2, extra: null }], + }); + + bridge.sendRawPacket.mockResolvedValueOnce( + packResponse( + protobuf_encode>({ + body: { groupCode: 12345n, result: { successfulUins: [67890n] } }, + }), + ), + ); + await api.setAdminReceiveVerify(12345, 67890, false); + const [verifyCmd, verifyBytes] = bridge.sendRawPacket.mock.calls[1]!; + expect(verifyCmd).toBe('OidbSvcTrpcTcp.0x904_1'); + expect( + protobuf_decode>(verifyBytes).body, + ).toEqual({ + groupCode: 12345n, + operation: { adminUins: [67890n], authority: 1, operate: 0 }, + }); + expect(bridge.sendRawPacket).toHaveBeenCalledTimes(2); + expect(bridge.resolveUserUid).not.toHaveBeenCalled(); + }); + + it('does not swallow an unconfirmed receive-verification response', async () => { + const bridge = mockBridge(); + await expect( + new GroupAdminApi(bridge as unknown as BridgeContext).setAdminReceiveVerify( + 12345, + 67890, + true, + ), + ).rejects.toThrow('missing or mismatched group'); + expect(bridge.sendRawPacket).toHaveBeenCalledTimes(1); + }); + it('muteMember resolves UID and dispatches 0x1253_1', async () => { const bridge = mockBridge(); await new GroupAdminApi(bridge as any).muteMember(12345, 67890, 600); diff --git a/packages/proto-defs/src/oidb-actions/group-admin-permissions.ts b/packages/proto-defs/src/oidb-actions/group-admin-permissions.ts new file mode 100644 index 00000000..d546e6da --- /dev/null +++ b/packages/proto-defs/src/oidb-actions/group-admin-permissions.ts @@ -0,0 +1,65 @@ +import type { pb, pb_optional, pb_repeated, bytes, uint_32, uint_64 } from '@snowluma/proton'; + +export interface GroupAdminPermissionItem { + item?: pb<1, uint_32>; + value?: pb<2, uint_32>; + extra?: pb<3, bytes>; +} + +export interface GroupAdminKickLimit { + maxKickPerDay?: pb<1, uint_32>; +} + +export interface SetGroupAdminPermissionsRequest { + groupCode?: pb<1, uint_64>; + memberUin?: pb<2, uint_64>; + permissions?: pb_repeated<3, GroupAdminPermissionItem>; +} + +export interface GroupAdminPermissionsFilter { + permissions?: pb<52, bytes>; +} + +export interface GetGroupAdminPermissionsRequest { + groupCode?: pb<1, uint_64>; + memberUins?: pb_repeated<2, uint_64>; + filter?: pb<4, GroupAdminPermissionsFilter>; +} + +export interface GroupAdminPermissions { + items?: pb_repeated<1, GroupAdminPermissionItem>; +} + +export interface GroupAdminPermissionsExt { + permissions?: pb<52, GroupAdminPermissions>; +} + +export interface GroupAdminPermissionsMember { + memberUin?: pb<1, uint_64>; + ext?: pb<3, GroupAdminPermissionsExt>; +} + +export interface GetGroupAdminPermissionsResponse { + groupCode?: pb<1, uint_64>; + members?: pb_repeated<3, GroupAdminPermissionsMember>; +} + +export interface GroupAdminReceiveVerifyOperation { + adminUins?: pb_repeated<1, uint_64>; + authority?: pb<2, uint_32>; + operate?: pb_optional<3, uint_32>; +} + +export interface SetGroupAdminReceiveVerifyRequest { + groupCode?: pb<1, uint_64>; + operation?: pb<2, GroupAdminReceiveVerifyOperation>; +} + +export interface GroupAdminReceiveVerifyResult { + successfulUins?: pb_repeated<1, uint_64>; +} + +export interface SetGroupAdminReceiveVerifyResponse { + groupCode?: pb<1, uint_64>; + result?: pb<2, GroupAdminReceiveVerifyResult>; +} diff --git a/packages/protocol/src/oidb-services/group-admin/get-admin-permissions.ts b/packages/protocol/src/oidb-services/group-admin/get-admin-permissions.ts new file mode 100644 index 00000000..f6ce6b19 --- /dev/null +++ b/packages/protocol/src/oidb-services/group-admin/get-admin-permissions.ts @@ -0,0 +1,101 @@ +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { OidbBase } from '@snowluma/proto-defs/oidb'; +import type { + GetGroupAdminPermissionsRequest, + GetGroupAdminPermissionsResponse, + GroupAdminKickLimit, +} from '@snowluma/proto-defs/oidb-actions/group-admin-permissions'; +import { invokeOidb, type OidbSender } from '../../oidb-service'; + +export interface AdminPermissionsSnapshot { + recallMemberMessage: boolean | null; + kickMember: boolean | null; + kickMemberPerDay: number | null; +} + +export namespace GetAdminPermissions { + export const command = 0x9164; + export const subCommand = 2; + export const uinForm = true; + export type Deps = OidbSender; + export interface Params { + groupId: number; + userId: number; + } + + export const serialize = (_ctx: Deps, p: Params): GetGroupAdminPermissionsRequest => ({ + groupCode: BigInt(p.groupId), + memberUins: [BigInt(p.userId)], + filter: { permissions: new Uint8Array([1]) }, + }); + export const deserialize = ( + _ctx: Deps, + body: GetGroupAdminPermissionsResponse, + ): GetGroupAdminPermissionsResponse => body; + export const encode = (env: OidbBase): Uint8Array => + protobuf_encode>(env); + export const decode = (bytes: Uint8Array): OidbBase => + protobuf_decode>(bytes); + + export async function invoke(deps: Deps, params: Params): Promise { + const body = await invokeOidb(deps, GetAdminPermissions, params); + + if (body.groupCode !== BigInt(params.groupId)) { + throw new Error('administrator permissions response has a missing or mismatched group'); + } + + const members = (body.members ?? []).filter( + (member) => member.memberUin === BigInt(params.userId), + ); + + if (members.length !== 1) { + throw new Error( + 'administrator permissions response must contain exactly one matching member', + ); + } + + const snapshot: AdminPermissionsSnapshot = { + recallMemberMessage: null, + kickMember: null, + kickMemberPerDay: null, + }; + const seen = new Set(); + + for (const permission of members[0]!.ext?.permissions?.items ?? []) { + const id = permission.item; + + if (id !== 1 && id !== 2) { + continue; + } + + if (seen.has(id)) { + throw new Error(`administrator permissions response contains duplicate item ${id}`); + } + + seen.add(id); + const value = permission.value ?? 0; + + if (value !== 0 && value !== 1 && value !== 2) { + throw new Error(`administrator permission ${id} has unsupported value ${value}`); + } + + // Zero or an absent item does not establish an effective permission. + const enabled = value === 0 ? null : value === 1; + + if (id === 2) { + snapshot.recallMemberMessage = enabled; + } else { + snapshot.kickMember = enabled; + + if (enabled) { + const limit = protobuf_decode( + permission.extra ?? new Uint8Array(0), + ); + snapshot.kickMemberPerDay = limit.maxKickPerDay ?? 0; + } + } + } + + return snapshot; + } +} diff --git a/packages/protocol/src/oidb-services/group-admin/set-admin-permissions.ts b/packages/protocol/src/oidb-services/group-admin/set-admin-permissions.ts new file mode 100644 index 00000000..84526520 --- /dev/null +++ b/packages/protocol/src/oidb-services/group-admin/set-admin-permissions.ts @@ -0,0 +1,72 @@ +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { OidbBase, OidbEmpty } from '@snowluma/proto-defs/oidb'; +import type { + GroupAdminKickLimit, + GroupAdminPermissionItem, + SetGroupAdminPermissionsRequest, +} from '@snowluma/proto-defs/oidb-actions/group-admin-permissions'; +import { invokeOidb, type OidbSender } from '../../oidb-service'; + +export interface GroupAdminPermissionPatch { + recallMemberMessage?: boolean; + kickMember?: boolean; + /** Required when enabling removal; zero explicitly means unlimited. */ + kickMemberPerDay?: number; +} + +export namespace SetAdminPermissions { + export const command = 0x9c1c; + export const subCommand = 2; + export const uinForm = false; + export type Deps = OidbSender; + export interface Params extends GroupAdminPermissionPatch { + groupId: number; + userId: number; + } + + export function serialize(_ctx: Deps, p: Params): SetGroupAdminPermissionsRequest { + if (p.recallMemberMessage === undefined && p.kickMember === undefined) { + throw new Error('at least one administrator permission must be specified'); + } + + if (p.kickMember === true) { + if ( + p.kickMemberPerDay === undefined || + ![0, 5, 10, 20, 50, 100, 200].includes(p.kickMemberPerDay) + ) { + throw new Error( + 'enabling kick_member requires kick_member_per_day: 0, 5, 10, 20, 50, 100 or 200', + ); + } + } else if (p.kickMemberPerDay !== undefined) { + throw new Error('kick_member_per_day requires kick_member=true'); + } + + const permissions: GroupAdminPermissionItem[] = []; + + if (p.kickMember !== undefined) { + permissions.push({ + item: 1, + value: p.kickMember ? 1 : 2, + extra: p.kickMember + ? protobuf_encode({ maxKickPerDay: p.kickMemberPerDay }) + : undefined, + }); + } + + if (p.recallMemberMessage !== undefined) { + permissions.push({ item: 2, value: p.recallMemberMessage ? 1 : 2 }); + } + + return { groupCode: BigInt(p.groupId), memberUin: BigInt(p.userId), permissions }; + } + + export const deserialize = (_ctx: Deps, _body: OidbEmpty): void => {}; + + export const encode = (env: OidbBase): Uint8Array => + protobuf_encode>(env); + export const decode = (bytes: Uint8Array): OidbBase => + protobuf_decode>(bytes); + export const invoke = (deps: Deps, params: Params): Promise => + invokeOidb(deps, SetAdminPermissions, params); +} diff --git a/packages/protocol/src/oidb-services/group-admin/set-admin-receive-verify.ts b/packages/protocol/src/oidb-services/group-admin/set-admin-receive-verify.ts new file mode 100644 index 00000000..48a5a98e --- /dev/null +++ b/packages/protocol/src/oidb-services/group-admin/set-admin-receive-verify.ts @@ -0,0 +1,48 @@ +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { OidbBase } from '@snowluma/proto-defs/oidb'; +import type { + SetGroupAdminReceiveVerifyRequest, + SetGroupAdminReceiveVerifyResponse, +} from '@snowluma/proto-defs/oidb-actions/group-admin-permissions'; +import { invokeOidb, type OidbSender } from '../../oidb-service'; + +export namespace SetAdminReceiveVerify { + export const command = 0x904; + export const subCommand = 1; + export const uinForm = true; + export type Deps = OidbSender; + export interface Params { + groupId: number; + userId: number; + enable: boolean; + } + + export const serialize = (_ctx: Deps, p: Params): SetGroupAdminReceiveVerifyRequest => ({ + groupCode: BigInt(p.groupId), + operation: { adminUins: [BigInt(p.userId)], authority: 1, operate: p.enable ? 1 : 0 }, + }); + export const deserialize = ( + _ctx: Deps, + body: SetGroupAdminReceiveVerifyResponse, + ): SetGroupAdminReceiveVerifyResponse => body; + export const encode = (env: OidbBase): Uint8Array => + protobuf_encode>(env); + export const decode = (bytes: Uint8Array): OidbBase => + protobuf_decode>(bytes); + + export async function invoke(deps: Deps, params: Params): Promise { + const body = await invokeOidb(deps, SetAdminReceiveVerify, params); + + if (body.groupCode !== BigInt(params.groupId)) { + throw new Error( + 'administrator receive-verify response has a missing or mismatched group', + ); + } + + if (!body.result?.successfulUins?.includes(BigInt(params.userId))) { + throw new Error( + 'administrator receive-verify response did not confirm the target member', + ); + } + } +} diff --git a/packages/protocol/tests/oidb-services/group-admin/admin-permissions.test.ts b/packages/protocol/tests/oidb-services/group-admin/admin-permissions.test.ts new file mode 100644 index 00000000..84aa8eab --- /dev/null +++ b/packages/protocol/tests/oidb-services/group-admin/admin-permissions.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SendPacketResult } from '@snowluma/common/packet-sender'; +import { SetAdminPermissions } from '../../../src/oidb-services/group-admin/set-admin-permissions'; +import { GetAdminPermissions } from '../../../src/oidb-services/group-admin/get-admin-permissions'; +import { SetAdminReceiveVerify } from '../../../src/oidb-services/group-admin/set-admin-receive-verify'; +import { env, m, v } from '../_pb-oracle'; + +const target = { groupId: 12345, userId: 67890 }; + +function sender(body: number[] = []) { + const result: SendPacketResult = { + success: true, + gotResponse: true, + errorCode: 0, + errorMessage: '', + responseData: Buffer.from(body), + }; + return { sendRawPacket: vi.fn(async (_cmd: string, _bytes: Uint8Array) => result) }; +} + +function member(uin: number, permissions: number[]) { + return [...v(1, uin), ...m(3, m(52, permissions))]; +} + +function snapshot(permissions: number[], group = target.groupId, uin = target.userId) { + return m(4, [...v(1, group), ...m(3, member(uin, permissions))]); +} + +function permission(id: number, value: number, extra?: number[]) { + return m(1, [...v(1, id), ...v(2, value), ...(extra === undefined ? [] : m(3, extra))]); +} + +describe('administrator permission requests', () => { + it('writes both permissions with the service-specific envelope and nested daily limit', async () => { + const deps = sender(); + await SetAdminPermissions.invoke(deps, { + ...target, + recallMemberMessage: false, + kickMember: true, + kickMemberPerDay: 20, + }); + const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!; + expect(cmd).toBe('OidbSvcTrpcTcp.0x9c1c_2'); + expect(Buffer.from(bytes).toString('hex')).toBe( + env( + 0x9c1c, + 2, + [ + ...v(1, target.groupId), + ...v(2, target.userId), + ...m(3, [...v(1, 1), ...v(2, 1), ...m(3, v(1, 20))]), + ...m(3, [...v(1, 2), ...v(2, 2)]), + ], + false, + ), + ); + }); + + it('does not write omitted permissions and encodes disable as 2', async () => { + const deps = sender(); + await SetAdminPermissions.invoke(deps, { ...target, kickMember: false }); + expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe( + env( + 0x9c1c, + 2, + [ + ...v(1, target.groupId), + ...v(2, target.userId), + ...m(3, [...v(1, 1), ...v(2, 2)]), + ], + false, + ), + ); + await SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: true }); + expect(Buffer.from(deps.sendRawPacket.mock.calls[1]![1]).toString('hex')).toBe( + env( + 0x9c1c, + 2, + [ + ...v(1, target.groupId), + ...v(2, target.userId), + ...m(3, [...v(1, 2), ...v(2, 1)]), + ], + false, + ), + ); + }); + + it('accepts explicit unlimited without manufacturing an extra limit field', async () => { + const deps = sender(); + await SetAdminPermissions.invoke(deps, { + ...target, + kickMember: true, + kickMemberPerDay: 0, + }); + expect(Buffer.from(deps.sendRawPacket.mock.calls[0]![1]).toString('hex')).toBe( + env( + 0x9c1c, + 2, + [ + ...v(1, target.groupId), + ...v(2, target.userId), + ...m(3, [...v(1, 1), ...v(2, 1)]), + ], + false, + ), + ); + }); + + it.each([ + {}, + { kickMemberPerDay: 20 }, + { kickMember: true }, + { kickMember: false, kickMemberPerDay: 20 }, + { kickMember: true, kickMemberPerDay: -1 }, + { kickMember: true, kickMemberPerDay: 1 }, + { kickMember: true, kickMemberPerDay: 201 }, + { kickMember: true, kickMemberPerDay: NaN }, + ])('rejects invalid patches before sending: %j', async (patch) => { + const deps = sender(); + await expect(SetAdminPermissions.invoke(deps, { ...target, ...patch })).rejects.toThrow(); + expect(deps.sendRawPacket).not.toHaveBeenCalled(); + }); + + it('propagates envelope rejection instead of accepting an empty response body', async () => { + const deps = sender(v(3, 1287)); + await expect( + SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }), + ).rejects.toThrow('OIDB error 1287'); + }); + + it('does not retry transport failures', async () => { + const deps = sender(); + deps.sendRawPacket.mockRejectedValueOnce(new Error('transport disconnected')); + await expect( + SetAdminPermissions.invoke(deps, { ...target, recallMemberMessage: false }), + ).rejects.toThrow('transport disconnected'); + expect(deps.sendRawPacket).toHaveBeenCalledTimes(1); + }); +}); + +describe('administrator permission snapshot', () => { + it('requests only permission data and reads the matching member', async () => { + const deps = sender(snapshot([...permission(2, 2), ...permission(1, 1, v(1, 50))])); + await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({ + recallMemberMessage: false, + kickMember: true, + kickMemberPerDay: 50, + }); + const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!; + expect(cmd).toBe('OidbSvcTrpcTcp.0x9164_2'); + expect(Buffer.from(bytes).toString('hex')).toBe( + env( + 0x9164, + 2, + [...v(1, target.groupId), ...v(2, target.userId), ...m(4, m(52, [1]))], + true, + ), + ); + }); + + it('finds the requested member rather than using the first returned member', async () => { + const deps = sender( + m(4, [ + ...v(1, target.groupId), + ...m(3, member(1, permission(2, 1))), + ...m(3, member(target.userId, permission(2, 2))), + ]), + ); + await expect(GetAdminPermissions.invoke(deps, target)).resolves.toEqual({ + recallMemberMessage: false, + kickMember: null, + kickMemberPerDay: null, + }); + }); + + it('uses the protobuf zero limit only when removal is explicitly enabled', async () => { + await expect( + GetAdminPermissions.invoke(sender(snapshot(permission(1, 1))), target), + ).resolves.toEqual({ recallMemberMessage: null, kickMember: true, kickMemberPerDay: 0 }); + await expect( + GetAdminPermissions.invoke(sender(snapshot(permission(1, 2))), target), + ).resolves.toEqual({ + recallMemberMessage: null, + kickMember: false, + kickMemberPerDay: null, + }); + }); + + it('does not turn missing permissions or future item IDs into enabled defaults', async () => { + await expect( + GetAdminPermissions.invoke( + sender(snapshot([...permission(999, 1), ...permission(2, 0)])), + target, + ), + ).resolves.toEqual({ recallMemberMessage: null, kickMember: null, kickMemberPerDay: null }); + }); + + it.each([ + ['missing body', []], + ['wrong group', snapshot([], 1)], + ['wrong member', snapshot([], target.groupId, 1)], + ['no member', m(4, v(1, target.groupId))], + [ + 'duplicate member', + m(4, [ + ...v(1, target.groupId), + ...m(3, member(target.userId, [])), + ...m(3, member(target.userId, [])), + ]), + ], + ['duplicate permission', snapshot([...permission(1, 1), ...permission(1, 2)])], + ['unsupported value', snapshot(permission(2, 3))], + ['malformed limit', snapshot(permission(1, 1, [8, 128]))], + ] as const)('rejects %s', async (_name, bytes) => { + await expect(GetAdminPermissions.invoke(sender([...bytes]), target)).rejects.toThrow(); + }); +}); + +describe('administrator join verification', () => { + it.each([true, false])( + 'encodes enable=%s and requires a successful target acknowledgement', + async (enable) => { + const deps = sender(m(4, [...v(1, target.groupId), ...m(2, v(1, target.userId))])); + await expect( + SetAdminReceiveVerify.invoke(deps, { ...target, enable }), + ).resolves.toBeUndefined(); + const [cmd, bytes] = deps.sendRawPacket.mock.calls[0]!; + expect(cmd).toBe('OidbSvcTrpcTcp.0x904_1'); + expect(Buffer.from(bytes).toString('hex')).toBe( + env( + 0x904, + 1, + [ + ...v(1, target.groupId), + ...m(2, [...v(1, target.userId), ...v(2, 1), ...v(3, enable ? 1 : 0)]), + ], + true, + ), + ); + }, + ); + + it.each( + [ + [], + m(4, v(1, target.groupId)), + m(4, [...v(1, 1), ...m(2, v(1, target.userId))]), + m(4, [...v(1, target.groupId), ...m(2, v(1, 1))]), + ].map((response) => ({ response })), + )('rejects an unconfirmed target: %j', async ({ response }) => { + await expect( + SetAdminReceiveVerify.invoke(sender(response), { ...target, enable: false }), + ).rejects.toThrow(); + }); +});