fix(tokens): align api token storage with cosmos partitioning

This commit is contained in:
root 2026-03-15 05:57:34 +00:00
parent a76b932502
commit 07e9475b70
5 changed files with 40 additions and 19 deletions

View File

@ -54,13 +54,13 @@ describe('tokens repository', () => {
describe('getById', () => {
it('returns token when found', async () => {
await create(baseToken);
const result = await getById('tok_1', 'user_1');
const result = await getById('tok_1');
expect(result).not.toBeNull();
expect(result!.id).toBe('tok_1');
});
it('returns null when not found', async () => {
const result = await getById('tok_1', 'user_1');
const result = await getById('tok_1');
expect(result).toBeNull();
});
});
@ -76,14 +76,20 @@ describe('tokens repository', () => {
describe('revoke', () => {
it('revokes an existing token', async () => {
await create(baseToken);
const result = await revoke('tok_1', 'user_1');
const result = await revoke('tok_1', 'lysnrai');
expect(result).toBe(true);
const updated = await getById('tok_1', 'user_1');
const updated = await getById('tok_1');
expect(updated!.status).toBe('revoked');
});
it('returns false when token not found', async () => {
const result = await revoke('tok_1', 'user_1');
const result = await revoke('tok_1', 'lysnrai');
expect(result).toBe(false);
});
it('returns false when token belongs to another product', async () => {
await create(baseToken);
const result = await revoke('tok_1', 'chronomind');
expect(result).toBe(false);
});
});
@ -91,9 +97,15 @@ describe('tokens repository', () => {
describe('remove', () => {
it('deletes and returns true', async () => {
await create(baseToken);
const result = await remove('tok_1', 'user_1');
const result = await remove('tok_1', 'lysnrai');
expect(result).toBe(true);
});
it('returns false when token belongs to another product', async () => {
await create(baseToken);
const result = await remove('tok_1', 'chronomind');
expect(result).toBe(false);
});
});
describe('countActive', () => {

View File

@ -7,7 +7,7 @@ import { getCollection } from '../../lib/datastore.js';
import type { ApiTokenDoc, ApiTokenResponse } from './types.js';
function collection() {
return getCollection<ApiTokenDoc>('api_tokens', '/userId');
return getCollection<ApiTokenDoc>('api_tokens', '/id');
}
function stripHash(doc: ApiTokenDoc): ApiTokenResponse {
@ -33,9 +33,9 @@ export async function listByUser(userId: string, productId: string): Promise<Api
return results.map(stripHash);
}
export async function getById(id: string, userId: string): Promise<ApiTokenDoc | null> {
export async function getById(id: string): Promise<ApiTokenDoc | null> {
try {
return await collection().findById(id, userId);
return await collection().findById(id, id);
} catch {
return null;
}
@ -46,17 +46,19 @@ export async function create(doc: ApiTokenDoc): Promise<ApiTokenResponse> {
return stripHash(created);
}
export async function revoke(id: string, userId: string): Promise<boolean> {
const existing = await getById(id, userId);
if (!existing) return false;
export async function revoke(id: string, productId?: string): Promise<boolean> {
const existing = await getById(id);
if (!existing || (productId && existing.productId !== productId)) return false;
await collection().update(id, userId, { status: 'revoked' } as Partial<ApiTokenDoc>);
await collection().update(id, id, { status: 'revoked' } as Partial<ApiTokenDoc>);
return true;
}
export async function remove(id: string, userId: string): Promise<boolean> {
export async function remove(id: string, productId?: string): Promise<boolean> {
try {
await collection().delete(id, userId);
const existing = await getById(id);
if (!existing || (productId && existing.productId !== productId)) return false;
await collection().delete(id, id);
return true;
} catch {
return false;

View File

@ -31,7 +31,12 @@ const baseToken = {
lastUsed: null,
};
async function buildApp(payload?: { sub: string; productId: string; role?: string; email?: string }) {
async function buildApp(payload?: {
sub: string;
productId: string;
role?: string;
email?: string;
}) {
const { tokenRoutes } = await import('./routes.js');
const app = Fastify({ logger: false });
if (payload) {
@ -171,6 +176,6 @@ describe('tokenRoutes', () => {
expect(res.statusCode).toBe(200);
const data = JSON.parse(res.body);
expect(data.success).toBe(true);
expect(repoMock.remove).toHaveBeenCalledWith('tok_1', 'root_1');
expect(repoMock.remove).toHaveBeenCalledWith('tok_1');
});
});

View File

@ -96,7 +96,7 @@ export async function tokenRoutes(app: FastifyInstance) {
}
if (parsed.data.action === 'revoke') {
const success = await repo.revoke(id, payload.sub);
const success = await repo.revoke(id, payload.productId);
if (!success) throw new BadRequestError('Token not found');
return { success: true };
}
@ -111,7 +111,7 @@ export async function tokenRoutes(app: FastifyInstance) {
throw new ForbiddenError('Super admin access required');
}
const { id } = req.params as { id: string };
const success = await repo.remove(id, payload.sub);
const success = await repo.remove(id);
if (!success) throw new BadRequestError('Token not found');
return { success: true };
});

View File

@ -14,6 +14,7 @@ const startTriggerEvaluationJobMock = vi.fn();
const appMock = {
register: vi.fn(async () => undefined),
addHook: vi.fn(),
};
vi.mock('@bytelyst/config', () => ({
@ -82,6 +83,7 @@ describe('server bootstrap', () => {
createServiceAppMock.mockResolvedValue(appMock);
appMock.register.mockReset();
appMock.register.mockResolvedValue(undefined);
appMock.addHook.mockReset();
});
it('initializes secrets, app, routes, and starts service', async () => {