@tailor-platform/erp-kit 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +196 -28
- package/dist/cli.js +914 -0
- package/package.json +67 -8
- package/schemas/app-compose/actors.yml +34 -0
- package/schemas/app-compose/business-flow.yml +50 -0
- package/schemas/app-compose/requirements.yml +33 -0
- package/schemas/app-compose/resolver.yml +47 -0
- package/schemas/app-compose/screen.yml +81 -0
- package/schemas/app-compose/story.yml +67 -0
- package/schemas/module/command.yml +52 -0
- package/schemas/module/feature.yml +58 -0
- package/schemas/module/model.yml +70 -0
- package/schemas/module/module.yml +50 -0
- package/skills/1-module-docs/SKILL.md +111 -0
- package/skills/1-module-docs/references/structure.md +22 -0
- package/skills/2-module-feature-breakdown/SKILL.md +72 -0
- package/skills/2-module-feature-breakdown/references/commands.md +48 -0
- package/skills/2-module-feature-breakdown/references/models.md +29 -0
- package/skills/2-module-feature-breakdown/references/structure.md +22 -0
- package/skills/3-module-doc-review/SKILL.md +236 -0
- package/skills/3-module-doc-review/references/commands.md +54 -0
- package/skills/3-module-doc-review/references/models.md +29 -0
- package/skills/3-module-doc-review/references/testing.md +37 -0
- package/skills/4-module-tdd-implementation/SKILL.md +74 -0
- package/skills/4-module-tdd-implementation/references/commands.md +45 -0
- package/skills/4-module-tdd-implementation/references/db-relations.md +69 -0
- package/skills/4-module-tdd-implementation/references/errors.md +7 -0
- package/skills/4-module-tdd-implementation/references/exports.md +8 -0
- package/skills/4-module-tdd-implementation/references/models.md +30 -0
- package/skills/4-module-tdd-implementation/references/structure.md +22 -0
- package/skills/4-module-tdd-implementation/references/testing.md +37 -0
- package/skills/5-module-implementation-review/SKILL.md +408 -0
- package/skills/5-module-implementation-review/references/commands.md +45 -0
- package/skills/5-module-implementation-review/references/errors.md +7 -0
- package/skills/5-module-implementation-review/references/exports.md +8 -0
- package/skills/5-module-implementation-review/references/models.md +30 -0
- package/skills/5-module-implementation-review/references/testing.md +29 -0
- package/skills/app-compose-1-requirement-analysis/SKILL.md +89 -0
- package/skills/app-compose-1-requirement-analysis/references/structure.md +27 -0
- package/skills/app-compose-2-requirements-breakdown/SKILL.md +95 -0
- package/skills/app-compose-2-requirements-breakdown/references/screen-detailview.md +106 -0
- package/skills/app-compose-2-requirements-breakdown/references/screen-form.md +139 -0
- package/skills/app-compose-2-requirements-breakdown/references/screen-listview.md +153 -0
- package/skills/app-compose-2-requirements-breakdown/references/structure.md +27 -0
- package/skills/app-compose-3-doc-review/SKILL.md +116 -0
- package/skills/app-compose-3-doc-review/references/structure.md +27 -0
- package/skills/app-compose-4-design-mock/SKILL.md +256 -0
- package/skills/app-compose-4-design-mock/references/component.md +50 -0
- package/skills/app-compose-4-design-mock/references/screen-detailview.md +106 -0
- package/skills/app-compose-4-design-mock/references/screen-form.md +139 -0
- package/skills/app-compose-4-design-mock/references/screen-listview.md +153 -0
- package/skills/app-compose-4-design-mock/references/structure.md +27 -0
- package/skills/app-compose-5-design-mock-review/SKILL.md +290 -0
- package/skills/app-compose-5-design-mock-review/references/component.md +50 -0
- package/skills/app-compose-5-design-mock-review/references/screen-detailview.md +106 -0
- package/skills/app-compose-5-design-mock-review/references/screen-form.md +139 -0
- package/skills/app-compose-5-design-mock-review/references/screen-listview.md +153 -0
- package/skills/app-compose-6-implementation-spec/SKILL.md +127 -0
- package/skills/app-compose-6-implementation-spec/references/auth.md +72 -0
- package/skills/app-compose-6-implementation-spec/references/structure.md +27 -0
- package/skills/mock-scenario/SKILL.md +118 -0
- package/src/app.ts +1 -0
- package/src/cli.ts +120 -0
- package/src/commands/check.test.ts +30 -0
- package/src/commands/check.ts +66 -0
- package/src/commands/init.test.ts +88 -0
- package/src/commands/init.ts +120 -0
- package/src/commands/mock/index.ts +53 -0
- package/src/commands/mock/start.ts +179 -0
- package/src/commands/mock/validate.test.ts +185 -0
- package/src/commands/mock/validate.ts +198 -0
- package/src/commands/scaffold.test.ts +76 -0
- package/src/commands/scaffold.ts +119 -0
- package/src/commands/sync-check.test.ts +125 -0
- package/src/commands/sync-check.ts +182 -0
- package/src/integration.test.ts +63 -0
- package/src/mdschema.ts +48 -0
- package/src/mockServer.ts +55 -0
- package/src/module.ts +86 -0
- package/src/modules/accounting/.gitkeep +0 -0
- package/src/modules/coa-management/.gitkeep +0 -0
- package/src/modules/inventory/.gitkeep +0 -0
- package/src/modules/manufacturing/.gitkeep +0 -0
- package/src/modules/primitives/README.md +39 -0
- package/src/modules/primitives/command/activateCategory.test.ts +75 -0
- package/src/modules/primitives/command/activateCategory.ts +50 -0
- package/src/modules/primitives/command/activateCurrency.test.ts +70 -0
- package/src/modules/primitives/command/activateCurrency.ts +50 -0
- package/src/modules/primitives/command/activateUnit.test.ts +53 -0
- package/src/modules/primitives/command/activateUnit.ts +50 -0
- package/src/modules/primitives/command/convertAmount.test.ts +275 -0
- package/src/modules/primitives/command/convertAmount.ts +126 -0
- package/src/modules/primitives/command/convertQuantity.test.ts +219 -0
- package/src/modules/primitives/command/convertQuantity.ts +73 -0
- package/src/modules/primitives/command/createCategory.test.ts +126 -0
- package/src/modules/primitives/command/createCategory.ts +89 -0
- package/src/modules/primitives/command/createCurrency.test.ts +191 -0
- package/src/modules/primitives/command/createCurrency.ts +77 -0
- package/src/modules/primitives/command/createExchangeRate.test.ts +216 -0
- package/src/modules/primitives/command/createExchangeRate.ts +91 -0
- package/src/modules/primitives/command/createUnit.test.ts +214 -0
- package/src/modules/primitives/command/createUnit.ts +88 -0
- package/src/modules/primitives/command/deactivateCategory.test.ts +97 -0
- package/src/modules/primitives/command/deactivateCategory.ts +62 -0
- package/src/modules/primitives/command/deactivateCurrency.test.ts +85 -0
- package/src/modules/primitives/command/deactivateCurrency.ts +55 -0
- package/src/modules/primitives/command/deactivateUnit.test.ts +78 -0
- package/src/modules/primitives/command/deactivateUnit.ts +62 -0
- package/src/modules/primitives/command/setBaseCurrency.test.ts +98 -0
- package/src/modules/primitives/command/setBaseCurrency.ts +74 -0
- package/src/modules/primitives/command/setReferenceUnit.test.ts +108 -0
- package/src/modules/primitives/command/setReferenceUnit.ts +84 -0
- package/src/modules/primitives/db/currency.ts +30 -0
- package/src/modules/primitives/db/exchangeRate.ts +28 -0
- package/src/modules/primitives/db/unit.ts +32 -0
- package/src/modules/primitives/db/uomCategory.ts +32 -0
- package/src/modules/primitives/docs/commands/ActivateCategory.md +34 -0
- package/src/modules/primitives/docs/commands/ActivateCurrency.md +33 -0
- package/src/modules/primitives/docs/commands/ActivateUnit.md +34 -0
- package/src/modules/primitives/docs/commands/ConvertAmount.md +50 -0
- package/src/modules/primitives/docs/commands/ConvertQuantity.md +43 -0
- package/src/modules/primitives/docs/commands/CreateCategory.md +44 -0
- package/src/modules/primitives/docs/commands/CreateCurrency.md +47 -0
- package/src/modules/primitives/docs/commands/CreateExchangeRate.md +48 -0
- package/src/modules/primitives/docs/commands/CreateUnit.md +48 -0
- package/src/modules/primitives/docs/commands/DeactivateCategory.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateCurrency.md +38 -0
- package/src/modules/primitives/docs/commands/DeactivateUnit.md +38 -0
- package/src/modules/primitives/docs/commands/SetBaseCurrency.md +39 -0
- package/src/modules/primitives/docs/commands/SetReferenceUnit.md +43 -0
- package/src/modules/primitives/docs/features/currency-definitions.md +55 -0
- package/src/modules/primitives/docs/features/exchange-rates.md +61 -0
- package/src/modules/primitives/docs/features/unit-conversion.md +66 -0
- package/src/modules/primitives/docs/features/uom-categories.md +52 -0
- package/src/modules/primitives/docs/models/Currency.md +45 -0
- package/src/modules/primitives/docs/models/ExchangeRate.md +33 -0
- package/src/modules/primitives/docs/models/Unit.md +46 -0
- package/src/modules/primitives/docs/models/UoMCategory.md +44 -0
- package/src/modules/primitives/generated/kysely-tailordb.ts +95 -0
- package/src/modules/primitives/index.ts +40 -0
- package/src/modules/primitives/lib/errors.ts +138 -0
- package/src/modules/primitives/lib/types.ts +20 -0
- package/src/modules/primitives/module.ts +66 -0
- package/src/modules/primitives/permissions.ts +18 -0
- package/src/modules/primitives/tailor.config.ts +11 -0
- package/src/modules/primitives/testing/fixtures.ts +161 -0
- package/src/modules/product-management/.gitkeep +0 -0
- package/src/modules/purchase/.gitkeep +0 -0
- package/src/modules/sales/.gitkeep +0 -0
- package/src/modules/shared/createContext.test.ts +39 -0
- package/src/modules/shared/createContext.ts +15 -0
- package/src/modules/shared/defineCommand.test.ts +42 -0
- package/src/modules/shared/defineCommand.ts +19 -0
- package/src/modules/shared/definePermissions.test.ts +146 -0
- package/src/modules/shared/definePermissions.ts +94 -0
- package/src/modules/shared/entityTypes.ts +15 -0
- package/src/modules/shared/errors.ts +22 -0
- package/src/modules/shared/index.ts +1 -0
- package/src/modules/shared/internal.ts +13 -0
- package/src/modules/shared/requirePermission.test.ts +47 -0
- package/src/modules/shared/requirePermission.ts +8 -0
- package/src/modules/shared/types.ts +4 -0
- package/src/modules/supplier-management/.gitkeep +0 -0
- package/src/modules/supplier-portal/.gitkeep +0 -0
- package/src/modules/testing/index.ts +120 -0
- package/src/modules/user-management/README.md +38 -0
- package/src/modules/user-management/command/activateUser.test.ts +112 -0
- package/src/modules/user-management/command/activateUser.ts +67 -0
- package/src/modules/user-management/command/assignPermissionToRole.test.ts +119 -0
- package/src/modules/user-management/command/assignPermissionToRole.ts +87 -0
- package/src/modules/user-management/command/assignRoleToUser.test.ts +162 -0
- package/src/modules/user-management/command/assignRoleToUser.ts +93 -0
- package/src/modules/user-management/command/createPermission.test.ts +143 -0
- package/src/modules/user-management/command/createPermission.ts +66 -0
- package/src/modules/user-management/command/createRole.test.ts +115 -0
- package/src/modules/user-management/command/createRole.ts +52 -0
- package/src/modules/user-management/command/createUser.test.ts +198 -0
- package/src/modules/user-management/command/createUser.ts +85 -0
- package/src/modules/user-management/command/deactivateUser.test.ts +112 -0
- package/src/modules/user-management/command/deactivateUser.ts +67 -0
- package/src/modules/user-management/command/logAuditEvent.test.ts +179 -0
- package/src/modules/user-management/command/logAuditEvent.ts +59 -0
- package/src/modules/user-management/command/reactivateUser.test.ts +115 -0
- package/src/modules/user-management/command/reactivateUser.ts +67 -0
- package/src/modules/user-management/command/revokePermissionFromRole.test.ts +112 -0
- package/src/modules/user-management/command/revokePermissionFromRole.ts +81 -0
- package/src/modules/user-management/command/revokeRoleFromUser.test.ts +112 -0
- package/src/modules/user-management/command/revokeRoleFromUser.ts +81 -0
- package/src/modules/user-management/db/auditEvent.ts +47 -0
- package/src/modules/user-management/db/permission.ts +31 -0
- package/src/modules/user-management/db/role.ts +28 -0
- package/src/modules/user-management/db/rolePermission.ts +44 -0
- package/src/modules/user-management/db/user.ts +38 -0
- package/src/modules/user-management/db/userRole.ts +44 -0
- package/src/modules/user-management/docs/commands/ActivateUser.md +36 -0
- package/src/modules/user-management/docs/commands/AssignPermissionToRole.md +39 -0
- package/src/modules/user-management/docs/commands/AssignRoleToUser.md +43 -0
- package/src/modules/user-management/docs/commands/CreatePermission.md +35 -0
- package/src/modules/user-management/docs/commands/CreateRole.md +35 -0
- package/src/modules/user-management/docs/commands/CreateUser.md +41 -0
- package/src/modules/user-management/docs/commands/DeactivateUser.md +38 -0
- package/src/modules/user-management/docs/commands/LogAuditEvent.md +37 -0
- package/src/modules/user-management/docs/commands/ReactivateUser.md +37 -0
- package/src/modules/user-management/docs/commands/RevokePermissionFromRole.md +40 -0
- package/src/modules/user-management/docs/commands/RevokeRoleFromUser.md +40 -0
- package/src/modules/user-management/docs/features/audit-trail.md +80 -0
- package/src/modules/user-management/docs/features/role-based-access-control.md +76 -0
- package/src/modules/user-management/docs/features/user-account-management.md +64 -0
- package/src/modules/user-management/docs/models/AuditEvent.md +34 -0
- package/src/modules/user-management/docs/models/Permission.md +31 -0
- package/src/modules/user-management/docs/models/Role.md +31 -0
- package/src/modules/user-management/docs/models/RolePermission.md +33 -0
- package/src/modules/user-management/docs/models/User.md +47 -0
- package/src/modules/user-management/docs/models/UserRole.md +34 -0
- package/src/modules/user-management/docs/plans/2026-01-30-flattened-permissions-design.md +52 -0
- package/src/modules/user-management/executor/recomputeOnRolePermissionChange.ts +61 -0
- package/src/modules/user-management/generated/enums.ts +24 -0
- package/src/modules/user-management/generated/kysely-tailordb.ts +112 -0
- package/src/modules/user-management/index.ts +32 -0
- package/src/modules/user-management/lib/errors.ts +81 -0
- package/src/modules/user-management/lib/recomputeUserPermissions.ts +53 -0
- package/src/modules/user-management/lib/types.ts +31 -0
- package/src/modules/user-management/module.ts +77 -0
- package/src/modules/user-management/permissions.ts +15 -0
- package/src/modules/user-management/tailor.config.ts +11 -0
- package/src/modules/user-management/testing/fixtures.ts +98 -0
- package/src/schemas.ts +25 -0
- package/src/testing.ts +10 -0
- package/src/util.ts +3 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InsufficientPermissionError } from "./errors";
|
|
3
|
+
import { requirePermission } from "./requirePermission";
|
|
4
|
+
import type { CommandContext } from "./types";
|
|
5
|
+
|
|
6
|
+
describe("requirePermission", () => {
|
|
7
|
+
const ctx: CommandContext = {
|
|
8
|
+
actorId: "user-1",
|
|
9
|
+
permissions: [
|
|
10
|
+
"primitives:createCategory",
|
|
11
|
+
"primitives:activateCategory",
|
|
12
|
+
"inventory:createItem",
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
it("does not throw when permission is present", () => {
|
|
17
|
+
expect(() => requirePermission(ctx, "primitives:createCategory")).not.toThrow();
|
|
18
|
+
expect(() => requirePermission(ctx, "inventory:createItem")).not.toThrow();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("throws InsufficientPermissionError when permission is missing", () => {
|
|
22
|
+
expect(() => requirePermission(ctx, "primitives:deleteCategory")).toThrow(
|
|
23
|
+
InsufficientPermissionError,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("includes actorId and permission key in error", () => {
|
|
28
|
+
try {
|
|
29
|
+
requirePermission(ctx, "orders:createOrder");
|
|
30
|
+
expect.fail("Should have thrown");
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const error = err as InstanceType<typeof InsufficientPermissionError>;
|
|
33
|
+
expect(error.name).toBe("InsufficientPermissionError");
|
|
34
|
+
expect(error.code).toBe("INSUFFICIENT_PERMISSION");
|
|
35
|
+
expect(error.message).toContain("user-1");
|
|
36
|
+
expect(error.message).toContain("orders:createOrder");
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throws when permissions array is empty", () => {
|
|
41
|
+
const emptyCtx: CommandContext = { actorId: "user-2", permissions: [] };
|
|
42
|
+
|
|
43
|
+
expect(() => requirePermission(emptyCtx, "primitives:createCategory")).toThrow(
|
|
44
|
+
InsufficientPermissionError,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CommandContext } from "./types";
|
|
2
|
+
import { InsufficientPermissionError } from "./errors";
|
|
3
|
+
|
|
4
|
+
export function requirePermission(ctx: CommandContext, key: string): void {
|
|
5
|
+
if (!ctx.permissions.includes(key)) {
|
|
6
|
+
throw new InsufficientPermissionError(ctx.actorId, key);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { vi, expect } from "vitest";
|
|
2
|
+
import type { Kysely } from "kysely";
|
|
3
|
+
import { InsufficientPermissionError, type CommandContext, type Command } from "../shared/internal";
|
|
4
|
+
|
|
5
|
+
export type Spy = ReturnType<typeof vi.fn>;
|
|
6
|
+
|
|
7
|
+
export interface QueryBuilder {
|
|
8
|
+
where(...args: unknown[]): QueryBuilder;
|
|
9
|
+
selectAll(...args: unknown[]): QueryBuilder;
|
|
10
|
+
select(...args: unknown[]): QueryBuilder;
|
|
11
|
+
innerJoin(...args: unknown[]): QueryBuilder;
|
|
12
|
+
values(...args: unknown[]): QueryBuilder;
|
|
13
|
+
returningAll(...args: unknown[]): QueryBuilder;
|
|
14
|
+
set(...args: unknown[]): QueryBuilder;
|
|
15
|
+
orderBy(...args: unknown[]): QueryBuilder;
|
|
16
|
+
executeTakeFirst(): unknown;
|
|
17
|
+
executeTakeFirstOrThrow(): unknown;
|
|
18
|
+
execute(): unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MockDbSpies {
|
|
22
|
+
select: Spy;
|
|
23
|
+
update: Spy;
|
|
24
|
+
insert: Spy;
|
|
25
|
+
delete: Spy;
|
|
26
|
+
values: Spy;
|
|
27
|
+
set: Spy;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeBuilder(executeSpy: Spy, valuesSpy: Spy, setSpy: Spy): QueryBuilder {
|
|
31
|
+
const builder: QueryBuilder = {} as QueryBuilder;
|
|
32
|
+
builder.where = () => builder;
|
|
33
|
+
builder.selectAll = () => builder;
|
|
34
|
+
builder.select = () => builder;
|
|
35
|
+
builder.innerJoin = () => builder;
|
|
36
|
+
builder.values = (...args: unknown[]) => {
|
|
37
|
+
valuesSpy(...args);
|
|
38
|
+
return builder;
|
|
39
|
+
};
|
|
40
|
+
builder.returningAll = () => builder;
|
|
41
|
+
builder.set = (...args: unknown[]) => {
|
|
42
|
+
setSpy(...args);
|
|
43
|
+
return builder;
|
|
44
|
+
};
|
|
45
|
+
builder.orderBy = () => builder;
|
|
46
|
+
builder.executeTakeFirst = () => executeSpy();
|
|
47
|
+
builder.executeTakeFirstOrThrow = () => executeSpy();
|
|
48
|
+
builder.execute = () => executeSpy();
|
|
49
|
+
return builder;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createMockDb<T = Kysely<Record<string, unknown>>>(): {
|
|
53
|
+
db: T;
|
|
54
|
+
spies: MockDbSpies;
|
|
55
|
+
reset: () => void;
|
|
56
|
+
} {
|
|
57
|
+
const select = vi.fn();
|
|
58
|
+
const update = vi.fn();
|
|
59
|
+
const insert = vi.fn();
|
|
60
|
+
const del = vi.fn();
|
|
61
|
+
const values = vi.fn();
|
|
62
|
+
const set = vi.fn();
|
|
63
|
+
|
|
64
|
+
const db = {
|
|
65
|
+
selectFrom: () => makeBuilder(select, values, set),
|
|
66
|
+
updateTable: () => makeBuilder(update, values, set),
|
|
67
|
+
insertInto: () => makeBuilder(insert, values, set),
|
|
68
|
+
deleteFrom: () => makeBuilder(del, values, set),
|
|
69
|
+
} as unknown as T;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
db,
|
|
73
|
+
spies: { select, update, insert, delete: del, values, set },
|
|
74
|
+
reset: () => {
|
|
75
|
+
select.mockReset();
|
|
76
|
+
update.mockReset();
|
|
77
|
+
insert.mockReset();
|
|
78
|
+
del.mockReset();
|
|
79
|
+
values.mockReset();
|
|
80
|
+
set.mockReset();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function testNotFound<TInput>(
|
|
86
|
+
command: Command<TInput, unknown>,
|
|
87
|
+
input: TInput,
|
|
88
|
+
ctx: CommandContext,
|
|
89
|
+
ErrorClass: abstract new (...args: never[]) => Error,
|
|
90
|
+
) {
|
|
91
|
+
return async () => {
|
|
92
|
+
const { db, spies } = createMockDb();
|
|
93
|
+
spies.select.mockReturnValue(undefined);
|
|
94
|
+
await expect(command(db, input, ctx)).rejects.toBeInstanceOf(ErrorClass);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function testPermissionDenied<TInput>(command: Command<TInput, unknown>, input: TInput) {
|
|
99
|
+
return async () => {
|
|
100
|
+
const { db } = createMockDb();
|
|
101
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
102
|
+
await expect(command(db, input, denied)).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function testIdempotent<TInput>(
|
|
107
|
+
command: Command<TInput, Record<string, unknown>>,
|
|
108
|
+
input: TInput,
|
|
109
|
+
ctx: CommandContext,
|
|
110
|
+
existingEntity: Record<string, unknown>,
|
|
111
|
+
entityKey: string,
|
|
112
|
+
) {
|
|
113
|
+
return async () => {
|
|
114
|
+
const { db, spies } = createMockDb();
|
|
115
|
+
spies.select.mockReturnValue(existingEntity);
|
|
116
|
+
const result = await command(db, input, ctx);
|
|
117
|
+
expect(result[entityKey]).toEqual(existingEntity);
|
|
118
|
+
expect(spies.update).not.toHaveBeenCalled();
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# README
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
The User Management module provides identity and access control capabilities for the ERP system. It manages user accounts through their lifecycle (creation, activation, deactivation) and implements Role-Based Access Control (RBAC) for authorization. All security-relevant changes are logged to an immutable audit trail.
|
|
6
|
+
|
|
7
|
+
This module serves as a foundational component that other modules depend on for user identity and authorization checks.
|
|
8
|
+
|
|
9
|
+
## Key Features
|
|
10
|
+
|
|
11
|
+
- **User Account Lifecycle**: Create, activate, deactivate, and reactivate user accounts with clear status transitions (PENDING, ACTIVE, INACTIVE)
|
|
12
|
+
- **Email Uniqueness**: Enforce unique email addresses across all user accounts
|
|
13
|
+
- **Role-Based Access Control**: Define roles that bundle permissions, and assign roles to users for scalable authorization
|
|
14
|
+
- **Permission Management**: Define granular permissions using a `resource:action` format (e.g., `orders:read`, `inventory:write`)
|
|
15
|
+
- **Audit Trail**: Capture immutable records of all security-relevant events for compliance and investigation
|
|
16
|
+
|
|
17
|
+
## Module Scope
|
|
18
|
+
|
|
19
|
+
### In Scope
|
|
20
|
+
|
|
21
|
+
- User CRUD operations with status management (PENDING, ACTIVE, INACTIVE)
|
|
22
|
+
- Role definitions and role-to-user assignments
|
|
23
|
+
- Permission definitions and permission-to-role assignments
|
|
24
|
+
- Audit logging of user lifecycle and authorization changes
|
|
25
|
+
- Status-based access restrictions (e.g., only active users can be assigned roles)
|
|
26
|
+
|
|
27
|
+
### Out of Scope
|
|
28
|
+
|
|
29
|
+
- External identity provider integration (SAML, OIDC, OAuth)
|
|
30
|
+
- Multi-factor authentication (MFA)
|
|
31
|
+
- Single sign-on (SSO) federation
|
|
32
|
+
- Fine-grained attribute-based access control (ABAC)
|
|
33
|
+
- Password storage and authentication mechanisms
|
|
34
|
+
- Session management
|
|
35
|
+
|
|
36
|
+
## Module Dependencies
|
|
37
|
+
|
|
38
|
+
- None (this is a foundational module)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createMockDb, testNotFound, testPermissionDenied } from "../../testing/index";
|
|
3
|
+
import { type CommandContext } from "../../shared/internal";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import { InvalidStatusTransitionError, UserNotFoundError } from "../lib/errors";
|
|
6
|
+
import { activeUser, baseAuditEvent, inactiveUser, pendingUser } from "../testing/fixtures";
|
|
7
|
+
import { activateUser } from "./activateUser";
|
|
8
|
+
|
|
9
|
+
describe("activateUser", () => {
|
|
10
|
+
const ctx: CommandContext = {
|
|
11
|
+
actorId: "test-actor",
|
|
12
|
+
permissions: ["user-management:activateUser"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
it(
|
|
16
|
+
"throws when user does not exist",
|
|
17
|
+
testNotFound(
|
|
18
|
+
activateUser,
|
|
19
|
+
{ userId: "nonexistent-user", actorId: "actor-1" },
|
|
20
|
+
ctx,
|
|
21
|
+
UserNotFoundError,
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
it("throws when user is already ACTIVE", async () => {
|
|
26
|
+
const { db, spies } = createMockDb<DB>();
|
|
27
|
+
spies.select.mockReturnValue(activeUser);
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
activateUser(db, { userId: activeUser.id, actorId: "actor-1" }, ctx),
|
|
31
|
+
).rejects.toBeInstanceOf(InvalidStatusTransitionError);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("throws when user is INACTIVE (use reactivateUser instead)", async () => {
|
|
35
|
+
const { db, spies } = createMockDb<DB>();
|
|
36
|
+
spies.select.mockReturnValue(inactiveUser);
|
|
37
|
+
|
|
38
|
+
await expect(
|
|
39
|
+
activateUser(db, { userId: inactiveUser.id, actorId: "actor-1" }, ctx),
|
|
40
|
+
).rejects.toBeInstanceOf(InvalidStatusTransitionError);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Success cases
|
|
44
|
+
it("activates PENDING user to ACTIVE", async () => {
|
|
45
|
+
const { db, spies } = createMockDb<DB>();
|
|
46
|
+
const activatedUser = {
|
|
47
|
+
...pendingUser,
|
|
48
|
+
status: "ACTIVE" as const,
|
|
49
|
+
updatedAt: new Date("2024-01-15T00:00:00.000Z"),
|
|
50
|
+
};
|
|
51
|
+
const createdAuditEvent = {
|
|
52
|
+
...baseAuditEvent,
|
|
53
|
+
eventType: "USER_ACTIVATED" as const,
|
|
54
|
+
actorId: "actor-1",
|
|
55
|
+
targetId: pendingUser.id,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
spies.select.mockReturnValue(pendingUser);
|
|
59
|
+
spies.update.mockReturnValue(activatedUser);
|
|
60
|
+
spies.insert.mockReturnValue(createdAuditEvent);
|
|
61
|
+
|
|
62
|
+
const result = await activateUser(
|
|
63
|
+
db,
|
|
64
|
+
{
|
|
65
|
+
userId: pendingUser.id,
|
|
66
|
+
actorId: "actor-1",
|
|
67
|
+
},
|
|
68
|
+
ctx,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(result.user.status).toBe("ACTIVE");
|
|
72
|
+
expect(result.user.updatedAt).not.toBeNull();
|
|
73
|
+
expect(spies.update).toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("logs USER_ACTIVATED audit event", async () => {
|
|
77
|
+
const { db, spies } = createMockDb<DB>();
|
|
78
|
+
const activatedUser = {
|
|
79
|
+
...pendingUser,
|
|
80
|
+
status: "ACTIVE" as const,
|
|
81
|
+
updatedAt: new Date("2024-01-15T00:00:00.000Z"),
|
|
82
|
+
};
|
|
83
|
+
const createdAuditEvent = {
|
|
84
|
+
...baseAuditEvent,
|
|
85
|
+
eventType: "USER_ACTIVATED" as const,
|
|
86
|
+
actorId: "actor-1",
|
|
87
|
+
targetId: pendingUser.id,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
spies.select.mockReturnValue(pendingUser);
|
|
91
|
+
spies.update.mockReturnValue(activatedUser);
|
|
92
|
+
spies.insert.mockReturnValue(createdAuditEvent);
|
|
93
|
+
|
|
94
|
+
const result = await activateUser(
|
|
95
|
+
db,
|
|
96
|
+
{
|
|
97
|
+
userId: pendingUser.id,
|
|
98
|
+
actorId: "actor-1",
|
|
99
|
+
},
|
|
100
|
+
ctx,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(result.auditEvent.eventType).toBe("USER_ACTIVATED");
|
|
104
|
+
expect(result.auditEvent.actorId).toBe("actor-1");
|
|
105
|
+
expect(result.auditEvent.targetId).toBe(pendingUser.id);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it(
|
|
109
|
+
"throws when permission is missing",
|
|
110
|
+
testPermissionDenied(activateUser, { userId: "some-user", actorId: "actor-1" }),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import { InvalidStatusTransitionError, UserNotFoundError } from "../lib/errors";
|
|
4
|
+
import { permissions } from "../permissions";
|
|
5
|
+
|
|
6
|
+
export interface ActivateUserInput {
|
|
7
|
+
userId: string;
|
|
8
|
+
actorId: string;
|
|
9
|
+
from?: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Function: activateUser
|
|
14
|
+
*
|
|
15
|
+
* Transitions a user from PENDING to ACTIVE status.
|
|
16
|
+
* Only PENDING users can be activated; use reactivateUser for INACTIVE users.
|
|
17
|
+
*/
|
|
18
|
+
export const activateUser = defineCommand(
|
|
19
|
+
permissions.activateUser,
|
|
20
|
+
async (db: DB, input: ActivateUserInput) => {
|
|
21
|
+
// 1. Find user by ID
|
|
22
|
+
const user = await db
|
|
23
|
+
.selectFrom("User")
|
|
24
|
+
.selectAll()
|
|
25
|
+
.where("id", "=", input.userId)
|
|
26
|
+
.executeTakeFirst();
|
|
27
|
+
|
|
28
|
+
// 2. If not found, throw error
|
|
29
|
+
if (!user) {
|
|
30
|
+
throw new UserNotFoundError(input.userId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 3. Validate status transition
|
|
34
|
+
const validFromStatuses = input.from ?? ["PENDING"];
|
|
35
|
+
if (!validFromStatuses.includes(user.status)) {
|
|
36
|
+
throw new InvalidStatusTransitionError(user.status, "ACTIVE");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 4. Update status to ACTIVE
|
|
40
|
+
const updatedUser = await db
|
|
41
|
+
.updateTable("User")
|
|
42
|
+
.set({
|
|
43
|
+
status: "ACTIVE",
|
|
44
|
+
updatedAt: new Date(),
|
|
45
|
+
})
|
|
46
|
+
.where("id", "=", input.userId)
|
|
47
|
+
.returningAll()
|
|
48
|
+
.executeTakeFirst();
|
|
49
|
+
|
|
50
|
+
// 5. Log USER_ACTIVATED audit event
|
|
51
|
+
const auditEvent = await db
|
|
52
|
+
.insertInto("AuditEvent")
|
|
53
|
+
.values({
|
|
54
|
+
eventType: "USER_ACTIVATED",
|
|
55
|
+
actorId: input.actorId,
|
|
56
|
+
targetId: input.userId,
|
|
57
|
+
targetType: "User",
|
|
58
|
+
payload: JSON.stringify({ previousStatus: user.status }),
|
|
59
|
+
createdAt: new Date(),
|
|
60
|
+
updatedAt: null,
|
|
61
|
+
})
|
|
62
|
+
.returningAll()
|
|
63
|
+
.executeTakeFirst();
|
|
64
|
+
|
|
65
|
+
return { user: updatedUser!, auditEvent: auditEvent! };
|
|
66
|
+
},
|
|
67
|
+
);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { InsufficientPermissionError, type CommandContext } from "../../shared/internal";
|
|
3
|
+
import { createMockDb } from "../../testing/index";
|
|
4
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
5
|
+
import { PermissionNotFoundError, RoleNotFoundError } from "../lib/errors";
|
|
6
|
+
import { baseAuditEvent, basePermission, baseRole, baseRolePermission } from "../testing/fixtures";
|
|
7
|
+
import { assignPermissionToRole } from "./assignPermissionToRole";
|
|
8
|
+
|
|
9
|
+
describe("assignPermissionToRole", () => {
|
|
10
|
+
const ctx: CommandContext = {
|
|
11
|
+
actorId: "test-actor",
|
|
12
|
+
permissions: ["user-management:assignPermissionToRole"],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
it("throws when role does not exist", async () => {
|
|
16
|
+
const { db, spies } = createMockDb<DB>();
|
|
17
|
+
spies.select.mockReturnValue(undefined);
|
|
18
|
+
|
|
19
|
+
await expect(
|
|
20
|
+
assignPermissionToRole(
|
|
21
|
+
db,
|
|
22
|
+
{
|
|
23
|
+
roleId: "nonexistent-role",
|
|
24
|
+
permissionId: basePermission.id,
|
|
25
|
+
actorId: "actor-1",
|
|
26
|
+
},
|
|
27
|
+
ctx,
|
|
28
|
+
),
|
|
29
|
+
).rejects.toBeInstanceOf(RoleNotFoundError);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("throws when permission does not exist", async () => {
|
|
33
|
+
const { db, spies } = createMockDb<DB>();
|
|
34
|
+
spies.select
|
|
35
|
+
.mockReturnValueOnce(baseRole) // Role found
|
|
36
|
+
.mockReturnValueOnce(undefined); // Permission not found
|
|
37
|
+
|
|
38
|
+
await expect(
|
|
39
|
+
assignPermissionToRole(
|
|
40
|
+
db,
|
|
41
|
+
{
|
|
42
|
+
roleId: baseRole.id,
|
|
43
|
+
permissionId: "nonexistent-permission",
|
|
44
|
+
actorId: "actor-1",
|
|
45
|
+
},
|
|
46
|
+
ctx,
|
|
47
|
+
),
|
|
48
|
+
).rejects.toBeInstanceOf(PermissionNotFoundError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns success when assignment already exists (idempotent)", async () => {
|
|
52
|
+
const { db, spies } = createMockDb<DB>();
|
|
53
|
+
spies.select
|
|
54
|
+
.mockReturnValueOnce(baseRole) // Role found
|
|
55
|
+
.mockReturnValueOnce(basePermission) // Permission found
|
|
56
|
+
.mockReturnValueOnce(baseRolePermission); // Assignment already exists
|
|
57
|
+
|
|
58
|
+
const result = await assignPermissionToRole(
|
|
59
|
+
db,
|
|
60
|
+
{
|
|
61
|
+
roleId: baseRole.id,
|
|
62
|
+
permissionId: basePermission.id,
|
|
63
|
+
actorId: "actor-1",
|
|
64
|
+
},
|
|
65
|
+
ctx,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(result.rolePermission).toEqual(baseRolePermission);
|
|
69
|
+
expect(spies.insert).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("creates RolePermission and logs PERMISSION_ASSIGNED event", async () => {
|
|
73
|
+
const { db, spies } = createMockDb<DB>();
|
|
74
|
+
const newRolePermission = {
|
|
75
|
+
...baseRolePermission,
|
|
76
|
+
id: "new-role-permission-id",
|
|
77
|
+
};
|
|
78
|
+
const createdAuditEvent = {
|
|
79
|
+
...baseAuditEvent,
|
|
80
|
+
eventType: "PERMISSION_ASSIGNED" as const,
|
|
81
|
+
actorId: "actor-1",
|
|
82
|
+
targetId: baseRole.id,
|
|
83
|
+
targetType: "Role",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
spies.select
|
|
87
|
+
.mockReturnValueOnce(baseRole) // Role found
|
|
88
|
+
.mockReturnValueOnce(basePermission) // Permission found
|
|
89
|
+
.mockReturnValueOnce(undefined); // No existing assignment
|
|
90
|
+
spies.insert.mockReturnValueOnce(newRolePermission).mockReturnValueOnce(createdAuditEvent);
|
|
91
|
+
|
|
92
|
+
const result = await assignPermissionToRole(
|
|
93
|
+
db,
|
|
94
|
+
{
|
|
95
|
+
roleId: baseRole.id,
|
|
96
|
+
permissionId: basePermission.id,
|
|
97
|
+
actorId: "actor-1",
|
|
98
|
+
},
|
|
99
|
+
ctx,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
expect(result.rolePermission.roleId).toBe(baseRole.id);
|
|
103
|
+
expect(result.rolePermission.permissionId).toBe(basePermission.id);
|
|
104
|
+
expect(result.auditEvent?.eventType).toBe("PERMISSION_ASSIGNED");
|
|
105
|
+
expect(spies.insert).toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("throws when permission is missing", async () => {
|
|
109
|
+
const { db } = createMockDb<DB>();
|
|
110
|
+
const denied: CommandContext = { actorId: "test-actor", permissions: [] };
|
|
111
|
+
await expect(
|
|
112
|
+
assignPermissionToRole(
|
|
113
|
+
db,
|
|
114
|
+
{ roleId: "role-1", permissionId: "perm-1", actorId: "actor-1" },
|
|
115
|
+
denied,
|
|
116
|
+
),
|
|
117
|
+
).rejects.toBeInstanceOf(InsufficientPermissionError);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { defineCommand } from "../../shared/internal";
|
|
2
|
+
import { DB } from "../generated/kysely-tailordb";
|
|
3
|
+
import { PermissionNotFoundError, RoleNotFoundError } from "../lib/errors";
|
|
4
|
+
import { permissions } from "../permissions";
|
|
5
|
+
|
|
6
|
+
export interface AssignPermissionToRoleInput {
|
|
7
|
+
roleId: string;
|
|
8
|
+
permissionId: string;
|
|
9
|
+
actorId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Function: assignPermissionToRole
|
|
14
|
+
*
|
|
15
|
+
* Assigns a permission to a role. Operation is idempotent - assigning
|
|
16
|
+
* the same permission twice does not create duplicate records or error.
|
|
17
|
+
*
|
|
18
|
+
* Note: Permissions for affected users are recomputed asynchronously via executor.
|
|
19
|
+
*/
|
|
20
|
+
export const assignPermissionToRole = defineCommand(
|
|
21
|
+
permissions.assignPermissionToRole,
|
|
22
|
+
async (db: DB, input: AssignPermissionToRoleInput) => {
|
|
23
|
+
const role = await db
|
|
24
|
+
.selectFrom("Role")
|
|
25
|
+
.selectAll()
|
|
26
|
+
.where("id", "=", input.roleId)
|
|
27
|
+
.executeTakeFirst();
|
|
28
|
+
|
|
29
|
+
if (!role) {
|
|
30
|
+
throw new RoleNotFoundError(input.roleId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const permission = await db
|
|
34
|
+
.selectFrom("Permission")
|
|
35
|
+
.selectAll()
|
|
36
|
+
.where("id", "=", input.permissionId)
|
|
37
|
+
.executeTakeFirst();
|
|
38
|
+
|
|
39
|
+
if (!permission) {
|
|
40
|
+
throw new PermissionNotFoundError(input.permissionId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Idempotent: return existing assignment without creating duplicates
|
|
44
|
+
const existingAssignment = await db
|
|
45
|
+
.selectFrom("RolePermission")
|
|
46
|
+
.selectAll()
|
|
47
|
+
.where("roleId", "=", input.roleId)
|
|
48
|
+
.where("permissionId", "=", input.permissionId)
|
|
49
|
+
.executeTakeFirst();
|
|
50
|
+
|
|
51
|
+
if (existingAssignment) {
|
|
52
|
+
return { rolePermission: existingAssignment };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Permission recomputation for affected users is handled asynchronously by executor
|
|
56
|
+
const rolePermission = await db
|
|
57
|
+
.insertInto("RolePermission")
|
|
58
|
+
.values({
|
|
59
|
+
roleId: input.roleId,
|
|
60
|
+
permissionId: input.permissionId,
|
|
61
|
+
createdAt: new Date(),
|
|
62
|
+
updatedAt: null,
|
|
63
|
+
})
|
|
64
|
+
.returningAll()
|
|
65
|
+
.executeTakeFirst();
|
|
66
|
+
|
|
67
|
+
const auditEvent = await db
|
|
68
|
+
.insertInto("AuditEvent")
|
|
69
|
+
.values({
|
|
70
|
+
eventType: "PERMISSION_ASSIGNED",
|
|
71
|
+
actorId: input.actorId,
|
|
72
|
+
targetId: input.roleId,
|
|
73
|
+
targetType: "Role",
|
|
74
|
+
payload: JSON.stringify({
|
|
75
|
+
roleId: input.roleId,
|
|
76
|
+
permissionId: input.permissionId,
|
|
77
|
+
permissionKey: permission.key,
|
|
78
|
+
}),
|
|
79
|
+
createdAt: new Date(),
|
|
80
|
+
updatedAt: null,
|
|
81
|
+
})
|
|
82
|
+
.returningAll()
|
|
83
|
+
.executeTakeFirst();
|
|
84
|
+
|
|
85
|
+
return { rolePermission: rolePermission!, auditEvent: auditEvent! };
|
|
86
|
+
},
|
|
87
|
+
);
|