Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
489 lines
13 KiB
TypeScript
489 lines
13 KiB
TypeScript
import { testDb } from '@n8n/backend-test-utils';
|
|
import { type Scope, ScopeRepository } from '@n8n/db';
|
|
import { Container } from '@n8n/di';
|
|
import type { Scope as ScopeType } from '@n8n/permissions';
|
|
|
|
import { createScope, createScopes, createTestScopes } from '../../shared/db/roles';
|
|
|
|
describe('ScopeRepository', () => {
|
|
let scopeRepository: ScopeRepository;
|
|
|
|
beforeAll(async () => {
|
|
await testDb.init();
|
|
scopeRepository = Container.get(ScopeRepository);
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
// Truncate in the correct order to respect foreign key constraints
|
|
// user table references role via roleSlug
|
|
// project_relation references role
|
|
// role_scope references scope, so truncate it first
|
|
await testDb.truncate(['User', 'ProjectRelation', 'Role', 'Scope']);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await testDb.terminate();
|
|
});
|
|
|
|
describe('findByList()', () => {
|
|
describe('successful queries', () => {
|
|
it('should return empty array when given empty slug array', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
await createTestScopes(); // Create some scopes but don't query for them
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array when no scopes exist', async () => {
|
|
//
|
|
// ARRANGE & ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList(['non-existent:scope']);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toEqual([]);
|
|
});
|
|
|
|
it('should return single scope when one slug matches', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([readScope.slug]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(1);
|
|
expect(scopes[0]).toEqual(
|
|
expect.objectContaining({
|
|
slug: readScope.slug,
|
|
displayName: readScope.displayName,
|
|
description: readScope.description,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return multiple scopes when multiple slugs match', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([
|
|
readScope.slug,
|
|
writeScope.slug,
|
|
deleteScope.slug,
|
|
]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(3);
|
|
expect(scopes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ slug: readScope.slug }),
|
|
expect.objectContaining({ slug: writeScope.slug }),
|
|
expect.objectContaining({ slug: deleteScope.slug }),
|
|
]),
|
|
);
|
|
|
|
// Verify adminScope is NOT included
|
|
expect(scopes.find((s) => s.slug === adminScope.slug)).toBeUndefined();
|
|
});
|
|
|
|
it('should return all existing scopes when all slugs match', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([
|
|
readScope.slug,
|
|
writeScope.slug,
|
|
deleteScope.slug,
|
|
adminScope.slug,
|
|
]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(4);
|
|
expect(scopes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ slug: readScope.slug }),
|
|
expect.objectContaining({ slug: writeScope.slug }),
|
|
expect.objectContaining({ slug: deleteScope.slug }),
|
|
expect.objectContaining({ slug: adminScope.slug }),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('partial matches', () => {
|
|
it('should return only existing scopes when some slugs do not exist', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope, writeScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([
|
|
readScope.slug,
|
|
'non-existent:scope:1' as ScopeType,
|
|
writeScope.slug,
|
|
'non-existent:scope:2' as ScopeType,
|
|
]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(2);
|
|
expect(scopes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ slug: readScope.slug }),
|
|
expect.objectContaining({ slug: writeScope.slug }),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should return empty array when none of the slugs exist', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
await createTestScopes(); // Create scopes but don't query for them
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([
|
|
'non-existent:scope:1' as ScopeType,
|
|
'non-existent:scope:2' as ScopeType,
|
|
'non-existent:scope:3' as ScopeType,
|
|
]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('duplicate handling', () => {
|
|
it('should return each scope only once when slug array contains duplicates', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope, writeScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([
|
|
readScope.slug,
|
|
writeScope.slug,
|
|
readScope.slug, // Duplicate
|
|
writeScope.slug, // Duplicate
|
|
readScope.slug, // Another duplicate
|
|
]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(2);
|
|
expect(scopes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ slug: readScope.slug }),
|
|
expect.objectContaining({ slug: writeScope.slug }),
|
|
]),
|
|
);
|
|
|
|
// Verify no duplicates in result
|
|
const slugs = scopes.map((s) => s.slug);
|
|
const uniqueSlugs = [...new Set(slugs)];
|
|
expect(slugs).toEqual(uniqueSlugs);
|
|
});
|
|
|
|
it('should handle mix of valid, invalid, and duplicate slugs', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([
|
|
readScope.slug,
|
|
'invalid:scope:1' as ScopeType,
|
|
readScope.slug, // Duplicate valid
|
|
'invalid:scope:2' as ScopeType,
|
|
'invalid:scope:1' as ScopeType, // Duplicate invalid
|
|
readScope.slug, // Another duplicate valid
|
|
]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(1);
|
|
expect(scopes[0]).toEqual(expect.objectContaining({ slug: readScope.slug }));
|
|
});
|
|
});
|
|
|
|
describe('large datasets', () => {
|
|
it('should handle querying for many scopes efficiently', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const createdScopes = await createScopes(50, { description: 'Bulk test scope' });
|
|
const slugsToQuery = createdScopes.slice(0, 25).map((s) => s.slug);
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const startTime = Date.now();
|
|
const scopes = await scopeRepository.findByList(slugsToQuery);
|
|
const endTime = Date.now();
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(25);
|
|
expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
|
|
|
|
// Verify all requested scopes are returned
|
|
const returnedSlugs = scopes.map((s) => s.slug).sort();
|
|
const expectedSlugs = slugsToQuery.sort();
|
|
expect(returnedSlugs).toEqual(expectedSlugs);
|
|
});
|
|
|
|
it('should maintain data integrity with complex scope structures', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const complexScopes = await Promise.all([
|
|
createScope({
|
|
slug: 'complex:scope:with:colons' as ScopeType,
|
|
displayName: 'Complex Scope With Colons',
|
|
description: 'A scope with multiple colons in the slug',
|
|
}),
|
|
createScope({
|
|
slug: 'scope-with-dashes' as ScopeType,
|
|
displayName: 'Scope With Dashes',
|
|
description: 'A scope with dashes',
|
|
}),
|
|
createScope({
|
|
slug: 'scope_with_underscores' as ScopeType,
|
|
displayName: 'Scope With Underscores',
|
|
description: 'A scope with underscores',
|
|
}),
|
|
]);
|
|
|
|
const slugsToQuery = complexScopes.map((s) => s.slug);
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList(slugsToQuery);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(3);
|
|
|
|
for (const originalScope of complexScopes) {
|
|
const foundScope = scopes.find((s) => s.slug === originalScope.slug);
|
|
expect(foundScope).toBeDefined();
|
|
expect(foundScope!.displayName).toBe(originalScope.displayName);
|
|
expect(foundScope!.description).toBe(originalScope.description);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('edge cases and validation', () => {
|
|
it('should handle null and undefined values gracefully', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const scope = await createScope({
|
|
slug: 'scope-with-nulls' as ScopeType,
|
|
displayName: null,
|
|
description: null,
|
|
});
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes = await scopeRepository.findByList([scope.slug]);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes).toHaveLength(1);
|
|
expect(scopes[0].slug).toBe(scope.slug);
|
|
expect(scopes[0].displayName).toBeNull();
|
|
expect(scopes[0].description).toBeNull();
|
|
});
|
|
|
|
it('should preserve order consistency across multiple queries', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope, writeScope, deleteScope, adminScope } = await createTestScopes();
|
|
const slugsToQuery = [adminScope.slug, readScope.slug, deleteScope.slug, writeScope.slug];
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopes1 = await scopeRepository.findByList(slugsToQuery);
|
|
const scopes2 = await scopeRepository.findByList(slugsToQuery);
|
|
const scopes3 = await scopeRepository.findByList(slugsToQuery);
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopes1).toHaveLength(4);
|
|
expect(scopes2).toHaveLength(4);
|
|
expect(scopes3).toHaveLength(4);
|
|
|
|
// All queries should return the same scopes (though order may vary due to SQL implementation)
|
|
const getSortedSlugs = (scopeList: Scope[]) => scopeList.map((s) => s.slug).sort();
|
|
|
|
expect(getSortedSlugs(scopes1)).toEqual(getSortedSlugs(scopes2));
|
|
expect(getSortedSlugs(scopes2)).toEqual(getSortedSlugs(scopes3));
|
|
});
|
|
|
|
it('should verify database state remains consistent after queries', async () => {
|
|
//
|
|
// ARRANGE
|
|
//
|
|
const { readScope, writeScope } = await createTestScopes();
|
|
|
|
//
|
|
// ACT
|
|
//
|
|
const scopesBefore = await scopeRepository.find();
|
|
await scopeRepository.findByList([readScope.slug, writeScope.slug]);
|
|
const scopesAfter = await scopeRepository.find();
|
|
|
|
//
|
|
// ASSERT
|
|
//
|
|
expect(scopesBefore).toHaveLength(4); // readScope, writeScope, deleteScope, adminScope
|
|
expect(scopesAfter).toHaveLength(4);
|
|
expect(scopesBefore.map((s) => s.slug).sort()).toEqual(
|
|
scopesAfter.map((s) => s.slug).sort(),
|
|
);
|
|
|
|
// Verify specific scopes are unchanged
|
|
const readScopeBefore = scopesBefore.find((s) => s.slug === readScope.slug);
|
|
const readScopeAfter = scopesAfter.find((s) => s.slug === readScope.slug);
|
|
expect(readScopeBefore).toEqual(readScopeAfter);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('findByListOrFail()', () => {
|
|
describe('success path', () => {
|
|
it('should return empty array when given empty slug array', async () => {
|
|
await createTestScopes();
|
|
|
|
const scopes = await scopeRepository.findByListOrFail([]);
|
|
|
|
expect(scopes).toEqual([]);
|
|
});
|
|
|
|
it('should return single scope when one slug matches', async () => {
|
|
const { readScope } = await createTestScopes();
|
|
|
|
const scopes = await scopeRepository.findByListOrFail([readScope.slug]);
|
|
|
|
expect(scopes).toHaveLength(1);
|
|
expect(scopes[0]).toEqual(
|
|
expect.objectContaining({
|
|
slug: readScope.slug,
|
|
displayName: readScope.displayName,
|
|
description: readScope.description,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should return multiple scopes when multiple slugs match', async () => {
|
|
const { readScope, writeScope, deleteScope } = await createTestScopes();
|
|
|
|
const scopes = await scopeRepository.findByListOrFail([
|
|
readScope.slug,
|
|
writeScope.slug,
|
|
deleteScope.slug,
|
|
]);
|
|
|
|
expect(scopes).toHaveLength(3);
|
|
expect(scopes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ slug: readScope.slug }),
|
|
expect.objectContaining({ slug: writeScope.slug }),
|
|
expect.objectContaining({ slug: deleteScope.slug }),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('error path (invalid scopes)', () => {
|
|
it('should throw when one slug does not exist', async () => {
|
|
await createTestScopes();
|
|
|
|
await expect(
|
|
scopeRepository.findByListOrFail(['non-existent:scope' as ScopeType]),
|
|
).rejects.toThrow('The following scopes are invalid: non-existent:scope');
|
|
});
|
|
|
|
it('should throw when some slugs do not exist and list invalid scopes in message', async () => {
|
|
const { readScope } = await createTestScopes();
|
|
|
|
await expect(
|
|
scopeRepository.findByListOrFail([
|
|
readScope.slug,
|
|
'non-existent:1' as ScopeType,
|
|
'non-existent:2' as ScopeType,
|
|
]),
|
|
).rejects.toThrow('The following scopes are invalid: non-existent:1, non-existent:2');
|
|
});
|
|
|
|
it('should throw when all slugs do not exist', async () => {
|
|
await createTestScopes();
|
|
|
|
await expect(
|
|
scopeRepository.findByListOrFail([
|
|
'non-existent:1' as ScopeType,
|
|
'non-existent:2' as ScopeType,
|
|
]),
|
|
).rejects.toThrow('The following scopes are invalid: non-existent:1, non-existent:2');
|
|
});
|
|
});
|
|
});
|
|
});
|