| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Context, Effect, Fiber, Queue, type Scope } from "effect" |
| 3 | import * as EventJournal from "effect/eventlog/EventJournal" |
| 4 | import * as EventLog from "effect/eventlog/EventLog" |
| 5 | import * as EventLogEncryption from "effect/eventlog/EventLogEncryption" |
| 6 | import * as EventLogMessage from "effect/eventlog/EventLogMessage" |
| 7 | import * as EventLogRemote from "effect/eventlog/EventLogRemote" |
| 8 | import * as EventLogSessionAuth from "effect/eventlog/EventLogSessionAuth" |
| 9 | import { makeGetIdentityRootSecretMaterial } from "effect/eventlog/internal/identityRootSecretDerivation" |
| 10 | import type * as Rpc from "effect/rpc/Rpc" |
| 11 | import type * as RpcGroup from "effect/rpc/RpcGroup" |
| 12 | import * as RpcTest from "effect/rpc/RpcTest" |
| 13 | |
| 14 | describe("EventLogRemote", () => { |
| 15 | it.effect("retries wrapped Forbidden authentication errors", () => |
| 16 | Effect.gen(function*() { |
| 17 | const harness = yield* makeHarness(EventLogRemote.makeUnencrypted) |
| 18 | const identity = yield* EventLog.makeIdentity |
| 19 | let authenticationAttempts = 0 |
| 20 | |
| 21 | yield* Effect.forever(Effect.gen(function*() { |
| 22 | const request = yield* harness.take |
| 23 | if (request._tag === "EventLog.Hello") { |
| 24 | request.resume(Effect.succeed(new EventLogMessage.HelloResponse({ remoteId, challenge }))) |
| 25 | } else if (request._tag === "EventLog.Authenticate") { |
| 26 | authenticationAttempts++ |
| 27 | request.resume( |
| 28 | authenticationAttempts === 1 |
| 29 | ? Effect.fail( |
| 30 | new EventLogMessage.EventLogProtocolError({ |
| 31 | requestTag: "Authenticate", |
| 32 | publicKey: undefined, |
| 33 | code: "Forbidden", |
| 34 | message: "Session auth challenge has expired" |
| 35 | }) |
| 36 | ) |
| 37 | : Effect.void |
| 38 | ) |
| 39 | } else if (request._tag === "EventLog.WriteSingle") { |
| 40 | request.resume(Effect.void) |
| 41 | } |
| 42 | })).pipe(Effect.forkChild) |
| 43 | |
| 44 | yield* harness.remote.write({ |
| 45 | identity, |
| 46 | storeId: defaultStoreId, |
| 47 | entries: [makeEntry()] |
| 48 | }) |
| 49 | |
| 50 | assert.strictEqual(authenticationAttempts, 2) |
| 51 | }).pipe(Effect.provide(EventLogEncryption.layerSubtle))) |
| 52 | |
| 53 | it.effect("makeUnencrypted authenticates before writing plaintext entries", () => |
| 54 | Effect.gen(function*() { |
| 55 | const harness = yield* makeHarness(EventLogRemote.makeUnencrypted) |
| 56 | const identity = yield* EventLog.makeIdentity |
| 57 | const entry = makeEntry() |
| 58 | |
| 59 | const writeFiber = yield* harness.remote.write({ |
| 60 | identity, |
| 61 | storeId: defaultStoreId, |
| 62 | entries: [entry] |
| 63 | }).pipe(Effect.forkChild) |
| 64 | yield* authenticate({ harness, identity }) |
| 65 | |
| 66 | const request = yield* harness.take |
| 67 | assert(request._tag === "EventLog.WriteSingle") |
| 68 | const payload = request.request as Rpc.Payload<typeof EventLogMessage.WriteSingleRpc> |
| 69 | const decoded = yield* EventLogMessage.WriteEntriesUnencrypted.decode(payload.data) |
| 70 | |
| 71 | assert.strictEqual(decoded.publicKey, identity.publicKey) |
| 72 | assert.strictEqual(decoded.storeId, defaultStoreId) |
| 73 | assert.strictEqual(decoded.entries.length, 1) |
| 74 | assert.strictEqual(decoded.entries[0].idString, entry.idString) |
| 75 | request.resume(Effect.void) |
| 76 | |
| 77 | yield* Fiber.join(writeFiber) |
| 78 | }).pipe(Effect.provide(EventLogEncryption.layerSubtle))) |
| 79 | |
| 80 | it.effect("makeEncrypted authenticates before writing encrypted entries", () => |
| 81 | Effect.gen(function*() { |
| 82 | const harness = yield* makeHarness(EventLogRemote.makeEncrypted) |
| 83 | const identity = yield* EventLog.makeIdentity |
| 84 | |
| 85 | const writeFiber = yield* harness.remote.write({ |
| 86 | identity, |
| 87 | storeId: defaultStoreId, |
| 88 | entries: [makeEntry()] |
| 89 | }).pipe(Effect.forkScoped) |
| 90 | yield* authenticate({ harness, identity }) |
| 91 | |
| 92 | const request = yield* harness.take |
| 93 | assert(request._tag === "EventLog.WriteSingle") |
| 94 | const payload = request.request as Rpc.Payload<typeof EventLogMessage.WriteSingleRpc> |
| 95 | const decoded = yield* EventLogMessage.WriteEntries.decode(payload.data) |
| 96 | |
| 97 | assert.strictEqual(decoded.publicKey, identity.publicKey) |
| 98 | assert.strictEqual(decoded.storeId, defaultStoreId) |
| 99 | assert.strictEqual(decoded.encryptedEntries.length, 1) |
| 100 | |
| 101 | request.resume(Effect.void) |
| 102 | yield* Fiber.join(writeFiber) |
| 103 | }).pipe(Effect.provide(EventLogEncryption.layerSubtle))) |
| 104 | }) |
| 105 | |
| 106 | class RemoteHarness extends Context.Service<RemoteHarness, { |
| 107 | readonly remote: EventLogRemote.EventLogRemote["Service"] |
| 108 | readonly take: Effect.Effect<{ |
| 109 | readonly _tag: Rpc.Tag<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>> |
| 110 | readonly request: Rpc.Payload<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>> |
| 111 | readonly resume: (response: Effect.Effect<unknown, unknown>) => void |
| 112 | }> |
| 113 | }>()("RemoteHarness") {} |
| 114 | |
| 115 | const makeHarness = Effect.fn(function*( |
| 116 | makeClient: Effect.Effect< |
| 117 | EventLogRemote.EventLogRemote["Service"], |
| 118 | EventLogRemote.EventLogRemoteError, |
| 119 | EventLogEncryption.EventLogEncryption | EventLogRemote.EventLogRemoteClient | EventLog.Registry | Scope.Scope |
| 120 | > |
| 121 | ): Effect.fn.Return< |
| 122 | RemoteHarness["Service"], |
| 123 | EventLogRemote.EventLogRemoteError, |
| 124 | Scope.Scope |
| 125 | > { |
| 126 | const requests = yield* Queue.make<{ |
| 127 | readonly _tag: Rpc.Tag<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>> |
| 128 | readonly request: Rpc.Payload<RpcGroup.Rpcs<typeof EventLogMessage.EventLogRemoteRpcs>> |
| 129 | readonly resume: (response: Effect.Effect<unknown, unknown>) => void |
| 130 | }>() |
| 131 | |
| 132 | const handleRequest = (tag: string) => <A, E>(request: any) => |
| 133 | Effect.callback<A, E>((resume) => { |
| 134 | Queue.offerUnsafe(requests, { |
| 135 | _tag: tag as any, |
| 136 | request, |
| 137 | resume: resume as any |
| 138 | }) |
| 139 | }) |
| 140 | |
| 141 | const client = yield* RpcTest.makeClient(EventLogMessage.EventLogRemoteRpcs).pipe( |
| 142 | Effect.provide(EventLogMessage.EventLogRemoteRpcs.toLayer({ |
| 143 | "EventLog.Hello": handleRequest("EventLog.Hello"), |
| 144 | "EventLog.Authenticate": handleRequest("EventLog.Authenticate"), |
| 145 | "EventLog.WriteChunked": handleRequest("EventLog.WriteChunked"), |
| 146 | "EventLog.WriteSingle": handleRequest("EventLog.WriteSingle"), |
| 147 | "EventLog.Changes": handleRequest("EventLog.Changes") |
| 148 | })), |
| 149 | Effect.provideService( |
| 150 | EventLogMessage.EventLogAuthentication, |
| 151 | (effect) => Effect.provideService(effect, EventLog.Identity, {} as any) |
| 152 | ) |
| 153 | ) |
| 154 | const remote = yield* makeClient.pipe( |
| 155 | Effect.provideService(EventLogRemote.EventLogRemoteClient, client), |
| 156 | Effect.provide([EventLogEncryption.layerSubtle, EventLog.layerRegistry]), |
| 157 | Effect.forkChild |
| 158 | ) |
| 159 | |
| 160 | const hello = yield* Queue.take(requests) |
| 161 | assert(hello._tag === "EventLog.Hello") |
| 162 | hello.resume(Effect.succeed( |
| 163 | new EventLogMessage.HelloResponse({ |
| 164 | remoteId, |
| 165 | challenge |
| 166 | }) |
| 167 | )) |
| 168 | |
| 169 | return RemoteHarness.of({ |
| 170 | remote: yield* Fiber.join(remote), |
| 171 | take: Queue.take(requests) |
| 172 | }) |
| 173 | }) |
| 174 | |
| 175 | const makeEntry = (options?: { |
| 176 | readonly primaryKey?: string | undefined |
| 177 | readonly payloadSize?: number | undefined |
| 178 | }): EventJournal.Entry => |
| 179 | new EventJournal.Entry({ |
| 180 | id: EventJournal.makeEntryIdUnsafe(), |
| 181 | event: "UserCreated", |
| 182 | primaryKey: options?.primaryKey ?? "user-1", |
| 183 | payload: new Uint8Array(options?.payloadSize ?? 4).fill(1) |
| 184 | }) |
| 185 | |
| 186 | const defaultStoreId = EventLogMessage.StoreId.make("default") |
| 187 | const remoteId = EventJournal.makeRemoteIdUnsafe() |
| 188 | const challenge = new Uint8Array(16).fill(1) |
| 189 | const getIdentityRootSecretMaterial = makeGetIdentityRootSecretMaterial(globalThis.crypto) |
| 190 | |
| 191 | const authenticate = Effect.fnUntraced(function*(options: { |
| 192 | readonly harness: RemoteHarness["Service"] |
| 193 | readonly identity: EventLog.Identity["Service"] |
| 194 | }) { |
| 195 | const auth = yield* options.harness.take |
| 196 | assert(auth._tag === "EventLog.Authenticate") |
| 197 | const payload = auth.request as Rpc.Payload<typeof EventLogMessage.AuthenticateRpc> |
| 198 | const rootSecretMaterial = yield* getIdentityRootSecretMaterial(options.identity) |
| 199 | |
| 200 | assert.strictEqual(payload.publicKey, options.identity.publicKey) |
| 201 | assert.strictEqual(payload.algorithm, "Ed25519") |
| 202 | assert.deepStrictEqual(payload.signingPublicKey, rootSecretMaterial.signingPublicKey) |
| 203 | |
| 204 | const verified = yield* EventLogSessionAuth.verifySessionAuthenticateRequest({ |
| 205 | remoteId, |
| 206 | challenge, |
| 207 | publicKey: payload.publicKey, |
| 208 | signingPublicKey: payload.signingPublicKey, |
| 209 | signature: payload.signature, |
| 210 | algorithm: payload.algorithm |
| 211 | }) |
| 212 | assert.isTrue(verified) |
| 213 | auth.resume(Effect.void) |
| 214 | }) |