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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
}
},
"dependencies": {
"@authzed/authzed-node": "^1.5.1",
"@authzed/authzed-node": "^1.6.1",
"lru-cache": "^10.4.3"
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './types';
export * from './client-configuration';
export * from './entitlements.client';
export * from './entitlements-client-factory';
export * from './spicedb/spicedb-entitlements.client';
1 change: 1 addition & 0 deletions src/logging/logging-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export interface LoggingClient {
requestContext: RequestContext,
queryResult: OpaResponse<EntitlementsResult> | SpiceDBResponse<EntitlementsResult>
): void | Promise<void>;
logRequest<TRequest, TResponse>(request: TRequest, response: TResponse): void | Promise<void>;
error(error: unknown): void | Promise<void>;
}
4 changes: 4 additions & 0 deletions src/logging/simple-logging.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export class SimpleLoggingClient implements LoggingClient {
console.log({ subjectContext, requestContext, queryResult: queryResult.result });
}

public logRequest<TRequest, TResponse>(request: TRequest, response: TResponse): void {
console.log(JSON.stringify({ request, response }, null, 2));
}

public error(error: unknown): void {
console.error(error);
}
Expand Down
9 changes: 9 additions & 0 deletions src/spicedb/lookup.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { v1 } from '@authzed/authzed-node';
import { Permissionship } from '../types';

export const DEFAULT_LOOKUP_LIMIT = 50;

export const permissionshipMap = new Map<v1.LookupPermissionship, Permissionship>([
[v1.LookupPermissionship.HAS_PERMISSION, 'HAS_PERMISSION'],
[v1.LookupPermissionship.CONDITIONAL_PERMISSION, 'CONDITIONAL_PERMISSION']
] as const);
303 changes: 303 additions & 0 deletions src/spicedb/spicedb-entitlements.client.lookup-resources.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
import { SpiceDBEntitlementsClient } from './spicedb-entitlements.client';
import { mock, MockProxy } from 'jest-mock-extended';
import { LoggingClient } from '../logging';
import { ClientConfiguration } from '../client-configuration';
import { v1 } from '@authzed/authzed-node';
import { LookupResourcesRequest } from '../types';
import { encodeObjectId } from './spicedb-queries/base64.utils';

describe('SpiceDBEntitlementsClient.lookupResources', () => {
let mockSpiceClient: MockProxy<v1.ZedPromiseClientInterface>;
let mockLoggingClient: MockProxy<LoggingClient>;
let client: SpiceDBEntitlementsClient;

const mockClientConfig: ClientConfiguration = {
spiceDBEndpoint: 'mock-endpoint',
spiceDBToken: 'mock-token'
};

const defaultRequest: LookupResourcesRequest = {
subjectType: 'user',
subjectId: 'user-123',
resourceType: 'document',
permission: 'read'
};

beforeEach(() => {
mockSpiceClient = mock<v1.ZedPromiseClientInterface>();
mockLoggingClient = mock<LoggingClient>();
client = new SpiceDBEntitlementsClient(mockClientConfig, mockLoggingClient, false);
// Replace the internal spiceClient with our mock
(client as any).spiceClient = mockSpiceClient;

Check warning on line 31 in src/spicedb/spicedb-entitlements.client.lookup-resources.spec.ts

View workflow job for this annotation

GitHub Actions / lint / lint

Unexpected any. Specify a different type
});

describe('successful lookups', () => {
it('should return resources with correct structure (decoded from base64)', async () => {
// SpiceDB returns base64-encoded IDs
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-1'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
},
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-2'),
permissionship: v1.LookupPermissionship.CONDITIONAL_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.resources).toHaveLength(2);
// Response should contain decoded IDs
expect(result.resources[0]).toEqual({
resourceType: 'document',
resourceId: 'resource-1',
permissionship: 'HAS_PERMISSION'
});
expect(result.resources[1]).toEqual({
resourceType: 'document',
resourceId: 'resource-2',
permissionship: 'CONDITIONAL_PERMISSION'
});
expect(result.totalReturned).toBe(2);
expect(result.cursor).toBeUndefined();
});

it('should return empty resources array when no results', async () => {
mockSpiceClient.lookupResources.mockResolvedValue([]);

const result = await client.lookupResources(defaultRequest);

expect(result.resources).toHaveLength(0);
expect(result.totalReturned).toBe(0);
expect(result.cursor).toBeUndefined();
});

it('should use default limit of 50 when not provided', async () => {
mockSpiceClient.lookupResources.mockResolvedValue([]);

await client.lookupResources(defaultRequest);

expect(mockSpiceClient.lookupResources).toHaveBeenCalledWith(
expect.objectContaining({
optionalLimit: 50
})
);
});

it('should use provided limit', async () => {
mockSpiceClient.lookupResources.mockResolvedValue([]);

await client.lookupResources({ ...defaultRequest, limit: 100 });

expect(mockSpiceClient.lookupResources).toHaveBeenCalledWith(
expect.objectContaining({
optionalLimit: 100
})
);
});

it('should build correct SpiceDB request with base64-encoded subjectId', async () => {
mockSpiceClient.lookupResources.mockResolvedValue([]);

await client.lookupResources(defaultRequest);

expect(mockSpiceClient.lookupResources).toHaveBeenCalledWith(
expect.objectContaining({
resourceObjectType: 'document',
permission: 'read',
subject: expect.objectContaining({
object: expect.objectContaining({
objectType: 'user',
objectId: encodeObjectId('user-123') // Request should encode the ID
}),
optionalRelation: ''
})
})
);
});
});

describe('pagination', () => {
it('should return cursor when results equal limit and have afterResultCursor', async () => {
// Create 50 mock results (the default limit) with a cursor
const mockResults: v1.LookupResourcesResponse[] = Array.from({ length: 50 }, (_, i) => ({
lookedUpAt: undefined,
resourceObjectId: encodeObjectId(`resource-${i + 1}`),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: i === 49 ? { token: 'next-page-token' } : undefined
}));
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.cursor).toBe('next-page-token'); // Returns cursor because results.length (50) === limit (50)
expect(result.totalReturned).toBe(50);
});

it('should not return cursor when results are less than limit', async () => {
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-1'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: { token: 'next-page-token' }
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.cursor).toBeUndefined(); // No cursor because results.length (1) < limit (50)
expect(result.totalReturned).toBe(1);
});

it('should return no cursor when SpiceDB provides none', async () => {
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-1'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined // No cursor
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.cursor).toBeUndefined();
expect(result.totalReturned).toBe(1);
});

it('should pass cursor to SpiceDB request when provided', async () => {
mockSpiceClient.lookupResources.mockResolvedValue([]);

await client.lookupResources({
...defaultRequest,
cursor: 'existing-cursor-token'
});

expect(mockSpiceClient.lookupResources).toHaveBeenCalledWith(
expect.objectContaining({
optionalCursor: expect.objectContaining({
token: 'existing-cursor-token'
})
})
);
});

it('should not pass cursor when not provided', async () => {
mockSpiceClient.lookupResources.mockResolvedValue([]);

await client.lookupResources(defaultRequest);

const callArg = mockSpiceClient.lookupResources.mock.calls[0][0];
expect(callArg.optionalCursor).toBeUndefined();
});
});

describe('permissionship mapping', () => {
it('should map HAS_PERMISSION correctly', async () => {
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-1'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.resources[0].permissionship).toBe('HAS_PERMISSION');
});

it('should map CONDITIONAL_PERMISSION correctly', async () => {
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-1'),
permissionship: v1.LookupPermissionship.CONDITIONAL_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.resources[0].permissionship).toBe('CONDITIONAL_PERMISSION');
});

it('should map UNSPECIFIED to undefined', async () => {
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('resource-1'),
permissionship: v1.LookupPermissionship.UNSPECIFIED,
partialCaveatInfo: undefined,
afterResultCursor: undefined
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.resources[0].permissionship).toBeUndefined();
});
});

describe('error handling', () => {
it('should propagate SpiceDB errors', async () => {
const spiceDBError = new Error('SpiceDB error');
mockSpiceClient.lookupResources.mockRejectedValue(spiceDBError);

await expect(client.lookupResources(defaultRequest)).rejects.toThrow('SpiceDB error');
});
});

describe('result ordering', () => {
it('should preserve the order of results from SpiceDB', async () => {
const mockResults: v1.LookupResourcesResponse[] = [
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('first'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
},
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('second'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
},
{
lookedUpAt: undefined,
resourceObjectId: encodeObjectId('third'),
permissionship: v1.LookupPermissionship.HAS_PERMISSION,
partialCaveatInfo: undefined,
afterResultCursor: undefined
}
];
mockSpiceClient.lookupResources.mockResolvedValue(mockResults);

const result = await client.lookupResources(defaultRequest);

expect(result.resources.map((r) => r.resourceId)).toEqual(['first', 'second', 'third']);
});
});
});
Loading
Loading