| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { |
| 4 | AuthOrchestrationOperateScope, |
| 5 | AuthOrchestrationReadScope, |
| 6 | AuthSessionId, |
| 7 | type HubOrganizationCommand, |
| 8 | HubInbox as HubInboxSchema, |
| 9 | HubInboxEnvironment, |
| 10 | EnvironmentId, |
| 11 | } from "@t3tools/contracts"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as DateTime from "effect/DateTime"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | import { HttpRouter } from "effect/http"; |
| 17 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 18 | import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; |
| 19 | import * as HubInbox from "./HubInbox.ts"; |
| 20 | import * as HubInboxHttp from "./HubInboxHttp.ts"; |
| 21 | import { hubInboxThread } from "./HubInbox.testkit.ts"; |
| 22 | |
| 23 | const decodeInbox = Schema.decodeUnknownEffect(Schema.toCodecJson(HubInboxSchema)); |
| 24 | |
| 25 | it.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 | ); |