Skip to content
Open
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
25 changes: 25 additions & 0 deletions apps/backend/drizzle/0007_device_revocation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TABLE "devices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text,
"public_key" text NOT NULL,
"revoked_at" timestamp,
"last_seen_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "device_prekeys" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"device_id" uuid NOT NULL,
"key_id" integer NOT NULL,
"public_key" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "devices" ADD CONSTRAINT "devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "device_prekeys" ADD CONSTRAINT "device_prekeys_device_id_devices_id_fk" FOREIGN KEY ("device_id") REFERENCES "public"."devices"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "devices_user_id_idx" ON "devices" USING btree ("user_id");
--> statement-breakpoint
CREATE INDEX "device_prekeys_device_id_idx" ON "device_prekeys" USING btree ("device_id");
7 changes: 7 additions & 0 deletions apps/backend/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@
"when": 1780560000000,
"tag": "0006_add_conversation_avatar_url",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780646400000,
"tag": "0007_device_revocation",
"breakpoints": true
}
]
}
122 changes: 122 additions & 0 deletions apps/backend/src/__tests__/deviceRevocation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';

const mockFindDevice = vi.fn();
const mockFindMembers = vi.fn();
const mockCount = vi.fn();
const mockUpdateReturning = vi.fn();
const mockDeleteWhere = vi.fn();

const tx = {
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn(() => ({ returning: mockUpdateReturning })) })),
})),
delete: vi.fn(() => ({ where: mockDeleteWhere })),
};

const mockTransaction = vi.fn(
(cb: (t: typeof tx) => Promise<unknown>) => cb(tx) as Promise<unknown>,
);

vi.mock('../db/index.js', () => ({
db: {
query: {
devices: { findFirst: mockFindDevice },
conversationMembers: { findMany: mockFindMembers },
},
$count: mockCount,
transaction: mockTransaction,
},
}));

vi.mock('../db/schema.js', () => ({
devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' },
devicePrekeys: { deviceId: 'deviceId' },
conversationMembers: { userId: 'userId', conversationId: 'conversationId' },
}));

vi.mock('drizzle-orm', () => ({
and: vi.fn((...args: unknown[]) => args.filter(Boolean)),
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
isNull: vi.fn((col: unknown) => ({ col, op: 'isNull' })),
sql: vi.fn(),
}));

const { revokeDevice } = await import('../services/deviceRevocation.js');

beforeEach(() => {
vi.clearAllMocks();
});

describe('revokeDevice', () => {
it('returns 404 when the device is missing', async () => {
mockFindDevice.mockResolvedValue(undefined);

const result = await revokeDevice('user-1', 'dev-1');

expect(result).toEqual({ ok: false, status: 404, error: 'Device not found' });
expect(mockTransaction).not.toHaveBeenCalled();
});

it('returns 403 when the device belongs to someone else', async () => {
mockFindDevice.mockResolvedValue({ id: 'dev-1', userId: 'other', revokedAt: null });

const result = await revokeDevice('user-1', 'dev-1');

expect(result).toMatchObject({ ok: false, status: 403 });
});

it('returns 409 when the device is already revoked', async () => {
mockFindDevice.mockResolvedValue({
id: 'dev-1',
userId: 'user-1',
revokedAt: new Date(),
});

const result = await revokeDevice('user-1', 'dev-1');

expect(result).toMatchObject({ ok: false, status: 409, error: 'Device is already revoked' });
});

it('returns 409 when it is the last active device', async () => {
mockFindDevice.mockResolvedValue({ id: 'dev-1', userId: 'user-1', revokedAt: null });
mockCount.mockResolvedValue(1);

const result = await revokeDevice('user-1', 'dev-1');

expect(result).toMatchObject({
ok: false,
status: 409,
error: 'Cannot revoke the last active device',
});
expect(mockTransaction).not.toHaveBeenCalled();
});

it('revokes, deletes prekeys, and returns shared conversations', async () => {
const revoked = { id: 'dev-1', userId: 'user-1', revokedAt: new Date() };
mockFindDevice.mockResolvedValue({ id: 'dev-1', userId: 'user-1', revokedAt: null });
mockCount.mockResolvedValue(2);
mockUpdateReturning.mockResolvedValue([revoked]);
mockFindMembers.mockResolvedValue([{ conversationId: 'conv-1' }, { conversationId: 'conv-2' }]);

const result = await revokeDevice('user-1', 'dev-1');

expect(result).toEqual({
ok: true,
device: revoked,
conversationIds: ['conv-1', 'conv-2'],
});
expect(tx.delete).toHaveBeenCalled();
expect(mockDeleteWhere).toHaveBeenCalled();
});

it('returns 409 when the atomic revoke loses a race', async () => {
mockFindDevice.mockResolvedValue({ id: 'dev-1', userId: 'user-1', revokedAt: null });
mockCount.mockResolvedValue(2);
mockUpdateReturning.mockResolvedValue([]);

const result = await revokeDevice('user-1', 'dev-1');

expect(result).toMatchObject({ ok: false, status: 409 });
expect(mockFindMembers).not.toHaveBeenCalled();
});
});
169 changes: 169 additions & 0 deletions apps/backend/src/__tests__/devices.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';

const mockRevokeDevice = vi.fn();
const mockFindManyDevices = vi.fn();

const mockEmit = vi.fn();
const mockTo = vi.fn(() => ({ emit: mockEmit }));
const mockDisconnectSockets = vi.fn();
const mockIn = vi.fn(() => ({ disconnectSockets: mockDisconnectSockets }));
const mockPublish = vi.fn();

vi.mock('../services/deviceRevocation.js', () => ({
revokeDevice: mockRevokeDevice,
}));

vi.mock('../db/index.js', () => ({
db: {
query: {
devices: { findMany: mockFindManyDevices },
},
},
}));

vi.mock('../db/schema.js', () => ({
devices: { id: 'id', userId: 'userId', createdAt: 'createdAt' },
}));

vi.mock('drizzle-orm', () => ({
asc: vi.fn(),
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
}));

vi.mock('../lib/socket.js', () => ({
getSocketServer: () => ({ to: mockTo, in: mockIn }),
}));

vi.mock('../lib/redis.js', () => ({
get redis() {
return { publish: mockPublish };
},
}));

vi.mock('../lib/deviceBus.js', () => ({
deviceRoom: (id: string) => `device:${id}`,
publishDeviceRevoked: (_redis: unknown, event: unknown) => mockPublish(event),
}));

vi.mock('../middleware/auth.js', () => ({
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
(req as express.Request & { auth: { userId: string } }).auth = { userId: 'user-1' };
next();
},
}));

const { devicesRouter } = await import('../routes/devices.js');

function makeApp() {
const app = express();
app.use(express.json());
app.use('/devices', devicesRouter);
return app;
}

beforeEach(() => {
vi.clearAllMocks();
});

describe('GET /devices', () => {
it("lists the caller's devices", async () => {
mockFindManyDevices.mockResolvedValue([
{
id: 'dev-1',
name: 'Laptop',
publicKey: 'pk',
revokedAt: null,
lastSeenAt: null,
createdAt: new Date('2026-01-01'),
},
]);

const res = await request(makeApp()).get('/devices');

expect(res.status).toBe(200);
expect(res.body.devices).toHaveLength(1);
expect(res.body.devices[0]).toMatchObject({ id: 'dev-1', name: 'Laptop' });
});
});

describe('DELETE /devices/:id', () => {
it('returns 404 when the device does not exist', async () => {
mockRevokeDevice.mockResolvedValue({ ok: false, status: 404, error: 'Device not found' });

const res = await request(makeApp()).delete('/devices/missing');

expect(res.status).toBe(404);
expect(mockDisconnectSockets).not.toHaveBeenCalled();
expect(mockPublish).not.toHaveBeenCalled();
});

it('returns 403 when the device belongs to another user', async () => {
mockRevokeDevice.mockResolvedValue({
ok: false,
status: 403,
error: 'You do not own this device',
});

const res = await request(makeApp()).delete('/devices/dev-9');

expect(res.status).toBe(403);
});

it('returns 409 when revoking the last active device', async () => {
mockRevokeDevice.mockResolvedValue({
ok: false,
status: 409,
error: 'Cannot revoke the last active device',
});

const res = await request(makeApp()).delete('/devices/dev-1');

expect(res.status).toBe(409);
expect(res.body.error).toBe('Cannot revoke the last active device');
expect(mockDisconnectSockets).not.toHaveBeenCalled();
});

it('revokes the device, disconnects sockets, notifies peers, and publishes on the bus', async () => {
const revokedAt = new Date('2026-06-25T00:00:00.000Z');
mockRevokeDevice.mockResolvedValue({
ok: true,
device: {
id: 'dev-1',
name: 'Laptop',
publicKey: 'pk',
revokedAt,
lastSeenAt: null,
createdAt: new Date('2026-01-01'),
},
conversationIds: ['conv-1', 'conv-2'],
});

const res = await request(makeApp()).delete('/devices/dev-1');

expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: 'dev-1' });

// Live sockets bound to the device are disconnected.
expect(mockIn).toHaveBeenCalledWith('device:dev-1');
expect(mockDisconnectSockets).toHaveBeenCalledWith(true);

// Peers in each shared conversation receive a key-change notice.
expect(mockTo).toHaveBeenCalledWith('conv-1');
expect(mockTo).toHaveBeenCalledWith('conv-2');
expect(mockEmit).toHaveBeenCalledWith(
'key_change',
expect.objectContaining({ userId: 'user-1', deviceId: 'dev-1' }),
);

// Revocation is published on the Redis bus for cross-instance fan-out.
expect(mockPublish).toHaveBeenCalledWith(
expect.objectContaining({
deviceId: 'dev-1',
userId: 'user-1',
conversationIds: ['conv-1', 'conv-2'],
}),
);
});
});
2 changes: 2 additions & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { authRouter } from './routes/auth.js';
import { conversationsRouter } from './routes/conversations.js';
import { messagesRouter } from './routes/messages.js';
import { usersRouter } from './routes/users.js';
import { devicesRouter } from './routes/devices.js';
import { requireAuth, type AuthRequest } from './middleware/auth.js';

const packageJson = JSON.parse(
Expand Down Expand Up @@ -47,6 +48,7 @@ app.use('/auth', authRouter);
app.use('/conversations', conversationsRouter);
app.use('/messages', messagesRouter);
app.use('/users', usersRouter);
app.use('/devices', devicesRouter);

app.get('/me', requireAuth, (req, res) => {
res.json({ user: (req as AuthRequest).auth });
Expand Down
Loading