@reventlessdev/reventless-local 3.0.0-alpha.219 → 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.
- package/CHANGELOG.md +15 -0
- package/package.json +11 -11
- package/src/LocalPlatformRegistry.res +189 -0
- package/src/LocalPlatformRegistry.res.mjs +162 -0
- package/src/LocalSeedTarget.res +177 -0
- package/src/LocalSeedTarget.res.mjs +172 -0
- package/src/Platform.res +13 -0
- package/src/Platform.res.mjs +11 -0
- package/src/adapter/Auth/Auth_GraphqlContext.res +75 -7
- package/src/adapter/Auth/Auth_GraphqlContext.res.mjs +38 -1
- package/src/adapter/Auth/LocalAuth.res +11 -26
- package/src/adapter/Auth/LocalAuth.res.mjs +5 -8
- package/src/adapter/BackendState.res +15 -0
- package/src/adapter/BackendState.res.mjs +29 -0
- package/src/adapter/PlatformGraphQL_Server.res +9 -4
- package/src/reset/LocalSeedReset.res +38 -16
- package/src/reset/LocalSeedReset.res.mjs +49 -21
- package/tests/LocalPlatformRegistryTest.res +143 -0
- package/tests/LocalPlatformRegistryTest.res.mjs +152 -0
- package/tests/adapter/LocalAuthLoginTest.res +44 -0
- package/tests/adapter/LocalAuthLoginTest.res.mjs +32 -0
- package/tests/adapter/RequireGroupRefusalTest.res +107 -0
- package/tests/adapter/RequireGroupRefusalTest.res.mjs +130 -0
|
@@ -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
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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 */
|
|
@@ -392,3 +392,47 @@ testPromise("switching refuses a token this server did not sign", async () => {
|
|
|
392
392
|
| Error(msg) => expect(msg)->toEqual("Invalid token")
|
|
393
393
|
}
|
|
394
394
|
})
|
|
395
|
+
|
|
396
|
+
// ── The shared conformance table ──────────────────────────────────────────
|
|
397
|
+
//
|
|
398
|
+
// The cases above were written against this path. `ReventlessCore.Auth_ActiveRole`
|
|
399
|
+
// holds them in a form the Cognito path runs too — the two minting
|
|
400
|
+
// implementations cannot share code across a process boundary, so a table both
|
|
401
|
+
// drive is what keeps them from drifting. A case added there fails here until
|
|
402
|
+
// this path satisfies it, which is the point.
|
|
403
|
+
|
|
404
|
+
describe("the conformance table, minted locally", () => {
|
|
405
|
+
ReventlessCore.Auth_ActiveRole.conformanceCases->Array.forEach(({
|
|
406
|
+
label,
|
|
407
|
+
membership,
|
|
408
|
+
requested,
|
|
409
|
+
expected,
|
|
410
|
+
}) =>
|
|
411
|
+
testPromise(label, async () => {
|
|
412
|
+
resetAll()
|
|
413
|
+
LocalAuth.Login.setCredentials(
|
|
414
|
+
~username="conformance",
|
|
415
|
+
~password="pw",
|
|
416
|
+
~identity={
|
|
417
|
+
userId: "u-conformance",
|
|
418
|
+
username: "conformance",
|
|
419
|
+
groups: membership,
|
|
420
|
+
provider: InMemory,
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
let minted = await LocalAuth.Login.issue(
|
|
424
|
+
~username="conformance",
|
|
425
|
+
~password="pw",
|
|
426
|
+
~activeRole=?requested,
|
|
427
|
+
)
|
|
428
|
+
switch (minted, expected) {
|
|
429
|
+
| (Ok(token), Some(groups)) => expect(decodeOrThrow(token).groups)->toEqual(groups)
|
|
430
|
+
// `None` means refused — not honoured, not ignored, and not reduced to the
|
|
431
|
+
// empty set. A token minted here at all would be the vulnerability.
|
|
432
|
+
| (Ok(_), None) => JsError.throwWithMessage("expected the request to be refused")
|
|
433
|
+
| (Error(msg), Some(_)) => JsError.throwWithMessage("expected a token, got: " ++ msg)
|
|
434
|
+
| (Error(_), None) => expect(true)->toEqual(true)
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
)
|
|
438
|
+
})
|
|
@@ -4,6 +4,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
4
4
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
5
5
|
import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
|
|
6
6
|
import * as LocalAuth$ReventlessLocal from "../../src/adapter/Auth/LocalAuth.res.mjs";
|
|
7
|
+
import * as Auth_ActiveRole$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Auth/Auth_ActiveRole.res.mjs";
|
|
7
8
|
|
|
8
9
|
let alice_groups = ["Editor"];
|
|
9
10
|
|
|
@@ -378,6 +379,37 @@ globalThis.test("switching refuses a token this server did not sign", async () =
|
|
|
378
379
|
globalThis.expect(msg._0).toEqual("Invalid token");
|
|
379
380
|
});
|
|
380
381
|
|
|
382
|
+
globalThis.describe("the conformance table, minted locally", () => {
|
|
383
|
+
Auth_ActiveRole$ReventlessCore.conformanceCases.forEach(param => {
|
|
384
|
+
let expected = param.expected;
|
|
385
|
+
let requested = param.requested;
|
|
386
|
+
let membership = param.membership;
|
|
387
|
+
globalThis.test(param.label, async () => {
|
|
388
|
+
resetAll();
|
|
389
|
+
LocalAuth$ReventlessLocal.Login.setCredentials("conformance", "pw", {
|
|
390
|
+
userId: "u-conformance",
|
|
391
|
+
username: "conformance",
|
|
392
|
+
groups: membership,
|
|
393
|
+
provider: "InMemory"
|
|
394
|
+
});
|
|
395
|
+
let minted = await LocalAuth$ReventlessLocal.Login.issue("conformance", "pw", requested);
|
|
396
|
+
if (minted.TAG === "Ok") {
|
|
397
|
+
if (expected !== undefined) {
|
|
398
|
+
globalThis.expect(decodeOrThrow(minted._0).groups).toEqual(expected);
|
|
399
|
+
return;
|
|
400
|
+
} else {
|
|
401
|
+
return Stdlib_JsError.throwWithMessage("expected the request to be refused");
|
|
402
|
+
}
|
|
403
|
+
} else if (expected !== undefined) {
|
|
404
|
+
return Stdlib_JsError.throwWithMessage("expected a token, got: " + minted._0);
|
|
405
|
+
} else {
|
|
406
|
+
globalThis.expect(true).toEqual(true);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
381
413
|
export {
|
|
382
414
|
alice,
|
|
383
415
|
bob,
|
|
@@ -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
|
+
})
|