test(relay): cross-package e2e adversarial security harness
New e2e/ package wires the REAL P5(auth)+P4(crypto)+P2(agent) exports through in-memory seams and an untrusted-relay attacker vantage (RelaySpy). Dynamically re-validates F1–F4/F6 plus MITM/reflection/replay/reorder/INV2/cross-tenant/ single-use — every assertion runs the production code path. 21 tests green, tsc clean. node_modules via symlinks (gitignored); no npm install needed.
This commit is contained in:
226
e2e/tests/adversarial-auth.test.ts
Normal file
226
e2e/tests/adversarial-auth.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Auth (P5) attack matrix — dynamically re-validates the confirmed findings F1–F4 plus cross-tenant
|
||||
* isolation and capability single-use. Each case ASSERTS the defense holds through the REAL
|
||||
* relay-auth enforcement path. Pass an explicit `now` everywhere.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
needsStepUp,
|
||||
recordStepUp,
|
||||
verifyCapabilityToken,
|
||||
verifyDpopProof,
|
||||
finishAuthentication,
|
||||
WebAuthnError,
|
||||
type WebAuthnVerifier,
|
||||
type AuthenticationResponse,
|
||||
type WebAuthnCredential,
|
||||
} from 'relay-auth'
|
||||
import {
|
||||
buildRelayWorld,
|
||||
DEFAULT_NOW,
|
||||
STRICT_PASSKEY,
|
||||
NO_STEP_UP,
|
||||
type RelayWorld,
|
||||
} from '../harness/world.js'
|
||||
|
||||
const NOW = DEFAULT_NOW
|
||||
|
||||
describe('adversarial — auth (P5)', () => {
|
||||
let world: RelayWorld
|
||||
beforeEach(async () => {
|
||||
world = await buildRelayWorld(NOW)
|
||||
})
|
||||
|
||||
// ── F2 · mandatory step-up is fail-closed on the new-session path ──────────────────────────────
|
||||
describe('F2 (dynamic): host-driven step-up gate is fail-closed', () => {
|
||||
it('STRICT policy + principal:null → DENIED 403 step_up_required', async () => {
|
||||
const { hostA } = world
|
||||
world.setStepUpPolicy(STRICT_PASSKEY)
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: null,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
|
||||
expect(world.audit.events.some((e) => e.action === 'stepup' && e.outcome === 'deny')).toBe(true)
|
||||
})
|
||||
|
||||
it('required:false policy + principal:null → allowed (non-step-up host preserved)', async () => {
|
||||
const { hostA } = world
|
||||
world.setStepUpPolicy(NO_STEP_UP)
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: null,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F1 · step-up factor must be method-bound (a passkey requirement ≠ a TOTP step-up) ───────────
|
||||
describe('F1 (dynamic): step-up freshness is bound to the required method', () => {
|
||||
it('a fresh TOTP step-up does NOT satisfy a STRICT passkey policy; a passkey step-up DOES', async () => {
|
||||
const { hostA } = world
|
||||
world.setStepUpPolicy(STRICT_PASSKEY)
|
||||
|
||||
const totp = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'totp', NOW)
|
||||
expect(needsStepUp(totp, STRICT_PASSKEY, NOW)).toBe(true)
|
||||
const capT = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const deniedTotp = await world.upgrade({
|
||||
raw: capT.raw,
|
||||
dpop: capT.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: totp,
|
||||
now: NOW,
|
||||
})
|
||||
expect(deniedTotp).toMatchObject({ ok: false, status: 403, reason: 'step_up_required' })
|
||||
|
||||
const passkey = recordStepUp(world.principal('acct-A', { authAt: NOW }), 'passkey', NOW)
|
||||
expect(needsStepUp(passkey, STRICT_PASSKEY, NOW)).toBe(false)
|
||||
const capP = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const allowedPasskey = await world.upgrade({
|
||||
raw: capP.raw,
|
||||
dpop: capP.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
principal: passkey,
|
||||
now: NOW,
|
||||
})
|
||||
expect(allowedPasskey.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F4 · a thumbprint-matching but malformed DPoP key must fail-safe (resolve false, never throw) ─
|
||||
describe('F4 (dynamic): verifyDpopProof is totally fail-safe', () => {
|
||||
it('a proof whose jwk.x is a non-32-byte blob RESOLVES false and yields a clean denied outcome', async () => {
|
||||
const { hostA } = world
|
||||
const craft = await world.craftMalformedDpop({
|
||||
accountId: hostA.accountId,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
})
|
||||
|
||||
// Direct: the verifier resolves to false — it never throws / rejects (audit-evasion + DoS fix).
|
||||
const tok = await verifyCapabilityToken(craft.raw, hostA.aud, NOW)
|
||||
await expect(verifyDpopProof(tok, craft.dpop, NOW)).resolves.toBe(false)
|
||||
|
||||
// Via the full enforcement path: a clean denied AuthzOutcome, not an exception.
|
||||
const out = await world.upgrade({
|
||||
raw: craft.raw,
|
||||
dpop: craft.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out).toMatchObject({ ok: false, reason: 'dpop_proof_failed' })
|
||||
// Exactly one audited deny was emitted (no unhandled rejection, no audit evasion).
|
||||
expect(world.audit.events.some((e) => e.outcome === 'deny')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── F3 · WebAuthn assertion must be bound to the stored credential id ───────────────────────────
|
||||
describe('F3: an assertion whose credentialId ≠ the stored credential is rejected', () => {
|
||||
const RP_ID = 'alice.term.example.com'
|
||||
const ORIGIN = 'https://alice.term.example.com'
|
||||
const cred: WebAuthnCredential = {
|
||||
credentialId: 'cred-1',
|
||||
accountId: 'acct-A',
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
signCount: 5,
|
||||
transports: ['internal'],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
// A permissive verifier that would "verify" ANY assertion — the credentialId cross-check must
|
||||
// still reject before the verifier's verdict is trusted.
|
||||
const forgivingVerifier: WebAuthnVerifier = {
|
||||
verifyRegistration: async () => ({
|
||||
verified: true,
|
||||
credentialId: 'cred-1',
|
||||
publicKey: new Uint8Array([1, 2, 3]),
|
||||
signCount: 0,
|
||||
transports: ['internal'],
|
||||
}),
|
||||
verifyAuthentication: async (_r, _c, _rp, _o, _pk, _cid, stored) => ({
|
||||
verified: true,
|
||||
newSignCount: stored + 1,
|
||||
}),
|
||||
}
|
||||
|
||||
it('rejects with WebAuthnError even though the mock verifier returns verified:true', async () => {
|
||||
const resp: AuthenticationResponse = {
|
||||
clientChallenge: 'chal',
|
||||
origin: ORIGIN,
|
||||
rpId: RP_ID,
|
||||
credentialId: 'other-cred', // ≠ cred.credentialId
|
||||
}
|
||||
await expect(
|
||||
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
|
||||
).rejects.toBeInstanceOf(WebAuthnError)
|
||||
await expect(
|
||||
finishAuthentication(cred, resp, 'chal', RP_ID, ORIGIN, forgivingVerifier, NOW),
|
||||
).rejects.toThrow('credential id mismatch')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Cross-tenant · a token for account A can never reach a host owned by account B ──────────────
|
||||
describe('cross-tenant isolation (INV1)', () => {
|
||||
it('an A-account token presented at host B (owned by B) is DENIED 403', async () => {
|
||||
const { hostA, hostB } = world
|
||||
// token.host === requestedHostId (hostB) so host-scope passes; the registry then shows hostB
|
||||
// belongs to acct-B ≠ token.sub (acct-A) → the cross_tenant gate fires.
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostB.hostId, aud: hostB.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostB.hostId,
|
||||
aud: hostB.aud,
|
||||
origin: hostB.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out).toMatchObject({ ok: false, status: 403 })
|
||||
if (!out.ok) expect(['cross_tenant', 'host_scope_mismatch']).toContain(out.reason)
|
||||
expect(world.audit.events.some((e) => e.action === 'cross-tenant-attempt' && e.outcome === 'deny')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Capability single-use · a token+jti may be upgraded exactly once ────────────────────────────
|
||||
describe('capability single-use (consumeOnce)', () => {
|
||||
it('the same token+jti upgraded twice → the second is denied token_replayed', async () => {
|
||||
const { hostA } = world
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const first = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(first.ok).toBe(true)
|
||||
|
||||
const second = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: await cap.newDpop(NOW), // fresh DPoP so the jti burn (not the DPoP cache) is what denies
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_replayed' })
|
||||
})
|
||||
})
|
||||
})
|
||||
115
e2e/tests/adversarial-transport.test.ts
Normal file
115
e2e/tests/adversarial-transport.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Transport (P4) attack matrix, run from the untrusted-relay / attacker vantage (the RelaySpy).
|
||||
* Each case ASSERTS the defense holds. Wires the REAL relay-e2e handshake/session + agent replay
|
||||
* sealer through the harness. Pass an explicit `now` everywhere.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { FingerprintMismatchError } from 'relay-e2e'
|
||||
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
|
||||
|
||||
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
|
||||
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
|
||||
const NOW = DEFAULT_NOW
|
||||
|
||||
function flipByte(b: Uint8Array, i = 0): Uint8Array {
|
||||
const c = b.slice()
|
||||
c[i] = (c[i]! ^ 0xff) & 0xff
|
||||
return c
|
||||
}
|
||||
|
||||
describe('adversarial — transport (P4)', () => {
|
||||
let world: RelayWorld
|
||||
beforeEach(async () => {
|
||||
world = await buildRelayWorld(NOW)
|
||||
})
|
||||
|
||||
describe('MITM: host_hello must verify against the registry key', () => {
|
||||
it('rejects when the client verifies host_hello against a WRONG agentPubkey (no keys derived)', async () => {
|
||||
// The malicious relay swaps in a key it controls; the client checks against hostB's real key.
|
||||
await expect(
|
||||
world.establishSession({ verifyAgainstPubkey: world.hostB.agentPubkey }),
|
||||
).rejects.toBeInstanceOf(FingerprintMismatchError)
|
||||
})
|
||||
|
||||
it('rejects a tampered host_hello (flipped hostEphPub breaks the transcript signature)', async () => {
|
||||
await expect(
|
||||
world.establishSession({
|
||||
tamperHostHello: (h) => ({ ...h, hostEphPub: flipByte(h.hostEphPub) }),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(FingerprintMismatchError)
|
||||
})
|
||||
})
|
||||
|
||||
it('reflection: a c2h frame fed back into the client’s own open is rejected (direction split)', async () => {
|
||||
const { client } = await world.establishSession()
|
||||
const c2h = client.seal(utf8('sudo rm -rf /'))
|
||||
// Reflecting the client's own ciphertext back to it must fail: the client opens with the h2c
|
||||
// read key + h2c aad label, so the tag over the c2h direction never verifies.
|
||||
expect(() => client.open(c2h)).toThrow()
|
||||
})
|
||||
|
||||
it('replay: delivering the same valid frame twice → the second open throws (SequenceGuard)', async () => {
|
||||
const { client, host, spy } = await world.establishSession()
|
||||
const f0 = spy.forward(client.seal(utf8('whoami')))
|
||||
expect(fromUtf8(host.open(f0))).toBe('whoami')
|
||||
expect(() => host.open(f0)).toThrow() // seq 0 already consumed → strict-successor guard
|
||||
})
|
||||
|
||||
it('reorder: delivering seq 1 before seq 0 → open throws (strict successor)', async () => {
|
||||
const { client, host } = await world.establishSession()
|
||||
const f0 = client.seal(utf8('cmd-0'))
|
||||
const f1 = client.seal(utf8('cmd-1'))
|
||||
expect(() => host.open(f1)).toThrow() // expected seq 0, got 1
|
||||
// and the in-order pair still works on a fresh pair (sanity)
|
||||
expect(fromUtf8(host.open(f0))).toBe('cmd-0')
|
||||
expect(fromUtf8(host.open(f1))).toBe('cmd-1')
|
||||
})
|
||||
|
||||
it('INV2: the RelaySpy ciphertext never contains the plaintext marker', async () => {
|
||||
const { client, host, spy } = await world.establishSession()
|
||||
const marker = `TOP_SECRET_${crypto.randomUUID()}`
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const wire = spy.forward(client.seal(utf8(`${marker}#${i}`)))
|
||||
expect(fromUtf8(host.open(wire))).toBe(`${marker}#${i}`)
|
||||
}
|
||||
expect(spy.contains(marker)).toBe(false)
|
||||
expect(spy.captured.length).toBe(3)
|
||||
})
|
||||
|
||||
describe('F6 (dynamic): recoverable K_content must not reuse (key, nonce) across sealer generations', () => {
|
||||
it('two generations for the same (secret, sessionId) get different epochs → different keys at seq 0', () => {
|
||||
const sessionId = 'sess-restart-1'
|
||||
const gen1 = world.newReplaySealer(sessionId)
|
||||
const gen2 = world.newReplaySealer(sessionId) // simulates an agent restart / re-attach
|
||||
|
||||
expect(gen2.epoch).not.toBe(gen1.epoch)
|
||||
|
||||
const pt = utf8('IDENTICAL PLAINTEXT AT SEQ 0')
|
||||
const e1 = gen1.seal(pt)
|
||||
const e2 = gen2.seal(pt)
|
||||
|
||||
// Same deterministic nonce (seq 0) …
|
||||
expect(e1.seq).toBe(0n)
|
||||
expect(e2.seq).toBe(0n)
|
||||
expect(Buffer.from(e1.nonce).equals(Buffer.from(e2.nonce))).toBe(true)
|
||||
// … yet a FRESH key per generation ⇒ ciphertext + tag differ (no (key, nonce) reuse).
|
||||
expect(Buffer.from(e1.ciphertext).equals(Buffer.from(e2.ciphertext))).toBe(false)
|
||||
expect(Buffer.from(e1.tag).equals(Buffer.from(e2.tag))).toBe(false)
|
||||
|
||||
// Cross-generation open fails: gen1's epoch-derived key cannot open gen2's frame.
|
||||
expect(() => world.openReplay(sessionId, gen1.epoch, e2)).toThrow()
|
||||
|
||||
// Recoverability WITHIN a generation is preserved (same epoch ⇒ same key).
|
||||
expect(fromUtf8(world.openReplay(sessionId, gen1.epoch, e1))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
|
||||
expect(fromUtf8(world.openReplay(sessionId, gen2.epoch, e2))).toBe('IDENTICAL PLAINTEXT AT SEQ 0')
|
||||
})
|
||||
|
||||
it('the replay ciphertext itself never contains the plaintext marker (INV2 on the replay path)', () => {
|
||||
const sealer = world.newReplaySealer('sess-inv2')
|
||||
const marker = 'REPLAY_MARKER_ABC'
|
||||
const env = sealer.seal(utf8(marker))
|
||||
expect(Buffer.from(env.ciphertext).toString('latin1')).not.toContain(marker)
|
||||
expect(Buffer.from(env.ciphertext).toString('utf8')).not.toContain(marker)
|
||||
})
|
||||
})
|
||||
})
|
||||
99
e2e/tests/flow.test.ts
Normal file
99
e2e/tests/flow.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Happy-path full flow, end-to-end across P5 (auth) + P4 (E2E crypto) + P2 (replay), asserting each
|
||||
* stage. Everything runs through the REAL package exports; only registries/buckets/sockets are faked.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { buildRelayWorld, DEFAULT_NOW, type RelayWorld } from '../harness/world.js'
|
||||
|
||||
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
|
||||
const fromUtf8 = (b: Uint8Array): string => new TextDecoder().decode(b)
|
||||
const NOW = DEFAULT_NOW
|
||||
|
||||
describe('relay flow (happy path)', () => {
|
||||
let world: RelayWorld
|
||||
beforeEach(async () => {
|
||||
world = await buildRelayWorld(NOW)
|
||||
})
|
||||
|
||||
it('issueCap → upgrade allows (origin ok, DPoP bound, deny-by-default satisfied)', async () => {
|
||||
const { hostA } = world
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out.ok).toBe(true)
|
||||
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'attach' })
|
||||
})
|
||||
|
||||
it('handshake establishes MATCHING session keys on both sides (client↔host round-trip)', async () => {
|
||||
const { client, host } = await world.establishSession()
|
||||
// Matching keys ⇒ bidirectional plaintext round-trips through the direction split.
|
||||
const c2h = host.open(client.seal(utf8('ls -la')))
|
||||
expect(fromUtf8(c2h)).toBe('ls -la')
|
||||
const h2c = client.open(host.seal(utf8('total 0')))
|
||||
expect(fromUtf8(h2c)).toBe('total 0')
|
||||
})
|
||||
|
||||
it('seals client→host and host→client through the RelaySpy; the RelaySpy never sees plaintext (INV2)', async () => {
|
||||
const { client, host, spy } = await world.establishSession()
|
||||
const marker = `PLAINTEXT_${crypto.randomUUID()}`
|
||||
|
||||
const up = spy.forward(client.seal(utf8(marker)))
|
||||
expect(fromUtf8(host.open(up))).toBe(marker)
|
||||
const down = spy.forward(host.seal(utf8(`reply_${marker}`)))
|
||||
expect(fromUtf8(client.open(down))).toBe(`reply_${marker}`)
|
||||
|
||||
expect(spy.contains(marker)).toBe(false)
|
||||
expect(spy.captured.length).toBe(2)
|
||||
})
|
||||
|
||||
it('reattach to an own-account session is allowed', async () => {
|
||||
const { hostA } = world
|
||||
const sessionId = crypto.randomUUID()
|
||||
world.addSession({ sessionId, hostId: hostA.hostId, accountId: hostA.accountId })
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const out = await world.reattach({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
sessionId,
|
||||
now: NOW,
|
||||
})
|
||||
expect(out.ok).toBe(true)
|
||||
expect(world.audit.events.at(-1)).toMatchObject({ outcome: 'allow', action: 'reattach' })
|
||||
})
|
||||
|
||||
it('revokeToken then re-presenting the same jti (fresh DPoP) is denied', async () => {
|
||||
const { hostA } = world
|
||||
const cap = await world.issueCap({ accountId: hostA.accountId, host: hostA.hostId, aud: hostA.aud })
|
||||
const first = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: cap.dpop,
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(first.ok).toBe(true)
|
||||
if (!first.ok) return
|
||||
|
||||
await world.revokeToken(first.jti)
|
||||
|
||||
const second = await world.upgrade({
|
||||
raw: cap.raw,
|
||||
dpop: await cap.newDpop(NOW), // fresh DPoP jti so we reach the revocation gate, not the DPoP cache
|
||||
host: hostA.hostId,
|
||||
aud: hostA.aud,
|
||||
origin: hostA.origin,
|
||||
now: NOW,
|
||||
})
|
||||
expect(second).toMatchObject({ ok: false, status: 403, reason: 'token_revoked' })
|
||||
})
|
||||
})
|
||||
21
e2e/tests/smoke.test.ts
Normal file
21
e2e/tests/smoke.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
// Cross-package resolution smoke test: prove the harness can bare-import the REAL exports of
|
||||
// every relay package (each resolves via its own node_modules/relay-contracts symlink transitively).
|
||||
import { issueCapabilityToken, onUpgrade, needsStepUp } from 'relay-auth'
|
||||
import { createClientHandshake, createHostHandshake, createE2ESession, deriveContentKey } from 'relay-e2e'
|
||||
import { createReplaySealer } from 'agent'
|
||||
import { CapabilityTokenSchema } from 'relay-contracts'
|
||||
|
||||
describe('cross-package import smoke', () => {
|
||||
it('resolves the real exports of relay-auth / relay-e2e / agent / relay-contracts', () => {
|
||||
expect(typeof issueCapabilityToken).toBe('function')
|
||||
expect(typeof onUpgrade).toBe('function')
|
||||
expect(typeof needsStepUp).toBe('function')
|
||||
expect(typeof createClientHandshake).toBe('function')
|
||||
expect(typeof createHostHandshake).toBe('function')
|
||||
expect(typeof createE2ESession).toBe('function')
|
||||
expect(typeof deriveContentKey).toBe('function')
|
||||
expect(typeof createReplaySealer).toBe('function')
|
||||
expect(CapabilityTokenSchema).toBeDefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user