@maravilla-labs/platform 0.5.1 → 0.6.0

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.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Unit tests for the typed policy builder (FR-7) in `src/config.ts`.
3
+ *
4
+ * Covers the emitted REL source for each helper, `.and()`/`.or()`
5
+ * composition, the legacy-footgun lint in `Policy.raw`, fragment
6
+ * expansion, and the relatesVia/group cross-validation in `defineConfig`.
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest';
10
+
11
+ import {
12
+ Policy,
13
+ ownsIt,
14
+ isStaff,
15
+ isAdmin,
16
+ relatesVia,
17
+ publicWhen,
18
+ fragment,
19
+ defineConfig,
20
+ assertNoLegacyShapes,
21
+ } from '../src/config.js';
22
+
23
+ describe('policy builder — emitted source', () => {
24
+ it('ownsIt() defaults to node.owner', () => {
25
+ expect(ownsIt().toString()).toBe('auth.user_id == node.owner');
26
+ expect(ownsIt('author').toString()).toBe('auth.user_id == node.author');
27
+ });
28
+
29
+ it('isStaff() defaults to staff', () => {
30
+ expect(isStaff().toString()).toBe("auth.roles.contains('staff')");
31
+ expect(isStaff('editors').toString()).toBe("auth.roles.contains('editors')");
32
+ });
33
+
34
+ it('isAdmin() emits auth.is_admin', () => {
35
+ expect(isAdmin().toString()).toBe('auth.is_admin');
36
+ });
37
+
38
+ it('publicWhen() defaults to node.public', () => {
39
+ expect(publicWhen().toString()).toBe('node.public == true');
40
+ expect(publicWhen('listed').toString()).toBe('node.listed == true');
41
+ });
42
+
43
+ it('ownsIt().or(isStaff()) composes with ||', () => {
44
+ expect(ownsIt().or(isStaff()).toString()).toBe(
45
+ "auth.user_id == node.owner || auth.roles.contains('staff')",
46
+ );
47
+ });
48
+
49
+ it('and() composes with &&', () => {
50
+ expect(ownsIt().and(publicWhen()).toString()).toBe(
51
+ 'auth.user_id == node.owner && node.public == true',
52
+ );
53
+ });
54
+
55
+ it('relatesVia() with depth includes the RELATES … VIA … DEPTH clause', () => {
56
+ const s = relatesVia('STEWARDS', { depth: [1, 3] }).toString();
57
+ expect(s).toContain("RELATES auth.user_id VIA 'STEWARDS' DEPTH 1..3");
58
+ expect(s).toBe("node.owner RELATES auth.user_id VIA 'STEWARDS' DEPTH 1..3");
59
+ });
60
+
61
+ it('relatesVia() honors custom subject/object', () => {
62
+ expect(
63
+ relatesVia('GUARDIAN', { subject: 'node.child', object: 'auth.user_id' }).toString(),
64
+ ).toBe("auth.user_id RELATES node.child VIA 'GUARDIAN'");
65
+ });
66
+ });
67
+
68
+ describe('Policy.raw — legacy footgun lint', () => {
69
+ it('throws on bare is_admin', () => {
70
+ expect(() => Policy.raw('is_admin')).toThrow();
71
+ });
72
+ it('throws on auth.isAdmin', () => {
73
+ expect(() => Policy.raw('auth.isAdmin')).toThrow();
74
+ });
75
+ it('throws on auth.admin', () => {
76
+ expect(() => Policy.raw('auth.admin')).toThrow();
77
+ });
78
+ it('allows the valid auth.is_admin', () => {
79
+ expect(() => Policy.raw('auth.is_admin')).not.toThrow();
80
+ });
81
+ it('throws on double-quoted VIA', () => {
82
+ expect(() => Policy.raw('node.owner RELATES auth.user_id VIA "STEWARDS"')).toThrow();
83
+ });
84
+ it('throws on bareword VIA', () => {
85
+ expect(() => Policy.raw('node.owner RELATES auth.user_id VIA STEWARDS')).toThrow();
86
+ });
87
+ it('throws on path-expr VIA', () => {
88
+ expect(() => Policy.raw('node.owner RELATES auth.user_id VIA auth.x')).toThrow();
89
+ });
90
+ it('allows a correct single-quoted VIA', () => {
91
+ expect(() => Policy.raw("node.owner RELATES auth.user_id VIA 'STEWARDS'")).not.toThrow();
92
+ });
93
+
94
+ it('assertNoLegacyShapes is exported and matches Policy.raw', () => {
95
+ expect(() => assertNoLegacyShapes('is_admin')).toThrow();
96
+ expect(() => assertNoLegacyShapes('auth.is_admin')).not.toThrow();
97
+ });
98
+ });
99
+
100
+ describe('defineConfig — fragments + cross-validation', () => {
101
+ it('serializes Policy resource policies to strings', () => {
102
+ const cfg = defineConfig({
103
+ auth: {
104
+ resources: [
105
+ { name: 'todos', title: 'Todos', actions: ['read'], policy: ownsIt().or(isStaff()) },
106
+ ],
107
+ },
108
+ });
109
+ expect(cfg.auth!.resources![0].policy).toBe(
110
+ "auth.user_id == node.owner || auth.roles.contains('staff')",
111
+ );
112
+ });
113
+
114
+ it('expands fragment() references inline (parenthesized)', () => {
115
+ const cfg = defineConfig({
116
+ auth: {
117
+ groups: [{ name: 'staff' }],
118
+ fragments: { staffOrAdmin: isStaff().or(isAdmin()) },
119
+ resources: [
120
+ { name: 'todos', title: 'Todos', actions: ['read'], policy: ownsIt().or(fragment('staffOrAdmin')) },
121
+ ],
122
+ },
123
+ });
124
+ expect(cfg.auth!.resources![0].policy).toBe(
125
+ "auth.user_id == node.owner || (auth.roles.contains('staff') || auth.is_admin)",
126
+ );
127
+ });
128
+
129
+ it('throws on an unknown fragment reference', () => {
130
+ expect(() =>
131
+ defineConfig({
132
+ auth: {
133
+ resources: [
134
+ { name: 'todos', title: 'Todos', actions: ['read'], policy: ownsIt().or(fragment('missing')) },
135
+ ],
136
+ },
137
+ }),
138
+ ).toThrow(/fragment/i);
139
+ });
140
+
141
+ it('throws when relatesVia references an undeclared relation', () => {
142
+ expect(() =>
143
+ defineConfig({
144
+ auth: {
145
+ relations: [
146
+ { relation_name: 'GUARDIAN', title: 'Guardian' },
147
+ ],
148
+ resources: [
149
+ { name: 'records', title: 'Records', actions: ['read'], policy: relatesVia('STEWARDS') },
150
+ ],
151
+ },
152
+ }),
153
+ ).toThrow(/STEWARDS/);
154
+ });
155
+
156
+ it('passes when relatesVia references a declared relation', () => {
157
+ expect(() =>
158
+ defineConfig({
159
+ auth: {
160
+ relations: [{ relation_name: 'STEWARDS', title: 'Stewards' }],
161
+ resources: [
162
+ { name: 'records', title: 'Records', actions: ['read'], policy: relatesVia('STEWARDS', { depth: [1, 2] }) },
163
+ ],
164
+ },
165
+ }),
166
+ ).not.toThrow();
167
+ });
168
+
169
+ it('throws when a group reference is undeclared (and groups are declared)', () => {
170
+ expect(() =>
171
+ defineConfig({
172
+ auth: {
173
+ groups: [{ name: 'staff' }],
174
+ resources: [
175
+ { name: 'todos', title: 'Todos', actions: ['read'], policy: isStaff('editors') },
176
+ ],
177
+ },
178
+ }),
179
+ ).toThrow(/editors/);
180
+ });
181
+
182
+ it('leaves a config without auth untouched', () => {
183
+ const cfg = defineConfig({ database: { indexes: [{ collection: 'x', keys: { a: 1 } }] } });
184
+ expect(cfg.auth).toBeUndefined();
185
+ });
186
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Type-level ("test-d") checks for the Phase 4 type contract. These are
3
+ * verified by `tsc --noEmit` (see the `typecheck:test-d` script / the
4
+ * tsconfig.test.json include) — there is no runtime assertion here.
5
+ *
6
+ * - `DbDocument<T>` round-trips your fields plus `id`/`_id`.
7
+ * - `insertOne` returns a `string`, NOT a `DbDocument` (@ts-expect-error).
8
+ * - The global `Platform['auth']` is exactly the canonical `AuthService`.
9
+ * - `db.find<T>()` / `db.findOne<T>()` return `DbDocument<T>`.
10
+ */
11
+
12
+ import type { DbDocument, AuthService, Database } from '../src/types.js';
13
+ import type { Platform } from '../src/types.js';
14
+ import type { Platform as TypesPlatform, AuthService as TypesAuthService } from '@maravilla-labs/types';
15
+
16
+ // ── DbDocument<T> round-trip ──────────────────────────────────────────
17
+
18
+ interface Todo {
19
+ name: string;
20
+ done: boolean;
21
+ }
22
+
23
+ declare const doc: DbDocument<Todo>;
24
+ // Own fields survive:
25
+ const _name: string = doc.name;
26
+ const _done: boolean = doc.done;
27
+ // Row identity is injected:
28
+ const _id1: string = doc.id;
29
+ const _id2: string = doc._id;
30
+ // Optional server timestamps:
31
+ const _ts: string | undefined = doc._created_at;
32
+
33
+ // A plain T is assignable into the doc's own-field surface.
34
+ const _todoFields: Todo = { name: doc.name, done: doc.done };
35
+ void _name;
36
+ void _done;
37
+ void _id1;
38
+ void _id2;
39
+ void _ts;
40
+ void _todoFields;
41
+
42
+ // ── insertOne returns a string, not a DbDocument ──────────────────────
43
+
44
+ declare const db: Database;
45
+
46
+ async function insertOneIsString() {
47
+ const inserted = await db.insertOne('todos', { name: 'x', done: false });
48
+ const _s: string = inserted; // ok — insertOne resolves to the id string
49
+ void _s;
50
+ // @ts-expect-error insertOne resolves to a string id, never a DbDocument
51
+ const _bad: DbDocument<Todo> = inserted;
52
+ void _bad;
53
+ }
54
+ void insertOneIsString;
55
+
56
+ // ── find<T> / findOne<T> are DbDocument-typed ─────────────────────────
57
+
58
+ async function findIsTyped() {
59
+ const rows = await db.find<Todo>('todos', { done: false });
60
+ const _row: DbDocument<Todo> = rows[0];
61
+ const _rowName: string = rows[0].name;
62
+ const one = await db.findOne<Todo>('todos', { name: 'x' });
63
+ const _one: DbDocument<Todo> | null = one;
64
+ void _row;
65
+ void _rowName;
66
+ void _one;
67
+ }
68
+ void findIsTyped;
69
+
70
+ // ── Platform['auth'] is the canonical AuthService ─────────────────────
71
+
72
+ // The global Platform's `auth` must be assignable to AuthService and back.
73
+ declare const platformAuth: TypesPlatform['auth'];
74
+ const _auth: AuthService = platformAuth;
75
+ const _authBack: TypesPlatform['auth'] = {} as AuthService;
76
+ void _auth;
77
+ void _authBack;
78
+
79
+ // The platform package re-exports the SAME AuthService as @maravilla-labs/types.
80
+ const _sameAuth: TypesAuthService = {} as AuthService;
81
+ const _sameAuthBack: AuthService = {} as TypesAuthService;
82
+ void _sameAuth;
83
+ void _sameAuthBack;
84
+
85
+ // The platform package's own Platform.auth is also an AuthService.
86
+ declare const platform2: Platform;
87
+ const _auth2: AuthService = platform2.auth;
88
+ void _auth2;
89
+
90
+ // New FR methods are present on the canonical surface.
91
+ type _HasAddRelation = AuthService['addRelation'];
92
+ type _HasExplain = AuthService['explain'];
93
+ type _HasCanMany = AuthService['canMany'];
94
+ type _HasCreateManaged = AuthService['createManagedUser'];
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "rootDir": "."
6
+ },
7
+ "include": ["src/**/*", "tests/**/*"]
8
+ }