Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,27 @@ describe('Authorization', () => {
});
});

it('invokes voter functions declared in the authorization metadata', async () => {
givenRequestContext();
setupAuthorization({});
const result = await invokeMethod(controller, 'approveOrder', reqCtx, [
'order-01',
]);
expect(result).to.equal('order-01');
});

it('denies access when a voter function in the metadata denies', async () => {
givenRequestContext();
setupAuthorization({});
const result = invokeMethod(controller, 'rejectOrder', reqCtx, [
'order-01',
]);
await expect(result).to.be.rejectedWith({
statusCode: 403,
message: 'Access denied',
});
});

async function testCancelOrder(matrix: DecisionMatrix) {
let index = 0;
for (const row of matrix) {
Expand Down Expand Up @@ -191,6 +212,16 @@ describe('Authorization', () => {
async placeOrder(productId: string, quantity: number) {
return '001';
}

@authorize({voters: [async () => Allow]})
async approveOrder(orderId: string) {
return orderId;
}

@authorize({voters: [async () => Deny]})
async rejectOrder(orderId: string) {
return orderId;
}
}

function setupAuthorization(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,5 +221,15 @@ describe('Authentication', () => {
scopes: ['s1', 's2'],
});
});

it('throws an error when used on a property', () => {
expect(() => {
class TestClass {
@authorize({allowedRoles: ['ADMIN']})
prop: string;
}
return TestClass;
}).to.throw(/cannot be used on a property: TestClass\.prototype\.prop/);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// License text available at https://opensource.org/licenses/MIT

import {expect} from '@loopback/testlab';
import debugFactory from 'debug';
import {resolve} from 'path';
import {ArtifactOptions, BaseArtifactBooter} from '../../..';

Expand All @@ -26,6 +27,13 @@ describe('base-artifact booter unit tests', () => {
expect(booterInst.extensions).to.be.eql(['.test.js']);
});

it(`defaults 'dirs' / 'extensions' to empty arrays if not provided`, async () => {
const booterInst = givenBaseBooter({});
await booterInst.configure();
expect(booterInst.dirs).to.eql([]);
expect(booterInst.extensions).to.eql([]);
});

it(`creates and sets 'glob' pattern`, async () => {
const booterInst = givenBaseBooter();
const expected = '/{test,test2}/*@(.test.js|test2.js)';
Expand All @@ -52,6 +60,14 @@ describe('base-artifact booter unit tests', () => {
});
});

describe('artifactName', () => {
it('is derived from the class name without the Booter suffix', () => {
class WidgetBooter extends BaseArtifactBooter {}
const booterInst = new WidgetBooter(__dirname, TEST_OPTIONS);
expect(booterInst.artifactName).to.equal('Widget');
});
});

describe('discover()', () => {
it(`sets 'discovered' property`, async () => {
const booterInst = givenBaseBooter();
Expand All @@ -60,6 +76,38 @@ describe('base-artifact booter unit tests', () => {
await booterInst.discover();
expect(booterInst.discovered).to.eql([]);
});

it('discovers files matching the glob', async () => {
const booterInst = givenBaseBooter(
TEST_OPTIONS,
resolve(__dirname, '../../fixtures'),
);
booterInst.glob = '/multiple.artifact.js';
await booterInst.discover();
expect(booterInst.discovered).to.eql([
resolve(__dirname, '../../fixtures/multiple.artifact.js'),
]);
});

it('discovers files when debug logging is enabled', async () => {
const namespaces = debugFactory.disable();
const originalLog = debugFactory.log;
// Keep the test output clean
debugFactory.log = () => {};
debugFactory.enable('loopback:boot:base-artifact-booter');
try {
const booterInst = givenBaseBooter(
TEST_OPTIONS,
resolve(__dirname, '../../fixtures'),
);
booterInst.glob = '/multiple.artifact.js';
await booterInst.discover();
expect(booterInst.discovered).to.have.lengthOf(1);
} finally {
debugFactory.log = originalLog;
debugFactory.enable(namespaces);
}
});
});

describe('load()', () => {
Expand All @@ -74,7 +122,10 @@ describe('base-artifact booter unit tests', () => {
});
});

function givenBaseBooter(options?: ArtifactOptions) {
return new BaseArtifactBooter(__dirname, options ?? TEST_OPTIONS);
function givenBaseBooter(
options?: ArtifactOptions,
projectRoot: string = __dirname,
) {
return new BaseArtifactBooter(projectRoot, options ?? TEST_OPTIONS);
}
});
50 changes: 50 additions & 0 deletions packages/boot/src/__tests__/unit/booters/model-api.booter.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved.
// Node module: @loopback/boot
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {ModelApiBuilder} from '@loopback/model-api-builder';
import {ApplicationWithRepositories} from '@loopback/repository';
import {expect} from '@loopback/testlab';
import {ModelApiBooter} from '../../..';

describe('model API booter unit tests', () => {
describe('getApiBuilderForPattern()', () => {
it('returns the builder matching the pattern', async () => {
const builder = givenBuilder('rest');
const booter = givenBooter([givenBuilder('other'), builder]);

expect(await booter.getApiBuilderForPattern('rest')).to.equal(builder);
});

it('lists the available patterns if no builder matches', async () => {
const booter = givenBooter([givenBuilder('rest'), givenBuilder('grpc')]);

await expect(
booter.getApiBuilderForPattern('missing'),
).to.be.rejectedWith(
'Unsupported API pattern "missing". Available patterns: rest, grpc',
);
});

it('reports <none> if there are no builders registered', async () => {
const booter = givenBooter([]);

await expect(booter.getApiBuilderForPattern('rest')).to.be.rejectedWith(
'Unsupported API pattern "rest". Available patterns: <none>',
);
});
});

function givenBuilder(pattern: string): ModelApiBuilder {
return {pattern, build: async () => {}};
}

function givenBooter(builders: ModelApiBuilder[]) {
return new ModelApiBooter(
{} as ApplicationWithRepositories,
__dirname,
async () => builders,
);
}
});
63 changes: 63 additions & 0 deletions packages/boot/src/__tests__/unit/booters/model.booter.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved.
// Node module: @loopback/boot
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Application} from '@loopback/core';
import {
ApplicationWithRepositories,
RepositoryMixin,
} from '@loopback/repository';
import {expect, TestSandbox} from '@loopback/testlab';
import {resolve} from 'path';
import {ModelBooter, ModelDefaults} from '../../..';

describe('model booter unit tests', () => {
const sandbox = new TestSandbox(resolve(__dirname, '../../../.sandbox'));

const MODELS_PREFIX = 'models';
const MODELS_TAG = 'model';

class AppWithRepo extends RepositoryMixin(Application) {}

let app: ApplicationWithRepositories;

beforeEach('reset sandbox', () => sandbox.reset());
beforeEach(getApp);

it(`uses ModelDefaults for 'options' if none are given`, () => {
const booterInst = new ModelBooter(app, sandbox.path);
expect(booterInst.options).to.deepEqual(ModelDefaults);
});

it('overrides defaults with provided options and uses defaults for the rest', () => {
const options = {
dirs: ['test'],
extensions: ['.ext1'],
};
const expected = Object.assign({}, options, {
nested: ModelDefaults.nested,
});

const booterInst = new ModelBooter(app, sandbox.path, options);
expect(booterInst.options).to.deepEqual(expected);
});

it('binds model classes during the load phase', async () => {
await sandbox.copyFile(
resolve(__dirname, '../../fixtures/product.model.js'),
'models/product.model.js',
);
const booterInst = new ModelBooter(app, sandbox.path);

booterInst.discovered = [resolve(sandbox.path, 'models/product.model.js')];
await booterInst.load();

const keys = app.findByTag(MODELS_TAG).map(b => b.key);
expect(keys).to.eql([`${MODELS_PREFIX}.Product`]);
});

function getApp() {
app = new AppWithRepo();
}
});
19 changes: 19 additions & 0 deletions packages/boot/src/__tests__/unit/bootstrapper.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ describe('boot-strapper unit tests', () => {
expect(phasesCalled).to.eql(['AnotherBooter:configure']);
});

it('reuses the booter instances of a context passed to boot()', async () => {
const ctx = await bootstrapper.boot({
filter: {booters: ['TestBooter'], phases: ['configure']},
});
const booterInst = await ctx.get<TestBooter>(booterKey);
expect(booterInst.phasesCalled).to.eql(['TestBooter:configure']);

const sameCtx = await bootstrapper.boot(
{filter: {booters: ['TestBooter'], phases: ['load']}},
ctx,
);

expect(sameCtx).to.equal(ctx);
expect(booterInst.phasesCalled).to.eql([
'TestBooter:configure',
'TestBooter:load',
]);
});

it('only runs phases passed in via BootOptions.filter.phases', async () => {
const ctx = await bootstrapper.boot({filter: {phases: ['configure']}});
const booterInst = await ctx.get<TestBooter>(booterKey);
Expand Down
16 changes: 16 additions & 0 deletions packages/boot/src/__tests__/unit/mixins/boot.mixin.unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ describe('BootMixin unit tests', () => {
expect(boundComponent).to.containEql('components.BootComponent');
});

it('binds BOOT_OPTIONS to an empty object by default', async () => {
expect(await app.get(BootBindings.BOOT_OPTIONS)).to.eql({});
});

it('binds BOOT_OPTIONS to the bootOptions of the app', async () => {
app.bootOptions = {controllers: {dirs: ['ctrl']}};
expect(await app.get(BootBindings.BOOT_OPTIONS)).to.eql({
controllers: {dirs: ['ctrl']},
});
});

it('binds PROJECT_ROOT to the projectRoot of the app', async () => {
app.projectRoot = '/my/project';
expect(await app.get(BootBindings.PROJECT_ROOT)).to.equal('/my/project');
});

it('binds booter from app.booters()', async () => {
app.booters(TestBooter);
const booter = await app.get(`${BootBindings.BOOTERS}.TestBooter`);
Expand Down
48 changes: 48 additions & 0 deletions packages/booter-lb3app/src/__tests__/unit/lb3app.booter.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright IBM Corp. and LoopBack contributors 2025. All Rights Reserved.
// Node module: @loopback/booter-lb3app
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

import {Application} from '@loopback/core';
import {RestApplication} from '@loopback/rest';
import {expect} from '@loopback/testlab';
import {Lb3AppBooter} from '../../lb3app.booter';

describe('Lb3AppBooter (unit)', () => {
describe('constructor', () => {
it('throws if the application does not support mountExpressRouter', () => {
const app = new Application() as unknown as RestApplication;

expect(() => new Lb3AppBooter(app, '/project-root')).to.throw(
'Lb3AppBooter requires RestApplication with mountExpressRouter API',
);
});

it('merges custom options with the defaults', () => {
const app = {mountExpressRouter: () => {}} as unknown as RestApplication;

const booter = new Lb3AppBooter(app, '/project-root', {
path: '../custom/server',
});

expect(booter.options).to.eql({
path: '../custom/server',
mode: 'fullApp',
restApiRoot: '/api',
});
});
});

describe('configure()', () => {
it('resolves the lb3 app path against the project root', async () => {
const app = {mountExpressRouter: () => {}} as unknown as RestApplication;
const booter = new Lb3AppBooter(app, '/project-root', {
path: '../custom/server',
});

await booter.configure!();

expect(booter.appPath).to.equal('/custom/server');
});
});
});
Loading