Files
lobe-chat/apps/server/src/routers/lambda/__tests__/pushToken.test.ts
T
Tsuki 29974d3ab9 🐛 fix(mobile): preserve authenticated legacy unregister cleanup (#15723)
Follow-up to #15719 addressing a Codex P2 review note.

After #15719, legacy v1.0.7 clients that only send `deviceId` were
silent-OKed unconditionally. But `publicProcedure` still receives
`ctx.userId` from `createLambdaContext` — and in the *active*
sign-out path (the user is still authenticated when logout fires)
that userId is valid. Skipping the delete in that case orphans the
existing `(userId, deviceId)` row, so `PushChannel.deliver` keeps
fanning notifications out to a signed-out device. Expo's
`DeviceNotRegistered` receipt only fires on uninstall, not on
logout, so the cron worker doesn't catch this either.

Fix: add a Path B fallback — when `ctx.userId` is available, run
the original `(userId, deviceId)` delete. Path A (expoToken pair)
still wins when present; Path C (silent OK) is now reserved for
the case the original PR was actually targeting: a v1.0.7 client
whose session is already gone, which is the source of the 401
storm.

Path matrix:
  expoToken present                  → Path A: precise delete by (expoToken, deviceId)
  no expoToken, ctx.userId present   → Path B: legacy (userId, deviceId) delete
  no expoToken, no session           → Path C: silent OK, cron cleans up

Tests added:
- legacy + valid session → falls back to (userId, deviceId)
- legacy + no session    → silent OK
- expoToken always takes precedence over userId fallback
2026-06-12 21:58:23 +08:00

184 lines
6.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { pushTokenRouter } from '@/server/routers/lambda/pushToken';
const mockUpsert = vi.fn();
const mockUnregister = vi.fn();
const mockDeleteByExpoTokenAndDevice = vi.fn();
vi.mock('@/database/models/pushToken', () => ({
PushTokenModel: vi.fn(() => ({
unregister: mockUnregister,
upsert: mockUpsert,
})),
deletePushTokenByExpoTokenAndDevice: (...args: unknown[]) =>
mockDeleteByExpoTokenAndDevice(...args),
}));
const createCaller = (ctxOverrides: Partial<any> = {}) => {
const ctx = {
serverDB: {} as any,
userId: 'user-1',
...ctxOverrides,
};
return pushTokenRouter.createCaller(ctx);
};
describe('pushTokenRouter', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('register', () => {
it('should upsert with the input fields', async () => {
mockUpsert.mockResolvedValueOnce({ id: 'row-1' });
const caller = createCaller();
const result = await caller.register({
appVersion: '1.0.0',
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
locale: 'zh-CN',
platform: 'ios',
});
expect(mockUpsert).toHaveBeenCalledWith({
appVersion: '1.0.0',
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
locale: 'zh-CN',
platform: 'ios',
});
expect(result).toEqual({ id: 'row-1' });
});
it('should accept omitted optional fields', async () => {
mockUpsert.mockResolvedValueOnce({ id: 'row-1' });
const caller = createCaller();
await caller.register({
deviceId: 'd',
expoToken: 't',
platform: 'android',
});
expect(mockUpsert).toHaveBeenCalledWith({
deviceId: 'd',
expoToken: 't',
platform: 'android',
});
});
it('should reject empty deviceId', async () => {
const caller = createCaller();
await expect(
caller.register({ deviceId: '', expoToken: 't', platform: 'ios' }),
).rejects.toThrow();
});
it('should reject empty expoToken', async () => {
const caller = createCaller();
await expect(
caller.register({ deviceId: 'd', expoToken: '', platform: 'ios' }),
).rejects.toThrow();
});
it('should reject invalid platform', async () => {
const caller = createCaller();
await expect(
// @ts-expect-error testing runtime validation
caller.register({ deviceId: 'd', expoToken: 't', platform: 'windows' }),
).rejects.toThrow();
});
});
describe('unregister', () => {
it('should delete by (expoToken, deviceId) when expoToken is provided', async () => {
mockDeleteByExpoTokenAndDevice.mockResolvedValueOnce(undefined);
const caller = createCaller();
const result = await caller.unregister({
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalledWith(expect.anything(), {
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(result).toEqual({ success: true });
// Legacy (userId, deviceId) path must not fire when expoToken is present
expect(mockUnregister).not.toHaveBeenCalled();
});
it('should fall back to (userId, deviceId) for legacy clients with a session', async () => {
// Path B — v1.0.7 only sends deviceId; if the request still carries a
// valid session we MUST delete the row, otherwise PushChannel keeps
// notifying a signed-out device (Expo DeviceNotRegistered only fires on
// uninstall, not logout).
mockUnregister.mockResolvedValueOnce(undefined);
const caller = createCaller();
const result = await caller.unregister({ deviceId: 'device-1' });
expect(mockUnregister).toHaveBeenCalledWith('device-1');
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should silently succeed without expoToken AND without session', async () => {
// Path C — v1.0.7 + dead session: the only safe move is silent OK.
// Orphan row will be cleaned up by the process-push-receipts worker via
// Expo DeviceNotRegistered receipts. Returning 200 here stops the storm.
const caller = createCaller({ userId: undefined });
const result = await caller.unregister({ deviceId: 'device-1' });
expect(mockDeleteByExpoTokenAndDevice).not.toHaveBeenCalled();
expect(mockUnregister).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
it('should succeed for an unauthenticated caller carrying expoToken', async () => {
// New clients (>=1.0.8) hit Path A regardless of session.
const caller = createCaller({ userId: undefined });
const result = await caller.unregister({
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(result).toEqual({ success: true });
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
expect(mockUnregister).not.toHaveBeenCalled();
});
it('should prefer expoToken precision over the legacy userId fallback', async () => {
// If both are available, always take Path A — the (expoToken, deviceId)
// pair is more precise and doesn't risk deleting a wrong row.
const caller = createCaller();
await caller.unregister({
deviceId: 'device-1',
expoToken: 'ExponentPushToken[abc]',
});
expect(mockDeleteByExpoTokenAndDevice).toHaveBeenCalled();
expect(mockUnregister).not.toHaveBeenCalled();
});
it('should reject empty deviceId', async () => {
const caller = createCaller();
await expect(caller.unregister({ deviceId: '' })).rejects.toThrow();
});
it('should reject empty expoToken when provided', async () => {
const caller = createCaller();
await expect(
caller.unregister({ deviceId: 'device-1', expoToken: '' }),
).rejects.toThrow();
});
});
});