@reventlessdev/reventless-local 3.0.0-alpha.220 → 3.0.0-alpha.221

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.
@@ -11,6 +11,7 @@ import * as Seed_Prompt$ReventlessSeed from "@reventlessdev/reventless-seed/src/
11
11
  import * as UiFragments$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs";
12
12
  import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
13
13
  import * as SqliteDriver$ReventlessLocal from "../adapter/SqliteDriver.res.mjs";
14
+ import * as LocalSeedTarget$ReventlessLocal from "../LocalSeedTarget.res.mjs";
14
15
  import * as LocalObjectStore$ReventlessLocal from "../adapter/ObjectStore/LocalObjectStore.res.mjs";
15
16
  import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
16
17
  import * as ObjectStoreStorage_FileSystem$ReventlessLocal from "../adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs";
@@ -437,33 +438,60 @@ function scopeOptions(plugins) {
437
438
 
438
439
  function run(dbPath) {
439
440
  let go = async () => {
440
- let resolved;
441
- if (dbPath !== undefined) {
442
- resolved = dbPath;
443
- } else {
441
+ let fromBackendEnv = () => {
444
442
  let match = Backend$ReventlessLocal.fromEnv();
445
443
  if (typeof match !== "object") {
446
- console.log("Nothing to reset — REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.");
447
- resolved = undefined;
448
- } else if (match.TAG === "Sqlite") {
449
- let path = match.path;
450
- if (path !== ":memory:") {
451
- resolved = path;
452
- } else {
453
- console.log("Nothing to reset — REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.");
454
- resolved = undefined;
455
- }
444
+ return {
445
+ TAG: "Error",
446
+ _0: "REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties."
447
+ };
448
+ }
449
+ if (match.TAG !== "Sqlite") {
450
+ return {
451
+ TAG: "Error",
452
+ _0: "the Postgres backend keeps its event logs off this machine. Reset it against the database."
453
+ };
454
+ }
455
+ let path = match.path;
456
+ if (path !== ":memory:") {
457
+ return {
458
+ TAG: "Ok",
459
+ _0: path
460
+ };
456
461
  } else {
457
- console.log("Nothing to reset here — the Postgres backend keeps its event logs off this machine. Reset it against the database.");
458
- resolved = undefined;
462
+ return {
463
+ TAG: "Error",
464
+ _0: "REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties."
465
+ };
459
466
  }
467
+ };
468
+ let match = Seed_Prompt$ReventlessSeed.envValue("REVENTLESS_LOCAL_BACKEND");
469
+ let resolved;
470
+ if (dbPath !== undefined) {
471
+ resolved = {
472
+ TAG: "Ok",
473
+ _0: dbPath
474
+ };
475
+ } else if (match !== undefined) {
476
+ resolved = fromBackendEnv();
477
+ } else {
478
+ let target = await LocalSeedTarget$ReventlessLocal.select();
479
+ LocalSeedTarget$ReventlessLocal.announce(target);
480
+ resolved = LocalSeedTarget$ReventlessLocal.storePath(target);
481
+ }
482
+ let resolved$1;
483
+ if (resolved.TAG === "Ok") {
484
+ resolved$1 = resolved._0;
485
+ } else {
486
+ console.log(`Nothing to reset — ` + resolved._0);
487
+ resolved$1 = undefined;
460
488
  }
461
- if (resolved === undefined) {
489
+ if (resolved$1 === undefined) {
462
490
  return;
463
491
  }
464
- if (Nodefs.existsSync(resolved)) {
465
- let root = Nodepath.dirname(resolved);
466
- let db = SqliteDriver$ReventlessLocal.openDb(resolved);
492
+ if (Nodefs.existsSync(resolved$1)) {
493
+ let root = Nodepath.dirname(resolved$1);
494
+ let db = SqliteDriver$ReventlessLocal.openDb(resolved$1);
467
495
  let plugins = readPlugins(db, root).map(p => p.plugin);
468
496
  let scope = await Seed_Prompt$ReventlessSeed.select("Reset scope:", scopeOptions(plugins), "SEED_RESET_SCOPE");
469
497
  let plan = build(db, root, scope);
@@ -500,7 +528,7 @@ function run(dbPath) {
500
528
  Seed_Prompt$ReventlessSeed.close();
501
529
  return SqliteDriver$ReventlessLocal.close(db);
502
530
  }
503
- console.log(`Nothing to reset — no store at ` + resolved + `.`);
531
+ console.log(`Nothing to reset — no store at ` + resolved$1 + `.`);
504
532
  };
505
533
  go();
506
534
  }
@@ -0,0 +1,143 @@
1
+ // What the seed tools rely on when they ask "which platform do you mean".
2
+ //
3
+ // The failure these pin is not a crash: it is two tools reporting success about
4
+ // different databases. `seed:reset` emptied the store its own environment named
5
+ // while the platform served another, so the `seed` after it refused as though
6
+ // nothing had been reset. Everything here is therefore about the registry
7
+ // telling the truth — including when a platform died without cleaning up.
8
+
9
+ @@warning("-44")
10
+
11
+ open JestGlobals
12
+
13
+ let tempRoot = (): string =>
14
+ NodeFs.mkdtempSync(NodePath.join([NodeOs.tmpdir(), "reventless-registry-"]))
15
+
16
+ // Above any real pid on macOS and Linux, so `kill(pid, 0)` reports it gone
17
+ // rather than hitting an unrelated process that happens to be running.
18
+ let deadPid = 2147483646
19
+
20
+ let sqliteStore = (path): LocalPlatformRegistry.store => {kind: "sqlite", path: Some(path)}
21
+ let memoryStore: LocalPlatformRegistry.store = {kind: "memory", path: None}
22
+
23
+ let writeAt = (~cwd, ~port, ~store, ~pid=NodeProcess.pid) =>
24
+ LocalPlatformRegistry.write(
25
+ ~port,
26
+ ~endpoint=`http://localhost:${port->Int.toString}/graphql`,
27
+ ~loginEndpoint=`http://localhost:${port->Int.toString}/__inmemory/login`,
28
+ ~store,
29
+ ~pid,
30
+ ~cwd,
31
+ )
32
+
33
+ describe("LocalPlatformRegistry", () => {
34
+ testSync("reports a running platform with the store it opened", () => {
35
+ let cwd = tempRoot()
36
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore("/abs/.reventless/runner.db"))
37
+
38
+ switch LocalPlatformRegistry.list(~cwd, ())->Array.get(0) {
39
+ | Some(entry) =>
40
+ expect(entry.port)->toEqual(4000)
41
+ expect(entry.endpoint)->toEqual("http://localhost:4000/graphql")
42
+ expect(entry.store.kind)->toEqual("sqlite")
43
+ expect(entry.store.path)->toEqual(Some("/abs/.reventless/runner.db"))
44
+ | None => fail("expected the entry just written to be listed")
45
+ }
46
+ })
47
+
48
+ // The pair that broke: same app, same directory, two platforms. A registry
49
+ // that could only hold one of them would answer with a coin flip.
50
+ testSync("lists both platforms in one directory, by port", () => {
51
+ let cwd = tempRoot()
52
+ let _ = writeAt(~cwd, ~port=4010, ~store=memoryStore)
53
+ let _ = writeAt(~cwd, ~port=4000, ~store=sqliteStore("/abs/.reventless/runner.db"))
54
+
55
+ let ports = LocalPlatformRegistry.list(~cwd, ())->Array.map(e => e.port)
56
+ expect(ports)->toEqual([4000, 4010])
57
+ })
58
+
59
+ testSync("drops an entry whose process is gone, and deletes it", () => {
60
+ let cwd = tempRoot()
61
+ let path = writeAt(~cwd, ~port=4000, ~store=sqliteStore("/abs/x.db"), ~pid=deadPid)
62
+
63
+ expect(LocalPlatformRegistry.list(~cwd, ()))->toEqual([])
64
+ // Pruned, not merely skipped: a file that survived would be re-read (and
65
+ // re-rejected) on every seed for the life of the working copy.
66
+ expect(NodeFs.existsSync(path))->toEqual(false)
67
+ })
68
+
69
+ testSync("drops an unreadable entry rather than failing the run", () => {
70
+ let cwd = tempRoot()
71
+ let dir = LocalPlatformRegistry.runningDir(~cwd, ())
72
+ NodeFs.mkdirSync(dir, {recursive: true})
73
+ let path = NodePath.join([dir, "4000.json"])
74
+ NodeFs.writeFileSync(path, "{ this is not json")
75
+
76
+ expect(LocalPlatformRegistry.list(~cwd, ()))->toEqual([])
77
+ expect(NodeFs.existsSync(path))->toEqual(false)
78
+ })
79
+
80
+ testSync("reads empty where no platform ever ran", () => {
81
+ expect(LocalPlatformRegistry.list(~cwd=tempRoot(), ()))->toEqual([])
82
+ })
83
+ })
84
+
85
+ describe("LocalSeedTarget.loginFor", () => {
86
+ // Setting only REVENTLESS_GRAPHQL_ENDPOINT used to leave the login round-trip
87
+ // on :4000 — seeding one platform through another's front door.
88
+ testSync("follows the endpoint's own host and port", () => {
89
+ expect(LocalSeedTarget.loginFor("http://localhost:4010/graphql"))->toEqual(
90
+ "http://localhost:4010/__inmemory/login",
91
+ )
92
+ expect(LocalSeedTarget.loginFor("http://127.0.0.1:9999/graphql"))->toEqual(
93
+ "http://127.0.0.1:9999/__inmemory/login",
94
+ )
95
+ })
96
+ })
97
+
98
+ describe("LocalSeedTarget.storePath", () => {
99
+ let target = (store: LocalPlatformRegistry.store): LocalSeedTarget.t => {
100
+ endpoint: "http://localhost:4000/graphql",
101
+ loginEndpoint: "http://localhost:4000/__inmemory/login",
102
+ origin: Running({
103
+ app: "demo",
104
+ port: 4000,
105
+ pid: NodeProcess.pid,
106
+ endpoint: "http://localhost:4000/graphql",
107
+ loginEndpoint: "http://localhost:4000/__inmemory/login",
108
+ store,
109
+ startedAt: "2026-08-14T00:00:00.000Z",
110
+ }),
111
+ }
112
+
113
+ testSync("hands back the file a SQLite platform opened", () => {
114
+ expect(target(sqliteStore("/abs/.reventless/runner.db"))->LocalSeedTarget.storePath)->toEqual(
115
+ Ok("/abs/.reventless/runner.db"),
116
+ )
117
+ })
118
+
119
+ // Three different reasons there is no file, and a tool that collapsed them
120
+ // would tell an in-memory platform to go and check its disk.
121
+ testSync("explains each kind of store it cannot open, naming the platform", () => {
122
+ let reason = t =>
123
+ switch t->LocalSeedTarget.storePath {
124
+ | Ok(_) => "unexpectedly resolved a path"
125
+ | Error(message) => message
126
+ }
127
+
128
+ expect(target(memoryStore)->reason->String.includes("in memory"))->toEqual(true)
129
+ expect(target(memoryStore)->reason->String.includes("localhost:4000"))->toEqual(true)
130
+ expect(
131
+ target({kind: "postgres", path: None})->reason->String.includes("off this machine"),
132
+ )->toEqual(true)
133
+ expect(
134
+ {
135
+ endpoint: "http://localhost:4000/graphql",
136
+ loginEndpoint: "http://localhost:4000/__inmemory/login",
137
+ origin: NoneRunning,
138
+ }
139
+ ->reason
140
+ ->String.includes("no local platform is running"),
141
+ )->toEqual(true)
142
+ })
143
+ })
@@ -0,0 +1,152 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodeos from "node:os";
5
+ import * as Nodepath from "node:path";
6
+ import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.res.mjs";
7
+ import * as LocalSeedTarget$ReventlessLocal from "../src/LocalSeedTarget.res.mjs";
8
+ import * as LocalPlatformRegistry$ReventlessLocal from "../src/LocalPlatformRegistry.res.mjs";
9
+
10
+ function tempRoot() {
11
+ return Nodefs.mkdtempSync(Nodepath.join(Nodeos.tmpdir(), "reventless-registry-"));
12
+ }
13
+
14
+ function sqliteStore(path) {
15
+ return {
16
+ kind: "sqlite",
17
+ path: path
18
+ };
19
+ }
20
+
21
+ let memoryStore = {
22
+ kind: "memory",
23
+ path: undefined
24
+ };
25
+
26
+ function writeAt(cwd, port, store, pidOpt) {
27
+ let pid = pidOpt !== undefined ? pidOpt : process.pid;
28
+ return LocalPlatformRegistry$ReventlessLocal.write(port, `http://localhost:` + port.toString() + `/graphql`, `http://localhost:` + port.toString() + `/__inmemory/login`, store, pid, cwd);
29
+ }
30
+
31
+ globalThis.describe("LocalPlatformRegistry", () => {
32
+ globalThis.test("reports a running platform with the store it opened", () => {
33
+ let cwd = tempRoot();
34
+ writeAt(cwd, 4000, {
35
+ kind: "sqlite",
36
+ path: "/abs/.reventless/runner.db"
37
+ }, undefined);
38
+ let entry = LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined)[0];
39
+ if (entry !== undefined) {
40
+ globalThis.expect(entry.port).toEqual(4000);
41
+ globalThis.expect(entry.endpoint).toEqual("http://localhost:4000/graphql");
42
+ globalThis.expect(entry.store.kind).toEqual("sqlite");
43
+ globalThis.expect(entry.store.path).toEqual("/abs/.reventless/runner.db");
44
+ return;
45
+ } else {
46
+ return JestGlobals.fail("expected the entry just written to be listed");
47
+ }
48
+ });
49
+ globalThis.test("lists both platforms in one directory, by port", () => {
50
+ let cwd = tempRoot();
51
+ writeAt(cwd, 4010, memoryStore, undefined);
52
+ writeAt(cwd, 4000, {
53
+ kind: "sqlite",
54
+ path: "/abs/.reventless/runner.db"
55
+ }, undefined);
56
+ let ports = LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined).map(e => e.port);
57
+ globalThis.expect(ports).toEqual([
58
+ 4000,
59
+ 4010
60
+ ]);
61
+ });
62
+ globalThis.test("drops an entry whose process is gone, and deletes it", () => {
63
+ let cwd = tempRoot();
64
+ let path = writeAt(cwd, 4000, {
65
+ kind: "sqlite",
66
+ path: "/abs/x.db"
67
+ }, 2147483646);
68
+ globalThis.expect(LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined)).toEqual([]);
69
+ globalThis.expect(Nodefs.existsSync(path)).toEqual(false);
70
+ });
71
+ globalThis.test("drops an unreadable entry rather than failing the run", () => {
72
+ let cwd = tempRoot();
73
+ let dir = LocalPlatformRegistry$ReventlessLocal.runningDir(cwd, undefined);
74
+ Nodefs.mkdirSync(dir, {
75
+ recursive: true
76
+ });
77
+ let path = Nodepath.join(dir, "4000.json");
78
+ Nodefs.writeFileSync(path, "{ this is not json", "utf8");
79
+ globalThis.expect(LocalPlatformRegistry$ReventlessLocal.list(cwd, undefined)).toEqual([]);
80
+ globalThis.expect(Nodefs.existsSync(path)).toEqual(false);
81
+ });
82
+ globalThis.test("reads empty where no platform ever ran", () => {
83
+ globalThis.expect(LocalPlatformRegistry$ReventlessLocal.list(tempRoot(), undefined)).toEqual([]);
84
+ });
85
+ });
86
+
87
+ globalThis.describe("LocalSeedTarget.loginFor", () => {
88
+ globalThis.test("follows the endpoint's own host and port", () => {
89
+ globalThis.expect(LocalSeedTarget$ReventlessLocal.loginFor("http://localhost:4010/graphql")).toEqual("http://localhost:4010/__inmemory/login");
90
+ globalThis.expect(LocalSeedTarget$ReventlessLocal.loginFor("http://127.0.0.1:9999/graphql")).toEqual("http://127.0.0.1:9999/__inmemory/login");
91
+ });
92
+ });
93
+
94
+ globalThis.describe("LocalSeedTarget.storePath", () => {
95
+ let target = store => ({
96
+ endpoint: "http://localhost:4000/graphql",
97
+ loginEndpoint: "http://localhost:4000/__inmemory/login",
98
+ origin: {
99
+ TAG: "Running",
100
+ _0: {
101
+ app: "demo",
102
+ port: 4000,
103
+ pid: process.pid,
104
+ endpoint: "http://localhost:4000/graphql",
105
+ loginEndpoint: "http://localhost:4000/__inmemory/login",
106
+ store: store,
107
+ startedAt: "2026-08-14T00:00:00.000Z"
108
+ }
109
+ }
110
+ });
111
+ globalThis.test("hands back the file a SQLite platform opened", () => {
112
+ globalThis.expect(LocalSeedTarget$ReventlessLocal.storePath(target({
113
+ kind: "sqlite",
114
+ path: "/abs/.reventless/runner.db"
115
+ }))).toEqual({
116
+ TAG: "Ok",
117
+ _0: "/abs/.reventless/runner.db"
118
+ });
119
+ });
120
+ globalThis.test("explains each kind of store it cannot open, naming the platform", () => {
121
+ let reason = t => {
122
+ let message = LocalSeedTarget$ReventlessLocal.storePath(t);
123
+ if (message.TAG === "Ok") {
124
+ return "unexpectedly resolved a path";
125
+ } else {
126
+ return message._0;
127
+ }
128
+ };
129
+ globalThis.expect(reason(target(memoryStore)).includes("in memory")).toEqual(true);
130
+ globalThis.expect(reason(target(memoryStore)).includes("localhost:4000")).toEqual(true);
131
+ globalThis.expect(reason(target({
132
+ kind: "postgres",
133
+ path: undefined
134
+ })).includes("off this machine")).toEqual(true);
135
+ globalThis.expect(reason({
136
+ endpoint: "http://localhost:4000/graphql",
137
+ loginEndpoint: "http://localhost:4000/__inmemory/login",
138
+ origin: "NoneRunning"
139
+ }).includes("no local platform is running")).toEqual(true);
140
+ });
141
+ });
142
+
143
+ let deadPid = 2147483646;
144
+
145
+ export {
146
+ tempRoot,
147
+ deadPid,
148
+ sqliteStore,
149
+ memoryStore,
150
+ writeAt,
151
+ }
152
+ /* Not a pure module */
@@ -0,0 +1,107 @@
1
+ // Which refusal an admin-gated field gives, and why the two are not one.
2
+ //
3
+ // A group check has two ways to say no, and for a long time it said them with
4
+ // one code. A caller the server identified who does not hold the group, a
5
+ // caller whose token did not verify, and a caller who presented nothing at all
6
+ // all read `UNAUTHORIZED` — so the answer a client can draw from a refusal is
7
+ // the same in a case where presenting credentials again fixes it and a case
8
+ // where it never will.
9
+ //
10
+ // Clients resolve that by guessing, and the guess that fits an expired token is
11
+ // to discard the session. Applied to a caller who simply lacks the group, it
12
+ // ends a session that is working perfectly — and on a platform whose discovery
13
+ // surfaces are all admin-gated, every non-admin caller meets this refusal as a
14
+ // matter of course.
15
+ //
16
+ // `FORBIDDEN` is the fact that removes the guess: the caller is known, and the
17
+ // answer will not change if they authenticate again.
18
+
19
+ open JestGlobals
20
+
21
+ let identity = (~groups): Reventless.Identity.t => {
22
+ userId: "u-1",
23
+ username: "u",
24
+ groups,
25
+ provider: InMemory,
26
+ }
27
+
28
+ // The context shape `buildAuthContext` writes. Built here rather than driven
29
+ // through a server so the two axes — holds the group, and was authenticated —
30
+ // can be set independently, including the combination a real request cannot
31
+ // produce but a malformed context can.
32
+ let context = (~groups, ~authenticated): JSON.t =>
33
+ Obj.magic({"identity": identity(~groups), "authenticated": authenticated})
34
+
35
+ let ok: GraphqlYoga.resolverFn = async (_root, _args, _ctx) => JSON.Encode.string("ran")
36
+
37
+ // The thrown value is a `GraphQLError`, but a ReScript `catch` hands back its
38
+ // own exception wrapper — so it is unwrapped before the message and extensions
39
+ // are read off the raw object. No decoder: that shape is the contract this file
40
+ // is about, and describing it through one would test the decoder instead.
41
+ let describeRefusal = (e: exn): (string, string) =>
42
+ switch e->JsExn.fromException {
43
+ | None => ("not a JavaScript error", "")
44
+ | Some(js) => (js->JsExn.message->Option.getOr(""), (js->Obj.magic)["extensions"]["code"])
45
+ }
46
+
47
+ let refusalOf = async (~groups, ~authenticated): result<JSON.t, (string, string)> => {
48
+ let guarded = Auth_GraphqlContext.requireGroup(~group="Admin", ok)
49
+ try {
50
+ Ok(await guarded(JSON.Encode.null, JSON.Encode.null, context(~groups, ~authenticated)))
51
+ } catch {
52
+ | e => Error(describeRefusal(e))
53
+ }
54
+ }
55
+
56
+ describe("requireGroup", () => {
57
+ testPromise("runs the resolver for a caller holding the group", async () => {
58
+ let outcome = await refusalOf(~groups=["Admin"], ~authenticated=true)
59
+ expect(outcome)->toEqual(Ok(JSON.Encode.string("ran")))
60
+ })
61
+
62
+ // The case that ended working sessions: a real caller, a real token, and a
63
+ // group they do not hold. Nothing about signing in again changes it.
64
+ testPromise("refuses an identified caller without the group as FORBIDDEN", async () => {
65
+ let outcome = await refusalOf(~groups=["Shopper"], ~authenticated=true)
66
+ expect(outcome)->toEqual(Error(("Forbidden: requires group \"Admin\"", "FORBIDDEN")))
67
+ })
68
+
69
+ // Unchanged, deliberately. A client reading only `UNAUTHORIZED` already treats
70
+ // it as "your credentials are not being honoured", and confining the code to
71
+ // the case where that holds makes such a client right rather than broken.
72
+ testPromise("refuses a caller it could not identify as UNAUTHORIZED", async () => {
73
+ let outcome = await refusalOf(~groups=[], ~authenticated=false)
74
+ expect(outcome)->toEqual(Error(("Unauthorized: requires group \"Admin\"", "UNAUTHORIZED")))
75
+ })
76
+
77
+ // A context without the key proves nothing about the caller, so it takes the
78
+ // reading that asks for credentials rather than the one that says theirs were
79
+ // read and found wanting.
80
+ testPromise("reads a context missing the outcome as unidentified", async () => {
81
+ let guarded = Auth_GraphqlContext.requireGroup(~group="Admin", ok)
82
+ let legacyContext: JSON.t = Obj.magic({"identity": identity(~groups=["Shopper"])})
83
+ let code = try {
84
+ let _ = await guarded(JSON.Encode.null, JSON.Encode.null, legacyContext)
85
+ "no refusal"
86
+ } catch {
87
+ | e => describeRefusal(e)->Pair.second
88
+ }
89
+ expect(code)->toBe("UNAUTHORIZED")
90
+ })
91
+ })
92
+
93
+ describe("isAuthenticated", () => {
94
+ testSync("a verified caller is authenticated", () =>
95
+ expect(Auth_GraphqlContext.isAuthenticated(Authenticated(identity(~groups=[]))))->toBe(true)
96
+ )
97
+
98
+ // The distinction `identityFromAuthResult` loses: both of these become the
99
+ // anonymous identity, and only one of them means credentials were rejected.
100
+ testSync("a rejected token is not", () =>
101
+ expect(Auth_GraphqlContext.isAuthenticated(AuthError("Invalid bearer token")))->toBe(false)
102
+ )
103
+
104
+ testSync("nor is an anonymous caller", () =>
105
+ expect(Auth_GraphqlContext.isAuthenticated(Anonymous))->toBe(false)
106
+ )
107
+ })
@@ -0,0 +1,130 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
6
+ import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
+ import * as Auth_GraphqlContext$ReventlessLocal from "../../src/adapter/Auth/Auth_GraphqlContext.res.mjs";
8
+
9
+ function identity(groups) {
10
+ return {
11
+ userId: "u-1",
12
+ username: "u",
13
+ groups: groups,
14
+ provider: "InMemory"
15
+ };
16
+ }
17
+
18
+ function context(groups, authenticated) {
19
+ return {
20
+ identity: identity(groups),
21
+ authenticated: authenticated
22
+ };
23
+ }
24
+
25
+ async function ok(_root, _args, _ctx) {
26
+ return "ran";
27
+ }
28
+
29
+ function describeRefusal(e) {
30
+ let js = Stdlib_JsExn.fromException(e);
31
+ if (js === undefined) {
32
+ return [
33
+ "not a JavaScript error",
34
+ ""
35
+ ];
36
+ }
37
+ let js$1 = Primitive_option.valFromOption(js);
38
+ return [
39
+ Stdlib_Option.getOr(Stdlib_JsExn.message(js$1), ""),
40
+ js$1.extensions.code
41
+ ];
42
+ }
43
+
44
+ async function refusalOf(groups, authenticated) {
45
+ let guarded = Auth_GraphqlContext$ReventlessLocal.requireGroup("Admin", ok);
46
+ try {
47
+ return {
48
+ TAG: "Ok",
49
+ _0: await guarded(null, null, context(groups, authenticated))
50
+ };
51
+ } catch (raw_e) {
52
+ let e = Primitive_exceptions.internalToException(raw_e);
53
+ return {
54
+ TAG: "Error",
55
+ _0: describeRefusal(e)
56
+ };
57
+ }
58
+ }
59
+
60
+ globalThis.describe("requireGroup", () => {
61
+ globalThis.test("runs the resolver for a caller holding the group", async () => {
62
+ let outcome = await refusalOf(["Admin"], true);
63
+ globalThis.expect(outcome).toEqual({
64
+ TAG: "Ok",
65
+ _0: "ran"
66
+ });
67
+ });
68
+ globalThis.test("refuses an identified caller without the group as FORBIDDEN", async () => {
69
+ let outcome = await refusalOf(["Shopper"], true);
70
+ globalThis.expect(outcome).toEqual({
71
+ TAG: "Error",
72
+ _0: [
73
+ "Forbidden: requires group \"Admin\"",
74
+ "FORBIDDEN"
75
+ ]
76
+ });
77
+ });
78
+ globalThis.test("refuses a caller it could not identify as UNAUTHORIZED", async () => {
79
+ let outcome = await refusalOf([], false);
80
+ globalThis.expect(outcome).toEqual({
81
+ TAG: "Error",
82
+ _0: [
83
+ "Unauthorized: requires group \"Admin\"",
84
+ "UNAUTHORIZED"
85
+ ]
86
+ });
87
+ });
88
+ globalThis.test("reads a context missing the outcome as unidentified", async () => {
89
+ let guarded = Auth_GraphqlContext$ReventlessLocal.requireGroup("Admin", ok);
90
+ let legacyContext = {
91
+ identity: identity(["Shopper"])
92
+ };
93
+ let code;
94
+ try {
95
+ await guarded(null, null, legacyContext);
96
+ code = "no refusal";
97
+ } catch (raw_e) {
98
+ let e = Primitive_exceptions.internalToException(raw_e);
99
+ code = describeRefusal(e)[1];
100
+ }
101
+ globalThis.expect(code).toBe("UNAUTHORIZED");
102
+ });
103
+ });
104
+
105
+ globalThis.describe("isAuthenticated", () => {
106
+ globalThis.test("a verified caller is authenticated", () => {
107
+ globalThis.expect(Auth_GraphqlContext$ReventlessLocal.isAuthenticated({
108
+ TAG: "Authenticated",
109
+ _0: identity([])
110
+ })).toBe(true);
111
+ });
112
+ globalThis.test("a rejected token is not", () => {
113
+ globalThis.expect(Auth_GraphqlContext$ReventlessLocal.isAuthenticated({
114
+ TAG: "AuthError",
115
+ _0: "Invalid bearer token"
116
+ })).toBe(false);
117
+ });
118
+ globalThis.test("nor is an anonymous caller", () => {
119
+ globalThis.expect(Auth_GraphqlContext$ReventlessLocal.isAuthenticated("Anonymous")).toBe(false);
120
+ });
121
+ });
122
+
123
+ export {
124
+ identity,
125
+ context,
126
+ ok,
127
+ describeRefusal,
128
+ refusalOf,
129
+ }
130
+ /* Not a pure module */