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
14 changes: 14 additions & 0 deletions src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,20 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot
"/v1/line/link-start": {
"post": {},
},
"/v1/line/recipients": {
"get": {
"parameters": [
{
"in": "query",
"name": "devEuis",
"required": true,
"schema": {
"type": "string",
},
},
],
},
},
"/v1/line/webhook": {
"post": {},
},
Expand Down
21 changes: 21 additions & 0 deletions src/v1/common/v1-route-input-contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@
'handleEvents',
'createLinkNonce',
'createLinkCode',
'listEligibleRecipients',
'unlink',
]),
};
Expand Down Expand Up @@ -901,6 +902,18 @@
name: 'POST /v1/line/link-code mints a chat-linking code for the current user',
url: '/v1/line/link-code',
},
{
auth: true,
expectedCall: {
args: [MOCK_USER, ['AA', 'BB']],
method: 'listEligibleRecipients',
service: 'line',
},
expectedStatus: 200,
method: 'get',
name: 'GET /v1/line/recipients parses comma-separated devEuis',
url: '/v1/line/recipients?devEuis=AA,%20BB',
},
{
auth: true,
expectedCall: {
Expand All @@ -923,6 +936,14 @@
name: 'POST /v1/line/webhook rejects requests without a raw body',
url: '/v1/line/webhook',
},
{
auth: true,
expectedMessage: 'devEuis is required',
expectedStatus: 400,
method: 'get',
name: 'GET /v1/line/recipients rejects a missing devEuis param',
url: '/v1/line/recipients',
},
{
auth: true,
body: {
Expand Down Expand Up @@ -1050,7 +1071,7 @@
];

it.each(successCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1074 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1074 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down Expand Up @@ -1088,7 +1109,7 @@
});

it.each(rejectionCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1112 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1112 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down
41 changes: 39 additions & 2 deletions src/v1/line/line.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,31 @@ import {
BadRequestException,
Controller,
Delete,
Get,
Headers,
HttpCode,
HttpStatus,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { RawBodyRequest } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthenticatedUser } from '../auth/authenticated-user';
import { LineService, type LineWebhookEvent } from './line.service';
import {
LineService,
type LineRecipientCandidate,
type LineWebhookEvent,
} from './line.service';

@ApiTags('line')
@Controller({ path: 'line', version: '1' })
Expand Down Expand Up @@ -66,6 +77,32 @@ export class LineController {
return this.lineService.createLinkNonce(user.sub);
}

@Get('recipients')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'List users eligible as LINE recipients for the given devices',
})
@ApiQuery({
name: 'devEuis',
required: true,
type: String,
description: 'Comma-separated device EUIs',
})
listRecipients(
@CurrentUser() user: AuthenticatedUser,
@Query('devEuis') devEuis?: string,
): Promise<LineRecipientCandidate[]> {
const parsed = (devEuis ?? '')
.split(',')
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (parsed.length === 0) {
throw new BadRequestException('devEuis is required');
}
return this.lineService.listEligibleRecipients(user, parsed);
}

@Post('link-code')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
Expand Down
208 changes: 206 additions & 2 deletions src/v1/line/line.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ function chain(result: StubResult) {
'eq',
'gt',
'lt',
'in',
]) {
stub[method] = jest.fn((...args: unknown[]) => {
calls.push({ method, args });
Expand Down Expand Up @@ -185,7 +186,7 @@ describe('LineService', () => {
data: { nonce: 'nonce-1', user_id: 'user-1', expires_at: 'later' },
error: null,
});
const profileUpdate = chain({ data: null, error: null });
const profileUpdate = chain({ data: [{ id: 'user-1' }], error: null });
const nonceDelete = chain({ data: null, error: null });
const adminClient = buildAdminClient({
cw_line_link_nonces: [nonceLookup, nonceDelete],
Expand Down Expand Up @@ -292,7 +293,7 @@ describe('LineService', () => {

it('links the sender when an unbound user sends a valid 6-digit code', async () => {
const isLinkedLookup = chain({ data: null, error: null });
const profileUpdate = chain({ data: null, error: null });
const profileUpdate = chain({ data: [{ id: 'user-1' }], error: null });
const codeLookup = chain({
data: { nonce: '123456', user_id: 'user-1', expires_at: 'later' },
error: null,
Expand Down Expand Up @@ -325,6 +326,65 @@ describe('LineService', () => {
expect(String(messages[0].text)).toContain('連携が完了');
});

it('accepts full-width digits and surrounding whitespace in codes', async () => {
const isLinkedLookup = chain({ data: null, error: null });
const profileUpdate = chain({ data: [{ id: 'user-1' }], error: null });
const codeLookup = chain({
data: { nonce: '123456', user_id: 'user-1', expires_at: 'later' },
error: null,
});
const codeDelete = chain({ data: null, error: null });
const adminClient = buildAdminClient({
profiles: [isLinkedLookup, profileUpdate],
cw_line_link_nonces: [codeLookup, codeDelete],
});
const { service, apiClient } = createService({ adminClient });

await service.handleEvents([
{
type: 'message',
source: { userId: LINE_USER },
message: { type: 'text', text: ' 123456 ' },
},
]);

// Lookup must use the normalized ASCII code.
expect(codeLookup.calls).toContainEqual({
method: 'eq',
args: ['nonce', '123456'],
});
expect(profileUpdate.calls).toContainEqual({
method: 'update',
args: [{ line_id: LINE_USER }],
});
expect(apiClient.issueLinkToken).not.toHaveBeenCalled();
});

it('replies link-failed when no profiles row matches the code owner', async () => {
const isLinkedLookup = chain({ data: null, error: null });
const profileUpdate = chain({ data: [], error: null });
const codeLookup = chain({
data: { nonce: '123456', user_id: 'ghost-user', expires_at: 'later' },
error: null,
});
const adminClient = buildAdminClient({
profiles: [isLinkedLookup, profileUpdate],
cw_line_link_nonces: [codeLookup],
});
const { service, apiClient } = createService({ adminClient });

await service.handleEvents([
{
type: 'message',
source: { userId: LINE_USER },
message: { type: 'text', text: '123456' },
},
]);

const [, messages] = apiClient.pushMessage.mock.calls[0];
expect(String(messages[0].text)).toContain('連携に失敗');
});

it('replies invalid-code when the 6-digit code is unknown or expired', async () => {
const adminClient = buildAdminClient({
profiles: [chain({ data: null, error: null })],
Expand Down Expand Up @@ -391,6 +451,150 @@ describe('LineService', () => {
});
});

describe('listEligibleRecipients', () => {
const caller = {
sub: 'user-1',
email: 'kevin@example.com',
isStaff: false,
};

it('scopes to caller-viewable devices, includes the owner, excludes DISABLED', async () => {
const managedLookup = chain({
data: [
{
dev_eui: 'DEV-A',
name: 'A',
user_id: 'user-1',
cw_device_owners: [],
},
],
error: null,
});
const viewersLookup = chain({
data: [
{
user_id: 'owner-9',
cw_device_owners: [
{ user_id: 'viewer-4', permission_level: 4 },
{ user_id: 'disabled-5', permission_level: 5 },
],
},
],
error: null,
});
const profilesLookup = chain({
data: [
{
id: 'owner-9',
full_name: 'Zoe Owner',
username: null,
email: null,
line_id: 'U9',
},
{
id: 'viewer-4',
full_name: null,
username: null,
email: 'v4@example.com',
line_id: null,
},
],
error: null,
});
const adminClient = buildAdminClient({
cw_devices: [managedLookup, viewersLookup],
profiles: [profilesLookup],
});
const { service } = createService({ adminClient });

const result = await service.listEligibleRecipients(caller, [
'DEV-A',
'DEV-NOT-VISIBLE',
]);

// Scoped viewers query only includes the viewable device.
expect(viewersLookup.calls).toContainEqual({
method: 'in',
args: ['dev_eui', ['DEV-A']],
});
// Sorted by display name; owner included; DISABLED excluded; fallback
// chain and lineLinked flags applied.
expect(result).toEqual([
{
userId: 'viewer-4',
displayName: 'v4@example.com',
lineLinked: false,
},
{ userId: 'owner-9', displayName: 'Zoe Owner', lineLinked: true },
]);
});

it('returns empty without further queries when the caller can view none', async () => {
const managedLookup = chain({ data: [], error: null });
const adminClient = buildAdminClient({ cw_devices: [managedLookup] });
const { service } = createService({ adminClient });

await expect(
service.listEligibleRecipients(caller, ['DEV-X']),
).resolves.toEqual([]);
});

it('dedupes a user appearing on multiple devices', async () => {
const managedLookup = chain({
data: [
{
dev_eui: 'DEV-A',
name: 'A',
user_id: 'user-1',
cw_device_owners: [],
},
{
dev_eui: 'DEV-B',
name: 'B',
user_id: 'user-1',
cw_device_owners: [],
},
],
error: null,
});
const viewersLookup = chain({
data: [
{ user_id: 'shared-user', cw_device_owners: [] },
{ user_id: 'shared-user', cw_device_owners: [] },
],
error: null,
});
const profilesLookup = chain({
data: [
{
id: 'shared-user',
full_name: 'Shared',
username: null,
email: null,
line_id: null,
},
],
error: null,
});
const adminClient = buildAdminClient({
cw_devices: [managedLookup, viewersLookup],
profiles: [profilesLookup],
});
const { service } = createService({ adminClient });

const result = await service.listEligibleRecipients(caller, [
'DEV-A',
'DEV-B',
]);

expect(result).toHaveLength(1);
expect(profilesLookup.calls).toContainEqual({
method: 'in',
args: ['id', ['shared-user']],
});
});
});

describe('createLinkNonce / unlink', () => {
it('purges expired nonces and inserts a fresh 10-minute nonce', async () => {
const purge = chain({ data: null, error: null });
Expand Down
Loading
Loading