@things-factory/auth-ui 10.1.194 → 10.1.202
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/dist-client/bootstrap.js +4 -1
- package/dist-client/bootstrap.js.map +1 -1
- package/dist-client/components/access-decision-popup.js +7 -5
- package/dist-client/components/access-decision-popup.js.map +1 -1
- package/dist-client/components/access-request-popup.d.ts +9 -1
- package/dist-client/components/access-request-popup.js +55 -4
- package/dist-client/components/access-request-popup.js.map +1 -1
- package/dist-client/components/my-login-history.js +14 -10
- package/dist-client/components/my-login-history.js.map +1 -1
- package/dist-client/components/role-task-edit.d.ts +22 -0
- package/dist-client/components/role-task-edit.js +17 -0
- package/dist-client/components/role-task-edit.js.map +1 -0
- package/dist-client/components/role-task-edit.test.d.ts +1 -0
- package/dist-client/components/role-task-edit.test.js +34 -0
- package/dist-client/components/role-task-edit.test.js.map +1 -0
- package/dist-client/components/role-template-picker.d.ts +5 -0
- package/dist-client/components/role-template-picker.js +41 -5
- package/dist-client/components/role-template-picker.js.map +1 -1
- package/dist-client/components/user-status-renderer.d.ts +9 -0
- package/dist-client/components/user-status-renderer.js +36 -0
- package/dist-client/components/user-status-renderer.js.map +1 -0
- package/dist-client/entity360/person-360.d.ts +13 -0
- package/dist-client/entity360/person-360.js +96 -0
- package/dist-client/entity360/person-360.js.map +1 -1
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +14 -1
- package/dist-client/index.js.map +1 -1
- package/dist-client/pages/access-request/access-requests.js +4 -2
- package/dist-client/pages/access-request/access-requests.js.map +1 -1
- package/dist-client/pages/access-request/my-access-requests.js +3 -2
- package/dist-client/pages/access-request/my-access-requests.js.map +1 -1
- package/dist-client/pages/audit/audit-page.d.ts +46 -0
- package/dist-client/pages/audit/audit-page.js +417 -0
- package/dist-client/pages/audit/audit-page.js.map +1 -0
- package/dist-client/pages/role/role-privilege-page.d.ts +46 -0
- package/dist-client/pages/role/role-privilege-page.js +500 -5
- package/dist-client/pages/role/role-privilege-page.js.map +1 -1
- package/dist-client/pages/role/role-task-review.test.d.ts +1 -0
- package/dist-client/pages/role/role-task-review.test.js +60 -0
- package/dist-client/pages/role/role-task-review.test.js.map +1 -0
- package/dist-client/pages/user/user-list-page.js +1 -31
- package/dist-client/pages/user/user-list-page.js.map +1 -1
- package/dist-client/route.js +3 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/access-request.d.ts +15 -1
- package/dist-client/utils/access-request.js +19 -2
- package/dist-client/utils/access-request.js.map +1 -1
- package/dist-client/utils/access-request.test.js +26 -1
- package/dist-client/utils/access-request.test.js.map +1 -1
- package/dist-client/utils/audit-words.d.ts +30 -0
- package/dist-client/utils/audit-words.js +57 -0
- package/dist-client/utils/audit-words.js.map +1 -0
- package/dist-client/utils/audit-words.test.d.ts +1 -0
- package/dist-client/utils/audit-words.test.js +47 -0
- package/dist-client/utils/audit-words.test.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/things-factory.config.js +1 -0
- package/translations/en.json +78 -1
- package/translations/es.json +78 -1
- package/translations/ja.json +78 -1
- package/translations/ko.json +78 -1
- package/translations/ms.json +78 -1
- package/translations/zh.json +78 -1
|
@@ -14,6 +14,19 @@ type Translate = (key: string, options?: Record<string, unknown>) => string;
|
|
|
14
14
|
* (`privilege.allows.<category>.<privilege>`), or the name itself when none is written. Never made up.
|
|
15
15
|
*/
|
|
16
16
|
export declare function privilegeWords(t: Translate, category: string, privilege: string): string;
|
|
17
|
+
/** A task in the words of the module that declares it (`task.<id>.name`), the id when there are none (ADR-0139 decision 11). */
|
|
18
|
+
export declare function taskName(t: Translate, taskId: string): string;
|
|
19
|
+
/** What a task really opens, in the module's words - empty when none is written. */
|
|
20
|
+
export declare function taskSays(t: Translate, taskId: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* What was asked for, in the reader's words - the task the person picked when there is one (ADR-0103 decision 9),
|
|
23
|
+
* the refused privilege otherwise. One rule for the request popup, the decision popup and both lists.
|
|
24
|
+
*/
|
|
25
|
+
export declare function neededWords(t: Translate, item: {
|
|
26
|
+
category: string;
|
|
27
|
+
privilege: string;
|
|
28
|
+
taskId?: string | null;
|
|
29
|
+
}): string;
|
|
17
30
|
export type ReviewIssue = {
|
|
18
31
|
code: string;
|
|
19
32
|
blocking: boolean;
|
|
@@ -37,10 +50,11 @@ export declare function decisionButtons(grantIssues: ReviewIssue[] | null, denyI
|
|
|
37
50
|
grant: boolean;
|
|
38
51
|
deny: boolean;
|
|
39
52
|
};
|
|
40
|
-
/** The candidate a popup selects first - the smallest; ties by name, as the server sorts them. */
|
|
53
|
+
/** The candidate a popup selects first - one holding the task asked, then the smallest; ties by name, as the server sorts them. */
|
|
41
54
|
export declare function firstCandidate<T extends {
|
|
42
55
|
name: string;
|
|
43
56
|
privilegeCount: number;
|
|
57
|
+
holdsTask?: boolean;
|
|
44
58
|
}>(candidates: T[]): T | undefined;
|
|
45
59
|
/** How long a denial is shown back before asking again - the server holds the same words against it (auth-base ACCESS_REQUEST_DENIAL_DAYS). */
|
|
46
60
|
export declare const DENIAL_SHOWN_DAYS = 7;
|
|
@@ -29,6 +29,21 @@ export function requestableFrom(extensions, operationName) {
|
|
|
29
29
|
export function privilegeWords(t, category, privilege) {
|
|
30
30
|
return t(`privilege.allows.${category}.${privilege}`, { defaultValue: '' }) || `${category}:${privilege}`;
|
|
31
31
|
}
|
|
32
|
+
/** A task in the words of the module that declares it (`task.<id>.name`), the id when there are none (ADR-0139 decision 11). */
|
|
33
|
+
export function taskName(t, taskId) {
|
|
34
|
+
return t(`task.${taskId}.name`, { defaultValue: taskId });
|
|
35
|
+
}
|
|
36
|
+
/** What a task really opens, in the module's words - empty when none is written. */
|
|
37
|
+
export function taskSays(t, taskId) {
|
|
38
|
+
return t(`task.${taskId}.description`, { defaultValue: '' });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What was asked for, in the reader's words - the task the person picked when there is one (ADR-0103 decision 9),
|
|
42
|
+
* the refused privilege otherwise. One rule for the request popup, the decision popup and both lists.
|
|
43
|
+
*/
|
|
44
|
+
export function neededWords(t, item) {
|
|
45
|
+
return item.taskId ? taskName(t, item.taskId) : privilegeWords(t, item.category, item.privilege);
|
|
46
|
+
}
|
|
32
47
|
/** How many privileges a finding names before it says "and N more" - a role can carry sixty. */
|
|
33
48
|
export const PRIVILEGES_NAMED = 5;
|
|
34
49
|
/**
|
|
@@ -51,6 +66,8 @@ export function issueWords(t, issue) {
|
|
|
51
66
|
const params = { ...(issue.params || {}) };
|
|
52
67
|
if (typeof params.privileges === 'string')
|
|
53
68
|
params.privileges = shortList(t, params.privileges);
|
|
69
|
+
if (typeof params.task === 'string')
|
|
70
|
+
params.task = taskName(t, params.task);
|
|
54
71
|
if (typeof params.state === 'string')
|
|
55
72
|
params.state = t(`access-request.state.${String(params.state).toLowerCase()}`, { defaultValue: String(params.state) });
|
|
56
73
|
return t(`access-request.issue.${issue.code}`, { ...params, defaultValue: issue.code });
|
|
@@ -63,9 +80,9 @@ export function decisionButtons(grantIssues, denyIssues) {
|
|
|
63
80
|
deny: !!denyIssues && !blocks(denyIssues)
|
|
64
81
|
};
|
|
65
82
|
}
|
|
66
|
-
/** The candidate a popup selects first - the smallest; ties by name, as the server sorts them. */
|
|
83
|
+
/** The candidate a popup selects first - one holding the task asked, then the smallest; ties by name, as the server sorts them. */
|
|
67
84
|
export function firstCandidate(candidates) {
|
|
68
|
-
return [...(candidates || [])].sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0];
|
|
85
|
+
return [...(candidates || [])].sort((a, b) => Number(!!b.holdsTask) - Number(!!a.holdsTask) || a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0];
|
|
69
86
|
}
|
|
70
87
|
/** How long a denial is shown back before asking again - the server holds the same words against it (auth-base ACCESS_REQUEST_DENIAL_DAYS). */
|
|
71
88
|
export const DENIAL_SHOWN_DAYS = 7;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-request.js","sourceRoot":"","sources":["../../client/utils/access-request.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The access request on the screen - pure, so the rules are tested without a browser (ADR-0103).\n *\n * A refusal's toast gets its button from this package through @operato/layout's registerNotifyAction\n * (bootstrap.ts) - the toast and @operato/graphql do not know what a refusal means (ADR-0103 decision 5).\n */\n\nexport type PrivilegeRequestDetail = { category: string; privilege: string; operationName?: string }\n\n/**\n * What a refusal offers to ask for - only a refusal a role would open. Address, license, ownership and\n * unknown refusals offer nothing: giving a role would not open them.\n */\nexport function requestableFrom(extensions: any, operationName?: string): PrivilegeRequestDetail | null {\n if (!extensions || extensions.code !== 'FORBIDDEN' || extensions.because !== 'role') return null\n /*\n * Only a change the person made (questions Q9 · 4). A page fires several reads as it opens; offering a request on\n * each would put the button in front of someone who did not choose to do anything yet.\n */\n if (extensions.operation !== 'mutation') return null\n const requires = extensions.requires\n if (!requires || typeof requires !== 'object' || !requires.category || !requires.privilege) return null\n return { category: requires.category, privilege: requires.privilege, ...(operationName ? { operationName } : {}) }\n}\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\n/**\n * What a privilege lets one do, in the reader's words - the sentence the role screen shows\n * (`privilege.allows.<category>.<privilege>`), or the name itself when none is written. Never made up.\n */\nexport function privilegeWords(t: Translate, category: string, privilege: string): string {\n return t(`privilege.allows.${category}.${privilege}`, { defaultValue: '' }) || `${category}:${privilege}`\n}\n\nexport type ReviewIssue = { code: string; blocking: boolean; field?: string; params?: Record<string, unknown> }\n\n/** How many privileges a finding names before it says \"and N more\" - a role can carry sixty. */\nexport const PRIVILEGES_NAMED = 5\n\n/**\n * The findings in the order a decider should read them: what blocks, then what hands on power, then the rest.\n * A long list of extra privileges must not push \"they could give roles to others\" out of sight.\n */\nexport function orderedIssues<T extends ReviewIssue>(issues: T[]): T[] {\n const weight = (issue: T) => (issue.blocking ? 0 : issue.code === 'role-grants-admin' ? 1 : issue.code === 'smaller-role-exists' ? 2 : 3)\n return [...issues].sort((a, b) => weight(a) - weight(b))\n}\n\n/** \"a, b, c, d, e and 53 more\" - the full list stays in the server's params. */\nexport function shortList(t: Translate, csv: string, shown = PRIVILEGES_NAMED): string {\n const all = String(csv || '').split(',').map(x => x.trim()).filter(Boolean)\n if (all.length <= shown) return all.join(', ')\n return t('access-request.text.and more', { list: all.slice(0, shown).join(', '), count: all.length - shown, defaultValue: `${all.slice(0, shown).join(', ')} +${all.length - shown}` })\n}\n\n/** One review finding as a sentence - `access-request.issue.<code>`, the code itself when the key is missing. */\nexport function issueWords(t: Translate, issue: ReviewIssue): string {\n const params = { ...(issue.params || {}) }\n if (typeof params.privileges === 'string') params.privileges = shortList(t, params.privileges)\n if (typeof params.state === 'string') params.state = t(`access-request.state.${String(params.state).toLowerCase()}`, { defaultValue: String(params.state) })\n return t(`access-request.issue.${issue.code}`, { ...params, defaultValue: issue.code })\n}\n\nexport const blocks = (issues: ReviewIssue[]) => issues.some(issue => issue.blocking)\n\n/** Which buttons a decision popup offers, from the review the server gave. */\nexport function decisionButtons(grantIssues: ReviewIssue[] | null, denyIssues: ReviewIssue[] | null) {\n return {\n grant: !!grantIssues && !blocks(grantIssues),\n deny: !!denyIssues && !blocks(denyIssues)\n }\n}\n\n/** The candidate a popup selects first - the smallest; ties by name, as the server sorts them. */\nexport function firstCandidate<T extends { name: string; privilegeCount: number }>(candidates: T[]): T | undefined {\n return [...(candidates || [])].sort((a, b) => a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n}\n\n/** How long a denial is shown back before asking again - the server holds the same words against it (auth-base ACCESS_REQUEST_DENIAL_DAYS). */\nexport const DENIAL_SHOWN_DAYS = 7\n\n/** The latest denial of this privilege among the person's own requests, within DENIAL_SHOWN_DAYS. */\nexport function recentDenial<T extends { state: string; category: string; privilege: string; decidedAt?: string | null }>(\n mine: T[],\n category: string,\n privilege: string,\n now = Date.now()\n): T | undefined {\n return (mine || [])\n .filter(r => r.state === 'DENIED' && r.category === category && r.privilege === privilege && r.decidedAt)\n .filter(r => now - new Date(r.decidedAt as string).getTime() <= DENIAL_SHOWN_DAYS * 24 * 60 * 60 * 1000)\n .sort((a, b) => new Date(b.decidedAt as string).getTime() - new Date(a.decidedAt as string).getTime())[0]\n}\n\n/** Send is offered only with a reason - and, after a denial, with words other than the denied ones. */\nexport function maySend(reason: string, denied?: { reason?: string | null }): boolean {\n const said = (reason || '').trim()\n if (!said) return false\n return !(denied && (denied.reason || '').trim() === said)\n}\n"]}
|
|
1
|
+
{"version":3,"file":"access-request.js","sourceRoot":"","sources":["../../client/utils/access-request.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * The access request on the screen - pure, so the rules are tested without a browser (ADR-0103).\n *\n * A refusal's toast gets its button from this package through @operato/layout's registerNotifyAction\n * (bootstrap.ts) - the toast and @operato/graphql do not know what a refusal means (ADR-0103 decision 5).\n */\n\nexport type PrivilegeRequestDetail = { category: string; privilege: string; operationName?: string }\n\n/**\n * What a refusal offers to ask for - only a refusal a role would open. Address, license, ownership and\n * unknown refusals offer nothing: giving a role would not open them.\n */\nexport function requestableFrom(extensions: any, operationName?: string): PrivilegeRequestDetail | null {\n if (!extensions || extensions.code !== 'FORBIDDEN' || extensions.because !== 'role') return null\n /*\n * Only a change the person made (questions Q9 · 4). A page fires several reads as it opens; offering a request on\n * each would put the button in front of someone who did not choose to do anything yet.\n */\n if (extensions.operation !== 'mutation') return null\n const requires = extensions.requires\n if (!requires || typeof requires !== 'object' || !requires.category || !requires.privilege) return null\n return { category: requires.category, privilege: requires.privilege, ...(operationName ? { operationName } : {}) }\n}\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\n/**\n * What a privilege lets one do, in the reader's words - the sentence the role screen shows\n * (`privilege.allows.<category>.<privilege>`), or the name itself when none is written. Never made up.\n */\nexport function privilegeWords(t: Translate, category: string, privilege: string): string {\n return t(`privilege.allows.${category}.${privilege}`, { defaultValue: '' }) || `${category}:${privilege}`\n}\n\n/** A task in the words of the module that declares it (`task.<id>.name`), the id when there are none (ADR-0139 decision 11). */\nexport function taskName(t: Translate, taskId: string): string {\n return t(`task.${taskId}.name`, { defaultValue: taskId })\n}\n\n/** What a task really opens, in the module's words - empty when none is written. */\nexport function taskSays(t: Translate, taskId: string): string {\n return t(`task.${taskId}.description`, { defaultValue: '' })\n}\n\n/**\n * What was asked for, in the reader's words - the task the person picked when there is one (ADR-0103 decision 9),\n * the refused privilege otherwise. One rule for the request popup, the decision popup and both lists.\n */\nexport function neededWords(t: Translate, item: { category: string; privilege: string; taskId?: string | null }): string {\n return item.taskId ? taskName(t, item.taskId) : privilegeWords(t, item.category, item.privilege)\n}\n\nexport type ReviewIssue = { code: string; blocking: boolean; field?: string; params?: Record<string, unknown> }\n\n/** How many privileges a finding names before it says \"and N more\" - a role can carry sixty. */\nexport const PRIVILEGES_NAMED = 5\n\n/**\n * The findings in the order a decider should read them: what blocks, then what hands on power, then the rest.\n * A long list of extra privileges must not push \"they could give roles to others\" out of sight.\n */\nexport function orderedIssues<T extends ReviewIssue>(issues: T[]): T[] {\n const weight = (issue: T) => (issue.blocking ? 0 : issue.code === 'role-grants-admin' ? 1 : issue.code === 'smaller-role-exists' ? 2 : 3)\n return [...issues].sort((a, b) => weight(a) - weight(b))\n}\n\n/** \"a, b, c, d, e and 53 more\" - the full list stays in the server's params. */\nexport function shortList(t: Translate, csv: string, shown = PRIVILEGES_NAMED): string {\n const all = String(csv || '').split(',').map(x => x.trim()).filter(Boolean)\n if (all.length <= shown) return all.join(', ')\n return t('access-request.text.and more', { list: all.slice(0, shown).join(', '), count: all.length - shown, defaultValue: `${all.slice(0, shown).join(', ')} +${all.length - shown}` })\n}\n\n/** One review finding as a sentence - `access-request.issue.<code>`, the code itself when the key is missing. */\nexport function issueWords(t: Translate, issue: ReviewIssue): string {\n const params = { ...(issue.params || {}) }\n if (typeof params.privileges === 'string') params.privileges = shortList(t, params.privileges)\n if (typeof params.task === 'string') params.task = taskName(t, params.task)\n if (typeof params.state === 'string') params.state = t(`access-request.state.${String(params.state).toLowerCase()}`, { defaultValue: String(params.state) })\n return t(`access-request.issue.${issue.code}`, { ...params, defaultValue: issue.code })\n}\n\nexport const blocks = (issues: ReviewIssue[]) => issues.some(issue => issue.blocking)\n\n/** Which buttons a decision popup offers, from the review the server gave. */\nexport function decisionButtons(grantIssues: ReviewIssue[] | null, denyIssues: ReviewIssue[] | null) {\n return {\n grant: !!grantIssues && !blocks(grantIssues),\n deny: !!denyIssues && !blocks(denyIssues)\n }\n}\n\n/** The candidate a popup selects first - one holding the task asked, then the smallest; ties by name, as the server sorts them. */\nexport function firstCandidate<T extends { name: string; privilegeCount: number; holdsTask?: boolean }>(candidates: T[]): T | undefined {\n return [...(candidates || [])].sort((a, b) => Number(!!b.holdsTask) - Number(!!a.holdsTask) || a.privilegeCount - b.privilegeCount || a.name.localeCompare(b.name))[0]\n}\n\n/** How long a denial is shown back before asking again - the server holds the same words against it (auth-base ACCESS_REQUEST_DENIAL_DAYS). */\nexport const DENIAL_SHOWN_DAYS = 7\n\n/** The latest denial of this privilege among the person's own requests, within DENIAL_SHOWN_DAYS. */\nexport function recentDenial<T extends { state: string; category: string; privilege: string; decidedAt?: string | null }>(\n mine: T[],\n category: string,\n privilege: string,\n now = Date.now()\n): T | undefined {\n return (mine || [])\n .filter(r => r.state === 'DENIED' && r.category === category && r.privilege === privilege && r.decidedAt)\n .filter(r => now - new Date(r.decidedAt as string).getTime() <= DENIAL_SHOWN_DAYS * 24 * 60 * 60 * 1000)\n .sort((a, b) => new Date(b.decidedAt as string).getTime() - new Date(a.decidedAt as string).getTime())[0]\n}\n\n/** Send is offered only with a reason - and, after a denial, with words other than the denied ones. */\nexport function maySend(reason: string, denied?: { reason?: string | null }): boolean {\n const said = (reason || '').trim()\n if (!said) return false\n return !(denied && (denied.reason || '').trim() === said)\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { blocks, decisionButtons, firstCandidate, issueWords, maySend, orderedIssues, privilegeWords, recentDenial, requestableFrom, shortList } from './access-request';
|
|
1
|
+
import { blocks, decisionButtons, firstCandidate, issueWords, maySend, neededWords, orderedIssues, privilegeWords, recentDenial, requestableFrom, shortList } from './access-request';
|
|
2
2
|
/**
|
|
3
3
|
* The access request on the screen (ADR-0103). The refusal says why; only a missing role offers a request.
|
|
4
4
|
*/
|
|
@@ -83,4 +83,29 @@ describe('the decision popup reads well with a big role (found end to end, 2026-
|
|
|
83
83
|
expect(shortList(t, 'a, b')).toBe('a, b');
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
+
describe('the work asked for (ADR-0103 decision 9)', () => {
|
|
87
|
+
const words = {
|
|
88
|
+
'task.accounting.bank.name': '자금 관리',
|
|
89
|
+
'privilege.allows.bank.mutation': '입출금 줄을 바꿀 수 있습니다.',
|
|
90
|
+
'access-request.issue.role-lacks-task': '{role} · {task} · {privileges}'
|
|
91
|
+
};
|
|
92
|
+
const t = (key, options = {}) => (words[key] ?? options.defaultValue ?? key).replace(/\{(\w+)\}/g, (_, name) => String(options[name] ?? ''));
|
|
93
|
+
it('★ says the task asked for, and the privilege when no task was asked', () => {
|
|
94
|
+
expect(neededWords(t, { category: 'bank', privilege: 'mutation', taskId: 'accounting.bank' })).toBe('자금 관리');
|
|
95
|
+
expect(neededWords(t, { category: 'bank', privilege: 'mutation' })).toBe('입출금 줄을 바꿀 수 있습니다.');
|
|
96
|
+
});
|
|
97
|
+
it('★ draws the id of a task with no words - a task gone from the catalog is not an error', () => {
|
|
98
|
+
expect(neededWords(t, { category: 'bank', privilege: 'mutation', taskId: 'accounting.gone' })).toBe('accounting.gone');
|
|
99
|
+
});
|
|
100
|
+
it('★ picks a role holding the task first, then the smallest - the order the server sorts by', () => {
|
|
101
|
+
const roles = [
|
|
102
|
+
{ name: 'inbound', privilegeCount: 1, holdsTask: false },
|
|
103
|
+
{ name: 'keeper', privilegeCount: 3, holdsTask: true }
|
|
104
|
+
];
|
|
105
|
+
expect(firstCandidate(roles)?.name).toBe('keeper');
|
|
106
|
+
});
|
|
107
|
+
it('names the task in a finding by its words', () => {
|
|
108
|
+
expect(issueWords(t, { code: 'role-lacks-task', blocking: false, params: { role: 'inbound', task: 'accounting.bank', privileges: 'bank:query' } })).toBe('inbound · 자금 관리 · bank:query');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
86
111
|
//# sourceMappingURL=access-request.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"access-request.test.js","sourceRoot":"","sources":["../../client/utils/access-request.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { blocks, decisionButtons, firstCandidate, issueWords, maySend, orderedIssues, privilegeWords, recentDenial, requestableFrom, shortList } from './access-request'\n\n/**\n * The access request on the screen (ADR-0103). The refusal says why; only a missing role offers a request.\n */\n\nconst ROLE = { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' }\n\ndescribe('what a refusal offers to ask for', () => {\n it('★ a refusal a role would open - the privilege it named', () => {\n expect(requestableFrom(ROLE, 'updateInboundPlan')).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })\n })\n\n it('★ address, license, ownership and unknown offer nothing - a role would not open them', () => {\n for (const because of ['address', 'license', 'ownership', 'unknown']) {\n expect(requestableFrom({ ...ROLE, because })).toBeNull()\n }\n })\n\n it('★ a read the page fired offers nothing - only a change the person made (Q9)', () => {\n expect(requestableFrom({ ...ROLE, operation: 'query' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: 'subscription' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: undefined })).toBeNull()\n })\n\n it('a refusal from before the cause was carried offers nothing - it is not guessed', () => {\n expect(requestableFrom({ code: 'FORBIDDEN', requires: ROLE.requires })).toBeNull()\n })\n\n it('ownership-only and other errors offer nothing', () => {\n expect(requestableFrom({ ...ROLE, requires: 'ownership' })).toBeNull()\n expect(requestableFrom({ code: 'BAD_USER_INPUT' })).toBeNull()\n expect(requestableFrom(undefined)).toBeNull()\n })\n})\n\ndescribe('words', () => {\n const t = (key: string, options: any = {}) => (key === 'privilege.allows.warehouse.mutation' ? '창고 기록을 바꿀 수 있습니다.' : options.defaultValue)\n\n it('★ the sentence the role screen writes, or the name - never a key', () => {\n expect(privilegeWords(t, 'warehouse', 'mutation')).toBe('창고 기록을 바꿀 수 있습니다.')\n expect(privilegeWords(t, 'board', 'query')).toBe('board:query')\n })\n\n it('an issue without a sentence shows its code, not the key', () => {\n expect(issueWords(t, { code: 'own-request', blocking: true })).toBe('own-request')\n })\n})\n\ndescribe('the decision buttons', () => {\n it('★ a blocking finding takes the button away; a warning does not', () => {\n expect(decisionButtons([{ code: 'role-grants-more', blocking: false }], [{ code: 'deny-needs-reason', blocking: true }])).toEqual({ grant: true, deny: false })\n expect(decisionButtons([{ code: 'role-does-not-grant', blocking: true }], [])).toEqual({ grant: false, deny: true })\n expect(decisionButtons(null, [])).toEqual({ grant: false, deny: true })\n expect(blocks([])).toBe(false)\n })\n\n it('the smallest role first, ties by name', () => {\n expect(firstCandidate([{ name: 'b', privilegeCount: 1 }, { name: 'a', privilegeCount: 1 }, { name: 'c', privilegeCount: 0 }])?.name).toBe('c')\n })\n})\n\ndescribe('asking is not a reflex (Q9)', () => {\n const NOW = new Date('2026-10-02T09:00:00Z').getTime()\n const mine = [\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-30T09:00:00Z', reason: 'inbound', decisionReason: 'IT only' },\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-01T09:00:00Z', reason: 'old', decisionReason: 'old' },\n { state: 'DENIED', category: 'board', privilege: 'mutation', decidedAt: '2026-10-01T09:00:00Z', reason: 'x', decisionReason: 'y' }\n ]\n\n it('★ the latest denial of the same privilege within seven days is shown back', () => {\n expect(recentDenial(mine, 'warehouse', 'mutation', NOW)?.decisionReason).toBe('IT only')\n expect(recentDenial(mine, 'warehouse', 'query', NOW)).toBeUndefined()\n expect(recentDenial(mine.slice(1, 2), 'warehouse', 'mutation', NOW)).toBeUndefined()\n })\n\n it('★ send needs a reason, and after a denial other words than the denied ones', () => {\n expect(maySend('')).toBe(false)\n expect(maySend(' ')).toBe(false)\n expect(maySend('inbound')).toBe(true)\n expect(maySend('inbound', { reason: 'inbound' })).toBe(false)\n expect(maySend('moved to inbound', { reason: 'inbound' })).toBe(true)\n })\n})\n\ndescribe('the decision popup reads well with a big role (found end to end, 2026-10-02)', () => {\n const t = (key: string, options: any = {}) => (key === 'access-request.text.and more' ? `${options.list} +${options.count}` : options.defaultValue ?? key)\n\n it('★ what blocks first, then the power to give roles, then a smaller role, then the rest', () => {\n const issues = [\n { code: 'role-grants-more', blocking: false },\n { code: 'smaller-role-exists', blocking: false },\n { code: 'role-grants-admin', blocking: false },\n { code: 'deny-needs-reason', blocking: true }\n ]\n expect(orderedIssues(issues).map(i => i.code)).toEqual(['deny-needs-reason', 'role-grants-admin', 'smaller-role-exists', 'role-grants-more'])\n })\n\n it('★ a long privilege list names five and counts the rest', () => {\n expect(shortList(t, 'a, b, c, d, e, f, g')).toBe('a, b, c, d, e +2')\n expect(shortList(t, 'a, b')).toBe('a, b')\n })\n})\n"]}
|
|
1
|
+
{"version":3,"file":"access-request.test.js","sourceRoot":"","sources":["../../client/utils/access-request.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { blocks, decisionButtons, firstCandidate, issueWords, maySend, neededWords, orderedIssues, privilegeWords, recentDenial, requestableFrom, shortList } from './access-request'\n\n/**\n * The access request on the screen (ADR-0103). The refusal says why; only a missing role offers a request.\n */\n\nconst ROLE = { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' }\n\ndescribe('what a refusal offers to ask for', () => {\n it('★ a refusal a role would open - the privilege it named', () => {\n expect(requestableFrom(ROLE, 'updateInboundPlan')).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })\n })\n\n it('★ address, license, ownership and unknown offer nothing - a role would not open them', () => {\n for (const because of ['address', 'license', 'ownership', 'unknown']) {\n expect(requestableFrom({ ...ROLE, because })).toBeNull()\n }\n })\n\n it('★ a read the page fired offers nothing - only a change the person made (Q9)', () => {\n expect(requestableFrom({ ...ROLE, operation: 'query' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: 'subscription' })).toBeNull()\n expect(requestableFrom({ ...ROLE, operation: undefined })).toBeNull()\n })\n\n it('a refusal from before the cause was carried offers nothing - it is not guessed', () => {\n expect(requestableFrom({ code: 'FORBIDDEN', requires: ROLE.requires })).toBeNull()\n })\n\n it('ownership-only and other errors offer nothing', () => {\n expect(requestableFrom({ ...ROLE, requires: 'ownership' })).toBeNull()\n expect(requestableFrom({ code: 'BAD_USER_INPUT' })).toBeNull()\n expect(requestableFrom(undefined)).toBeNull()\n })\n})\n\ndescribe('words', () => {\n const t = (key: string, options: any = {}) => (key === 'privilege.allows.warehouse.mutation' ? '창고 기록을 바꿀 수 있습니다.' : options.defaultValue)\n\n it('★ the sentence the role screen writes, or the name - never a key', () => {\n expect(privilegeWords(t, 'warehouse', 'mutation')).toBe('창고 기록을 바꿀 수 있습니다.')\n expect(privilegeWords(t, 'board', 'query')).toBe('board:query')\n })\n\n it('an issue without a sentence shows its code, not the key', () => {\n expect(issueWords(t, { code: 'own-request', blocking: true })).toBe('own-request')\n })\n})\n\ndescribe('the decision buttons', () => {\n it('★ a blocking finding takes the button away; a warning does not', () => {\n expect(decisionButtons([{ code: 'role-grants-more', blocking: false }], [{ code: 'deny-needs-reason', blocking: true }])).toEqual({ grant: true, deny: false })\n expect(decisionButtons([{ code: 'role-does-not-grant', blocking: true }], [])).toEqual({ grant: false, deny: true })\n expect(decisionButtons(null, [])).toEqual({ grant: false, deny: true })\n expect(blocks([])).toBe(false)\n })\n\n it('the smallest role first, ties by name', () => {\n expect(firstCandidate([{ name: 'b', privilegeCount: 1 }, { name: 'a', privilegeCount: 1 }, { name: 'c', privilegeCount: 0 }])?.name).toBe('c')\n })\n})\n\ndescribe('asking is not a reflex (Q9)', () => {\n const NOW = new Date('2026-10-02T09:00:00Z').getTime()\n const mine = [\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-30T09:00:00Z', reason: 'inbound', decisionReason: 'IT only' },\n { state: 'DENIED', category: 'warehouse', privilege: 'mutation', decidedAt: '2026-09-01T09:00:00Z', reason: 'old', decisionReason: 'old' },\n { state: 'DENIED', category: 'board', privilege: 'mutation', decidedAt: '2026-10-01T09:00:00Z', reason: 'x', decisionReason: 'y' }\n ]\n\n it('★ the latest denial of the same privilege within seven days is shown back', () => {\n expect(recentDenial(mine, 'warehouse', 'mutation', NOW)?.decisionReason).toBe('IT only')\n expect(recentDenial(mine, 'warehouse', 'query', NOW)).toBeUndefined()\n expect(recentDenial(mine.slice(1, 2), 'warehouse', 'mutation', NOW)).toBeUndefined()\n })\n\n it('★ send needs a reason, and after a denial other words than the denied ones', () => {\n expect(maySend('')).toBe(false)\n expect(maySend(' ')).toBe(false)\n expect(maySend('inbound')).toBe(true)\n expect(maySend('inbound', { reason: 'inbound' })).toBe(false)\n expect(maySend('moved to inbound', { reason: 'inbound' })).toBe(true)\n })\n})\n\ndescribe('the decision popup reads well with a big role (found end to end, 2026-10-02)', () => {\n const t = (key: string, options: any = {}) => (key === 'access-request.text.and more' ? `${options.list} +${options.count}` : options.defaultValue ?? key)\n\n it('★ what blocks first, then the power to give roles, then a smaller role, then the rest', () => {\n const issues = [\n { code: 'role-grants-more', blocking: false },\n { code: 'smaller-role-exists', blocking: false },\n { code: 'role-grants-admin', blocking: false },\n { code: 'deny-needs-reason', blocking: true }\n ]\n expect(orderedIssues(issues).map(i => i.code)).toEqual(['deny-needs-reason', 'role-grants-admin', 'smaller-role-exists', 'role-grants-more'])\n })\n\n it('★ a long privilege list names five and counts the rest', () => {\n expect(shortList(t, 'a, b, c, d, e, f, g')).toBe('a, b, c, d, e +2')\n expect(shortList(t, 'a, b')).toBe('a, b')\n })\n})\n\ndescribe('the work asked for (ADR-0103 decision 9)', () => {\n const words: Record<string, string> = {\n 'task.accounting.bank.name': '자금 관리',\n 'privilege.allows.bank.mutation': '입출금 줄을 바꿀 수 있습니다.',\n 'access-request.issue.role-lacks-task': '{role} · {task} · {privileges}'\n }\n const t = (key: string, options: Record<string, unknown> = {}) =>\n (words[key] ?? (options.defaultValue as string) ?? key).replace(/\\{(\\w+)\\}/g, (_, name) => String(options[name] ?? ''))\n\n it('★ says the task asked for, and the privilege when no task was asked', () => {\n expect(neededWords(t, { category: 'bank', privilege: 'mutation', taskId: 'accounting.bank' })).toBe('자금 관리')\n expect(neededWords(t, { category: 'bank', privilege: 'mutation' })).toBe('입출금 줄을 바꿀 수 있습니다.')\n })\n\n it('★ draws the id of a task with no words - a task gone from the catalog is not an error', () => {\n expect(neededWords(t, { category: 'bank', privilege: 'mutation', taskId: 'accounting.gone' })).toBe('accounting.gone')\n })\n\n it('★ picks a role holding the task first, then the smallest - the order the server sorts by', () => {\n const roles = [\n { name: 'inbound', privilegeCount: 1, holdsTask: false },\n { name: 'keeper', privilegeCount: 3, holdsTask: true }\n ]\n expect(firstCandidate(roles)?.name).toBe('keeper')\n })\n\n it('names the task in a finding by its words', () => {\n expect(issueWords(t, { code: 'role-lacks-task', blocking: false, params: { role: 'inbound', task: 'accounting.bank', privileges: 'bank:query' } })).toBe('inbound · 자금 관리 · bank:query')\n })\n})\n"]}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the audit screen says what an audit row holds (ADR-0141 §6). The rows hold codes; the screen
|
|
3
|
+
* shows words. Pure, so the words are tested without a screen.
|
|
4
|
+
*/
|
|
5
|
+
type Translate = (key: string, options?: Record<string, unknown>) => string;
|
|
6
|
+
export interface AuditActionRow {
|
|
7
|
+
kind: string;
|
|
8
|
+
door?: string | null;
|
|
9
|
+
decision?: string | null;
|
|
10
|
+
outcome?: string | null;
|
|
11
|
+
code?: string | null;
|
|
12
|
+
count?: number | null;
|
|
13
|
+
objectId?: string | null;
|
|
14
|
+
detail?: Record<string, unknown> | null;
|
|
15
|
+
}
|
|
16
|
+
/** The door in words: the category's name and the declared axis - `payroll:query` → 「급여 query」. */
|
|
17
|
+
export declare function doorWords(t: Translate, door?: string | null): string;
|
|
18
|
+
/** One action in words, with how many times when more than once. */
|
|
19
|
+
export declare function actionWords(t: Translate, action: AuditActionRow): string;
|
|
20
|
+
/**
|
|
21
|
+
* A request's actions in one line - refusals and errors first, then events, then the doors passed.
|
|
22
|
+
* A door passed and its result say the same thing twice, so only an error result is shown.
|
|
23
|
+
*/
|
|
24
|
+
export declare function recordWords(t: Translate, actions: AuditActionRow[]): string;
|
|
25
|
+
/** The last `days` days up to `now`. */
|
|
26
|
+
export declare function lastDays(days: number, now?: Date): {
|
|
27
|
+
from: Date;
|
|
28
|
+
to: Date;
|
|
29
|
+
};
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the audit screen says what an audit row holds (ADR-0141 §6). The rows hold codes; the screen
|
|
3
|
+
* shows words. Pure, so the words are tested without a screen.
|
|
4
|
+
*/
|
|
5
|
+
/** The door in words: the category's name and the declared axis - `payroll:query` → 「급여 query」. */
|
|
6
|
+
export function doorWords(t, door) {
|
|
7
|
+
if (!door)
|
|
8
|
+
return '';
|
|
9
|
+
if (!door.includes(':')) {
|
|
10
|
+
return door
|
|
11
|
+
.split('+')
|
|
12
|
+
.map(part => t(`audit.door.${part}`, { defaultValue: part }))
|
|
13
|
+
.join(' · ');
|
|
14
|
+
}
|
|
15
|
+
const [category, privilege] = door.split(':');
|
|
16
|
+
return `${t(`privilege.category.${category}`, { defaultValue: category })} ${privilege}`;
|
|
17
|
+
}
|
|
18
|
+
function statusWords(t, status) {
|
|
19
|
+
return t(`label.${status}-user`, { defaultValue: String(status ?? '') });
|
|
20
|
+
}
|
|
21
|
+
/** One action in words, with how many times when more than once. */
|
|
22
|
+
export function actionWords(t, action) {
|
|
23
|
+
const times = (action.count ?? 1) > 1 ? ` ×${action.count}` : '';
|
|
24
|
+
if (action.kind === 'door.check') {
|
|
25
|
+
const refused = action.decision === 'denied' ? ` ${t('audit.outcome.denied')}` : '';
|
|
26
|
+
return `${doorWords(t, action.door)}${refused}${times}`;
|
|
27
|
+
}
|
|
28
|
+
if (action.kind === 'door.result') {
|
|
29
|
+
return action.outcome === 'error' ? `${doorWords(t, action.door)} ${t('audit.outcome.error')}${times}` : '';
|
|
30
|
+
}
|
|
31
|
+
const kind = t(`audit.kind.${action.kind}`, { defaultValue: action.kind });
|
|
32
|
+
const failed = action.outcome === 'error' ? ` ${t('audit.outcome.failed')}` : '';
|
|
33
|
+
const change = action.kind === 'user.status' && action.detail
|
|
34
|
+
? ` ${statusWords(t, action.detail.from)} → ${statusWords(t, action.detail.to)}`
|
|
35
|
+
: '';
|
|
36
|
+
return `${kind}${change}${failed}${times}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A request's actions in one line - refusals and errors first, then events, then the doors passed.
|
|
40
|
+
* A door passed and its result say the same thing twice, so only an error result is shown.
|
|
41
|
+
*/
|
|
42
|
+
export function recordWords(t, actions) {
|
|
43
|
+
const weight = (action) => action.decision === 'denied' || action.outcome === 'error' ? 0 : action.kind.startsWith('door.') ? 2 : 1;
|
|
44
|
+
return [...actions]
|
|
45
|
+
.sort((a, b) => weight(a) - weight(b))
|
|
46
|
+
.map(action => actionWords(t, action))
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(', ');
|
|
49
|
+
}
|
|
50
|
+
/** The last `days` days up to `now`. */
|
|
51
|
+
export function lastDays(days, now = new Date()) {
|
|
52
|
+
const from = new Date(now);
|
|
53
|
+
from.setHours(0, 0, 0, 0);
|
|
54
|
+
from.setDate(from.getDate() - (days - 1));
|
|
55
|
+
return { from, to: now };
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=audit-words.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-words.js","sourceRoot":"","sources":["../../client/utils/audit-words.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * How the audit screen says what an audit row holds (ADR-0141 §6). The rows hold codes; the screen\n * shows words. Pure, so the words are tested without a screen.\n */\n\ntype Translate = (key: string, options?: Record<string, unknown>) => string\n\nexport interface AuditActionRow {\n kind: string\n door?: string | null\n decision?: string | null\n outcome?: string | null\n code?: string | null\n count?: number | null\n objectId?: string | null\n detail?: Record<string, unknown> | null\n}\n\n/** The door in words: the category's name and the declared axis - `payroll:query` → 「급여 query」. */\nexport function doorWords(t: Translate, door?: string | null): string {\n if (!door) return ''\n if (!door.includes(':')) {\n return door\n .split('+')\n .map(part => t(`audit.door.${part}`, { defaultValue: part }))\n .join(' · ')\n }\n const [category, privilege] = door.split(':')\n return `${t(`privilege.category.${category}`, { defaultValue: category })} ${privilege}`\n}\n\nfunction statusWords(t: Translate, status: unknown) {\n return t(`label.${status}-user`, { defaultValue: String(status ?? '') })\n}\n\n/** One action in words, with how many times when more than once. */\nexport function actionWords(t: Translate, action: AuditActionRow): string {\n const times = (action.count ?? 1) > 1 ? ` ×${action.count}` : ''\n\n if (action.kind === 'door.check') {\n const refused = action.decision === 'denied' ? ` ${t('audit.outcome.denied')}` : ''\n return `${doorWords(t, action.door)}${refused}${times}`\n }\n if (action.kind === 'door.result') {\n return action.outcome === 'error' ? `${doorWords(t, action.door)} ${t('audit.outcome.error')}${times}` : ''\n }\n\n const kind = t(`audit.kind.${action.kind}`, { defaultValue: action.kind })\n const failed = action.outcome === 'error' ? ` ${t('audit.outcome.failed')}` : ''\n const change =\n action.kind === 'user.status' && action.detail\n ? ` ${statusWords(t, action.detail.from)} → ${statusWords(t, action.detail.to)}`\n : ''\n return `${kind}${change}${failed}${times}`\n}\n\n/**\n * A request's actions in one line - refusals and errors first, then events, then the doors passed.\n * A door passed and its result say the same thing twice, so only an error result is shown.\n */\nexport function recordWords(t: Translate, actions: AuditActionRow[]): string {\n const weight = (action: AuditActionRow) =>\n action.decision === 'denied' || action.outcome === 'error' ? 0 : action.kind.startsWith('door.') ? 2 : 1\n return [...actions]\n .sort((a, b) => weight(a) - weight(b))\n .map(action => actionWords(t, action))\n .filter(Boolean)\n .join(', ')\n}\n\n/** The last `days` days up to `now`. */\nexport function lastDays(days: number, now = new Date()): { from: Date; to: Date } {\n const from = new Date(now)\n from.setHours(0, 0, 0, 0)\n from.setDate(from.getDate() - (days - 1))\n return { from, to: now }\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { actionWords, doorWords, lastDays, recordWords } from './audit-words';
|
|
2
|
+
const WORDS = {
|
|
3
|
+
'privilege.category.payroll': '급여',
|
|
4
|
+
'audit.door.super': '시스템 소유자',
|
|
5
|
+
'audit.outcome.denied': '거절',
|
|
6
|
+
'audit.outcome.error': '오류',
|
|
7
|
+
'audit.outcome.failed': '실패',
|
|
8
|
+
'audit.kind.auth.signin': '로그인',
|
|
9
|
+
'audit.kind.role.grant': '역할 부여',
|
|
10
|
+
'audit.kind.user.status': '계정 상태',
|
|
11
|
+
'label.activated-user': '정상',
|
|
12
|
+
'label.locked-user': '잠김'
|
|
13
|
+
};
|
|
14
|
+
const t = (key, options) => WORDS[key] ?? options?.defaultValue ?? key;
|
|
15
|
+
describe('the audit screen says what a row holds', () => {
|
|
16
|
+
it('a door is its category in words and the declared axis', () => {
|
|
17
|
+
expect(doorWords(t, 'payroll:query')).toBe('급여 query');
|
|
18
|
+
expect(doorWords(t, 'super')).toBe('시스템 소유자');
|
|
19
|
+
expect(doorWords(t, 'unknown-category:query')).toBe('unknown-category query');
|
|
20
|
+
});
|
|
21
|
+
it('a refusal says so, with how many times', () => {
|
|
22
|
+
expect(actionWords(t, { kind: 'door.check', door: 'payroll:query', decision: 'denied', count: 3 })).toBe('급여 query 거절 ×3');
|
|
23
|
+
});
|
|
24
|
+
it('a result is shown only when it failed - passing already said it ran', () => {
|
|
25
|
+
expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'ok' })).toBe('');
|
|
26
|
+
expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'error' })).toBe('급여 query 오류');
|
|
27
|
+
});
|
|
28
|
+
it('an event in words, a status change with both states', () => {
|
|
29
|
+
expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', code: 'user not found' })).toBe('로그인 실패');
|
|
30
|
+
expect(actionWords(t, { kind: 'user.status', detail: { from: 'activated', to: 'locked' } })).toBe('계정 상태 정상 → 잠김');
|
|
31
|
+
});
|
|
32
|
+
it('a request reads refusals first, then events, then doors passed', () => {
|
|
33
|
+
expect(recordWords(t, [
|
|
34
|
+
{ kind: 'door.check', door: 'payroll:query', decision: 'granted' },
|
|
35
|
+
{ kind: 'door.result', door: 'payroll:query', outcome: 'ok' },
|
|
36
|
+
{ kind: 'role.grant' },
|
|
37
|
+
{ kind: 'door.check', door: 'payroll:mutation', decision: 'denied' }
|
|
38
|
+
])).toBe('급여 mutation 거절, 역할 부여, 급여 query');
|
|
39
|
+
});
|
|
40
|
+
it('the last seven days start at midnight six days back', () => {
|
|
41
|
+
const now = new Date(2026, 9, 19, 15, 30);
|
|
42
|
+
const { from, to } = lastDays(7, now);
|
|
43
|
+
expect(from).toEqual(new Date(2026, 9, 13, 0, 0));
|
|
44
|
+
expect(to).toBe(now);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
//# sourceMappingURL=audit-words.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit-words.test.js","sourceRoot":"","sources":["../../client/utils/audit-words.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { actionWords, doorWords, lastDays, recordWords } from './audit-words'\n\nconst WORDS: Record<string, string> = {\n 'privilege.category.payroll': '급여',\n 'audit.door.super': '시스템 소유자',\n 'audit.outcome.denied': '거절',\n 'audit.outcome.error': '오류',\n 'audit.outcome.failed': '실패',\n 'audit.kind.auth.signin': '로그인',\n 'audit.kind.role.grant': '역할 부여',\n 'audit.kind.user.status': '계정 상태',\n 'label.activated-user': '정상',\n 'label.locked-user': '잠김'\n}\nconst t = (key: string, options?: any) => WORDS[key] ?? options?.defaultValue ?? key\n\ndescribe('the audit screen says what a row holds', () => {\n it('a door is its category in words and the declared axis', () => {\n expect(doorWords(t, 'payroll:query')).toBe('급여 query')\n expect(doorWords(t, 'super')).toBe('시스템 소유자')\n expect(doorWords(t, 'unknown-category:query')).toBe('unknown-category query')\n })\n\n it('a refusal says so, with how many times', () => {\n expect(actionWords(t, { kind: 'door.check', door: 'payroll:query', decision: 'denied', count: 3 })).toBe('급여 query 거절 ×3')\n })\n\n it('a result is shown only when it failed - passing already said it ran', () => {\n expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'ok' })).toBe('')\n expect(actionWords(t, { kind: 'door.result', door: 'payroll:query', outcome: 'error' })).toBe('급여 query 오류')\n })\n\n it('an event in words, a status change with both states', () => {\n expect(actionWords(t, { kind: 'auth.signin', outcome: 'error', code: 'user not found' })).toBe('로그인 실패')\n expect(actionWords(t, { kind: 'user.status', detail: { from: 'activated', to: 'locked' } })).toBe('계정 상태 정상 → 잠김')\n })\n\n it('a request reads refusals first, then events, then doors passed', () => {\n expect(\n recordWords(t, [\n { kind: 'door.check', door: 'payroll:query', decision: 'granted' },\n { kind: 'door.result', door: 'payroll:query', outcome: 'ok' },\n { kind: 'role.grant' },\n { kind: 'door.check', door: 'payroll:mutation', decision: 'denied' }\n ])\n ).toBe('급여 mutation 거절, 역할 부여, 급여 query')\n })\n\n it('the last seven days start at midnight six days back', () => {\n const now = new Date(2026, 9, 19, 15, 30)\n const { from, to } = lastDays(7, now)\n expect(from).toEqual(new Date(2026, 9, 13, 0, 0))\n expect(to).toBe(now)\n })\n})\n"]}
|