1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 AuthOrchestrationOperateScope,
5 AuthOrchestrationReadScope,
6 AuthSessionId,
7 type HubOrganizationCommand,
8 HubInbox as HubInboxSchema,
9 HubInboxEnvironment,
10 EnvironmentId,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as DateTime from "effect/DateTime";
14import * as Layer from "effect/Layer";
15import * as Schema from "effect/Schema";
16import { HttpRouter } from "effect/http";
17import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
18import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
19import * as HubInbox from "./HubInbox.ts";
20import * as HubInboxHttp from "./HubInboxHttp.ts";
21import { hubInboxThread } from "./HubInbox.testkit.ts";
22
23const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxSchema));
24
25it.effect(
26 "authenticates inbox requests and restricts source reconciliation to organization commands",
27 () =>
28 Effect.gen(function* () {
29 const dispatched: Array<HubOrganizationCommand["type"]> = [];
30 let entry: HubInboxEnvironment = {
31 environmentId: EnvironmentId.make("laptop"),
32 snapshot: {
33 schemaVersion: 2,
34 snapshotSequence: 1,
35 projects: [],
36 threads: [
37 {
38 ...hubInboxThread,
39 snoozedUntil: DateTime.makeUnsafe("2030-01-01T00:00:00Z"),
40 snoozedAt: DateTime.makeUnsafe(0),
41 },
42 ],
43 archivedThreads: [],
44 },
45 pendingThreadIds: [hubInboxThread.id],
46 };
47 const auth = {
48 authenticateWebSocketUpgrade: (
49 request: import("effect/http/HttpServerRequest").HttpServerRequest,
50 ) =>
51 request.headers.authorization
52 ? Effect.succeed({
53 sessionId: AuthSessionId.make("phone"),
54 subject: "phone",
55 method: "bearer-access-token" as const,
56 scopes:
57 request.headers.authorization === "Bearer operate"
58 ? [AuthOrchestrationReadScope, AuthOrchestrationOperateScope]
59 : [AuthOrchestrationReadScope],
60 })
61 : Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError({})),
62 } as unknown as EnvironmentAuth.EnvironmentAuth["Service"];
63 const orchestrator = {
64 dispatch: (command: HubOrganizationCommand) =>
65 Effect.sync(() => {
66 dispatched.push(command.type);
67 return { sequence: 1 };
68 }),
69 } as unknown as Orchestrator.OrchestratorV2["Service"];
70 const { handler, dispose } = HttpRouter.toWebHandler(
71 HubInboxHttp.layer.pipe(
72 Layer.provideMerge(
73 Layer.mergeAll(
74 Layer.succeed(EnvironmentAuth.EnvironmentAuth, auth),
75 Layer.succeed(Orchestrator.OrchestratorV2, orchestrator),
76 Layer.succeed(HubInbox.HubInbox, {
77 read: () => Effect.succeed({ environments: [entry] }),
78 organize: () => Effect.succeed(entry),
79 refresh: Effect.void,
80 }),
81 ),
82 ),
83 Layer.provideMerge(NodeServices.layer),
84 ),
85 );
86 yield* Effect.addFinalizer(() => Effect.promise(dispose));
87 const send = (path: string, token?: string, type?: string, etag?: string) =>
88 Effect.promise(() =>
89 handler(
90 new Request(`http://hub${path}`, {
91 method: type ? "POST" : "GET",
92 headers: {
93 ...(token ? { authorization: `Bearer ${token}` } : {}),
94 "content-type": "application/json",
95 ...(etag ? { "if-none-match": etag } : {}),
96 },
97 ...(type
98 ? { body: JSON.stringify({ type, threadId: "chat", commandId: "command" }) }
99 : {}),
100 }),
101 ),
102 );
103 assert.equal((yield* send("/api/hub/inbox")).status, 401);
104 const first = yield* send("/api/hub/inbox", "read");
105 assert.equal(first.status, 200);
106 const decoded = yield* decodeInbox(yield* Effect.promise(() => first.json()));
107 assert.equal(
108 DateTime.formatIso(decoded.environments[0]!.snapshot.threads[0]!.snoozedUntil!),
109 "2030-01-01T00:00:00.000Z",
110 );
111 const etag = first.headers.get("etag")!;
112 for (const validator of [etag, `W/${etag}`, `"other", W/${etag}`, "*"])
113 assert.equal((yield* send("/api/hub/inbox", "read", undefined, validator)).status, 304);
114 assert.equal(
115 (yield* send("/api/hub/inbox", "read", undefined, '"other", W/"different"')).status,
116 200,
117 );
118 entry = { ...entry, pendingThreadIds: [] };
119 assert.equal((yield* send("/api/hub/inbox", "read", undefined, etag)).status, 200);
120 assert.equal((yield* send("/api/hub/organization", "read", "thread.settle")).status, 403);
121 assert.equal((yield* send("/api/hub/organization", "operate", "thread.stop")).status, 400);
122 assert.deepEqual(dispatched, []);
123 assert.equal((yield* send("/api/hub/organization", "operate", "thread.settle")).status, 200);
124 assert.deepEqual(dispatched, ["thread.settle"]);
125 }).pipe(Effect.scoped),
126);